diff --git a/.github/workflows/badge-web.yml b/.github/workflows/badge-web.yml new file mode 100644 index 0000000000..4961c79fd1 --- /dev/null +++ b/.github/workflows/badge-web.yml @@ -0,0 +1,33 @@ +name: badge web + +on: + push: + branches: + - master + - stable + paths: + - "apps/simplex-badge-service/web/**" + - ".github/workflows/badge-web.yml" + pull_request: + paths: + - "apps/simplex-badge-service/web/**" + - ".github/workflows/badge-web.yml" + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/simplex-badge-service/web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: apps/simplex-badge-service/web/package-lock.json + + - run: npm ci + + - run: npm test diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d92e2507c9..c20fce8c25 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -360,6 +360,14 @@ jobs: sudo chmod -R 777 dist-newstyle ~/.cabal sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal + - name: Mount RAM disk for tests + if: matrix.should_run == true && matrix.arch == 'x86_64' + shell: bash + run: | + mkdir -p tests/tmp + sudo mount -t tmpfs -o size=512M tmpfs tests/tmp + sudo chown $(id -u):$(id -g) tests/tmp + - name: Run tests if: matrix.should_run == true && matrix.arch == 'x86_64' timeout-minutes: 120 diff --git a/.gitignore b/.gitignore index 4f73442c36..4ceea4d1a1 100644 --- a/.gitignore +++ b/.gitignore @@ -87,4 +87,11 @@ website/.cache website/test/stubs-layout-cache/_includes/*.js apps/android/app/release apps/multiplatform/.kotlin/sessions +# anywhere in the tree: this file holds a real BTCPay api_key and webhook_secret +badge_service.ini +# Badge service Docker deployment: the Dockerfile, compose, and example config are tracked; the +# live config (a secret) and its data volume are not. +scripts/badge-service/badge_service.ini +scripts/badge-service/data/ +scripts/badge-service/web/ 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 64a2e1d553..68369995e2 100644 --- a/apps/simplex-badge-service/Main.hs +++ b/apps/simplex-badge-service/Main.hs @@ -4,11 +4,20 @@ 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 = do - opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts - if runCLI - then badgeServiceCLI opts - else newServiceState >>= badgeService opts terminalChatConfig + -- 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 9776a8de05..83b7ca4c40 100644 --- a/apps/simplex-badge-service/README.md +++ b/apps/simplex-badge-service/README.md @@ -1,16 +1,36 @@ # SimpleX badge service -Scaffolding for the SimpleX supporter-badge RPC service. The wire protocol is specified in [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md), and the implementation plans live under [`plans/`](../../plans) (`2026-07-30-supporter-badges-v3-ux.md`, `2026-07-31-badges-core-implementation.md`, `2026-08-04-badges-mvp-scope.md`). +`simplex-badge-service` runs the whole supporter-badge service as one process, two +lanes: a SimpleX chat bot that answers service RPC over a double-ratchet contact +address, and — only when `--service-config` names a `badge_service.ini` — the badge-codes +web checkout (BTCPay and Stripe payment, code issuance, the poller). **Without +`--service-config` the web listener does not start at all**; the process still runs the +chat side and nothing else. + +The wire protocol for the RPC side is +[`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md). The web checkout is +specified in +[`plans/badges-codes/2026-08-27-badge-codes.md`](../../plans/badges-codes/2026-08-27-badge-codes.md), +whose §9 is the `badge_service.ini` reference, with the implementation plan in +[`plans/badges-codes/2026-08-31-service-btcpay.md`](../../plans/badges-codes/2026-08-31-service-btcpay.md). +Earlier plans (`2026-07-30-supporter-badges-v3-ux.md`, `2026-07-31-badges-core-implementation.md`, +`2026-08-04-badges-mvp-scope.md`) predate the web checkout and describe the RPC-only +scaffold this service started as. 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 — 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`). -Every other command still answers `unsupported_version`. Ledger writes, invoices and provider webhooks are left for follow-up per the plans; a redemption issues one credential and reports an empty statement. +Every other command answers `unsupported_version`, or `unknown_purchase_key` when the key that +signed it is not one the service has stored — every command but `redeemBadgeCode` needs a +purchase that already exists. ## Build @@ -27,9 +47,12 @@ 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`). +- `--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 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: - `--issuer-key-idx IDX` — the index the apps find the matching public key under (`badgePublicKeys` in `ChatConfig`). @@ -39,10 +62,161 @@ The service checks the secret against the configured public key at that index an if they disagree: credentials signed with the wrong key cannot be verified by any client, and the codes redeemed against them would be spent for nothing. +The key can come from `badge_service.ini` instead: + +```ini +[issuer] +index = 1 +private_key = +``` + +`index` is the index clients verify against (`badgePublicKeys` in `ChatConfig`), and `private_key` +is the secret that signs. Both are required, and the key is checked at startup like the flags. +Rotating is a change to both and a restart, once clients trust the new index. + +The command line wins over the file when both `--issuer-key-idx` and `--issuer-secret` are given. +Note that a secret passed as a flag is visible to every user on the machine through `ps`, where one +in the ini is only as readable as the file: `badge_service.ini` is gitignored and already holds the +provider API keys and webhook secrets. + +Other options: + +- `--service-config INI_FILE`: path to `badge_service.ini`. Omit it to run the chat/RPC side + only; the process never starts a web listener without it, and never starts one under + `--run-cli`, which parses and validates the whole file (`[listener] static_dir` included) but + uses only its `[issuer]` section. An issuer key is still required either way. +- `--service-name NAME`: the bot's display name, without `*`s or spaces (default `SimpleX Badges`). +- `--client-service`: use the client service certificate. +- also accepts the standard SimpleX Chat core options — database path, SMP/XFTP servers, + `--socks-proxy`, `--log-level`/`-l`, and the rest — run `simplex-badge-service --help` + for the complete list. + +### Running the web checkout + +`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), 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. + +The full walkthrough is in +[`web/README.md`](web/README.md#running-the-real-service-against-this-build). Short version: + +``` +cd apps/simplex-badge-service/web && npm install && npm run build && cd ../../.. +cp apps/simplex-badge-service/badge_service.ini.example apps/simplex-badge-service/badge_service.ini +cabal run simplex-badge-service -- \ + --issuer-key-idx IDX --issuer-secret SECRET \ + --service-config apps/simplex-badge-service/badge_service.ini +``` + +`IDX` and `SECRET` must be one of the issuer keys clients already ship, not a fresh +`simplex-chat badge keygen` pair: startup refuses a key no client could verify against. + +`trust_forwarded_for` decides what the rate limiter counts. Behind a reverse proxy it must be +`on`, or every request keys on the proxy's address and the whole service shares one bucket of 60 +reads and 5 checkouts a minute. Where the listener is reached directly it must be `off`, because +then the header is whatever the caller wrote. The last entry is the one read, which is the one a +proxy appends. + +The BTCPay API key needs four permissions, each scoped to the one store: +`cancreateinvoice` and `canviewinvoices` for checkout and the poller, +`canviewstoresettings` to log the store's live payment methods at startup, and +`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 +and `POST /api/invoice` answers `provider_unavailable` for a card order, the same as an +absent `[btcpay]` does for Bitcoin and Monero. Its keys: + +- `secret_key` — a **restricted** key (`rk_...`), never a full secret key (`sk_...`). Grant it + **PaymentIntents: write**, to create and cancel intents, plus **Charges: read** — the poller + reads an intent with `expand[]=latest_charge`, and Stripe rejects the whole read if the key + lacks read on an expanded object (settlement then falls back to the slower list pass). +- `publishable_key` (`pk_...`) — the public key the browser mounts the Payment Element with. +- `webhook_secret` (`whsec_...`) — the signing secret of the `/webhooks/stripe` endpoint, + configured in the Stripe Dashboard alongside it. +- `session_minutes` — how many minutes an unpaid card order stays open (1 to 1440, default 60). + Ten minutes after that, the service cancels the payment at Stripe, and retries if that fails. + If the order is over 72 hours old and the cancel has kept failing for an hour, the service marks + it expired and logs an error. Check such orders in the Stripe Dashboard. After a restart, the + hour starts again. + +The service fills `publishable_key` into the shell at boot: the built `index.html` ships +with an empty `` +(`web/public/index.html`), and the listener serves a copy with the configured key +substituted in, so the ini is the only place the key is set. With no `[stripe]` section the +pristine (empty) shell is served and the page shows its development stand-in card form. The +dev mock (`web/mock/server.py`) does the same substitution from `$STRIPE_PUBLISHABLE_KEY`, +since it runs without an ini. + +`POST /webhooks/stripe` verifies `Stripe-Signature` against `webhook_secret` and queues a +read, the same hint-only role as `POST /webhooks/btcpay`: the poller carries authority, so +an unverified or unreadable delivery costs nothing but a log line. A delivery signed more than +15 minutes away from the server's clock is refused with a warning, so keep the clock synced (NTP). + +The reverse proxy in front of this service must send a Content-Security-Policy that +allows Stripe.js and its iframes, since Stripe forbids bundling or self-hosting its +script: + +``` +script-src 'self' https://js.stripe.com https://*.js.stripe.com; +frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com +``` + +**Stripe Link must be disabled in the Dashboard.** Left on, it needs `link.com` in the +CSP above and reintroduces an email prompt of its own, which this design otherwise avoids. + +**Enable card only; do not enable redirect-based methods** (iDEAL, Bancontact, PayPal, and +the rest) in the Dashboard. The embedded checkout sends no `return_url`, which Stripe requires +the moment a redirect-based method is offered, so enabling one makes every checkout fail to +create. The service pins the Stripe API version it was built against, so the account's default +version does not affect the card lane. + +### The checkout endpoints + +The browser in `web/` is the only client of the `/api` routes; the two webhooks below are the +providers', one each for BTCPay and Stripe. +The invoice id is the only credential for reading or cancelling an order: it is 16 random bytes, +it travels in the path, and anything that logs request paths logs it. It is also what the buyer +is shown as their reference and asked to quote, so support sees it: losing it lets someone cancel +an invoice, which is why the code, which lets them take the badge, is never printed until the +service says the invoice is paid. The service keeps it out of its own logs, which name the +provider's reference instead. + +| Route | Answers | +|---|---| +| `POST /api/invoice` | `{invoiceId, badgeType, months, amount, currency, expiresAt}` plus a destination: `clientSecret` for a card, or `address`, `cryptoAmount`, `cryptoCurrency`. Refuses with `code_conflict`, `catalog_changed`, `bad_request`, `provider_unavailable` or `rate_limited`. | +| `GET /api/invoice/:id` | `{status, badgeType, months, amount, currency, expiresAt}` and the same destination — no `invoiceId`, since the caller already has it — plus `amountPaid`, `cryptoAmountPaid`, `cryptoAmountDue`, `paidInFull`, `settledAt` and `requiredConfirmations` once each has a value. Never the code, which this service has never seen. | +| `GET /api/invoice/:id?wait=&seenPaid=
&seenFull=<0\|1>` | The same, held for up to 30 seconds while the invoice's status is still `` **and** its payment is the one the caller says it has rendered. `wait=paid` and any value that is not a status answer at once, since neither can change. A status the caller has not seen, or a payment it has not seen, answers at once — the provider's verdict counts as much as the figure, because Monero reports an invoice as confirming while its figures are still zero. A request that omits `seenPaid` holds on the status alone. | +| `POST /api/invoice/:id/cancel` | Invalidates an open invoice at the provider and expires it here. Refuses a settled or expired one with `not_open`, and one that already holds a payment with `funded`. The provider is told first, and a payment landing in between does not keep the invoice open: nothing can reach that address any more, so the row is expired either way and the poller settles or reports what arrived. | +| `POST /webhooks/btcpay` | Verifies `BTCPay-Sig` over the bytes as received and queues a read. A hint only: the poller is what carries authority, so an unverified or unreadable delivery costs nothing but a log line. | +| `POST /webhooks/stripe` | Verifies `Stripe-Signature` over the bytes as received and queues a read. The card lane's equivalent of the row above, and a hint just the same: the poller carries authority, so an unverified or unreadable delivery costs nothing but a log line. | + +Every `/api` refusal is `{"error": ""}`. Besides the codes above, any of them can answer +`internal`, an unknown id answers `not_found`, and a wrong verb answers `method_not_allowed` — +ten codes in all, which is what the browser's `WIRE_ERROR_CODES` lists. The two webhook routes are +the exception: each answers 200, 400 or 413 with an empty body, because its provider is the only +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`. + ## 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] @@ -51,10 +225,89 @@ to start it — so codes are issued without a second process touching the servic `months` defaults to 1 and must be between 1 and 255; the status defaults to `free` and records whether the code was sold (`paid`), is awaiting payment (`unpaid`), or was issued by an operator -(`free`) — redemption never reads it. +(`free`). Redemption refuses an `unpaid` code with `payment_pending`: the web checkout writes the +code row when the invoice is created, and settlement is what marks it paid. The code is printed once and only its `SHA-256` is stored, so a code that is not copied when it is shown cannot be recovered. +A code that leaked, or that was refunded, is withdrawn the same way: + +``` +//revoke +``` + +A revoked code answers redemption with `code_invalid`, as if it had never existed, so its holder +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 new file mode 100644 index 0000000000..00584fed41 --- /dev/null +++ b/apps/simplex-badge-service/badge_service.ini.example @@ -0,0 +1,62 @@ +[listener] +; bind locally; the reverse proxy terminates TLS. It must pass the query string through: +; the long poll's `wait`, `seenPaid` and `seenFull` all travel in it +host = 127.0.0.1 +port = 8080 +; required: the directory `npm run build` writes in apps/simplex-badge-service/web +static_dir = ./apps/simplex-badge-service/web/dist +; serve the webapp over HTTP. On (default) is the all-in-one deployment. Off is a split deployment +; where a reverse proxy serves the exported folder and this service is only the API and webhooks. +serve_webapp = on +; when set, the webapp is written here at boot with the publishable key injected, copied from +; static_dir, for a reverse proxy to serve. Leave unset in the all-in-one deployment. +; webapp_export_dir = /srv/webapp +; on behind the reverse proxy above: without it every request keys the rate limiter on the +; proxy's own address, so one bucket is shared by everyone. Set it off only where this +; listener is reached directly, since a client can write the header itself. +trust_forwarded_for = on + +; omit this whole section to disable Bitcoin and Monero (the browser shows the provider-unavailable screen) +[btcpay] +host = https://btcpay.example.org +api_key = replace-me +store_id = replace-me +webhook_secret = replace-me +expiry_minutes = 60 +speed_policy = MediumSpeed +payment_tolerance = 0.5 + +; omit this whole section to disable card payments (the browser shows the provider-unavailable screen) +[stripe] +; restricted key (rk_): Payment Intents write + Charges read — never a full secret key (sk_) +secret_key = rk_test_replace-me +publishable_key = pk_test_replace-me +; the signing secret of the /webhooks/stripe endpoint configured in the Dashboard +webhook_secret = whsec_replace-me +; minutes the service holds an unpaid card invoice open before it expires it; 1-1440 +session_minutes = 60 + +[poll] +waiting_seconds = 3 +idle_seconds = 60 + +; the key this service signs credentials with. index is the index clients verify against, so +; private_key has to be one whose public half already ships in the apps at that index: a fresh +; `simplex-chat badge keygen` pair is refused until it does. +; --issuer-key-idx and --issuer-secret override this section if both are given. +; to rotate, change index and private_key together, once clients trust the new index. +; the key is checked at startup, so this section stays commented out until it holds a real key. +;[issuer] +;index = 1 +;private_key = replace-me + +; 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/Catalog.hs b/apps/simplex-badge-service/src/BadgeService/Catalog.hs new file mode 100644 index 0000000000..b15185da68 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Catalog.hs @@ -0,0 +1,135 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.Catalog + ( OfferInvalid (..), + CatalogRefusal (..), + offerTotal, + PricedOffer (..), + priceOffer, + catalogCurrency, + defaultCatalog, + ) +where + +import Data.List (find) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Data.Word (Word32, Word64, Word8) +import Simplex.Chat.Badges (BadgeType (..)) +import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..)) +import Simplex.Chat.Badges.Types (BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..)) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) + +data OfferInvalid = OIZeroMonths | OIFreeMonthsExceedTerm | OIDiscountTooLarge | OIAmountUnsellable + deriving (Eq, Show) + +offerTotal :: CurrencyAmount -> Maybe BadgeOffer -> Either OfferInvalid (Word8, CurrencyAmount, CurrencyAmount) +offerTotal (CurrencyAmount p) = \case + Nothing -> charge 1 (gross 1) + Just BadgeOffer {months, discount} + | months == 0 -> Left OIZeroMonths + | otherwise -> case discount of + ODFreeMonths f + | f >= months -> Left OIFreeMonthsExceedTerm + | otherwise -> charge months (gross (months - f)) + ODDiscount d + | d >= 100 -> Left OIDiscountTooLarge + | otherwise -> charge months (gross months * (100 - fromIntegral d) `div` 100) + where + gross :: Word8 -> Word64 + gross m = fromIntegral p * fromIntegral m + maxAmount :: Word64 -- $1,000,000 in minor units + maxAmount = 100000000 + charge :: Word8 -> Word64 -> Either OfferInvalid (Word8, CurrencyAmount, CurrencyAmount) + charge m c + -- Bound gross too, not just the charge, or a large price wraps the Word32 amount field. + | c == 0 || c > maxAmount || gross m > maxAmount = Left OIAmountUnsellable + | otherwise = Right (m, CurrencyAmount (fromIntegral (gross m)), CurrencyAmount (fromIntegral c)) + +data PricedOffer = PricedOffer + { poBadgeType :: BadgeType, + poMonths :: Word8, + poPrice :: CurrencyAmount, + poAmount :: CurrencyAmount, + poCurrency :: Text + } + deriving (Eq, Show) + +data CatalogRefusal + = CRUnknownPrice + | CRDisabledPrice + | CRUnknownOffer + | CRDisabledOffer + | CROfferNotForPrice + | CRUnsoldBadgeType BadgeType + | CRUnpriced OfferInvalid + deriving (Eq, Show) + +priceOffer :: [BadgePrice] -> [BadgeOffer] -> BadgePriceId -> Maybe BadgeOfferId -> Either CatalogRefusal PricedOffer +priceOffer prices offers wantedPriceId wantedOfferId = do + BadgePrice {badgeType, monthPrice, currency, status = priceStatus} <- known CRUnknownPrice (findPrice wantedPriceId) + soldBadgeType badgeType + active CRDisabledPrice priceStatus + chosenOffer <- resolveOffer wantedPriceId wantedOfferId + (months, price, amount) <- either (Left . CRUnpriced) Right (offerTotal monthPrice chosenOffer) + pure PricedOffer {poBadgeType = badgeType, poMonths = months, poPrice = price, poAmount = amount, poCurrency = currency} + where + known refusal = maybe (Left refusal) Right + findPrice p = find (\BadgePrice {priceId} -> priceId == p) prices + findOffer o = find (\BadgeOffer {offerId} -> offerId == o) offers + soldBadgeType bt + | bt == BTSupporter || bt == BTLegend = Right () + | otherwise = Left (CRUnsoldBadgeType bt) + active _ BISActive = Right () + active _ BISDeprecated = Right () + active refusal BISDisabled = Left refusal + resolveOffer _ Nothing = Right Nothing + resolveOffer p (Just o) = do + chosen@BadgeOffer {priceId = offerPriceId, status = offerStatus} <- known CRUnknownOffer (findOffer o) + active CRDisabledOffer offerStatus + case offerPriceId of + Nothing -> Right (Just chosen) + Just op + | op == p -> Right (Just chosen) + | otherwise -> Left CROfferNotForPrice + +catalogCurrency :: Text +catalogCurrency = "usd" + +-- | Mirrors web/src/catalog.ts; seeded insert-only, so repricing needs a new price id. +defaultCatalog :: UTCTime -> ([BadgePrice], [BadgeOffer]) +defaultCatalog seededAt = (prices, offers) + where + prices = + [ mkPrice "price_supporter" BTSupporter 700, + mkPrice "price_legend" BTLegend 7000 + ] + offers = + [ mkOffer "offer_3m" "price_legend" 3 (ODFreeMonths 1), + mkOffer "offer_12m" "price_legend" 12 (ODDiscount 50), + mkOffer "offer_3m_s" "price_supporter" 3 (ODFreeMonths 1), + mkOffer "offer_12m_s" "price_supporter" 12 (ODDiscount 50) + ] + mkPrice :: Text -> BadgeType -> Word32 -> BadgePrice + mkPrice pId bType mPrice = + BadgePrice + { priceId = BadgePriceId pId, + badgeType = bType, + monthPrice = CurrencyAmount mPrice, + currency = catalogCurrency, + status = BISActive, + createdAt = seededAt + } + mkOffer :: Text -> Text -> Word8 -> OfferDiscount -> BadgeOffer + mkOffer oId pId mMonths mDiscount = + BadgeOffer + { offerId = BadgeOfferId oId, + priceId = Just (BadgePriceId pId), + months = mMonths, + discount = mDiscount, + status = BISActive, + createdAt = seededAt + } 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 new file mode 100644 index 0000000000..9e33f4459d --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Config.hs @@ -0,0 +1,302 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module BadgeService.Config + ( ListenerConfig (..), + BTCPayConfig (..), + StripeConfig (..), + SpeedPolicy (..), + speedPolicyName, + PollConfig (..), + BadgeIssuerKey (..), + GroupConfig (..), + ServiceConfig (..), + defaultExpiryMinutes, + defaultSessionMinutes, + readServiceConfig, + unknownKeys, + ) +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) +import Text.Read (readMaybe) + +data ListenerConfig = ListenerConfig + { lHost :: Text, + lPort :: Int, + lStaticDir :: FilePath, + lServeWebapp :: Bool, + lWebappExportDir :: Maybe FilePath, + lTrustForwardedFor :: Bool + } + deriving (Eq, Show) + +-- | Sent by name, since BTCPay's numbering is not in speed order. +data SpeedPolicy = HighSpeed | MediumSpeed | LowMediumSpeed | LowSpeed + deriving (Bounded, Enum, Eq, Show) + +speedPolicies :: [SpeedPolicy] +speedPolicies = [minBound .. maxBound] + +speedPolicyName :: SpeedPolicy -> Text +speedPolicyName = \case + HighSpeed -> "HighSpeed" + MediumSpeed -> "MediumSpeed" + LowMediumSpeed -> "LowMediumSpeed" + LowSpeed -> "LowSpeed" + +data BTCPayConfig = BTCPayConfig + { bHost :: Text, + bApiKey :: Text, + bStoreId :: Text, + bWebhookSecret :: Text, + bExpiryMinutes :: Int, + bSpeedPolicy :: SpeedPolicy, + bPaymentTolerance :: Double + } + deriving (Eq) + +-- Hand-written to keep the api key and webhook secret out of logs. +instance Show BTCPayConfig where + show BTCPayConfig {bHost, bStoreId} = "btcpay " <> T.unpack bHost <> " store " <> T.unpack bStoreId + +data StripeConfig = StripeConfig + { sSecretKey :: Text, + sPublishableKey :: Text, + sWebhookSecret :: Text, + sSessionMinutes :: Int, + sHost :: Text + } + deriving (Eq) + +-- Hand-written to keep the restricted key and signing secret out of logs. +instance Show StripeConfig where + show StripeConfig {sHost} = "stripe " <> T.unpack sHost + +data PollConfig = PollConfig {pWaitingSeconds :: Int, pIdleSeconds :: Int} + deriving (Eq, Show) + +data BadgeIssuerKey = BadgeIssuerKey + { keyIdx :: Int, + secretKey :: BBSSecretKey + } + deriving (Eq) + +-- BBSSecretKey derives Show, so this is written out to keep the secret out of logs and errors +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, + group :: Maybe GroupConfig + } + deriving (Eq, Show) + +defaultExpiryMinutes :: Int +defaultExpiryMinutes = 60 + +defaultStripeHost :: Text +defaultStripeHost = "https://api.stripe.com" + +defaultSessionMinutes :: Int +defaultSessionMinutes = 60 + +-- | At most one day, so a card order expires long before the poller stops checking it at 72 hours. +minSessionMinutes, maxSessionMinutes :: Int +minSessionMinutes = 1 +maxSessionMinutes = 1440 + +-- | An http host would carry the API key in the clear on every call. +requireHttps :: Text -> Either String Text +requireHttps u + | "https://" `T.isPrefixOf` u = Right u + | otherwise = Left "btcpay.host must be an absolute https URL" + +-- | Well below 100, where BTCPay settles an invoice for one satoshi. +maxTolerance :: Double +maxTolerance = 10 + +readServiceConfig :: FilePath -> IO (Either String ServiceConfig) +readServiceConfig path = + E.try (TIO.readFile path) >>= \case + Left (e :: E.IOException) -> pure (Left (ioeGetErrorString e)) + -- Data.Ini stops at the first unparseable line and keeps what it has, so a missing `=` silently drops every section below. + Right text -> case parseOnly (iniParser <* trailingNoise <* endOfInput) text of + Left _ -> pure (Left "could not be read as an ini file: a line is malformed") + Right ini -> do + mapM_ (\k -> logWarn (T.pack path <> ": nothing reads " <> k <> ", so it was ignored")) (unknownKeys ini) + pure (parseConfig ini) + +-- | iniParser stops before a trailing comment, so endOfInput alone would reject a valid file. +trailingNoise :: Parser () +trailingNoise = skipSpace *> skipMany (comment *> skipSpace) + where + comment = satisfy (\c -> c == ';' || c == '#') *> skipWhile (not . isEndOfLine) + +knownSettings :: [(Text, [Text])] +knownSettings = + [ ("listener", ["host", "port", "static_dir", "serve_webapp", "webapp_export_dir", "trust_forwarded_for"]), + ("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"]), + ("group", ["display_name", "description"]), + ("issuer", ["index", "private_key"]) + ] + +unknownKeys :: Ini -> [Text] +unknownKeys ini = beforeAnySection <> unknownSections <> settings + where + beforeAnySection = [k <> ", written above the first section header" | (k, _) <- iniGlobals ini] + unknownSections = ["[" <> s <> "]" | s <- sections ini, T.strip s `notElem` map fst knownSettings] + settings = + [ section <> "." <> key + | (section, known) <- knownSettings, + key <- either (const []) id (keys section ini), + T.strip key `notElem` known + ] + +parseConfig :: Ini -> Either String ServiceConfig +parseConfig ini = do + lStaticDir <- T.unpack <$> required "listener" "static_dir" + lHost <- optional "listener" "host" "127.0.0.1" + lPort <- 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 = 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 + pure + ServiceConfig + { listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor}, + btcpay = btc, + stripe = str, + poll = PollConfig {pWaitingSeconds, pIdleSeconds}, + issuer = iss, + group = grp + } + where + hasSection s = s `elem` sections ini + look s k = either (const Nothing) Just (lookupValue s k ini) + 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 + Just v -> case readMaybe (T.unpack (T.strip v)) of + Just n | n >= toInteger (minBound :: Int), n <= toInteger (maxBound :: Int) -> Right (fromInteger n) + _ -> Left (T.unpack k <> " must be a whole number") + bool s k d = case fmap (T.toLower . T.strip) (look s k) of + Nothing -> Right d + Just "on" -> Right True + Just "off" -> Right False + Just other -> Left (T.unpack k <> " must be on or off, not " <> T.unpack other) + -- num accepts 0 and negatives, and a zero cadence is a busy loop. + cadence k d = do + v <- num "poll" k d + if v >= 1 then Right v else Left ("poll." <> T.unpack k <> " must be at least 1 second") + issuerSection + | not (hasSection "issuer") = Right Nothing + | otherwise = do + keyIdx <- issuerIndex + secretKey <- issuerSecret + pure (Just BadgeIssuerKey {keyIdx, secretKey}) + issuerIndex = do + v <- required "issuer" "index" + case readMaybe (T.unpack v) of + Just n | n >= 1, n <= toInteger (maxBound :: Int) -> Right (fromInteger n) + _ -> Left "issuer.index must be a positive whole number" + issuerSecret = do + v <- required "issuer" "private_key" + either (const (Left "issuer.private_key is not a valid issuer secret; use the value from `simplex-chat badge keygen`")) Right $ + strDecode (B.pack (T.unpack v)) + btcpaySection + | not (hasSection "btcpay") = Right Nothing + | otherwise = do + bHost <- required "btcpay" "host" >>= requireHttps + bApiKey <- required "btcpay" "api_key" + bStoreId <- required "btcpay" "store_id" + bWebhookSecret <- required "btcpay" "webhook_secret" + bExpiryMinutes <- expiryMinutes + bSpeedPolicy <- speedPolicy + bPaymentTolerance <- tolerance + pure (Just BTCPayConfig {bHost, bApiKey, bStoreId, bWebhookSecret, bExpiryMinutes, bSpeedPolicy, bPaymentTolerance}) + -- A negative window puts BTCPay's startDate in the future, so every poll comes back empty. + expiryMinutes = do + v <- num "btcpay" "expiry_minutes" defaultExpiryMinutes + if v >= 1 then Right v else Left "btcpay.expiry_minutes must be at least 1 minute" + speedPolicy = case look "btcpay" "speed_policy" of + Nothing -> Right MediumSpeed + Just v -> case lookup (T.strip v) [(speedPolicyName p, p) | p <- speedPolicies] of + Just p -> Right p + Nothing -> + Left + ( "btcpay.speed_policy must be one of " + <> T.unpack (T.intercalate ", " (map speedPolicyName speedPolicies)) + <> ", not " + <> T.unpack (T.strip v) + ) + tolerance = case look "btcpay" "payment_tolerance" of + Nothing -> Right 0.5 + 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 + sSecretKey <- required "stripe" "secret_key" + sPublishableKey <- required "stripe" "publishable_key" + sWebhookSecret <- required "stripe" "webhook_secret" + sSessionMinutes <- sessionMinutes + let sHost = defaultStripeHost + pure (Just StripeConfig {sSecretKey, sPublishableKey, sWebhookSecret, sSessionMinutes, sHost}) + sessionMinutes = do + v <- num "stripe" "session_minutes" defaultSessionMinutes + if v >= minSessionMinutes && v <= maxSessionMinutes + then Right v + else Left ("stripe.session_minutes must be between " <> show minSessionMinutes <> " and " <> show maxSessionMinutes <> " minutes") 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/Log.hs b/apps/simplex-badge-service/src/BadgeService/Log.hs new file mode 100644 index 0000000000..a2f54f4420 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Log.hs @@ -0,0 +1,36 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | The SMP agent logs unconditionally at info on the global logger, so the service keeps its own info lines on this separate channel when that logger is raised to Warn. +module BadgeService.Log + ( logInfo, + logWarn, + logError, + ) +where + +import Control.Monad.IO.Class (MonadIO, liftIO) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as T +import Data.Time.Format (defaultTimeLocale, formatTime) +import Data.Time.LocalTime (getZonedTime) +import GHC.Stack (CallStack, HasCallStack, callStack, getCallStack, srcLocFile, srcLocStartLine) +import System.IO (stderr) + +logInfo :: (HasCallStack, MonadIO m) => Text -> m () +logInfo = logAt "INFO" callStack + +logWarn :: (HasCallStack, MonadIO m) => Text -> m () +logWarn = logAt "WARN" callStack + +logError :: (HasCallStack, MonadIO m) => Text -> m () +logError = logAt "ERROR" callStack + +logAt :: MonadIO m => Text -> CallStack -> Text -> m () +logAt tag cs msg = liftIO $ do + ts <- formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S %z" <$> getZonedTime + T.hPutStrLn stderr $ "[" <> tag <> " " <> T.pack ts <> " " <> loc <> "] " <> msg + where + loc = case getCallStack cs of + ((_, l) : _) -> T.pack (srcLocFile l) <> ":" <> T.pack (show (srcLocStartLine l)) + [] -> "unknown" diff --git a/apps/simplex-badge-service/src/BadgeService/Options.hs b/apps/simplex-badge-service/src/BadgeService/Options.hs index a2cd46fd84..1d2423771a 100644 --- a/apps/simplex-badge-service/src/BadgeService/Options.hs +++ b/apps/simplex-badge-service/src/BadgeService/Options.hs @@ -5,19 +5,18 @@ module BadgeService.Options ( BadgeServiceOpts (..), - BadgeIssuerKey (..), getBadgeServiceOpts, badgeServiceOpts, mkChatOpts, ) where +import BadgeService.Config (BadgeIssuerKey (..)) import qualified Data.ByteString.Char8 as B import qualified Data.Text as T import Options.Applicative import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) -import Simplex.Messaging.Crypto.BBS (BBSSecretKey) import Simplex.Messaging.Encoding.String (strDecode) data BadgeServiceOpts = BadgeServiceOpts @@ -26,21 +25,11 @@ data BadgeServiceOpts = BadgeServiceOpts clientService :: Bool, noAddress :: Bool, runCLI :: Bool, - -- the service refuses to start without this: it cannot sign a credential - issuerKey :: Maybe BadgeIssuerKey, + serviceConfigFile :: Maybe FilePath, + issuerKey :: Either String (Maybe BadgeIssuerKey), testing :: Bool } --- | The issuer secret that signs credentials, and the index the apps find its public half under. -data BadgeIssuerKey = BadgeIssuerKey - { keyIdx :: Int, - secretKey :: BBSSecretKey - } - --- BBSSecretKey derives Show, so this is written out to keep the secret out of logs and errors -instance Show BadgeIssuerKey where - show BadgeIssuerKey {keyIdx} = "issuer key " <> show keyIdx - badgeServiceOpts :: FilePath -> FilePath -> Parser BadgeServiceOpts badgeServiceOpts appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName @@ -66,6 +55,14 @@ badgeServiceOpts appDir defaultDbName = do ( long "run-cli" <> help "Run badge service as CLI" ) + serviceConfigFile <- + optional + ( strOption + ( long "service-config" + <> metavar "INI_FILE" + <> help "Path to badge_service.ini: parsed in full in every mode, but --run-cli starts no web listener" + ) + ) issuerKeyIdx <- optional $ option @@ -89,7 +86,11 @@ badgeServiceOpts appDir defaultDbName = do clientService, noAddress, runCLI, - issuerKey = BadgeIssuerKey <$> issuerKeyIdx <*> issuerSecret, + serviceConfigFile, + issuerKey = case (issuerKeyIdx, issuerSecret) of + (Just idx, Just secret) -> Right (Just (BadgeIssuerKey idx secret)) + (Nothing, Nothing) -> Right Nothing + _ -> Left "--issuer-key-idx and --issuer-secret are given together or not at all", testing = False } diff --git a/apps/simplex-badge-service/src/BadgeService/Orders.hs b/apps/simplex-badge-service/src/BadgeService/Orders.hs new file mode 100644 index 0000000000..63359b0880 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Orders.hs @@ -0,0 +1,117 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.Orders (settleOrder, decide, codeLifetime) where + +import BadgeService.Providers (Funded (..), PaymentSignal (..), Received (..)) +import BadgeService.Store.Invoices (InvoicePayment (..), InvoiceRow (..), markCodePaid, paymentStatusText, settlementCodeHash, settlementInvoice, truncateToSecond, updateInvoiceStatus, upsertPayment) +import BadgeService.Waiters (Waiters, publish, publishPayment) +import Control.Concurrent.STM (atomically) +import Control.Monad (when) +import Data.ByteString (ByteString) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), InvoiceId, InvoiceStatus (..), PaymentStatus (..)) +import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB + +-- | Written onto the row at settlement and read back, so changing it moves only codes sold afterwards. +codeLifetime :: NominalDiffTime +codeLifetime = 365 * 24 * 60 * 60 + +data Write = Write + { wStatus :: Maybe InvoiceStatus, + wPayment :: PaymentStatus, + wCode :: Bool + } + +decide :: InvoiceStatus -> PaymentSignal -> Maybe Write +decide ISPaid _ = Nothing +decide _ SigSettled {} = Just Write {wStatus = Just ISPaid, wPayment = PSSettled, wCode = True} +decide _ SigFunded {} = Just Write {wStatus = Nothing, wPayment = PSPending, wCode = False} +decide ISOpen SigClosed {} = Just Write {wStatus = Just ISExpired, wPayment = PSPending, wCode = False} +decide ISExpired SigClosed {} = Just Write {wStatus = Nothing, wPayment = PSPending, wCode = False} + +paidInFull :: PaymentSignal -> Bool +paidInFull = \case + SigFunded _ f -> f == PaidInFull + SigSettled {} -> True + SigClosed {} -> False + +received :: PaymentSignal -> Received +received = \case + SigFunded r _ -> r + SigSettled r _ -> r + SigClosed r -> r + +-- | Uses the provider's settlement instant, not now, so an outage does not push deadlines out; +-- an implausible value (zero, milliseconds, negative) falls back to now. +settledInstant :: PaymentSignal -> UTCTime -> UTCTime +settledInstant signal now = case signal of + SigSettled _ at | at <= now, at >= addUTCTime (negate maxBackdate) now -> at + _ -> now + +-- | Longer than any outage the poller must survive, far short of the code's lifetime. +maxBackdate :: NominalDiffTime +maxBackdate = 30 * 24 * 60 * 60 + +data Published = PubNothing | PubStatus InvoiceStatus | PubPayment + +settleOrder :: DBStore -> Waiters -> InvoiceId -> PaymentSignal -> UTCTime -> IO (Either Text InvoiceStatus) +settleOrder st waiters invId signal now' = do + outcome <- withTransaction st $ \db -> + settlementInvoice db invId >>= \case + Nothing -> pure (Left "no such invoice") + Just row@InvoiceRow {irStatus} -> case decide irStatus signal of + Nothing -> pure (Right (irStatus, PubNothing)) + -- look up the code first, so an invoice with none leaves the transaction empty, not paid with an unpaid code + Just w -> codeToMark db w >>= either (pure . Left) (settle db row w) + case outcome of + Left e -> pure (Left e) + Right (status, toPublish) -> do + -- after the commit, or a woken reader will not see the write + atomically $ case toPublish of + PubStatus s -> publish waiters invId s + PubPayment -> publishPayment waiters invId + PubNothing -> pure () + pure (Right status) + where + now = truncateToSecond now' + at = truncateToSecond (settledInstant signal now) + codeToMark :: DB.Connection -> Write -> IO (Either Text (Maybe ByteString)) + codeToMark db Write {wCode} + | not wCode = pure (Right Nothing) + | otherwise = maybe (Left "settled invoice has no code hash") (Right . Just) <$> settlementCodeHash db invId + newPayment :: InvoiceRow -> Write -> Bool + newPayment InvoiceRow {irPayment} Write {wPayment} + -- Monero reports an invoice as confirming while its figures are still zero, so a funded verdict earns a row + | not (paidInFull signal) && rcvCrypto == Nothing && rcvAmount == CurrencyAmount 0 = False + | otherwise = case irPayment of + Nothing -> True + -- the write is monotonic, so a lower figure or withdrawn verdict is not a new payment + Just InvoicePayment {ipAmount, ipCryptoPaid, ipPaidInFull, ipStatus} + | ipStatus == paymentStatusText PSSettled -> False + | otherwise -> + maybe True (\(CurrencyAmount held) -> held < minor) ipAmount + || (ipCryptoPaid == Nothing && rcvCrypto /= Nothing) + || (paidInFull signal && not ipPaidInFull) + || ipStatus /= paymentStatusText wPayment + where + Received {rcvAmount, rcvCrypto} = received signal + CurrencyAmount minor = rcvAmount + settle :: DB.Connection -> InvoiceRow -> Write -> Maybe ByteString -> IO (Either Text (InvoiceStatus, Published)) + settle db row@InvoiceRow {irStatus} w@Write {wStatus, wPayment} codeHash = do + let Received {rcvAmount, rcvCrypto, rcvDue} = received signal + wrotePayment = newPayment row w + when wrotePayment $ upsertPayment db row wPayment rcvAmount rcvCrypto rcvDue (paidInFull signal) at + moved <- maybe (pure True) (\new -> updateInvoiceStatus db invId irStatus new at) wStatus + if moved + then do + mapM_ (\h -> markCodePaid db h (addUTCTime codeLifetime at)) codeHash + pure (Right (fromMaybe irStatus wStatus, maybe (if wrotePayment then PubPayment else PubNothing) PubStatus wStatus)) + else do + -- another writer moved the row first, so publish what we found + current <- maybe irStatus (\InvoiceRow {irStatus = s} -> s) <$> settlementInvoice db invId + pure (Right (current, PubStatus current)) diff --git a/apps/simplex-badge-service/src/BadgeService/Poller.hs b/apps/simplex-badge-service/src/BadgeService/Poller.hs new file mode 100644 index 0000000000..c22df44ddb --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Poller.hs @@ -0,0 +1,390 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiWayIf #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module BadgeService.Poller + ( PollerEnv (..), + ReadHints, + newReadHints, + queueReadHint, + hintQueueSize, + newPollerEnv, + runOnePass, + drainHints, + passDue, + runPoller, + passDelay, + SkipOwner (..), + skipOwner, + dueToWarn, + expiryGrace, + cancelGiveUpDelay, + readsPerPass, + skipWarnInterval, + maxSkipReasons, + ) +where + +import BadgeService.Config (PollConfig (..)) +import BadgeService.Orders (decide, settleOrder) +import BadgeService.Providers (ListPass (..), PaymentSignal (..), Provider (..), ProviderError (..), Received (..), expiresItself, settleWindow) +import BadgeService.Store.Invoices (InvoiceRow (..), OverdueInvoice (..), expireOverdue, getInvoiceByProviderRef, overdueInvoices, providerText, unpaidRefs) +import BadgeService.Waiters (Waiters, publish, waitingCount, waitingCountSTM) +import Control.Concurrent.STM +import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try) +import BadgeService.Log (logError, logInfo, logWarn) +import Control.Monad (filterM, forever, unless, void, when) +import Data.List (find, partition, sortOn) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe, isJust) +import Data.Set (Set) +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.Messaging.Agent.Store.Common (DBStore) +import Simplex.Messaging.Util (tshow) + +-- | Allows for our clock running ahead of the provider's; an expired invoice can still be marked paid. +expiryGrace :: NominalDiffTime +expiryGrace = 600 + +-- | Give up on a failing cancel only after an hour, not on the first failure. +cancelGiveUpDelay :: NominalDiffTime +cancelGiveUpDelay = 3600 + +skipWarnInterval :: NominalDiffTime +skipWarnInterval = 3600 + +maxSkipReasons :: Int +maxSkipReasons = 4096 + +-- | Webhooks queue reads here, so all settling happens on the poller thread. +-- An order already waiting in the queue is not added again, so repeated webhooks don't pile up reads. +data ReadHints = ReadHints (TBQueue Text) (TVar (Set Text)) + +hintQueueSize :: Natural +hintQueueSize = 256 + +newReadHints :: IO ReadHints +newReadHints = ReadHints <$> newTBQueueIO hintQueueSize <*> newTVarIO S.empty + +queueReadHint :: ReadHints -> Text -> IO Bool +queueReadHint (ReadHints q queued) ref = atomically $ do + already <- S.member ref <$> readTVar queued + full <- isFullTBQueue q + if + | already -> pure True + | full -> pure False + | otherwise -> do + writeTBQueue q ref + modifyTVar' queued (S.insert ref) + pure True + +minCadenceSeconds :: Int +minCadenceSeconds = 1 + +data PollerEnv = PollerEnv + { peStore :: DBStore, + peWaiters :: Waiters, + peHints :: ReadHints, + peProviders :: [Provider], + pePoll :: PollConfig, + peSkipped :: TVar (Map Text UTCTime), + peListedAt :: TVar (Maybe UTCTime), + peStrayEvery :: NominalDiffTime, + -- When the cancel first failed, for orders older than 72 hours. + peCancelFailed :: TVar (Map Text UTCTime) + } + +newPollerEnv :: DBStore -> Waiters -> ReadHints -> [Provider] -> PollConfig -> IO PollerEnv +newPollerEnv peStore peWaiters peHints peProviders pePoll = do + peSkipped <- newTVarIO M.empty + peListedAt <- newTVarIO Nothing + peCancelFailed <- newTVarIO M.empty + pure PollerEnv {peStore, peWaiters, peHints, peProviders, pePoll, peSkipped, peListedAt, peStrayEvery = strayListInterval, peCancelFailed} + +-- | Read providers before the sweep, and skip the sweep entirely when a read failed, so an +-- invoice with money in it is never expired. +runOnePass :: PollerEnv -> IO () +runOnePass env@PollerEnv {peStore, peProviders} = do + now <- getCurrentTime + rows <- unpaidRefs peStore (addUTCTime (negate settleWindow) now) + let bulk = length rows > readsPerPass + listNow <- listDue env now bulk + accounted <- + if bulk + then do + covered <- and <$> mapM (rowIsCovered env now) rows + (covered &&) . and <$> mapM (listPass env now) peProviders + else do + read' <- readRows env now rows + when listNow $ mapM_ (listPass env now) peProviders + pure read' + pruneSkipLog env now + when (accounted && not (null peProviders)) $ sweepExpired env now + +-- | The cases the stray list catches are rare and not urgent, so minutes, not the pass cadence. +strayListInterval :: NominalDiffTime +strayListInterval = 60 + +listDue :: PollerEnv -> UTCTime -> Bool -> IO Bool +listDue PollerEnv {peListedAt, peStrayEvery} now forced = atomically $ do + last' <- readTVar peListedAt + let due = forced || maybe True (\at -> diffUTCTime now at >= peStrayEvery) last' + when due $ writeTVar peListedAt (Just now) + pure due + +-- | Past this many open invoices one list is fewer bytes and requests than reading each. +readsPerPass :: Int +readsPerPass = 25 + +coveringProvider :: PollerEnv -> UTCTime -> (Text, Text) -> IO (Maybe Provider) +coveringProvider env@PollerEnv {peProviders} now (provider, ref) = + case find ((== provider) . providerText . pProvider) peProviders of + Just p -> pure (Just p) + Nothing -> do + due <- dueToWarn env now ("no provider for " <> provider) + when due $ logError ("badge poller: invoice " <> ref <> " names provider " <> provider <> ", which this build has none of") + pure Nothing + +rowIsCovered :: PollerEnv -> UTCTime -> (Text, Text) -> IO Bool +rowIsCovered env now row = isJust <$> coveringProvider env now row + +readRows :: PollerEnv -> UTCTime -> [(Text, Text)] -> IO Bool +readRows env now rows = + and <$> mapM (\r -> safelyWith (readWhat r) False (readRow r)) rows + where + readWhat (provider, ref) = "reading " <> provider <> " invoice " <> ref + readRow row@(_, ref) = coveringProvider env now row >>= maybe (pure False) (`readOne` ref) + readOne p ref = + pReadInvoice p ref >>= \case + Left (ProviderError e) -> do + due <- dueToWarn env now ("read failed: " <> tshow (pProvider p)) + when due $ logWarn ("badge poller: " <> tshow (pProvider p) <> " reads are failing; every invoice waits for the next pass: " <> e) + pure False + Right Nothing -> pure True + Right (Just signal) -> True <$ settleMoved env (pProvider p) now (ref, signal) + +-- | False when the pass cannot account for every invoice sold, so the sweep does not expire one +-- over money it missed. +listPass :: PollerEnv -> UTCTime -> Provider -> IO Bool +listPass env now p = + pListOpen p >>= \case + Left (ProviderError e) -> do + due <- dueToWarn env now ("list failed: " <> tshow (pProvider p)) + when due $ logWarn ("badge poller: " <> tshow (pProvider p) <> " list failed; every invoice waits for the next pass: " <> e) + 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 + settleWhat (ref, _) = "settling " <> tshow (pProvider p) <> " invoice " <> ref + skipWhat (ref, _) = "reading the skipped " <> tshow (pProvider p) <> " invoice " <> fromMaybe "the provider did not name" ref + +-- | provider_ref is unique table-wide, not per provider, so check the provider too. +settleMoved :: PollerEnv -> PaymentProvider -> UTCTime -> (Text, PaymentSignal) -> IO () +settleMoved env@PollerEnv {peStore, peWaiters} provider now (ref, signal) = + getInvoiceByProviderRef peStore ref >>= \case + Just InvoiceRow {irInvoiceId, irStatus, irProvider} | irProvider == provider -> + when (isJust (decide irStatus signal)) $ + settleOrder peStore peWaiters irInvoiceId signal now >>= \case + Left e -> logError ("badge poller: settling order " <> ref <> " failed: " <> e) + Right status -> reportSettled irStatus status + _ -> pure () + where + reportSettled before after + -- checked before the no-change case, so a refund still alerts when money lands after expiry + | after == ISExpired, SigClosed Received {rcvCrypto = Just paid} <- signal = do + let alert = "badge poller: order " <> ref <> " expired holding " <> paid <> ", which needs a refund" + due <- dueToWarn env now alert + when due $ logError alert + | before == after = pure () + | otherwise = logInfo ("badge poller: order " <> ref <> " " <> tshow before <> " -> " <> tshow after) + +serveHint :: PollerEnv -> Text -> IO () +serveHint env ref = do + now <- getCurrentTime + hintSafely env now ref + +readHint :: PollerEnv -> UTCTime -> Text -> IO () +readHint env@PollerEnv {peStore, peProviders} now ref = + getInvoiceByProviderRef peStore ref >>= \case + Nothing -> pure () + Just InvoiceRow {irProvider} -> case find ((== irProvider) . pProvider) peProviders of + Nothing -> pure () + Just p -> + pReadInvoice p ref >>= \case + Left (ProviderError e) -> + logWarn ("badge poller: the hinted read of " <> ref <> " failed; the next pass will run: " <> e) + Right Nothing -> pure () + Right (Just signal) -> settleMoved env irProvider now (ref, signal) + +sweepExpired :: PollerEnv -> UTCTime -> IO () +sweepExpired env@PollerEnv {peStore, peWaiters, peCancelFailed} now = do + let cutoff = addUTCTime (negate expiryGrace) now + (selfExpiring, payable) <- partition (expiresItself . oiProvider) <$> overdueInvoices peStore cutoff + atomically $ modifyTVar' peCancelFailed (`M.restrictKeys` S.fromList (map oiProviderRef payable)) + -- Cancel only a few orders per pass, so a long list does not block other work. + cancelled <- filterM (closedAtProvider env now) (take readsPerPass payable) + expired <- expireOverdue peStore cutoff (map oiInvoiceId (selfExpiring <> cancelled)) + -- publish only after the write commits, or a woken reader will not see it + unless (null expired) $ do + atomically $ mapM_ (\invId -> publish peWaiters invId ISExpired) expired + logInfo ("badge poller: expired " <> tshow (length expired) <> " invoice(s) past their window") + +-- | Cancel the order at the provider first, so the buyer can no longer pay it. +closedAtProvider :: PollerEnv -> UTCTime -> OverdueInvoice -> IO Bool +closedAtProvider env@PollerEnv {peProviders} now oi@OverdueInvoice {oiProvider, oiProviderRef = ref} = + safelyWith ("cancelling " <> tshow oiProvider <> " invoice " <> ref) False $ + case find ((== oiProvider) . pProvider) peProviders of + Nothing -> uncancelled env now oi "no provider is configured for it" + Just p -> + pCancelInvoice p ref >>= \case + Right () -> pure True + -- The provider won't cancel an order that is already paid or closed. Reading it shows which. + Left (ProviderError cancelError) -> + pReadInvoice p ref >>= \case + Right (Just signal) -> False <$ settleMoved env oiProvider now (ref, signal) + Right Nothing -> uncancelled env now oi cancelError + Left (ProviderError readError) -> uncancelled env now oi (cancelError <> "; reading it failed too: " <> readError) + +-- | After 72 hours the poller only reads an order when a webhook asks. If an older order still +-- can't be cancelled after an hour of trying, mark it expired here and log an error. +uncancelled :: PollerEnv -> UTCTime -> OverdueInvoice -> Text -> IO Bool +uncancelled env@PollerEnv {peCancelFailed} now OverdueInvoice {oiProvider, oiProviderRef = ref, oiCreatedAt} e + | diffUTCTime now oiCreatedAt <= settleWindow = keepOpen "it stays open until a cancel succeeds or it settles" + | otherwise = do + firstFailed <- atomically $ stateTVar peCancelFailed $ \failed -> (M.lookup ref failed, M.insertWith (\_ first -> first) ref now failed) + case firstFailed of + Just at | diffUTCTime now at >= cancelGiveUpDelay -> do + logError ("badge poller: overdue " <> tshow oiProvider <> " invoice " <> ref <> " could not be cancelled at the provider, so it was expired here only; check it in the provider's dashboard: " <> e) + pure True + _ -> keepOpen "it is expired here if the cancel still fails an hour after its first failure" + where + keepOpen outcome = do + due <- dueToWarn env now ("cancel failed: " <> tshow oiProvider <> " invoice " <> ref) + when due $ logWarn ("badge poller: cancelling overdue " <> tshow oiProvider <> " invoice " <> ref <> " failed; " <> outcome <> ": " <> e) + pure False + +data SkipOwner + = SkipOurs + | SkipStranger + | -- | The provider named no invoice, so this skip could be any of ours. + SkipUnaccounted + deriving (Eq, Show) + +skipOwner :: PollerEnv -> PaymentProvider -> Maybe Text -> IO SkipOwner +skipOwner PollerEnv {peStore} provider = \case + Nothing -> pure SkipUnaccounted + Just ref -> + getInvoiceByProviderRef peStore ref >>= \case + Just InvoiceRow {irProvider} | irProvider == provider -> pure SkipOurs + _ -> pure SkipStranger + +reportSkip :: PollerEnv -> PaymentProvider -> UTCTime -> (Maybe Text, Text) -> IO SkipOwner +reportSkip env provider now (ref, reason) = do + owner <- skipOwner env provider ref + 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 + let due = case M.lookup reason seen of + Nothing -> True + Just at -> diffUTCTime now at >= skipWarnInterval + when due $ writeTVar peSkipped (M.insert reason now seen) + pure due + +pruneSkipLog :: PollerEnv -> UTCTime -> IO () +pruneSkipLog PollerEnv {peSkipped} now = atomically $ modifyTVar' peSkipped prune + where + prune seen + | M.size seen <= maxSkipReasons = seen + | M.size fresh <= maxSkipReasons = fresh + | otherwise = M.fromList (drop (M.size fresh - maxSkipReasons) (sortOn snd (M.toList fresh))) + where + fresh = M.filter (\at -> diffUTCTime now at < skipWarnInterval) seen + +passDelay :: PollConfig -> Int -> Int +passDelay PollConfig {pWaitingSeconds, pIdleSeconds} waiting = + 1000000 * max minCadenceSeconds (if waiting > 0 then pWaitingSeconds else pIdleSeconds) + +waitingDelay :: PollConfig -> Int +waitingDelay cfg = passDelay cfg 1 + +-- | Both timers start now, not when a browser arrives, so browsers cannot make us poll faster than the short cadence. +passDue :: PollerEnv -> IO (STM ()) +passDue PollerEnv {peWaiters, pePoll} = do + waiting <- waitingCount peWaiters + soonest <- registerDelay (waitingDelay pePoll) + full <- registerDelay (passDelay pePoll waiting) + pure $ + (readTVar full >>= check) + `orElse` ( do + readTVar soonest >>= check + waitingCountSTM peWaiters >>= \n -> check (n > 0) + ) + +runPoller :: PollerEnv -> IO () +runPoller env = forever $ do + passSafely env + passDue env >>= serveHints env + +-- | The deadline is checked per hint, not per batch, so a redelivery backlog cannot block the pass for the sum of their timeouts. +serveHints :: PollerEnv -> STM () -> IO () +serveHints env@PollerEnv {peHints = ReadHints q queued} due = do + next <- atomically ((Nothing <$ due) `orElse` (Just <$> takeHint)) + case next of + Nothing -> pure () + Just ref -> serveHint env ref >> serveHints env due + where + takeHint = do + ref <- readTBQueue q + modifyTVar' queued (S.delete ref) + pure ref + +drainHints :: PollerEnv -> IO () +drainHints env@PollerEnv {peHints = ReadHints q _} = serveHints env (isEmptyTBQueue q >>= check) + +passSafely :: PollerEnv -> IO () +passSafely env = void $ safely "the pass" (runOnePass env) + +hintSafely :: PollerEnv -> UTCTime -> Text -> IO () +hintSafely env now ref = void $ safely ("the hinted read of " <> ref) (readHint env now ref) + +safely :: Text -> IO () -> IO Bool +safely what action = safelyWith what False (True <$ action) + +-- | Asynchronous exceptions are rethrown, since that is how the race stops this thread. +safelyWith :: Text -> a -> IO a -> IO a +safelyWith what fallback action = + try action >>= \case + Right a -> pure a + Left (e :: SomeException) -> case fromException e :: Maybe SomeAsyncException of + Just _ -> throwIO e + Nothing -> fallback <$ logError ("badge poller: " <> what <> " failed; the next pass will run: " <> tshow e) diff --git a/apps/simplex-badge-service/src/BadgeService/Providers.hs b/apps/simplex-badge-service/src/BadgeService/Providers.hs new file mode 100644 index 0000000000..8faabd2a2c --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Providers.hs @@ -0,0 +1,81 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} + +module BadgeService.Providers + ( ProviderError (..), + WebhookError (..), + Received (..), + PaymentSignal (..), + ProviderInvoice (..), + Funded (..), + OrderDraft (..), + ListPass (..), + settleWindow, + expiresItself, + Provider (..), + ) +where + +import Data.ByteString (ByteString) +import Data.Text (Text) +import Data.Time.Clock (NominalDiffTime, UTCTime) +import Network.HTTP.Types.Header (Header) +import Simplex.Chat.PaymentService.Types (CurrencyAmount, PaymentProvider (..), ServicePaymentDestination, ServicePaymentMethod) + +newtype ProviderError = ProviderError Text deriving (Eq, Show) + +-- | WebhookStale is a valid signature with a timestamp too far from our clock. +data WebhookError = WebhookError Text | WebhookStale Text deriving (Eq, Show) + +-- rcvAmount is the total received on the invoice so far, not the amount of one payment. +-- rcvDue is the provider's figure for what is still owed. +data Received = Received {rcvAmount :: CurrencyAmount, rcvCrypto :: Maybe Text, rcvDue :: Maybe Text} + deriving (Eq, Show) + +-- The provider applies its own tolerance, so this cannot be recomputed from the amounts. +data Funded = PaidInFull | PaidInPart + deriving (Eq, Show) + +data PaymentSignal + = SigFunded Received Funded + | SigSettled Received UTCTime + | SigClosed Received + deriving (Eq, Show) + +data ProviderInvoice = ProviderInvoice + { piProviderRef :: Text, + piDestination :: ServicePaymentDestination + } + deriving (Eq, Show) + +-- | BTCPay invoices expire by themselves. Stripe payments stay open until we cancel them. +expiresItself :: PaymentProvider -> Bool +expiresItself = \case + PPCrypto -> True + _ -> False + +-- A payment can land after the buyer's checkout window closes, so the poller keeps asking about an invoice for this long after it was created. +settleWindow :: NominalDiffTime +settleWindow = 72 * 3600 + +data OrderDraft = OrderDraft + { odAmount :: CurrencyAmount, + odCurrency :: Text + } + deriving (Eq, Show) + +data ListPass = ListPass + { lpMoved :: [(Text, PaymentSignal)], + lpSkipped :: [(Maybe Text, Text)] + } + deriving (Eq, Show) + +data Provider = Provider + { pProvider :: PaymentProvider, + pCreateInvoice :: ServicePaymentMethod -> OrderDraft -> IO (Either ProviderError ProviderInvoice), + pReadInvoice :: Text -> IO (Either ProviderError (Maybe PaymentSignal)), + -- Stops the provider from accepting payment for this order. + pCancelInvoice :: Text -> IO (Either ProviderError ()), + pListOpen :: IO (Either ProviderError ListPass), + pVerifyWebhook :: UTCTime -> [Header] -> ByteString -> Either WebhookError (Maybe Text) + } diff --git a/apps/simplex-badge-service/src/BadgeService/Providers/BTCPay.hs b/apps/simplex-badge-service/src/BadgeService/Providers/BTCPay.hs new file mode 100644 index 0000000000..2df54789a3 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Providers/BTCPay.hs @@ -0,0 +1,519 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module BadgeService.Providers.BTCPay + ( btcpayProvider, + btcMethodId, + xmrMethodId, + minorToDecimal, + paymentMethodsSignal, + listSignals, + verifyBTCPaySig, + listPageSize, + maxListPages, + ) +where + +import BadgeService.Config (BTCPayConfig (..), speedPolicyName) +import BadgeService.Providers + ( ListPass (..), + OrderDraft (..), + PaymentSignal (..), + Provider (..), + ProviderError (..), + ProviderInvoice (..), + Funded (..), + Received (..), + settleWindow, + WebhookError (..), + ) +import Control.Exception (try) +import BadgeService.Log (logError, logInfo, logWarn) +import Control.Monad (unless) +import Crypto.Hash (Digest, SHA256) +import Crypto.MAC.HMAC (HMAC, hmac, hmacGetDigest) +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM +import qualified Data.Aeson.Types as JT +import Data.ByteArray (constEq) +import Data.ByteArray.Encoding (Base (Base16), convertFromBase) +import qualified Data.ByteString as B +import qualified Data.ByteString.Char8 as B8 +import qualified Data.ByteString.Lazy as LB +import Data.Char (toLower) +import Data.Int (Int64) +import Data.List (find) +import Data.Maybe (fromMaybe, mapMaybe) +import Data.Scientific (FPFormat (Fixed), Scientific, base10Exponent, formatScientific) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds) +import Data.Word (Word32) +import Network.HTTP.Client + ( HttpException, + Manager, + Request (..), + RequestBody (..), + Response (..), + brReadSome, + parseRequest, + withResponse, + ) +import Network.HTTP.Client.TLS (newTlsManager) +import Network.HTTP.Types (Header, HeaderName, Method, Query, Status (..), methodGet, methodPost, renderQuery, urlEncode) +import Simplex.Chat.PaymentService.Types + ( CryptoCurrency (..), + CurrencyAmount (..), + PaymentProvider (..), + ServicePaymentDestination (..), + ServicePaymentMethod (..), + ) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow) + +btcMethodId, xmrMethodId :: Text +btcMethodId = "BTC-CHAIN" +xmrMethodId = "XMR-CHAIN" + +knownMethodIds :: [Text] +knownMethodIds = [btcMethodId, xmrMethodId] + +actedOnEventTypes :: [Text] +actedOnEventTypes = ["InvoiceProcessing", "InvoiceSettled", "InvoiceExpired", "InvoiceInvalid"] + +maxErrorBytes :: Int64 +maxErrorBytes = 4000 + +-- Far above any BTCPay response, far below what would exhaust the poller thread. +maxProviderBytes :: Int64 +maxProviderBytes = 10 * 1024 * 1024 + +listPageSize :: Int +listPageSize = 100 + +-- Caps a server that ignores @take@, so the poller still reaches its expiry sweep. +maxListPages :: Int +maxListPages = 50 + +pageCapReason :: Text +pageCapReason = + "btcpay: the list stopped at " + <> tshow maxListPages + <> " pages, so any open invoice past the first " + <> tshow (maxListPages * listPageSize) + <> " was not read — and will not be read by a later pass either" + +minorToDecimal :: CurrencyAmount -> Text +minorToDecimal (CurrencyAmount a) = + T.pack (show (a `div` 100)) <> "." <> T.justifyRight 2 '0' (T.pack (show (a `mod` 100))) + +cryptoTarget :: ServicePaymentMethod -> Either ProviderError (Text, CryptoCurrency) +cryptoTarget = \case + SPMCrypto CCBtc -> Right (btcMethodId, CCBtc) + SPMCrypto CCXmr -> Right (xmrMethodId, CCXmr) + SPMCard _ -> Left (ProviderError "btcpay offers no card payment method") + +-- BTCPay sends numbers as JSON strings; keep the exact text as sent, plus a decimal to compute with. +data WireNum = WireNum {wnText :: Text, wnValue :: Scientific} + deriving (Eq, Show) + +instance J.FromJSON WireNum where + parseJSON = \case + J.String t -> case J.decodeStrict (TE.encodeUtf8 t) of + Just v | inRange v -> pure WireNum {wnText = t, wnValue = v} + Just v -> outOfRange v + Nothing -> fail ("not a decimal number: " <> show t) + J.Number v + | inRange v -> pure WireNum {wnText = T.pack (formatScientific Fixed (Just (wireDecimals v)) v), wnValue = v} + | otherwise -> outOfRange v + v -> JT.typeMismatch "numeric string" v + where + -- Check the exponent before formatting: 1e1000000000 parses but then demands a billion digits. + inRange v = base10Exponent v >= minExponent && base10Exponent v <= maxExponent + outOfRange v = fail ("decimal exponent out of range: " <> show (base10Exponent v)) + +-- Bounds the magnitude only, so no number demands more digits than a machine can hold. +minExponent, maxExponent :: Int +minExponent = -64 +maxExponent = 64 + +wireDecimals :: Scientific -> Int +wireDecimals = max 0 . negate . base10Exponent + +data GInvoice = GInvoice + { giId :: Text, + giStatus :: Text, + giAdditionalStatus :: Maybe Text, + giPaymentMethods :: Maybe [GPaymentMethod] + } + deriving (Show) + +instance J.FromJSON GInvoice where + parseJSON = J.withObject "invoice" $ \o -> + GInvoice + <$> o J..: "id" + <*> o J..: "status" + <*> o J..:? "additionalStatus" + <*> o J..:? "paymentMethods" + +-- totalPaid is not decoded because it counts every method's payment in this currency, so it is non-zero +-- even when this method got nothing. +data GPaymentMethod = GPaymentMethod + { gpmId :: Text, + gpmDestination :: Maybe Text, + gpmAmount :: Maybe WireNum, + gpmRate :: Maybe WireNum, + gpmPaid :: Maybe WireNum, + gpmDue :: Maybe WireNum, + gpmPayments :: [GPayment] + } + deriving (Show) + +instance J.FromJSON GPaymentMethod where + parseJSON = J.withObject "payment method" $ \o -> + GPaymentMethod + <$> o J..: "paymentMethodId" + <*> o J..:? "destination" + <*> o J..:? "amount" + <*> o J..:? "rate" + <*> o J..:? "paymentMethodPaid" + <*> o J..:? "due" + <*> (fromMaybe [] <$> o J..:? "payments") + +data GPayment = GPayment + { gpStatus :: Text, + gpReceivedDate :: WireNum + } + deriving (Show) + +instance J.FromJSON GPayment where + parseJSON = J.withObject "payment" $ \o -> + GPayment <$> o J..: "status" <*> o J..: "receivedDate" + +newtype GCreated = GCreated {gcId :: Text} + deriving (Show) + +instance J.FromJSON GCreated where + parseJSON = J.withObject "created invoice" $ \o -> GCreated <$> o J..: "id" + +newtype GStoreMethod = GStoreMethod {gsmId :: Text} + deriving (Show) + +instance J.FromJSON GStoreMethod where + parseJSON = J.withObject "store payment method" $ \o -> GStoreMethod <$> o J..: "paymentMethodId" + +data GEvent = GEvent {geType :: Text, geInvoiceId :: Text} + deriving (Show) + +instance J.FromJSON GEvent where + parseJSON = J.withObject "webhook event" $ \o -> GEvent <$> o J..: "type" <*> o J..: "invoiceId" + +data BTCPayEnv = BTCPayEnv {beCfg :: BTCPayConfig, beManager :: Manager} + +btcpayProvider :: BTCPayConfig -> IO Provider +btcpayProvider cfg = do + beManager <- newTlsManager + let env = BTCPayEnv {beCfg = cfg, beManager} + logStoreMethods env + pure + Provider + { pProvider = PPCrypto, + pCreateInvoice = createInvoice env, + pReadInvoice = readInvoice env, + pCancelInvoice = cancelInvoice env, + pListOpen = listOpen env, + pVerifyWebhook = const (verifyBTCPaySig (bWebhookSecret cfg)) + } + +-- enabledOnly returns only enabled methods, so the boot log shows what the store can actually use. +logStoreMethods :: BTCPayEnv -> IO () +logStoreMethods env@BTCPayEnv {beCfg} = do + r <- greenfield env what methodGet ["payment-methods"] [("enabledOnly", Just "true")] Nothing + case r >>= decodeGreenfield what of + Left (ProviderError e) -> do + logWarn ("btcpay: could not read the payment methods of store " <> bStoreId beCfg <> ": " <> e) + logInfo ("btcpay: this build offers " <> T.intercalate ", " knownMethodIds) + Right ms -> do + let enabled = map gsmId ms + missing = filter (`notElem` enabled) knownMethodIds + logInfo ("btcpay: store " <> bStoreId beCfg <> " has enabled " <> T.intercalate ", " enabled) + unless (null missing) $ + logError $ + "btcpay: this build offers " + <> T.intercalate ", " missing + <> ", which the store has not enabled; every checkout on those will be refused" + where + what = "read store payment methods" + +createInvoice :: BTCPayEnv -> ServicePaymentMethod -> OrderDraft -> IO (Either ProviderError ProviderInvoice) +createInvoice env@BTCPayEnv {beCfg} spm OrderDraft {odAmount, odCurrency} = + case cryptoTarget spm of + Left e -> pure (Left e) + Right (methodId, cc) -> do + created <- greenfield env what methodPost ["invoices"] [] (Just (body methodId)) + case created >>= decodeGreenfield what of + Left e@(ProviderError m) -> do + logWarn ("btcpay: the refused request was " <> safeDecodeUtf8 (LB.toStrict (J.encode (body methodId))) <> " -- " <> m) + pure (Left e) + Right GCreated {gcId} -> do + ms <- greenfield env what methodGet ["invoices", gcId, "payment-methods"] [] Nothing + pure (ms >>= destination methodId cc gcId) + where + what = "create invoice" + body methodId = + J.object + [ "amount" J..= minorToDecimal odAmount, + "currency" J..= T.toUpper odCurrency, + "checkout" + J..= J.object + [ -- the listener derives the row's expires_at from this same key + "expirationMinutes" J..= bExpiryMinutes beCfg, + "speedPolicy" J..= speedPolicyName (bSpeedPolicy beCfg), + "paymentTolerance" J..= bPaymentTolerance beCfg, + "paymentMethods" J..= [methodId] + ] + ] + destination methodId cc ref ms = do + m <- findMethod what ref methodId ms + addr <- required "destination" (gpmDestination m) + payable <- required "amount" (gpmAmount m) + pure + ProviderInvoice + { piProviderRef = ref, + piDestination = SPDCrypto cc addr (wnText payable) + } + where + required field = + maybe (Left (ProviderError (what <> ": " <> methodId <> " on invoice " <> ref <> " has no " <> field))) Right + +-- | Needs the API key's canmodifyinvoices permission, which the read and create calls do not. +cancelInvoice :: BTCPayEnv -> Text -> IO (Either ProviderError ()) +cancelInvoice env ref = + fmap (fmap (const ())) $ greenfield env what methodPost ["invoices", ref, "status"] [] (Just body) + where + what = "cancel invoice" + -- Invalid, not Archive: archiving only hides the invoice, and BTCPay keeps crediting + -- payments to it + body = J.object ["status" J..= ("Invalid" :: Text)] + +readInvoice :: BTCPayEnv -> Text -> IO (Either ProviderError (Maybe PaymentSignal)) +readInvoice env ref = do + inv <- greenfield env what methodGet ["invoices", ref] [] Nothing + case inv >>= decodeGreenfield what of + Left e -> pure (Left e) + Right GInvoice {giStatus, giAdditionalStatus} -> do + logAdditionalStatus ref giAdditionalStatus + ms <- greenfield env what methodGet ["invoices", ref, "payment-methods"] [] Nothing + now <- getCurrentTime + pure (ms >>= paymentMethodsSignal now ref giStatus) + where + what = "read invoice" + +listOpen :: BTCPayEnv -> IO (Either ProviderError ListPass) +listOpen env@BTCPayEnv {beCfg} = do + now <- getCurrentTime + let oldest = addUTCTime (negate (settleWindow + fromIntegral (60 * bExpiryMinutes beCfg))) now + fetch now (B8.pack (show (unixSeconds oldest))) 0 maxListPages (ListPass {lpMoved = [], lpSkipped = []}) + where + unixSeconds :: UTCTime -> Integer + unixSeconds = floor . utcTimeToPOSIXSeconds + query startDate skip = + [ ("includePaymentMethods", Just "true"), + ("startDate", Just startDate), + ("take", Just (B8.pack (show listPageSize))), + ("skip", Just (B8.pack (show skip))) + ] + fetch :: UTCTime -> B.ByteString -> Int -> Int -> ListPass -> IO (Either ProviderError ListPass) + fetch now startDate skip pagesLeft acc + | pagesLeft <= 0 = pure (Right acc {lpSkipped = lpSkipped acc <> [(Nothing, pageCapReason)]}) + | otherwise = do + listed <- greenfield env listWhat methodGet ["invoices"] (query startDate skip) Nothing + case listed >>= decodeGreenfield listWhat of + Left e -> pure (Left e) + Right invs -> case invoicesPass now invs of + Left e -> pure (Left e) + Right pass + | length invs < listPageSize -> pure (Right (merge acc pass)) + | otherwise -> fetch now startDate (skip + listPageSize) (pagesLeft - 1) (merge acc pass) + merge a b = ListPass {lpMoved = lpMoved a <> lpMoved b, lpSkipped = lpSkipped a <> lpSkipped b} + +listWhat :: Text +listWhat = "list invoices" + +listSignals :: UTCTime -> LB.ByteString -> Either ProviderError ListPass +listSignals now body = decodeGreenfield listWhat body >>= invoicesPass now + +-- When paymentMethods is absent or null the pass fails, because that means includePaymentMethods was ignored, and a +-- skip would report a healthy empty pass. Every other unreadable invoice is skipped, not failed. +invoicesPass :: UTCTime -> [J.Value] -> Either ProviderError ListPass +invoicesPass now invs = foldr add (Right ListPass {lpMoved = [], lpSkipped = []}) invs + where + add _ (Left e) = Left e + add v (Right pass) = case J.fromJSON v of + J.Error e -> Right pass {lpSkipped = (invoiceIdOf v, T.pack e) : lpSkipped pass} + J.Success gi -> addInvoice gi pass + addInvoice GInvoice {giId, giStatus, giPaymentMethods} pass = case giPaymentMethods of + Nothing -> + Left . ProviderError $ + listWhat <> ": invoice " <> giId <> " carries no paymentMethods, so includePaymentMethods was not honoured" + Just ms -> case invoiceSignal now giId giStatus ms of + Left (ProviderError e) -> Right pass {lpSkipped = (Just giId, e) : lpSkipped pass} + Right Nothing -> Right pass + Right (Just sig) -> Right pass {lpMoved = (giId, sig) : lpMoved pass} + +invoiceIdOf :: J.Value -> Maybe Text +invoiceIdOf v = case J.fromJSON v :: J.Result (KM.KeyMap J.Value) of + J.Success o -> case KM.lookup "id" o of + Just (J.String i) -> Just i + _ -> Nothing + J.Error _ -> Nothing + +logAdditionalStatus :: Text -> Maybe Text -> IO () +logAdditionalStatus ref = \case + Just s | s /= "None" -> logInfo ("btcpay: invoice " <> ref <> " additionalStatus " <> s) + _ -> pure () + +paymentMethodsSignal :: UTCTime -> Text -> Text -> LB.ByteString -> Either ProviderError (Maybe PaymentSignal) +paymentMethodsSignal now ref status body = + decodeGreenfield "read invoice" body >>= invoiceSignal now ref status + +-- Complete is the old API's status name and never matches, so it has no case here. +invoiceSignal :: UTCTime -> Text -> Text -> [GPaymentMethod] -> Either ProviderError (Maybe PaymentSignal) +invoiceSignal now ref status ms = do + m@GPaymentMethod {gpmRate, gpmPaid, gpmDue} <- chooseMethod ref ms + rate <- priced "rate" gpmRate + paid <- priced "paymentMethodPaid" gpmPaid + let received = receivedOf rate paid gpmDue + case status of + "Settled" -> Right (Just (SigSettled received (fromMaybe now (latestSettledAt m)))) + "Processing" -> Right (Just (SigFunded received PaidInFull)) + "Expired" -> Right (Just (SigClosed received)) + "Invalid" -> Right (Just (SigClosed received)) + "New" -> Right (SigFunded received PaidInPart <$ rcvCrypto received) + other -> Left (ProviderError ("btcpay invoice " <> ref <> ": unknown status " <> other)) + where + priced field = + maybe (Left (ProviderError ("btcpay invoice " <> ref <> ": its payment method carries no " <> field))) Right + +chooseMethod :: Text -> [GPaymentMethod] -> Either ProviderError GPaymentMethod +chooseMethod ref ms = case filter ((`elem` knownMethodIds) . gpmId) ms of + m : _ -> Right m + [] -> + Left . ProviderError $ + "btcpay invoice " + <> ref + <> ": no known payment method in [" + <> T.intercalate ", " (map gpmId ms) + <> "]; this build knows " + <> T.intercalate ", " knownMethodIds + +findMethod :: Text -> Text -> Text -> LB.ByteString -> Either ProviderError GPaymentMethod +findMethod what ref methodId body = do + ms <- decodeGreenfield what body + case find ((methodId ==) . gpmId) ms of + Just m -> Right m + Nothing -> + Left . ProviderError $ + what + <> ": invoice " + <> ref + <> " offers no " + <> methodId + <> ", only [" + <> T.intercalate ", " (map gpmId ms) + <> "]" + +receivedOf :: WireNum -> WireNum -> Maybe WireNum -> Received +receivedOf rate paid due = + Received + { rcvAmount = toMinorUnits (wnValue paid * wnValue rate), + rcvCrypto = if wnValue paid > 0 then Just (wnText paid) else Nothing, + rcvDue = wnText <$> due + } + +-- Clamp stops a wildly wrong figure wrapping Word32 and coming out small. +toMinorUnits :: Scientific -> CurrencyAmount +toMinorUnits s = CurrencyAmount (fromInteger (max 0 (min largestAmount (round (s * 100))))) + where + largestAmount = toInteger (maxBound :: Word32) + +latestSettledAt :: GPaymentMethod -> Maybe UTCTime +latestSettledAt GPaymentMethod {gpmPayments} = case mapMaybe settledAt gpmPayments of + [] -> Nothing + ts -> Just (posixSecondsToUTCTime (maximum ts)) + where + settledAt GPayment {gpStatus, gpReceivedDate} + | gpStatus == "Settled" = Just (fromRational (toRational (wnValue gpReceivedDate))) + | otherwise = Nothing + +sigHeaderName :: HeaderName +sigHeaderName = "BTCPay-Sig" + +sigPrefix :: B.ByteString +sigPrefix = "sha256=" + +-- Constant-time, over the bytes as they arrived: BTCPay indents its payload, so re-encoding would not match. +verifyBTCPaySig :: Text -> [Header] -> B.ByteString -> Either WebhookError (Maybe Text) +verifyBTCPaySig secret hdrs body = do + provided <- note "missing BTCPay-Sig header" (lookup sigHeaderName hdrs) + hex <- note "BTCPay-Sig is not sha256=" (B.stripPrefix sigPrefix provided) + given <- case convertFromBase Base16 (B8.map toLower hex) of + Right (bs :: B.ByteString) -> Right bs + Left (_ :: String) -> Left (WebhookError "BTCPay-Sig is not hex") + if constEq expected given + then Right actedOn + else Left (WebhookError "BTCPay-Sig does not verify") + where + expected :: Digest SHA256 + expected = hmacGetDigest (hmac (TE.encodeUtf8 secret) body :: HMAC SHA256) + note e = maybe (Left (WebhookError e)) Right + actedOn = do + GEvent {geType, geInvoiceId} <- J.decodeStrict' body + if geType `elem` actedOnEventTypes then Just geInvoiceId else Nothing + +greenfield :: BTCPayEnv -> Text -> Method -> [Text] -> Query -> Maybe J.Value -> IO (Either ProviderError LB.ByteString) +greenfield BTCPayEnv {beCfg, beManager} what verb segments query body = do + r <- try $ do + req <- parseRequest (T.unpack url) + let asked = + req + { method = verb, + requestHeaders = + [ ("Authorization", "token " <> TE.encodeUtf8 (bApiKey beCfg)), + ("Accept", "application/json"), + ("Content-Type", "application/json") + ], + requestBody = RequestBodyLBS (maybe LB.empty J.encode body) + } + -- Read to a bound, not the whole body, so an enormous response cannot exhaust the poller thread. + withResponse asked beManager $ \resp -> do + taken <- brReadSome (responseBody resp) (fromIntegral maxProviderBytes + 1) + pure (statusCode (responseStatus resp), taken) + pure $ case r of + -- http-client hides the Authorization header when showing a Request + Left (e :: HttpException) -> Left (ProviderError (what <> " failed: " <> tshow e)) + Right (code, taken) + | LB.length taken > maxProviderBytes -> + Left . ProviderError $ + what <> " failed: HTTP " <> tshow code <> ", and the answer is over " <> tshow maxProviderBytes <> " bytes" + | code >= 200 && code < 300 -> Right taken + | otherwise -> + Left . ProviderError $ + what <> " failed: HTTP " <> tshow code <> " " <> snippet taken + where + url = + T.dropWhileEnd (== '/') (bHost beCfg) + <> "/api/v1/stores/" + <> T.intercalate "/" (map escape (bStoreId beCfg : segments)) + <> TE.decodeUtf8 (renderQuery True query) + escape = TE.decodeUtf8 . urlEncode False . TE.encodeUtf8 + -- BTCPay puts the refusal reason at the end of an inline log, so keep enough bytes to reach it. + snippet = TE.decodeUtf8With (\_ _ -> Just '?') . LB.toStrict . LB.take maxErrorBytes + +decodeGreenfield :: J.FromJSON a => Text -> LB.ByteString -> Either ProviderError a +decodeGreenfield what body = case J.eitherDecode' body of + Right v -> Right v + Left e -> Left (ProviderError (what <> ": could not read the response: " <> T.pack e)) diff --git a/apps/simplex-badge-service/src/BadgeService/Providers/Stripe.hs b/apps/simplex-badge-service/src/BadgeService/Providers/Stripe.hs new file mode 100644 index 0000000000..93f55f37b2 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Providers/Stripe.hs @@ -0,0 +1,355 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module BadgeService.Providers.Stripe + ( stripeProvider, + signalOf, + IntentRead (..), + ) +where + +import BadgeService.Config (StripeConfig (..)) +import BadgeService.Providers + ( ListPass (..), + OrderDraft (..), + PaymentSignal (..), + Provider (..), + ProviderError (..), + ProviderInvoice (..), + Received (..), + settleWindow, + WebhookError (..), + ) +import Control.Exception (try) +import Control.Monad (unless) +import Crypto.Hash (Digest, SHA256) +import Crypto.MAC.HMAC (HMAC, hmac, hmacGetDigest) +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM +import Data.ByteArray (constEq) +import Data.ByteArray.Encoding (Base (Base16), convertFromBase) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Char8 as B8 +import qualified Data.ByteString.Lazy as LB +import Data.Char (toLower) +import Data.Functor ((<&>)) +import Data.Int (Int64) +import Data.Maybe (isJust) +import Data.Scientific (floatingOrInteger) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime) +import Data.Time.Clock.POSIX (POSIXTime, posixSecondsToUTCTime, utcTimeToPOSIXSeconds) +import Data.Word (Word32) +import Network.HTTP.Client + ( HttpException, + Manager, + Request (..), + RequestBody (..), + Response (..), + applyBasicAuth, + brReadSome, + parseRequest, + withResponse, + ) +import Network.HTTP.Client.TLS (newTlsManager) +import Network.HTTP.Types (Header, HeaderName, Method, Query, Status (..), methodGet, methodPost, renderQuery, renderSimpleQuery) +import Simplex.Chat.PaymentService.Types + ( CardProvider (..), + CurrencyAmount (..), + PaymentProvider (..), + ServicePaymentDestination (..), + ServicePaymentMethod (..), + ) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow) + +maxErrorBytes :: Int64 +maxErrorBytes = 4000 + +-- Far above any Stripe response, far below what would exhaust the poller thread. +maxProviderBytes :: Int64 +maxProviderBytes = 10 * 1024 * 1024 + +secondsPerMinute :: Int +secondsPerMinute = 60 + +listPageSize :: Int +listPageSize = 100 + +-- Caps a server that ignores @limit@, so the poller still reaches its expiry sweep. +maxListPages :: Int +maxListPages = 50 + +pageCapReason :: Text +pageCapReason = + "stripe: the list stopped at " + <> tshow maxListPages + <> " pages, so any intent past the first " + <> tshow (maxListPages * listPageSize) + <> " was not read — and will not be read by a later pass either" + +untraversableReason :: Text +untraversableReason = + "stripe: the list reports more pages but the last row carries no id to page from, so any intent past this page was not read" + +actedOnStripeEvents :: [Text] +actedOnStripeEvents = ["payment_intent.succeeded", "payment_intent.payment_failed", "payment_intent.canceled"] + +sigHeaderName :: HeaderName +sigHeaderName = "Stripe-Signature" + +listWhat :: Text +listWhat = "list intents" + +-- Pin the API version so response shapes this adapter parses do not shift under it. +stripeApiVersion :: ByteString +stripeApiVersion = "2026-03-25.dahlia" + +data StripeEnv = StripeEnv {seCfg :: StripeConfig, seManager :: Manager} + +stripeProvider :: StripeConfig -> IO Provider +stripeProvider cfg = do + seManager <- newTlsManager + let env = StripeEnv {seCfg = cfg, seManager} + pure + Provider + { pProvider = PPStripe, + pCreateInvoice = createInvoice env, + pReadInvoice = readInvoice env, + pCancelInvoice = cancelInvoice env, + pListOpen = listOpen env, + pVerifyWebhook = verifyStripeSig (sWebhookSecret cfg) + } + +listOpen :: StripeEnv -> IO (Either ProviderError ListPass) +listOpen env@StripeEnv {seCfg} = do + now <- getCurrentTime + let oldest = addUTCTime (negate (settleWindow + fromIntegral (sSessionMinutes seCfg * secondsPerMinute))) now + createdGte = B8.pack (show (unixSeconds oldest)) + go now createdGte Nothing maxListPages (ListPass [] []) + where + unixSeconds :: UTCTime -> Integer + unixSeconds = floor . utcTimeToPOSIXSeconds + go now createdGte after pagesLeft acc + | pagesLeft <= 0 = pure (Right acc {lpSkipped = lpSkipped acc <> [(Nothing, pageCapReason)]}) + | otherwise = do + let q = + [("created[gte]", Just createdGte), ("limit", Just (B8.pack (show listPageSize)))] + <> maybe [] (\a -> [("starting_after", Just (TE.encodeUtf8 a))]) after + got <- stripeApi env listWhat methodGet ["v1", "payment_intents"] q Nothing + case got >>= decodeStripe listWhat of + Left e -> pure (Left e) + Right IntentList {ilData, ilHasMore} -> + let acc' = merge acc (intentsPass now ilData) + in if ilHasMore + then case lastId ilData of + Just next -> go now createdGte (Just next) (pagesLeft - 1) acc' + Nothing -> pure (Right acc' {lpSkipped = lpSkipped acc' <> [(Nothing, untraversableReason)]}) + else pure (Right acc') + merge a b = ListPass {lpMoved = lpMoved a <> lpMoved b, lpSkipped = lpSkipped a <> lpSkipped b} + +intentsPass :: UTCTime -> [J.Value] -> ListPass +intentsPass now = foldr add (ListPass [] []) + where + add v pass = case J.fromJSON v :: J.Result IntentRead of + J.Error e -> pass {lpSkipped = (intentIdOf v, T.pack e) : lpSkipped pass} + J.Success ir -> case signalOf now ir of + Left (ProviderError e) -> pass {lpSkipped = (Just (irId ir), e) : lpSkipped pass} + Right Nothing -> pass + Right (Just sig) -> pass {lpMoved = (irId ir, sig) : lpMoved pass} + +intentIdOf :: J.Value -> Maybe Text +intentIdOf v = case J.fromJSON v :: J.Result (KM.KeyMap J.Value) of + J.Success o -> case KM.lookup "id" o of + Just (J.String i) -> Just i + _ -> Nothing + J.Error _ -> Nothing + +lastId :: [J.Value] -> Maybe Text +lastId vs = case vs of + [] -> Nothing + _ -> intentIdOf (last vs) + +data IntentList = IntentList {ilData :: [J.Value], ilHasMore :: Bool} + +instance J.FromJSON IntentList where + parseJSON = J.withObject "intent list" $ \o -> + IntentList <$> o J..: "data" <*> o J..:? "has_more" J..!= False + +-- | Stripe signs each retry again with the current time, so an old timestamp means a replayed request. +webhookTolerance :: NominalDiffTime +webhookTolerance = 15 * 60 + +-- Constant-time over the raw bytes. Stripe signs "{t}.{body}", so hash the timestamp, a dot, then the body as it arrived. +verifyStripeSig :: Text -> UTCTime -> [Header] -> ByteString -> Either WebhookError (Maybe Text) +verifyStripeSig secret now hdrs body = do + raw <- note "missing Stripe-Signature header" (lookup sigHeaderName hdrs) + (t, v1hexes) <- note "Stripe-Signature is not t=..,v1=.." (parseStripeSig raw) + givens <- mapM decodeHex v1hexes + let expected :: Digest SHA256 + expected = hmacGetDigest (hmac (TE.encodeUtf8 secret) (t <> "." <> body) :: HMAC SHA256) + -- during signing-secret rotation Stripe sends one v1 per active secret; any match verifies + unless (any (constEq expected) givens) $ Left (WebhookError "Stripe-Signature does not verify") + signedAt <- note "Stripe-Signature t is not a whole number of seconds" (fullInt t) + if abs (diffUTCTime now (posixSecondsToUTCTime (fromIntegral signedAt))) <= webhookTolerance + then Right actedOn + else Left (WebhookStale ("Stripe-Signature t is more than " <> tshow (round webhookTolerance `div` 60 :: Int) <> " minutes from now; check the server clock")) + where + fullInt s = case B8.readInt s of + Just (n, rest) | B8.null rest -> Just n + _ -> Nothing + note e = maybe (Left (WebhookError e)) Right + decodeHex v1hex = case convertFromBase Base16 (B8.map toLower v1hex) of + Right (bs :: ByteString) -> Right bs + Left (_ :: String) -> Left (WebhookError "Stripe-Signature v1 is not hex") + actedOn = do + SEvent {seType, seRef} <- J.decodeStrict' body + if seType `elem` actedOnStripeEvents then Just seRef else Nothing + +parseStripeSig :: ByteString -> Maybe (ByteString, [ByteString]) +parseStripeSig raw = do + let pairs = [(k, B8.drop 1 rest) | part <- B8.split ',' raw, let (k, rest) = B8.break (== '=') part, not (B8.null rest)] + t <- lookup "t" pairs + case [v | ("v1", v) <- pairs] of + [] -> Nothing + v1s -> Just (t, v1s) + +data SEvent = SEvent {seType :: Text, seRef :: Text} + +instance J.FromJSON SEvent where + parseJSON = J.withObject "stripe event" $ \o -> do + seType <- o J..: "type" + dataO <- o J..: "data" + obj <- dataO J..: "object" + seRef <- obj J..: "id" + pure SEvent {seType, seRef} + +data CreatedIntent = CreatedIntent {ciId :: Text, ciClientSecret :: Text} + +instance J.FromJSON CreatedIntent where + parseJSON = J.withObject "payment_intent" $ \o -> + CreatedIntent <$> o J..: "id" <*> o J..: "client_secret" + +createInvoice :: StripeEnv -> ServicePaymentMethod -> OrderDraft -> IO (Either ProviderError ProviderInvoice) +createInvoice _ (SPMCrypto _) _ = pure (Left (ProviderError "stripe offers no crypto payment method")) +createInvoice env (SPMCard CPStripe) OrderDraft {odAmount = CurrencyAmount minor, odCurrency} = do + let form = + [ ("amount", B8.pack (show minor)), + ("currency", TE.encodeUtf8 (T.toLower odCurrency)), + -- Only card is offered, because a redirect-based method would navigate the top window out of the embedded frame. + ("allowed_payment_method_types[]", "card") + ] + created <- stripeApi env what methodPost ["v1", "payment_intents"] [] (Just form) + pure $ created >>= decodeStripe what <&> \CreatedIntent {ciId, ciClientSecret} -> + ProviderInvoice {piProviderRef = ciId, piDestination = SPDCard CPStripe ciClientSecret} + where + what = "create intent" + +cancelInvoice :: StripeEnv -> Text -> IO (Either ProviderError ()) +cancelInvoice env pid = + fmap (fmap (const ())) $ stripeApi env "cancel intent" methodPost ["v1", "payment_intents", pid, "cancel"] [] (Just []) + +readInvoice :: StripeEnv -> Text -> IO (Either ProviderError (Maybe PaymentSignal)) +readInvoice env pid = do + now <- getCurrentTime + got <- stripeApi env what methodGet ["v1", "payment_intents", pid] [("expand[]", Just "latest_charge")] Nothing + pure $ got >>= decodeStripe what >>= signalOf now + where + what = "read intent " <> pid + +signalOf :: UTCTime -> IntentRead -> Either ProviderError (Maybe PaymentSignal) +signalOf now IntentRead {irStatus, irAmountReceived, irChargeCreated} = + case irStatus of + "succeeded" -> Right (Just (SigSettled (received irAmountReceived) settledAt)) + "canceled" -> Right (Just (SigClosed (received 0))) + s | s `elem` openStatuses -> Right Nothing + | otherwise -> Left (ProviderError ("stripe payment_intent: unknown status " <> s)) + where + openStatuses = ["requires_payment_method", "requires_confirmation", "requires_action", "processing", "requires_capture"] + received amt = Received {rcvAmount = amountFrom amt, rcvCrypto = Nothing, rcvDue = Nothing} + settledAt = maybe now posixSecondsToUTCTime irChargeCreated + +-- Clamp stops a wildly wrong figure wrapping Word32 and coming out small. +amountFrom :: Int64 -> CurrencyAmount +amountFrom n = CurrencyAmount (fromInteger (max 0 (min largestAmount (toInteger n)))) + where + largestAmount = toInteger (maxBound :: Word32) + +data IntentRead = IntentRead + { irId :: Text, + irStatus :: Text, + irAmountReceived :: Int64, + irChargeCreated :: Maybe POSIXTime + } + +instance J.FromJSON IntentRead where + parseJSON = J.withObject "payment_intent" $ \o -> do + irId <- o J..: "id" + irStatus <- o J..: "status" + irAmountReceived <- o J..:? "amount_received" J..!= 0 + -- expand[]=latest_charge makes this the charge object; unexpanded it is a string id we ignore + latest <- o J..:? "latest_charge" + let irChargeCreated = fmap fromInteger (chargeCreated latest) + pure IntentRead {irId, irStatus, irAmountReceived, irChargeCreated} + +chargeCreated :: Maybe J.Value -> Maybe Integer +chargeCreated = \case + Just (J.Object c) -> KM.lookup "created" c >>= asInteger + _ -> Nothing + +asInteger :: J.Value -> Maybe Integer +asInteger = \case + J.Number n -> either (const Nothing) Just (floatingOrInteger n :: Either Double Integer) + _ -> Nothing + +stripeApi :: + StripeEnv -> + Text -> + Method -> + [Text] -> + Query -> + Maybe [(ByteString, ByteString)] -> + IO (Either ProviderError LB.ByteString) +stripeApi StripeEnv {seCfg, seManager} what verb segments query form = do + r <- try $ do + req0 <- parseRequest (T.unpack url) + -- applyBasicAuth must wrap the record update, because updating requestHeaders after it would drop the header it adds. + let req = + applyBasicAuth (TE.encodeUtf8 (sSecretKey seCfg)) "" $ + req0 + { method = verb, + requestHeaders = + ("Accept", "application/json") + : ("Stripe-Version", stripeApiVersion) + : [("Content-Type", "application/x-www-form-urlencoded") | isJust form], + requestBody = RequestBodyBS (maybe "" (renderSimpleQuery False) form) + } + withResponse req seManager $ \resp -> do + taken <- brReadSome (responseBody resp) (fromIntegral maxProviderBytes + 1) + pure (statusCode (responseStatus resp), taken) + pure $ case r of + -- http-client hides the Authorization header when showing a Request + Left (e :: HttpException) -> Left (ProviderError (what <> " failed: " <> tshow e)) + Right (code, taken) + | LB.length taken > maxProviderBytes -> + Left . ProviderError $ + what <> " failed: HTTP " <> tshow code <> ", and the answer is over " <> tshow maxProviderBytes <> " bytes" + | code >= 200 && code < 300 -> Right taken + | otherwise -> + Left . ProviderError $ + what <> " failed: HTTP " <> tshow code <> " " <> snippet taken + where + url = + T.dropWhileEnd (== '/') (sHost seCfg) + <> "/" + <> T.intercalate "/" segments + <> TE.decodeUtf8 (renderQuery True query) + snippet = safeDecodeUtf8 . LB.toStrict . LB.take maxErrorBytes + +decodeStripe :: J.FromJSON a => Text -> LB.ByteString -> Either ProviderError a +decodeStripe what body = case J.eitherDecode' body of + Right v -> Right v + Left e -> Left (ProviderError (what <> ": could not read the response: " <> T.pack e)) diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 8337f672a4..0e189a3489 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -18,23 +18,33 @@ 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) +import BadgeService.Providers.Stripe (stripeProvider) import BadgeService.Store +import BadgeService.Store.Invoices (seedCatalog, truncateToSecond) import BadgeService.Store.Migrate (runBadgeServiceMigrations) +import BadgeService.Waiters (Waiters, newWaiters) +import BadgeService.Web.Server (exportWebapp, newWebEnv, runWebListener) import Control.Applicative (optional) import Control.Concurrent.STM -import Control.Logger.Simple +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 Data.Char (isSpace) import Data.Either (fromRight) -import Data.Functor (($>)) -import Data.Maybe (fromMaybe, maybeToList) +import Data.Functor (($>), (<&>)) import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe, isJust, maybeToList) import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) import Data.Word (Word32) @@ -51,24 +61,27 @@ import Simplex.Chat.Options (printDbOpts) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') 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) -import Simplex.Messaging.Version (isCompatible) +import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow) +import Simplex.Messaging.Version (isCompatible) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) data ServiceState = ServiceState { serviceCC :: TMVar ChatController, - serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object) + serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object), + groupEventQ :: TQueue GroupEvent } newServiceState :: IO ServiceState newServiceState = do serviceCC <- newEmptyTMVarIO serviceRequestQ <- newTQueueIO - pure ServiceState {serviceCC, serviceRequestQ} + groupEventQ <- newTQueueIO + pure ServiceState {serviceCC, serviceRequestQ, groupEventQ} welcomeGetOpts :: IO BadgeServiceOpts welcomeGetOpts = do @@ -80,45 +93,88 @@ welcomeGetOpts = do putStrLn $ "Service name: " ++ T.unpack serviceName pure opts --- | Check the secret is the key trusted at its index: otherwise every code redeemed is burned. -checkIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO (Either String BadgeIssuerKey) -checkIssuerKey BadgeServiceOpts {issuerKey} ChatConfig {badgePublicKeys} = case issuerKey of - Nothing -> pure $ Left "an issuer key is required - pass both --issuer-key-idx and --issuer-secret (see `simplex-chat badge keygen`)" - Just k@BadgeIssuerKey {keyIdx, secretKey} -> - bbsPublicKey secretKey >>= \case - Left e -> pure $ Left $ "issuer secret is not a valid key: " <> e - Right pk -> pure $ case M.lookup keyIdx badgePublicKeys of - Just pk' | pk' == pk -> Right k - Just _ -> Left $ "issuer secret does not match the configured key at index " <> show keyIdx <> ", its public key is " <> T.unpack (safeDecodeUtf8 $ strEncode pk) - Nothing -> Left $ "no configured badge key at index " <> show keyIdx <> ", clients could not verify what this service signs" +checkIssuerKey :: BadgeServiceOpts -> Maybe ServiceConfig -> ChatConfig -> IO (Either String BadgeIssuerKey) +checkIssuerKey BadgeServiceOpts {issuerKey} serviceCfg cfg = case issuerKey of + Left e -> pure (Left e) + Right (Just k) -> checkOne cfg k + Right Nothing -> case serviceCfg >>= issuer of + Nothing -> pure $ Left "an issuer key is required - pass --issuer-key-idx and --issuer-secret, or add an [issuer] section to badge_service.ini (see `simplex-chat badge keygen`)" + Just k -> checkOne cfg k -requireIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO BadgeIssuerKey -requireIssuerKey opts cfg = - checkIssuerKey opts cfg >>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure +checkOne :: ChatConfig -> BadgeIssuerKey -> IO (Either String BadgeIssuerKey) +checkOne ChatConfig {badgePublicKeys} k@BadgeIssuerKey {keyIdx, secretKey} = + bbsPublicKey secretKey >>= \case + Left e -> pure $ Left $ "issuer secret at index " <> show keyIdx <> " is not a valid key: " <> e + Right pk -> pure $ case M.lookup keyIdx badgePublicKeys of + Just pk' | pk' == pk -> Right k + Just _ -> Left $ "issuer secret does not match the configured key at index " <> show keyIdx <> ", its public key is " <> T.unpack (safeDecodeUtf8 $ strEncode pk) + Nothing -> Left $ "no configured badge key at index " <> show keyIdx <> ", clients could not verify what this service signs" + +requireIssuerKey :: BadgeServiceOpts -> Maybe ServiceConfig -> ChatConfig -> IO BadgeIssuerKey +requireIssuerKey opts serviceCfg cfg = + checkIssuerKey opts serviceCfg cfg >>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure + +readConfigOrExit :: FilePath -> IO ServiceConfig +readConfigOrExit path = + readServiceConfig path >>= \case + Left e -> putStrLn (path <> ": " <> e) >> exitFailure + Right sc -> pure sc badgeService :: BadgeServiceOpts -> ChatConfig -> ServiceState -> IO () -badgeService opts cfg env = do - key <- requireIssuerKey opts cfg - let chatHooks = +badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do + serviceCfg <- traverse readConfigOrExit serviceConfigFile + key <- requireIssuerKey opts serviceCfg cfg + waiters <- newWaiters + 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 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 must not block: outputQ carries every chat event - simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> - raceAny_ - [ forever $ - atomically (readTBQueue $ outputQ cc) >>= \case - (_, Right (CEvtServiceRequest u reqId sigKey reqData)) -> - atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) - _ -> pure (), - processQueuedRequests key env - ] + -- 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 + -- Seed before the listener accepts anything, since every checkout is priced from these. + seedServiceCatalog chatStore + btc <- maybe (pure []) (fmap (: []) . btcpayProvider) (btcpay sc) + str <- maybe (pure []) (fmap (: []) . stripeProvider) (stripe sc) + let providers = btc <> str + hints <- newReadHints + webEnv <- newWebEnv chatStore sc ws hints providers + exportWebapp (listener sc) (stripe sc) + pollerEnv <- newPollerEnv chatStore ws hints providers (poll sc) + pure [runWebListener webEnv, runPoller pollerEnv] + +seedServiceCatalog :: DBStore -> IO () +seedServiceCatalog st = do + now <- truncateToSecond <$> getCurrentTime + let (prices, offers) = defaultCatalog now + (seededPrices, seededOffers) <- seedCatalog st prices offers + logInfo $ + "badge catalog: " <> tshow (length prices) <> " prices and " <> tshow (length offers) <> " offers compiled in, " + <> tshow seededPrices <> " prices and " <> tshow seededOffers <> " offers inserted" badgeServiceCLI :: BadgeServiceOpts -> IO () -badgeServiceCLI opts = do - key <- requireIssuerKey opts terminalChatConfig +badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do + serviceCfg <- traverse readConfigOrExit serviceConfigFile + key <- requireIssuerKey opts serviceCfg terminalChatConfig env <- newServiceState let eventHook _cc ev = do case ev of @@ -135,47 +191,45 @@ badgeServiceCLI opts = do } raceAny_ [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, - processQueuedRequests key env + processQueuedRequests key Nothing env ] --- | issuing codes lives here rather than in core: every user's app would otherwise ship it badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand) badgeCmdHook cc = \case CustomChatCommand cmd -> Left <$> runBadgeCmd cc cmd cmd -> pure $ Right cmd runBadgeCmd :: ChatController -> ByteString -> IO (Either ChatError ChatResponse) -runBadgeCmd cc cmd = case A.parseOnly issueCmdP cmd of - Left _ -> pure $ chatCmdError "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free]" - Right issueOpts -> - issueBadgeCode cc issueOpts >>= \case - Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "code " <> formatBadgeCode code} - Left e -> pure $ chatCmdError $ "issuing code: " <> e +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 _ -> pure $ chatCmdError (T.unpack issueFailedText) + | Right code <- A.parseOnly revokeCmdP cmd = + 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 " *> codeP <* (A.skipSpace *> A.endOfInput) issueCmdP :: A.Parser IssueCodeOpts issueCmdP = "issue " *> do badgeType <- badgeTypeP - months_ <- optional (A.space *> A.decimal) - -- outside `optional`, which would otherwise backtrack past a bad count + months_ <- optional (A.space *> (A.decimal :: A.Parser Integer)) + -- Kept outside optional, which would otherwise backtrack past a bad count. months <- maybe (pure 1) checkMonths months_ paymentStatus <- fromMaybe CPSFree <$> optional (A.space *> textTokenP) A.skipSpace A.endOfInput pure IssueCodeOpts {badgeType, months, paymentStatus} where + -- Integer, because attoparsec's decimal wraps silently at Int, so the guard would check a truncated count. checkMonths n - | n >= 1 && n <= (255 :: Int) = pure 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, @@ -183,20 +237,16 @@ 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 + handleServiceRequest key cc trackerQ_ u reqId sigKey reqData badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO () badgePreStartHook opts ChatController {config, chatStore} = @@ -204,20 +254,20 @@ badgePreStartHook opts ChatController {config, chatStore} = badgePostStartHook :: BadgeServiceOpts -> ServiceState -> ChatController -> IO () badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do - -- SREQ delivery gates on this flag; Core starts serviceRequests=False, so the hook must set it. + -- 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 - -- DR required for service RPC; autoAccept off because badge service ignores contact events. Just _ -> do + -- 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 @@ -230,8 +280,6 @@ responseObject r = case J.toJSON r of errorResponse :: BadgeServiceErrorCode -> BadgeServiceResponse errorResponse code = BSPError {code, message = Nothing, retryAfter = badgeErrorRetryAfter code} --- | Seconds, for the three codes badges-rpc.md marks transient. Every other code is terminal for --- the command attempted - internal included, which would otherwise press a failing service. badgeErrorRetryAfter :: BadgeServiceErrorCode -> Maybe Word32 badgeErrorRetryAfter = \case BSEPaymentPending -> Just 300 @@ -240,23 +288,21 @@ badgeErrorRetryAfter = \case _ -> Nothing --- | The agent verified the signature, so sigKey is a key the sender holds - a purchaseKey that --- differs 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 +-- | 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 (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 Nothing -> pure $ errorResponse BSEBadRequest - -- every command but redeemBadgeCode needs a key the service already knows: that one - -- creates the purchase, so its key is unknown on a first redemption + -- Every command but redeemBadgeCode needs a key the service already knows; that one creates the purchase, so its key is unknown on a first redemption. _ -> case purchaseKey of Nothing -> pure $ errorResponse BSEUnsupportedVersion Just k -> @@ -265,21 +311,18 @@ badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) Right False -> pure $ errorResponse BSEUnknownPurchaseKey Left _ -> pure $ errorResponse BSEInternal --- | The only clock the service reads, so a test can move both sides of a request together. badgeNow :: ChatController -> IO UTCTime badgeNow ChatController {config = ChatConfig {badgeCurrentTime}} = badgeCurrentTime randomId :: ChatController -> IO T.Text randomId cc = safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 (random cc)) --- | Neither value comes from the caller: the badge type is the entry's, the expiry is derived --- from the period end it carries. +-- | Neither the badge type nor the expiry comes from the caller; both derive from the entry. credentialForEntry :: BadgeIssuerKey -> BadgeMasterKey -> StatementEntry -> IO (Either String (StatementEntry, BadgeCredential)) credentialForEntry BadgeIssuerKey {keyIdx, secretKey} masterKey e@StatementEntry {balanceStartTs = periodEnd, balanceBadgeType} = do let badgeInfo = BadgeInfo {badgeType = balanceBadgeType, badgeExpiry = endOfMondayAfter periodEnd, badgeExtra = ""} fmap (e,) <$> issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) --- | Pairs the issued entry with the one before it, which the writer needs for the period start. issuanceAfter :: StatementEntry -> (StatementEntry, BadgeCredential) -> (StatementEntry, StatementEntry, BadgeCredential) issuanceAfter previous (issued, credential) = (previous, issued, credential) @@ -287,54 +330,62 @@ credentialResponse :: Maybe BadgeCredential -> Maybe T.Text -> [StatementEntry] 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 rather than spent with nothing behind it. -redeemCode :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse -redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText of +-- | Nothing is written until the credential is signed, so a signing failure leaves the code unspent. +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 -> - withDB "getBadgeCode" cc (readCode code) >>= \case + 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 - now <- badgeNow cc + Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) -> do (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc - -- the purchase is created here, so there is no ledger to lapse -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the -- months it adds are counted from a start already in the past let granted = grantEntry now grantUuid months SCCode $ emptyEntry now badgeType - -- a grant of at least one month starting now always has a month to issue + -- A grant of at least one month starting now always has a month to issue. case issueEntry now issueUuid granted of Nothing -> pure $ errorResponse BSEInternal Just issued -> credentialForEntry key masterKey issued >>= \case Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal Right signed -> do - -- re-read: a concurrent redemption may have landed while this one was signing + -- 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 -> - readCode code db >>= \case - Left resp -> pure resp - Right _ -> liftIO $ do - purchaseId <- createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now + liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case + Nothing -> + readCode now code db >>= \case + 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 - -- used before signing and again inside the write transaction; every Left is a finished - -- response, an unknown code included - readCode code db = liftIO $ + readCode now code db = liftIO $ getBadgeCode db (badgeCodeHash code) >>= \case Nothing -> pure $ Left $ errorResponse BSECodeInvalid - Just c@IssuedCode {redemption} -> fmap (const c) <$> checkUnspent db redemption - checkUnspent db = \case - CodeUnredeemed -> pure $ Right () - CodeRedeemedUnreadable -> pure $ Left $ errorResponse BSEInternal - CodeRedeemed RedeemedCode {purchaseKey = k, badgePurchaseId, credential} - | k /= purchaseKey -> pure $ Left $ errorResponse BSECodeUsed - -- the whole ledger, so a client that lost the first response still ends holding it - | 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 @@ -359,21 +410,19 @@ issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do Right signed -> writeIssued badgePurchaseId tip (maybeToList lapsed) now $ Just $ issuanceAfter current signed where - -- the rows were computed from a tip that another request may have moved, and an issuance was - -- signed against it - so write only if it is still the tip + -- Write only if the tip has not moved, since another request may have advanced it. writeIssued purchaseId tip rows t issuance_ = do r <- withDB "issueBadge" cc $ \db -> liftIO $ do tip' <- getLedgerTip db purchaseId when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId rows issuance_ issueResponse db purchaseId t pure $ fromRight (errorResponse BSEInternal) r - -- entries after the one asserted, or the whole ledger when this purchase does not hold it. -- Only the asserted entry's identity is read, never the months it claims. issueResponse db purchaseId t = do let StatementEntry {entryId = assertedUuid} = lastEntry assertedId <- getLedgerEntryId db purchaseId assertedUuid -- TODO [badges] when the assertion does not resolve, heal the ledger and restate it as a - -- single opening credit (badges-rpc.md), rather than resending the whole history + -- single opening credit, rather than resending the whole history entries_ <- getLedgerEntries db purchaseId (fromMaybe 0 assertedId) credential_ <- getCurrentIssuance db purchaseId t pure $ maybe (errorResponse BSEInternal) (credentialResponse credential_ (assertedUuid <$ assertedId)) entries_ diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs index dac51a5793..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,13 +27,21 @@ module BadgeService.Store appendLedgerPlan, createCodePurchase, insertBadgeCode, + setCodeGroupItem, + CodeTracker (..), + getCodeTracker, + getEditableTrackers, + RevokeResult (..), + revokeCode, ) where +import BadgeService.Store.Invoices (executeChanging) 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) @@ -35,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') @@ -52,24 +66,23 @@ data IssuedCode = IssuedCode { badgeCodeId :: Int64, badgeType :: BadgeType, months :: Int, - redemption :: CodeRedemption + paymentStatus :: BadgeCodePaymentStatus, + revokedAt :: Maybe UTCTime, + expiresAt :: Maybe UTCTime, + redeemLimit :: Int, + redeemCount :: Int } --- A code that has a purchase is spent, even if its credential cannot be read. Treating that as --- an unredeemed code would issue a second credential for it. -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 } --- Its rows and issuance are appended by 'appendLedgerPlan' in the same transaction: a code marked --- redeemed while another write failed would be spent with no credential, and nothing reissues it. data NewCodePurchase = NewCodePurchase { badgeCodeId :: Int64, purchaseKey :: C.PublicKeyEd25519, @@ -83,29 +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, 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, purchaseId_, purchaseKey_, credential_) = - IssuedCode {badgeCodeId, badgeType, months, 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 @@ -113,7 +178,6 @@ purchaseKeyExists db key = maybeFirstRow' False (\(Only (_ :: Int64)) -> True) $ DB.query db "SELECT badge_purchase_id FROM sx_badge_service_badge_purchases WHERE purchase_key = ?" (Only key) --- | The only route from a command to a purchase, so a client cannot name one it cannot sign for. getPurchaseByKey :: DB.Connection -> C.PublicKeyEd25519 -> IO (Maybe ServicePurchase) getPurchaseByKey db key = maybeFirstRow toPurchase $ @@ -144,8 +208,6 @@ getLedgerTip db purchaseId = |] (Only purchaseId) --- | The uuid is the client's claim about its last held entry, so the lookup is scoped to its own --- purchase - an entry_id taken from another ledger would silently skip rows of this one. getLedgerEntryId :: DB.Connection -> Int64 -> Text -> IO (Maybe Int64) getLedgerEntryId db purchaseId entryUuid = maybeFirstRow fromOnly $ @@ -154,8 +216,7 @@ getLedgerEntryId db purchaseId entryUuid = "SELECT entry_id FROM sx_badge_service_badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?" (purchaseId, entryUuid) --- | 0 for the whole ledger, as entry_id starts at 1. 'Nothing' when a stored row has a type this --- version cannot represent, rather than sending it changed into another. +-- | 0 returns the whole ledger, as entry_id starts at 1. getLedgerEntries :: DB.Connection -> Int64 -> Int64 -> IO (Maybe [StatementEntry]) getLedgerEntries db purchaseId afterEntryId = mapM toEntry @@ -175,7 +236,6 @@ toEntry (entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince = Nothing, createdAt, entryType}) <$> entryTypeFromColumns entryType_ credit_ debit_ --- | Answers a repeat inside an issued month, rather than signing the same content twice. getCurrentIssuance :: DB.Connection -> Int64 -> UTCTime -> IO (Maybe BadgeCredential) getCurrentIssuance db purchaseId now = do rs <- @@ -192,10 +252,7 @@ getCurrentIssuance db purchaseId now = do [Only (Binary bs)] -> J.decodeStrict' bs _ -> Nothing --- | The issuance is the entry that spends the month and the one before it, which give the period. --- TODO [badges] also write the reference columns - payment_id, charge_id, from_purchase_id, --- to_purchase_id - for the entry types that carry one. Only the tag is written today, so a --- payment, charge, transferIn, upgrade or transferOut row would be stored without its reference. +-- TODO write the reference columns (payment_id, charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today. appendLedgerPlan :: DB.Connection -> Int64 -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () appendLedgerPlan db purchaseId rows issuance_ = do mapM_ appendRow rows @@ -210,8 +267,6 @@ appendLedgerPlan db purchaseId rows issuance_ = do (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at) VALUES (?,?,?,?,?,?,?,?,?) |] - -- the issued entry's uuid is the issuance id: one issuance per such entry, and entry uuids - -- are already unique across the ledger, so nothing has to be drawn for it ( (entryId, purchaseId, rowId, balanceBadgeType) :. (balanceStartTs previous, periodEnd, endOfMondayAfter periodEnd, Binary (LB.toStrict $ J.encode credential), createdAt) ) @@ -229,28 +284,101 @@ 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 share a transaction with the credential's rows: --- a code marked spent without one can never be reissued -createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO Int64 +-- 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 - DB.execute - db - [sql| - INSERT INTO sx_badge_service_badge_purchases - (purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?) - |] - (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now) - purchaseId <- insertedRowId db - DB.execute db "UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ?" (now, badgeCodeId) - pure purchaseId + claimed <- + executeChanging + db + "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 + else do + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_purchases + (purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now) + Just <$> insertedRowId db -insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO () -insertBadgeCode db codeHash badgeType months paymentStatus now = +-- | 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 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 redeem_count < redeem_limit" + (now, 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 + 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 -> 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/Invoices.hs b/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs new file mode 100644 index 0000000000..a3e177400e --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs @@ -0,0 +1,613 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeOperators #-} + +module BadgeService.Store.Invoices + ( InvoiceRow (..), + InvoicePayment (..), + paymentHolds, + NewInvoice (..), + CreateError (..), + StoreDecodeError (..), + createInvoiceRows, + executeChanging, + getInvoice, + getInvoiceByProviderRef, + unpaidRefs, + providerText, + codeHashExists, + OverdueInvoice (..), + overdueInvoices, + expireOverdue, + cancelOpenInvoice, + newInvoiceId, + readCatalogRows, + seedCatalog, + truncateToSecond, + settlementInvoice, + settlementCodeHash, + upsertPayment, + paymentStatusText, + cryptoCurrencyText, + textToInvoiceStatus, + invoiceStatusText, + updateInvoiceStatus, + markCodePaid, + ) +where + +import Control.Exception (Exception) +import qualified Control.Exception as E +import Control.Monad (filterM) +import Crypto.Random (getRandomBytes) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Char8 as BC8 +import Data.String (fromString) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Time.Clock (UTCTime) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds) +import Data.Word (Word8, Word32) +import Simplex.Chat.Badges (BadgeType (..)) +import Simplex.Chat.Badges.Service (BadgePrice (..), BadgeOffer (..)) +import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..), BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..)) +import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), PaymentStatus (..), ServicePaymentDestination (..)) +import Simplex.Messaging.Agent.Store.Common (DBStore, withConnection, withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Encoding.String (textEncode) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow) + +#if defined(dbPostgres) +import BadgeService.Store.Postgres.Migrations (servicePrefix, withPrefix) +import Database.PostgreSQL.Simple (Only (..), Query, ToRow, (:.) (..)) +import qualified Database.PostgreSQL.Simple as PSQL +import Database.PostgreSQL.Simple.Errors (ConstraintViolation (..), constraintViolation) +#else +import BadgeService.Store.SQLite.Migrations (servicePrefix, withPrefix) +import Database.SQLite.Simple (Only (..), Query, ToRow, (:.) (..)) +import qualified Database.SQLite.Simple as SQL +#endif + +data InvoiceRow = InvoiceRow + { irInvoiceId :: InvoiceId, + irProvider :: PaymentProvider, + irProviderRef :: Text, + irBadgeType :: BadgeType, + irMonths :: Word8, + irPrice :: CurrencyAmount, + irAmount :: CurrencyAmount, + irCurrency :: Text, + irDestination :: ServicePaymentDestination, + irExpiresAt :: UTCTime, + irStatus :: InvoiceStatus, + irCreatedAt :: UTCTime, + irPayment :: Maybe InvoicePayment + } + deriving (Eq, Show) + +paymentHolds :: InvoicePayment -> Bool +paymentHolds InvoicePayment {ipAmount, ipCryptoPaid, ipPaidInFull} = + maybe False (\(CurrencyAmount a) -> a > 0) ipAmount || ipCryptoPaid /= Nothing || ipPaidInFull + +data InvoicePayment = InvoicePayment + { ipAmount :: Maybe CurrencyAmount, + ipCryptoPaid :: Maybe Text, + ipCryptoDue :: Maybe Text, + ipPaidInFull :: Bool, + ipStatus :: Text, + ipUpdatedAt :: UTCTime + } + deriving (Eq, Show) + +data NewInvoice = NewInvoice + { niInvoiceId :: InvoiceId, + niProviderRef :: Text, + niCodeHash :: ByteString, + niPriceId :: BadgePriceId, + niOfferId :: Maybe BadgeOfferId, + niBadgeType :: BadgeType, + niMonths :: Word8, + niPrice :: CurrencyAmount, + niAmount :: CurrencyAmount, + niCurrency :: Text, + niProvider :: PaymentProvider, + niDestination :: ServicePaymentDestination, + niExpiresAt :: UTCTime, + niCreatedAt :: UTCTime + } + +data CreateError = CECodeConflict | CERefConflict | CEOther Text + deriving (Eq, Show) + +newtype StoreDecodeError = StoreDecodeError Text + deriving (Eq, Show) + +instance Exception StoreDecodeError + +-- | SQLite stores timestamps as text, so `expires_at < ?` compares as strings and only sorts chronologically when every value has the same width. +truncateToSecond :: UTCTime -> UTCTime +truncateToSecond = posixSecondsToUTCTime . fromInteger . truncate . utcTimeToPOSIXSeconds + +#if defined(dbPostgres) +mkQuery :: Text -> Query +mkQuery raw = fromString (T.unpack (withPrefix servicePrefix raw)) +#else +mkQuery :: Text -> Query +mkQuery raw = withPrefix servicePrefix (fromString (T.unpack raw)) +#endif + +-- Backslash-newline string gaps do not survive CPP, so these queries are joined with <>. +qInsertInvoice :: Query +qInsertInvoice = + mkQuery $ + "INSERT INTO @invoices " + <> "(invoice_id, provider, price, discount_amount, credit_amount, amount, currency, " + <> "payment_url, payment_address, payment_crypto_currency, payment_crypto_amount, " + <> "expires_at, status, created_at, updated_at) " + <> "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + +qInsertBadgeCodeInvoice :: Query +qInsertBadgeCodeInvoice = + mkQuery $ + "INSERT INTO @badge_code_invoices " + <> "(invoice_id, badge_code_id, price_id, offer_id, provider_ref, created_at) " + <> "VALUES (?,?,?,?,?,?)" + +qInsertBadgeCode :: Query +qInsertBadgeCode = + mkQuery $ + "INSERT INTO @badge_codes (code_hash, badge_type, months, code_payment_status, created_at) " + <> "VALUES (?,?,?,?,?)" + +invoiceRowSelect :: Text +invoiceRowSelect = + "SELECT i.invoice_id, i.provider, ci.provider_ref, bc.badge_type, bc.months, " + <> "i.price, i.amount, i.currency, i.payment_url, i.payment_address, i.payment_crypto_currency, " + <> "i.payment_crypto_amount, i.expires_at, i.status, i.created_at, " + <> "p.amount, p.crypto_paid, p.crypto_due, p.paid_in_full, p.status, p.updated_at " + <> "FROM @invoices i " + <> "JOIN @badge_code_invoices ci ON ci.invoice_id = i.invoice_id " + <> "JOIN @badge_codes bc ON bc.badge_code_id = ci.badge_code_id " + <> "LEFT JOIN @payments p ON p.invoice_id = i.invoice_id " + +qGetInvoice :: Query +qGetInvoice = mkQuery (invoiceRowSelect <> "WHERE i.invoice_id = ?") + +qGetInvoiceByProviderRef :: Query +qGetInvoiceByProviderRef = mkQuery (invoiceRowSelect <> "WHERE ci.provider_ref = ?") + +qUnpaidRefs :: Query +qUnpaidRefs = + mkQuery $ + "SELECT i.provider, ci.provider_ref FROM @invoices i " + <> "JOIN @badge_code_invoices ci ON ci.invoice_id = i.invoice_id " + <> "WHERE i.status <> 'paid' AND i.created_at >= ? " + <> "ORDER BY i.created_at" + +qCodeHashExists :: Query +qCodeHashExists = mkQuery "SELECT 1 FROM @badge_codes WHERE code_hash = ? LIMIT 1" + +unfundedOnly :: Text +unfundedOnly = + "AND NOT EXISTS (SELECT 1 FROM @payments p " + <> "WHERE p.invoice_id = @invoices.invoice_id AND (" + <> "COALESCE(p.amount, 0) > 0 OR p.crypto_paid IS NOT NULL OR p.paid_in_full = 1)) " + +qOverdueInvoices :: Query +qOverdueInvoices = + mkQuery $ + "SELECT @invoices.invoice_id, @invoices.provider, ci.provider_ref, @invoices.created_at FROM @invoices " + <> "JOIN @badge_code_invoices ci ON ci.invoice_id = @invoices.invoice_id " + <> "WHERE status = 'open' AND expires_at < ? " + <> unfundedOnly + <> "ORDER BY @invoices.created_at" + +qExpireOverdue :: Query +qExpireOverdue = + mkQuery $ + "UPDATE @invoices SET status = 'expired', updated_at = ? " + <> "WHERE status = 'open' AND expires_at < ? AND invoice_id = ? " + <> unfundedOnly + <> "AND EXISTS (SELECT 1 FROM @badge_code_invoices ci WHERE ci.invoice_id = @invoices.invoice_id)" + +qCodeHashForInvoice :: Query +qCodeHashForInvoice = + mkQuery $ + "SELECT bc.code_hash FROM @badge_code_invoices ci " + <> "JOIN @badge_codes bc ON bc.badge_code_id = ci.badge_code_id " + <> "WHERE ci.invoice_id = ?" + +-- | SQLite's two-argument MAX is GREATEST in Postgres, where MAX is an aggregate. +largerOf :: Text +#if defined(dbPostgres) +largerOf = "GREATEST" +#else +largerOf = "MAX" +#endif + +-- Once a row is settled it stays settled, so a later pending update cannot overwrite it. +qUpsertPayment :: Query +qUpsertPayment = + mkQuery $ + "INSERT INTO @payments " + <> "(payment_id, invoice_id, provider, provider_ref, amount, currency, crypto_paid, crypto_due, paid_in_full, status, created_at, updated_at) " + <> "VALUES (?,?,?,?,?,?,?,?,?,?,?,?) " + <> "ON CONFLICT (payment_id) DO UPDATE SET " + <> "amount = " + <> largerOf + <> "(COALESCE(@payments.amount, 0), excluded.amount), " + <> "crypto_paid = CASE WHEN excluded.amount > COALESCE(@payments.amount, 0) OR @payments.crypto_paid IS NULL " + <> "THEN excluded.crypto_paid ELSE @payments.crypto_paid END, " + <> "crypto_due = COALESCE(excluded.crypto_due, @payments.crypto_due), " + <> "paid_in_full = " + <> largerOf + <> "(@payments.paid_in_full, excluded.paid_in_full), " + <> "status = CASE WHEN " + <> alreadySettled + <> " THEN @payments.status ELSE excluded.status END, " + <> "updated_at = CASE WHEN " + <> alreadySettled + <> " THEN @payments.updated_at ELSE excluded.updated_at END" + where + alreadySettled = "@payments.status = '" <> paymentStatusText PSSettled <> "'" + +qUpdateInvoiceStatus :: Query +qUpdateInvoiceStatus = + mkQuery "UPDATE @invoices SET status = ?, updated_at = ? WHERE invoice_id = ? AND status = ?" + +qMarkCodePaid :: Query +qMarkCodePaid = + mkQuery $ + "UPDATE @badge_codes SET code_payment_status = ?, expires_at = ? " + <> "WHERE code_hash = ? AND code_payment_status = ?" + +qBadgePrices :: Query +qBadgePrices = + mkQuery $ + "SELECT price_id, badge_type, month_price, currency, status, created_at " + <> "FROM @badge_prices WHERE status <> 'disabled'" + +qBadgeOffers :: Query +qBadgeOffers = + mkQuery $ + "SELECT offer_id, price_id, months, free_months, discount, status, created_at " + <> "FROM @badge_offers WHERE status <> 'disabled'" + +qSeedBadgePrice :: Query +qSeedBadgePrice = + mkQuery $ + "INSERT INTO @badge_prices (price_id, badge_type, month_price, currency, status, created_at) " + <> "VALUES (?,?,?,?,?,?) ON CONFLICT (price_id) DO NOTHING" + +qSeedBadgeOffer :: Query +qSeedBadgeOffer = + mkQuery $ + "INSERT INTO @badge_offers (offer_id, price_id, months, free_months, discount, status, created_at) " + <> "VALUES (?,?,?,?,?,?,?) ON CONFLICT (offer_id) DO NOTHING" + +providerText :: PaymentProvider -> Text +providerText = \case + PPApple -> "apple" + PPGoogle -> "google" + PPStripe -> "stripe" + PPCrypto -> "crypto" + PPCode -> "code" + PPReceipt -> "receipt" + +textToProvider :: Text -> Maybe PaymentProvider +textToProvider = \case + "apple" -> Just PPApple + "google" -> Just PPGoogle + "stripe" -> Just PPStripe + "crypto" -> Just PPCrypto + "code" -> Just PPCode + "receipt" -> Just PPReceipt + _ -> Nothing + +cryptoCurrencyText :: CryptoCurrency -> Text +cryptoCurrencyText CCBtc = "btc" +cryptoCurrencyText CCXmr = "xmr" + +textToCryptoCurrency :: Text -> Maybe CryptoCurrency +textToCryptoCurrency "btc" = Just CCBtc +textToCryptoCurrency "xmr" = Just CCXmr +textToCryptoCurrency _ = Nothing + +invoiceStatusText :: InvoiceStatus -> Text +invoiceStatusText ISOpen = "open" +invoiceStatusText ISPaid = "paid" +invoiceStatusText ISExpired = "expired" + +textToInvoiceStatus :: Text -> Maybe InvoiceStatus +textToInvoiceStatus "open" = Just ISOpen +textToInvoiceStatus "paid" = Just ISPaid +textToInvoiceStatus "expired" = Just ISExpired +textToInvoiceStatus _ = Nothing + +itemStatusText :: BadgeItemStatus -> Text +itemStatusText BISActive = "active" +itemStatusText BISDeprecated = "deprecated" +itemStatusText BISDisabled = "disabled" + +textToItemStatus :: Text -> Maybe BadgeItemStatus +textToItemStatus "active" = Just BISActive +textToItemStatus "deprecated" = Just BISDeprecated +textToItemStatus "disabled" = Just BISDisabled +textToItemStatus _ = Nothing + +paymentStatusText :: PaymentStatus -> Text +paymentStatusText = \case + PSPending -> "pending" + PSSettled -> "settled" + PSFailed _ -> "failed" + +decodeDiscount :: Maybe Word8 -> Maybe Word8 -> Maybe OfferDiscount +decodeDiscount (Just f) _ = Just (ODFreeMonths f) +decodeDiscount Nothing (Just d) = Just (ODDiscount d) +decodeDiscount Nothing Nothing = Nothing + +discountCols :: OfferDiscount -> (Maybe Word32, Maybe Word32) +discountCols (ODFreeMonths f) = (Just (fromIntegral f), Nothing) +discountCols (ODDiscount d) = (Nothing, Just (fromIntegral d)) + +mkDestination :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe ServicePaymentDestination +mkDestination (Just url) _ _ _ = Just (SPDCard CPStripe url) +mkDestination Nothing (Just addr) (Just curTxt) (Just amt) = (\cur -> SPDCrypto cur addr amt) <$> textToCryptoCurrency curTxt +mkDestination _ _ _ _ = Nothing + +destinationCols :: ServicePaymentDestination -> (Maybe Text, Maybe Text, Maybe Text, Maybe Text) +destinationCols (SPDCard _ url) = (Just url, Nothing, Nothing, Nothing) +destinationCols (SPDCrypto cur addr amt) = (Nothing, Just addr, Just (cryptoCurrencyText cur), Just amt) + +note :: Text -> Maybe a -> Either Text a +note ctx = maybe (Left ctx) Right + +type InvoiceCols = + (Text, Text, Text, BadgeType, Word32) + :. (Word32, Word32, Text, Maybe Text, Maybe Text, Maybe Text) + :. (Maybe Text, UTCTime, Text, UTCTime) + :. (Maybe Word32, Maybe Text, Maybe Text, Maybe Int, Maybe Text, Maybe UTCTime) + +mkInvoiceRow :: InvoiceCols -> Either Text InvoiceRow +mkInvoiceRow + ( (invId, providerTxt, providerRef, badgeType, months) + :. (price, amount, currency, url, addr, cryptoCur) + :. (cryptoAmt, expiresAt, statusTxt, createdAt) + :. (pAmount, pCryptoPaid, pCryptoDue, pPaidInFull, pStatus, pUpdatedAt) + ) = do + provider <- note "invoices.provider" (textToProvider providerTxt) + status <- note "invoices.status" (textToInvoiceStatus statusTxt) + destination <- note "invoice payment destination" (mkDestination url addr cryptoCur cryptoAmt) + pure + InvoiceRow + { irInvoiceId = InvoiceId invId, + irProvider = provider, + irProviderRef = providerRef, + irBadgeType = badgeType, + irMonths = fromIntegral months, + irPrice = CurrencyAmount price, + irAmount = CurrencyAmount amount, + irCurrency = currency, + irDestination = destination, + irExpiresAt = expiresAt, + irStatus = status, + irCreatedAt = createdAt, + irPayment = mkPayment pAmount pCryptoPaid pCryptoDue pPaidInFull pStatus pUpdatedAt + } + +mkPayment :: Maybe Word32 -> Maybe Text -> Maybe Text -> Maybe Int -> Maybe Text -> Maybe UTCTime -> Maybe InvoicePayment +mkPayment amt cryptoPaid cryptoDue paidInFull status updatedAt = + InvoicePayment (CurrencyAmount <$> amt) cryptoPaid cryptoDue (paidInFull == Just 1) <$> status <*> updatedAt + +selectInvoiceRow :: DB.Connection -> Query -> Text -> IO (Maybe InvoiceRow) +selectInvoiceRow db q param = do + rows <- DB.query db q (Only param) + case rows of + [] -> pure Nothing + (row : _) -> either (E.throwIO . StoreDecodeError) (pure . Just) (mkInvoiceRow row) + +type BadgePriceCols = (Text, BadgeType, Word32, Text, Text, UTCTime) + +mkBadgePrice :: BadgePriceCols -> Either Text BadgePrice +mkBadgePrice (priceId, badgeType, monthPrice, currency, statusTxt, createdAt) = do + status <- note "badge_prices.status" (textToItemStatus statusTxt) + pure + BadgePrice + { priceId = BadgePriceId priceId, + badgeType, + monthPrice = CurrencyAmount monthPrice, + currency, + status, + createdAt + } + +type BadgeOfferCols = (Text, Maybe Text, Word32, Maybe Word32, Maybe Word32, Text, UTCTime) + +mkBadgeOffer :: BadgeOfferCols -> Either Text BadgeOffer +mkBadgeOffer (offerId, priceId, months, freeMonths, discountPct, statusTxt, createdAt) = do + status <- note "badge_offers.status" (textToItemStatus statusTxt) + discount <- note "badge_offers discount" (decodeDiscount (fromIntegral <$> freeMonths) (fromIntegral <$> discountPct)) + pure + BadgeOffer + { offerId = BadgeOfferId offerId, + priceId = BadgePriceId <$> priceId, + months = fromIntegral months, + discount, + status, + createdAt + } + +createInvoiceRows :: DBStore -> NewInvoice -> IO (Either CreateError ()) +createInvoiceRows st ni = + (Right <$> withTransaction st (`insertInvoiceRows` ni)) + `E.catch` (pure . Left . classifyCreateError) + +insertInvoiceRows :: DB.Connection -> NewInvoice -> IO () +insertInvoiceRows db NewInvoice {..} = do + let InvoiceId invId = niInvoiceId + CurrencyAmount price = niPrice + CurrencyAmount amount = niAmount + discountAmount = price - amount + (url, addr, cryptoCur, cryptoAmt) = destinationCols niDestination + expiresAt = truncateToSecond niExpiresAt + createdAt = truncateToSecond niCreatedAt + BadgePriceId priceId = niPriceId + offerId = (\(BadgeOfferId o) -> o) <$> niOfferId + months = fromIntegral niMonths :: Word32 + DB.execute + db + qInsertInvoice + ( (invId, providerText niProvider, price, discountAmount, Nothing :: Maybe Word32, amount, niCurrency) + :. (url, addr, cryptoCur, cryptoAmt, expiresAt, invoiceStatusText ISOpen, createdAt, createdAt) + ) + DB.execute + db + qInsertBadgeCode + (DB.Binary niCodeHash, niBadgeType, months, textEncode CPSUnpaid, createdAt) + badgeCodeId <- insertedRowId db + DB.execute + db + qInsertBadgeCodeInvoice + (invId, badgeCodeId, priceId, offerId, niProviderRef, createdAt) + +#if defined(dbPostgres) +classifyCreateError :: DB.SQLError -> CreateError +classifyCreateError e = case constraintViolation e of + Just (UniqueViolation name) + | "code_hash" `T.isInfixOf` nameText -> CECodeConflict + | "provider_ref" `T.isInfixOf` nameText -> CERefConflict + | otherwise -> CEOther (tshow e) + where + nameText = safeDecodeUtf8 name + _ -> CEOther (tshow e) +#else +classifyCreateError :: DB.SQLError -> CreateError +classifyCreateError e + | SQL.sqlError e == SQL.ErrorConstraint = + if "code_hash" `T.isInfixOf` details + then CECodeConflict + else + if "provider_ref" `T.isInfixOf` details + then CERefConflict + else CEOther details + | otherwise = CEOther (tshow e) + where + details = SQL.sqlErrorDetails e +#endif + +getInvoice :: DBStore -> InvoiceId -> IO (Maybe InvoiceRow) +getInvoice st (InvoiceId invId) = withConnection st $ \db -> selectInvoiceRow db qGetInvoice invId + +getInvoiceByProviderRef :: DBStore -> Text -> IO (Maybe InvoiceRow) +getInvoiceByProviderRef st ref = withConnection st $ \db -> selectInvoiceRow db qGetInvoiceByProviderRef ref + +unpaidRefs :: DBStore -> UTCTime -> IO [(Text, Text)] +unpaidRefs st since = withConnection st $ \db -> DB.query db qUnpaidRefs (Only since) + +codeHashExists :: DBStore -> ByteString -> IO Bool +codeHashExists st codeHash = withConnection st $ \db -> + not . null <$> (DB.query db qCodeHashExists (Only (DB.Binary codeHash)) :: IO [Only Int]) + +data OverdueInvoice = OverdueInvoice + { oiInvoiceId :: InvoiceId, + oiProvider :: PaymentProvider, + oiProviderRef :: Text, + oiCreatedAt :: UTCTime + } + deriving (Eq, Show) + +overdueInvoices :: DBStore -> UTCTime -> IO [OverdueInvoice] +overdueInvoices st cutoff = withConnection st $ \db -> do + rows <- DB.query db qOverdueInvoices (Only (truncateToSecond cutoff)) + either (E.throwIO . StoreDecodeError) pure (traverse toOverdue rows) + where + toOverdue (i, providerTxt, oiProviderRef, oiCreatedAt) = case textToProvider providerTxt of + Just oiProvider -> Right OverdueInvoice {oiInvoiceId = InvoiceId i, oiProvider, oiProviderRef, oiCreatedAt} + Nothing -> Left ("invoices.provider: " <> providerTxt) + +-- | Of the given invoices, expire those that are still open, have received no money, and are past their deadline. +expireOverdue :: DBStore -> UTCTime -> [InvoiceId] -> IO [InvoiceId] +expireOverdue st cutoff' invIds = withTransaction st $ \db -> do + let cutoff = truncateToSecond cutoff' + filterM (\(InvoiceId i) -> (> 0) <$> executeChanging db qExpireOverdue (cutoff, cutoff, i)) invIds + +cancelOpenInvoice :: DBStore -> InvoiceId -> UTCTime -> IO Bool +cancelOpenInvoice st invId at = withTransaction st $ \db -> updateInvoiceStatus db invId ISOpen ISExpired at + +readCatalogRows :: DBStore -> IO ([BadgePrice], [BadgeOffer]) +readCatalogRows st = withConnection st $ \db -> do + priceRows <- DB.query_ db qBadgePrices + offerRows <- DB.query_ db qBadgeOffers + prices <- either (E.throwIO . StoreDecodeError) pure (traverse mkBadgePrice priceRows) + offers <- either (E.throwIO . StoreDecodeError) pure (traverse mkBadgeOffer offerRows) + pure (prices, offers) + +seedCatalog :: DBStore -> [BadgePrice] -> [BadgeOffer] -> IO (Int, Int) +seedCatalog st prices offers = withTransaction st $ \db -> do + seededPrices <- sum <$> mapM (seedPrice db) prices + seededOffers <- sum <$> mapM (seedOffer db) offers + pure (seededPrices, seededOffers) + where + seedPrice db BadgePrice {priceId = BadgePriceId pId, badgeType = bType, monthPrice = CurrencyAmount mPrice, currency = cur, status = pStatus, createdAt = at} = + executeChanging db qSeedBadgePrice (pId, bType, mPrice, cur, itemStatusText pStatus, truncateToSecond at) + seedOffer db BadgeOffer {offerId = BadgeOfferId oId, priceId = oPriceId, months = mMonths, discount = disc, status = oStatus, createdAt = at} = + let (freeMonths, discountPct) = discountCols disc + in executeChanging + db + qSeedBadgeOffer + ( oId, + (\(BadgePriceId p) -> p) <$> oPriceId, + fromIntegral mMonths :: Word32, + freeMonths, + discountPct, + itemStatusText oStatus, + truncateToSecond at + ) + +executeChanging :: ToRow q => DB.Connection -> Query -> q -> IO Int +#if defined(dbPostgres) +executeChanging db q params = fromIntegral <$> PSQL.execute db q params +#else +executeChanging db q params = DB.execute db q params >> SQL.changes (DB.conn db) +#endif + +settlementInvoice :: DB.Connection -> InvoiceId -> IO (Maybe InvoiceRow) +settlementInvoice db (InvoiceId invId) = selectInvoiceRow db qGetInvoice invId + +settlementCodeHash :: DB.Connection -> InvoiceId -> IO (Maybe ByteString) +settlementCodeHash db (InvoiceId invId) = do + rows <- DB.query db qCodeHashForInvoice (Only invId) :: IO [Only (Maybe (DB.Binary ByteString))] + pure $ case rows of + (Only codeHash : _) -> DB.fromBinary <$> codeHash + [] -> Nothing + +upsertPayment :: DB.Connection -> InvoiceRow -> PaymentStatus -> CurrencyAmount -> Maybe Text -> Maybe Text -> Bool -> UTCTime -> IO () +upsertPayment db InvoiceRow {irInvoiceId, irProvider, irProviderRef, irCurrency} status (CurrencyAmount amount) cryptoAmount cryptoDue paidInFull at' = + DB.execute + db + qUpsertPayment + ( (invId, invId, providerText irProvider, irProviderRef, amount) + :. (irCurrency, cryptoAmount, cryptoDue, if paidInFull then 1 :: Int else 0, paymentStatusText status, at, at) + ) + where + InvoiceId invId = irInvoiceId + at = truncateToSecond at' + +updateInvoiceStatus :: DB.Connection -> InvoiceId -> InvoiceStatus -> InvoiceStatus -> UTCTime -> IO Bool +updateInvoiceStatus db (InvoiceId invId) observed new at = + (> 0) <$> executeChanging db qUpdateInvoiceStatus (invoiceStatusText new, truncateToSecond at, invId, invoiceStatusText observed) + +markCodePaid :: DB.Connection -> ByteString -> UTCTime -> IO () +markCodePaid db codeHash expiresAt = + DB.execute + db + qMarkCodePaid + (textEncode CPSPaid, truncateToSecond expiresAt, DB.Binary codeHash, textEncode CPSUnpaid) + +newInvoiceId :: IO InvoiceId +newInvoiceId = InvoiceId . safeDecodeUtf8 . BC8.filter (/= '=') . B64U.encode <$> getRandomBytes 16 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 e8b62c2d53..f096d3098d 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -2,7 +2,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations) where +module BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations, servicePrefix, withPrefix) where import Data.List (sortOn) import Data.Text (Text) @@ -17,19 +17,30 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Text, Maybe Text)] schemaMigrations = - [ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_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 are in the same database, so the service tables are the same names with this prefix +-- | The client tables share this database, so the service tables are the same names behind a prefix. servicePrefix :: Text servicePrefix = "sx_badge_service_" -m20260806_badge_service_schema :: Text -m20260806_badge_service_schema = +m20260915_badge_service_schema :: Text +m20260915_badge_service_schema = badgeSchema servicePrefix <> withPrefix servicePrefix + -- The payment columns are added to @payments, which badgeSchema owns. crypto_paid, + -- crypto_due and paid_in_full record the provider's own figures: it applies a payment + -- tolerance and adds a network fee after a partial payment, so what is owed and whether + -- an invoice is settled are its verdicts, not amounts recomputable from what we store. [r| +ALTER TABLE @payments ADD COLUMN crypto_paid TEXT; + +ALTER TABLE @payments ADD COLUMN crypto_due TEXT; + +ALTER TABLE @payments ADD COLUMN paid_in_full SMALLINT NOT NULL DEFAULT 0; + CREATE TABLE @badge_codes( badge_code_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, code_hash BYTEA NOT NULL, @@ -37,6 +48,8 @@ CREATE TABLE @badge_codes( months SMALLINT NOT NULL, code_payment_status TEXT NOT NULL, redeemed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, UNIQUE(code_hash) ); @@ -47,22 +60,43 @@ CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id) CREATE TABLE @badge_code_invoices( invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + badge_code_id BIGINT NOT NULL REFERENCES @badge_codes, price_id TEXT NOT NULL REFERENCES @badge_prices, offer_id TEXT REFERENCES @badge_offers, - months SMALLINT NOT NULL, + provider_ref TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL ); CREATE INDEX @idx_badge_code_invoices_offer ON @badge_code_invoices(offer_id); CREATE INDEX @idx_badge_code_invoices_price ON @badge_code_invoices(price_id); + +-- provider_ref is the provider's own id for the invoice; unique so a webhook or poller read +-- resolves a payment to exactly one invoice. +CREATE UNIQUE INDEX @idx_badge_code_invoices_provider_ref ON @badge_code_invoices(provider_ref); +|] + -- Two filters run on every poller pass and neither may read the whole table, or the pass + -- lengthens for as long as the service keeps selling: the expiry sweep, on (status, expires_at), + -- and the read lane, which takes a window of created_at. Status leads the first because it is + -- matched by equality there; the second matches it with <>, which no index can seek, so it seeks + -- the window and filters what little that leaves. + <> withPrefix + servicePrefix + [r| +CREATE INDEX @idx_invoices_status_expires_at ON @invoices(status, expires_at); + +CREATE INDEX @idx_invoices_created ON @invoices(created_at); |] -down_m20260806_badge_service_schema :: Text -down_m20260806_badge_service_schema = +down_m20260915_badge_service_schema :: Text +down_m20260915_badge_service_schema = withPrefix servicePrefix [r| +DROP INDEX @idx_invoices_created; +DROP INDEX @idx_invoices_status_expires_at; + +DROP INDEX @idx_badge_code_invoices_provider_ref; DROP INDEX @idx_badge_code_invoices_offer; DROP INDEX @idx_badge_code_invoices_price; DROP TABLE @badge_code_invoices; @@ -76,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 ce08db0c7a..cd89859686 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -2,7 +2,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations) where +module BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations, servicePrefix, withPrefix) where import Data.List (sortOn) import Data.Text (Text) @@ -18,19 +18,30 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations = - [ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_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 are in the same database, so the service tables are the same names with this prefix +-- | The client tables share this database, so the service tables are the same names behind a prefix. servicePrefix :: Text servicePrefix = "sx_badge_service_" -m20260806_badge_service_schema :: Query -m20260806_badge_service_schema = +m20260915_badge_service_schema :: Query +m20260915_badge_service_schema = badgeSchema servicePrefix <> withPrefix servicePrefix + -- The payment columns are added to @payments, which badgeSchema owns. crypto_paid, + -- crypto_due and paid_in_full record the provider's own figures: it applies a payment + -- tolerance and adds a network fee after a partial payment, so what is owed and whether + -- an invoice is settled are its verdicts, not amounts recomputable from what we store. [sql| +ALTER TABLE @payments ADD COLUMN crypto_paid TEXT; + +ALTER TABLE @payments ADD COLUMN crypto_due TEXT; + +ALTER TABLE @payments ADD COLUMN paid_in_full INTEGER NOT NULL DEFAULT 0; + CREATE TABLE @badge_codes( badge_code_id INTEGER PRIMARY KEY AUTOINCREMENT, code_hash BLOB NOT NULL, @@ -38,6 +49,8 @@ CREATE TABLE @badge_codes( months INTEGER NOT NULL, code_payment_status TEXT NOT NULL, redeemed_at TEXT, + expires_at TEXT, + revoked_at TEXT, created_at TEXT NOT NULL, UNIQUE(code_hash) ) STRICT; @@ -48,22 +61,43 @@ CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id) CREATE TABLE @badge_code_invoices( invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + badge_code_id INTEGER NOT NULL REFERENCES @badge_codes, price_id TEXT NOT NULL REFERENCES @badge_prices, offer_id TEXT REFERENCES @badge_offers, - months INTEGER NOT NULL, + provider_ref TEXT NOT NULL, created_at TEXT NOT NULL ) STRICT; CREATE INDEX @idx_badge_code_invoices_offer ON @badge_code_invoices(offer_id); CREATE INDEX @idx_badge_code_invoices_price ON @badge_code_invoices(price_id); + +-- provider_ref is the provider's own id for the invoice; unique so a webhook or poller read +-- resolves a payment to exactly one invoice. +CREATE UNIQUE INDEX @idx_badge_code_invoices_provider_ref ON @badge_code_invoices(provider_ref); +|] + -- Two filters run on every poller pass and neither may read the whole table, or the pass + -- lengthens for as long as the service keeps selling: the expiry sweep, on (status, expires_at), + -- and the read lane, which takes a window of created_at. Status leads the first because it is + -- matched by equality there; the second matches it with <>, which no index can seek, so it seeks + -- the window and filters what little that leaves. + <> withPrefix + servicePrefix + [sql| +CREATE INDEX @idx_invoices_status_expires_at ON @invoices(status, expires_at); + +CREATE INDEX @idx_invoices_created ON @invoices(created_at); |] -down_m20260806_badge_service_schema :: Query -down_m20260806_badge_service_schema = +down_m20260915_badge_service_schema :: Query +down_m20260915_badge_service_schema = withPrefix servicePrefix [sql| +DROP INDEX @idx_invoices_created; +DROP INDEX @idx_invoices_status_expires_at; + +DROP INDEX @idx_badge_code_invoices_provider_ref; DROP INDEX @idx_badge_code_invoices_offer; DROP INDEX @idx_badge_code_invoices_price; DROP TABLE @badge_code_invoices; @@ -77,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-badge-service/src/BadgeService/Waiters.hs b/apps/simplex-badge-service/src/BadgeService/Waiters.hs new file mode 100644 index 0000000000..d074b28185 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Waiters.hs @@ -0,0 +1,91 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module BadgeService.Waiters (Waiters, Seen, newWaiters, publish, publishPayment, awaitStatus, waitingCount, waitingCountSTM) where + +import Control.Concurrent.STM +import Control.Exception (bracket) +import Control.Monad (forM_, when) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map +import Data.Maybe (fromMaybe, isNothing) +import Data.Text (Text) +import Simplex.Chat.PaymentService.Types (InvoiceId (..), InvoiceStatus) + +-- | wStatus stays Nothing until a publish or a read fills it, so a publish mid-read is not overwritten by the older answer. +data Watch = Watch {wStatus :: TVar (Maybe InvoiceStatus), wPayments :: TVar Int, wRefs :: TVar Int} + +newtype Waiters = Waiters (TVar (Map Text Watch)) + +type Seen = (InvoiceStatus, (Text, Bool)) + +newWaiters :: IO Waiters +newWaiters = Waiters <$> newTVarIO Map.empty + +-- | Call after the settling transaction commits. The reader re-reads the row, so an overwriting publish costs nothing. +publish :: Waiters -> InvoiceId -> InvoiceStatus -> STM () +publish (Waiters wv) (InvoiceId iid) status = do + watches <- readTVar wv + forM_ (Map.lookup iid watches) $ \Watch {wStatus} -> writeTVar wStatus (Just status) + +-- | Call after the commit, for a payment that did not move the status. +publishPayment :: Waiters -> InvoiceId -> STM () +publishPayment (Waiters wv) (InvoiceId iid) = do + watches <- readTVar wv + forM_ (Map.lookup iid watches) $ \Watch {wPayments} -> modifyTVar' wPayments (+ 1) + +subscribe :: Waiters -> InvoiceId -> STM Watch +subscribe (Waiters wv) (InvoiceId iid) = do + watches <- readTVar wv + case Map.lookup iid watches of + Just watch@Watch {wRefs} -> do + modifyTVar' wRefs (+ 1) + pure watch + Nothing -> do + status <- newTVar Nothing + payments <- newTVar 0 + refs <- newTVar 1 + let watch = Watch {wStatus = status, wPayments = payments, wRefs = refs} + writeTVar wv (Map.insert iid watch watches) + pure watch + +release :: Waiters -> InvoiceId -> STM () +release (Waiters wv) (InvoiceId iid) = do + watches <- readTVar wv + forM_ (Map.lookup iid watches) $ \Watch {wRefs} -> do + n <- pred <$> readTVar wRefs + if n <= 0 + then writeTVar wv (Map.delete iid watches) + else writeTVar wRefs n + +-- | Subscribe, then read, then block; reading first would miss a settlement landing in between. +awaitStatus :: Waiters -> InvoiceId -> IO Seen -> Seen -> Int -> IO InvoiceStatus +awaitStatus w iid readSeen seen@(seenStatus, _) usec = + bracket (atomically $ subscribe w iid) (const . atomically $ release w iid) $ \Watch {wStatus, wPayments} -> do + paidAt <- readTVarIO wPayments + current@(currentStatus, _) <- readSeen -- after subscribing, never before + -- a publish that landed between subscribe and this read is the fresher answer, so keep it + atomically $ readTVar wStatus >>= \published -> when (isNothing published) (writeTVar wStatus (Just currentStatus)) + if current /= seen + then pure currentStatus + else do + timer <- registerDelay usec + atomically $ + ( do + published <- readTVar wStatus + payments <- readTVar wPayments + case published of + -- seeded above and only republished, so this stays `Just` while the bracket holds its reference + Just s | s /= seenStatus || payments /= paidAt -> pure s + _ -> retry + ) + `orElse` ( do + readTVar timer >>= check + fromMaybe seenStatus <$> readTVar wStatus + ) + +waitingCount :: Waiters -> IO Int +waitingCount = atomically . waitingCountSTM + +-- | In STM so the poller can block on it changing, letting an arriving browser shorten the sleep it lands in. +waitingCountSTM :: Waiters -> STM Int +waitingCountSTM (Waiters wv) = Map.size <$> readTVar wv diff --git a/apps/simplex-badge-service/src/BadgeService/Web/Server.hs b/apps/simplex-badge-service/src/BadgeService/Web/Server.hs new file mode 100644 index 0000000000..0c742bccf1 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Web/Server.hs @@ -0,0 +1,741 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module BadgeService.Web.Server + ( WebEnv (..), + Limit (..), + newWebEnv, + exportWebapp, + takeToken, + maxBuckets, + maxWebhookBytes, + webApp, + webSettings, + runWebListener, + holdMicros, + readLimit, + ) +where + +import BadgeService.Catalog (PricedOffer (..), priceOffer) +import BadgeService.Config (BTCPayConfig (..), ListenerConfig (..), ServiceConfig (..), SpeedPolicy (..), StripeConfig (..), defaultExpiryMinutes, defaultSessionMinutes) +import BadgeService.Poller (ReadHints, queueReadHint) +import BadgeService.Providers (OrderDraft (..), Provider (..), ProviderError (..), ProviderInvoice (..), WebhookError (..)) +import BadgeService.Store.Invoices (CreateError (..), InvoicePayment (..), InvoiceRow (..), NewInvoice (..), cancelOpenInvoice, codeHashExists, createInvoiceRows, cryptoCurrencyText, getInvoice, getInvoiceByProviderRef, invoiceStatusText, newInvoiceId, paymentHolds, readCatalogRows, textToInvoiceStatus, truncateToSecond) +import BadgeService.Waiters (Seen, Waiters, awaitStatus, publish) +import Control.Concurrent.STM +import qualified Control.Exception as E +import BadgeService.Log (logError, logInfo, logWarn) +import Control.Monad (forM_, when) +import Data.Aeson ((.=)) +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM +import Data.Aeson.Types (Pair) +import Data.ByteString (ByteString) +import qualified Data.ByteString as BS +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.Char (toLower) +import Data.List (find, isPrefixOf, sortOn) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map +import Data.Maybe (fromMaybe) +import Data.String (fromString) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (decodeUtf8', encodeUtf8) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime) +import Data.Word (Word16) +import Network.HTTP.Types (Header, Status, hCacheControl, hContentType, status200, status400, status404, status405, status409, status413, status429, status500, status503) +import Network.Socket (SockAddr (..), hostAddress6ToTuple, hostAddressToTuple) +import Network.Wai +import qualified Network.Wai.Handler.Warp as Warp +import Numeric (showHex) +import Simplex.Chat.Badges.Types (BadgeOfferId (..), BadgePriceId (..)) +import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), ServicePaymentDestination (..), ServicePaymentMethod (..)) +import Simplex.Messaging.Agent.Store.Common (DBStore) +import Simplex.Messaging.Encoding.String (textEncode) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow) +import System.Directory (canonicalizePath, copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, listDirectory, removePathForcibly) +import System.FilePath (pathSeparator, takeExtension, ()) +import Text.Read (readMaybe) + +data WebEnv = WebEnv + { weStore :: DBStore, + weConfig :: ServiceConfig, + weWaiters :: Waiters, + weProviders :: [Provider], + weHoldMicros :: Int, + weHints :: ReadHints, + weBuckets :: TVar (Map Text Bucket), + -- | @Nothing@ when no Stripe key is configured, and the pristine @static_dir@ shell is served instead. + weShell :: Maybe LB.ByteString + } + +newWebEnv :: DBStore -> ServiceConfig -> Waiters -> ReadHints -> [Provider] -> IO WebEnv +newWebEnv weStore weConfig weWaiters weHints weProviders = do + weBuckets <- newTVarIO Map.empty + weShell <- prepareShell (listener weConfig) (stripe weConfig) + pure WebEnv {weStore, weConfig, weWaiters, weProviders, weHoldMicros = holdMicros, weHints, weBuckets, weShell} + +listenerConfig :: WebEnv -> ListenerConfig +listenerConfig WebEnv {weConfig} = listener weConfig + +type Respond = Response -> IO ResponseReceived + +-- | Short enough to stay under the idle timeout of any proxy in front of us. +holdMicros :: Int +holdMicros = 30 * 1000000 + +readLimit :: Limit +readLimit = Limit {lmName = "read", lmPerMinute = 60} + +createLimit :: Limit +createLimit = Limit {lmName = "create", lmPerMinute = 5} + +maxBodyBytes :: Int +maxBodyBytes = 8192 + +maxWebhookBytes :: Int +maxWebhookBytes = 64 * 1024 + +sha256Bytes :: Int +sha256Bytes = 32 + +codeHashChars :: Int +codeHashChars = 43 + +data Limit = Limit {lmName :: Text, lmPerMinute :: Int} + deriving (Eq, Show) + +data Bucket = Bucket {bkCount :: Int, bkStarted :: UTCTime} + +limitWindow :: NominalDiffTime +limitWindow = 60 + +maxBuckets :: Int +maxBuckets = 8192 + +reclaim :: UTCTime -> Map Text Bucket -> Map Text Bucket +reclaim now buckets + | Map.size buckets < maxBuckets = buckets + | Map.size live * 2 <= maxBuckets = live + | otherwise = Map.fromList (drop (Map.size live `div` 2) (sortOn (bkCount . snd) (Map.toList live))) + where + live = Map.filter (fresh now) buckets + +fresh :: UTCTime -> Bucket -> Bool +fresh now Bucket {bkStarted} = diffUTCTime now bkStarted < limitWindow + +takeToken :: WebEnv -> Limit -> Text -> IO (Maybe Int) +takeToken WebEnv {weBuckets} Limit {lmName, lmPerMinute} client = do + now <- getCurrentTime + atomically $ do + buckets <- reclaim now <$> readTVar weBuckets + let key = lmName <> "\t" <> client + count bucket = writeTVar weBuckets (Map.insert key bucket buckets) >> pure Nothing + -- write the reclaimed map back here too, or a refused request redoes the filtering every time and the map stays at the limit + refuse seconds = writeTVar weBuckets buckets >> pure (Just seconds) + case Map.lookup key buckets of + Just bucket@Bucket {bkCount, bkStarted} + | fresh now bucket -> + if bkCount < lmPerMinute + then count bucket {bkCount = bkCount + 1} + else refuse (secondsLeft now bkStarted) + _ -> count Bucket {bkCount = 1, bkStarted = now} + where + secondsLeft :: UTCTime -> UTCTime -> Int + secondsLeft now started = max 1 (ceiling (limitWindow - diffUTCTime now started)) + +limited :: WebEnv -> Limit -> Request -> Respond -> IO ResponseReceived -> IO ResponseReceived +limited env limit req respond action = + takeToken env limit (clientKey env req) >>= \case + Nothing -> action + Just seconds -> respond (rateLimited seconds) + +-- | Every line is joined first, so a second header line a caller adds cannot leave its own line the one we read. +clientKey :: WebEnv -> Request -> Text +clientKey env req = fromMaybe (peerText (remoteHost req)) forwarded + where + forwarded + | not (lTrustForwardedFor (listenerConfig env)) = Nothing + | otherwise = case reverse (concatMap entries (requestHeaders req)) of + (ip : _) | isIpAddress ip -> Just ip + _ -> Nothing + entries (name, raw) + | name /= "x-forwarded-for" = [] + | otherwise = either (const []) (filter (not . T.null) . map T.strip . T.splitOn ",") (decodeUtf8' raw) + +isIpAddress :: Text -> Bool +isIpAddress t = isIPv4 t || isIPv6 t + +asciiDigit :: Char -> Bool +asciiDigit c = c >= '0' && c <= '9' + +-- Data.Char.isDigit and isHexDigit accept Unicode digits, which are not addresses. +asciiHexDigit :: Char -> Bool +asciiHexDigit c = asciiDigit c || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') + +-- | A leading zero is refused, so "010.0.0.1" cannot be a second key for "10.0.0.1". +isIPv4 :: Text -> Bool +isIPv4 t = case T.splitOn "." t of + [a, b, c, d] -> all octet [a, b, c, d] + _ -> False + where + octet s = + not (T.null s) + && T.length s <= 3 + && T.all asciiDigit s + && (T.length s == 1 || T.head s /= '0') + && maybe False (<= 255) (readMaybe (T.unpack s) :: Maybe Int) + +isIPv6 :: Text -> Bool +isIPv6 t + | T.any (== '%') t = False + | otherwise = case T.splitOn "::" t of + [full] -> groupCount True (segments full) == Just 8 + [before, after] -> case (groupCount False (segments before), groupCount True (segments after)) of + (Just b, Just a) -> b + a <= 7 -- "::" stands for at least one omitted group + _ -> False + _ -> False + where + segments s = if T.null s then [] else T.splitOn ":" s + +groupCount :: Bool -> [Text] -> Maybe Int +groupCount endsAddress gs + | null gs = Just 0 + | endsAddress && isIPv4 (last gs) = (+ 2) <$> plainGroups (init gs) + | otherwise = plainGroups gs + where + plainGroups ps = if all hexGroup ps then Just (length ps) else Nothing + hexGroup g = not (T.null g) && T.length g <= 4 && T.all asciiHexDigit g + +peerText :: SockAddr -> Text +peerText = \case + SockAddrInet _ addr -> + let (a, b, c, d) = hostAddressToTuple addr + in T.intercalate "." (map tshowInt [a, b, c, d]) + SockAddrInet6 _ _ addr _ -> + let (a, b, c, d, e, f, g, h) = hostAddress6ToTuple addr + in T.intercalate ":" (map hex16 [a, b, c, d, e, f, g, h]) + SockAddrUnix p -> "unix:" <> T.pack p + where + tshowInt :: Show a => a -> Text + tshowInt = T.pack . show + hex16 :: Word16 -> Text + hex16 w = T.pack (showHex w "") + +apiHeaders :: [Header] +apiHeaders = [(hContentType, "application/json"), (hCacheControl, "no-store")] + +jsonResponse :: Status -> J.Value -> Response +jsonResponse st = responseLBS st apiHeaders . J.encode + +jsonError :: Status -> Text -> Response +jsonError st code = jsonResponse st (J.object ["error" .= code]) + +notFound :: Response +notFound = jsonError status404 "not_found" + +badRequest :: Response +badRequest = jsonError status400 "bad_request" + +catalogChanged :: Response +catalogChanged = jsonError status400 "catalog_changed" + +codeConflict :: Response +codeConflict = jsonError status409 "code_conflict" + +providerUnavailable :: Response +providerUnavailable = jsonError status503 "provider_unavailable" + +rateLimited :: Int -> Response +rateLimited seconds = + responseLBS + status429 + (("Retry-After", B.pack (show seconds)) : apiHeaders) + (J.encode (J.object ["error" .= ("rate_limited" :: Text)])) + +methodNotAllowed :: ByteString -> Response +methodNotAllowed allow = responseLBS status405 (("Allow", allow) : apiHeaders) (J.encode (J.object ["error" .= ("method_not_allowed" :: Text)])) + +internalError :: Response +internalError = jsonError status500 "internal" + +webhookOk, webhookRefused, webhookTooLarge :: Response +webhookOk = webhookResponse status200 +webhookRefused = webhookResponse status400 +webhookTooLarge = webhookResponse status413 + +webhookResponse :: Status -> Response +webhookResponse st = responseLBS st [(hCacheControl, "no-store")] "" + +webApp :: WebEnv -> Application +webApp env req respond = case pathInfo req of + [] | serving -> only "GET" $ case weShell env of + Just shell -> respond (responseLBS status200 shellHeaders shell) + Nothing -> serveStatic env ["index.html"] respond + "assets" : rest | serving -> only "GET" $ serveStatic env ("assets" : rest) respond + ["sw.js"] | serving -> only "GET" $ serveStatic env ["sw.js"] respond + ["api", "invoice"] -> only "POST" $ limited env createLimit req respond $ createInvoiceHandler env req respond + ["api", "invoice", iid] -> only "GET" $ limited env readLimit req respond $ readInvoiceHandler env (InvoiceId iid) req respond + ["api", "invoice", iid, "cancel"] -> only "POST" $ limited env createLimit req respond $ cancelInvoiceHandler env (InvoiceId iid) respond + ["webhooks", "btcpay"] -> only "POST" $ webhookHandler env PPCrypto "POST /webhooks/btcpay" req respond + ["webhooks", "stripe"] -> only "POST" $ webhookHandler env PPStripe "POST /webhooks/stripe" req respond + _ -> respond notFound + where + serving = lServeWebapp (listenerConfig env) + only method action + | requestMethod req == method = action + | otherwise = respond (methodNotAllowed method) + +-- | A @".."@ check would miss separators WAI has already decoded, an absolute path, and a symlink out of the tree. +serveStatic :: WebEnv -> [Text] -> Respond -> IO ResponseReceived +serveStatic env segments respond = + resolveInside (lStaticDir (listenerConfig env)) segments >>= \case + Nothing -> respond notFound + Just (root, file) -> respond (responseFile status200 (staticHeaders root file) file Nothing) + +shellHeaders :: [Header] +shellHeaders = [(hContentType, "text/html; charset=utf-8"), (hCacheControl, "no-cache")] + +publishableKeyMeta :: Text +publishableKeyMeta = "id=\"stripe-publishable-key\" name=\"stripe-publishable-key\" content=\"" + +-- | @Nothing@ (serve the pristine file) when no key is set, no shell is present, or it cannot be read. +prepareShell :: ListenerConfig -> Maybe StripeConfig -> IO (Maybe LB.ByteString) +prepareShell _ Nothing = pure Nothing +prepareShell ListenerConfig {lStaticDir} (Just StripeConfig {sPublishableKey}) = + E.try attempt >>= \case + Right out -> pure out + Left (e :: E.IOException) -> do + logWarn ("could not read the shell in " <> T.pack lStaticDir <> ", serving the pristine one: " <> tshow e) + pure Nothing + where + attempt :: IO (Maybe LB.ByteString) + attempt = do + let shell = lStaticDir "index.html" + present <- doesFileExist shell + if not present + then pure Nothing + else do + -- TIO.readFile would decode in the locale's encoding, which fails outright under a C locale. + bytes <- BS.readFile shell + case decodeUtf8' bytes of + Left e -> do + logWarn ("the shell in " <> T.pack lStaticDir <> " is not valid UTF-8, serving the pristine one: " <> tshow e) + pure Nothing + Right html -> pure (Just (LB.fromStrict (encodeUtf8 (injectPublishableKey sPublishableKey html)))) + +exportWebapp :: ListenerConfig -> Maybe StripeConfig -> IO () +exportWebapp lc@ListenerConfig {lStaticDir, lWebappExportDir} stripeCfg = + forM_ lWebappExportDir $ \out -> do + emptyDir out + copyTree lStaticDir out + prepareShell lc stripeCfg >>= mapM_ (LB.writeFile (out "index.html")) + logInfo ("badge service: exported the webapp to " <> T.pack out) + +-- | Contents only: the directory itself may be a bind or volume mount point that cannot be removed. +emptyDir :: FilePath -> IO () +emptyDir dir = do + createDirectoryIfMissing True dir + entries <- listDirectory dir + forM_ entries (removePathForcibly . (dir )) + +copyTree :: FilePath -> FilePath -> IO () +copyTree src dst = do + isDir <- doesDirectoryExist src + if isDir + then do + createDirectoryIfMissing True dst + listDirectory src >>= mapM_ (\e -> copyTree (src e) (dst e)) + else copyFile src dst + +injectPublishableKey :: Text -> Text -> Text +injectPublishableKey key = T.replace (publishableKeyMeta <> "\"") (publishableKeyMeta <> key <> "\"") + +resolveInside :: FilePath -> [Text] -> IO (Maybe (FilePath, FilePath)) +resolveInside dir segments = either ioFailed id <$> E.try attempt + where + -- a NUL byte in the path makes the calls below throw + ioFailed :: E.IOException -> Maybe (FilePath, FilePath) + ioFailed _ = Nothing + attempt :: IO (Maybe (FilePath, FilePath)) + attempt = do + root <- canonicalizePath dir + file <- canonicalizePath (foldl (\acc s -> acc T.unpack s) root segments) + exists <- doesFileExist file + pure $ if exists && inside root file then Just (root, file) else Nothing + inside :: FilePath -> FilePath -> Bool + inside root file = (root <> [pathSeparator]) `isPrefixOf` file + +-- | The page at @\/@ must not be cached, or a browser would keep asking for assets from a build we no longer have. +staticHeaders :: FilePath -> FilePath -> [Header] +staticHeaders root file = + [ (hContentType, contentTypeFor file), + (hCacheControl, if hashedAsset then "public, max-age=31536000, immutable" else "no-cache") + ] + where + -- read off the resolved path, not the request: `/assets//%2e%2e/%2e%2e/index.html` resolves to the shell + hashedAsset = (root "assets" <> [pathSeparator]) `isPrefixOf` file + +contentTypeFor :: FilePath -> ByteString +contentTypeFor file = case map toLower (takeExtension file) of + ".html" -> "text/html; charset=utf-8" + ".css" -> "text/css; charset=utf-8" + ".js" -> "text/javascript; charset=utf-8" + ".json" -> "application/json" + ".webmanifest" -> "application/manifest+json" + ".svg" -> "image/svg+xml" + ".png" -> "image/png" + ".ico" -> "image/vnd.microsoft.icon" + ".woff2" -> "font/woff2" + ".txt" -> "text/plain; charset=utf-8" + _ -> "application/octet-stream" + +readInvoiceHandler :: WebEnv -> InvoiceId -> Request -> Respond -> IO ResponseReceived +readInvoiceHandler env@WebEnv {weStore} invId req respond = + getInvoice weStore invId >>= \case + Nothing -> respond notFound + Just row -> case holdFor row of + Nothing -> respond (jsonResponse status200 (invoiceView (confirmationsFor env) row)) + Just seen -> do + -- Settlement publishes after it commits, so the row read below is at least as new as whatever woke us. + _ <- awaitStatus (weWaiters env) invId readSeen seen (weHoldMicros env) + getInvoice weStore invId >>= \case + Nothing -> respond notFound + Just row' -> respond (jsonResponse status200 (invoiceView (confirmationsFor env) row')) + where + holdFor :: InvoiceRow -> Maybe Seen + holdFor row@InvoiceRow {irStatus} = case waitParam req of + Just seen | seen == irStatus, seen /= ISPaid, samePayment -> Just (irStatus, paymentMark row) + _ -> Nothing + where + -- The counter a hold watches starts at zero, so a payment recorded before this request arrived cannot wake it. + samePayment = case paidParam req of + Nothing -> True + Just seen -> seen == paymentMark row + readSeen :: IO Seen + readSeen = maybe (ISOpen, ("", False)) (\row -> (irStatus row, paymentMark row)) <$> getInvoice weStore invId + +-- | Monero reports a payment as confirming while its figures are zero, so the verdict counts as much as the figure. +paymentMark :: InvoiceRow -> (Text, Bool) +paymentMark InvoiceRow {irPayment} = + (maybe "" (fromMaybe "" . ipCryptoPaid) irPayment, maybe False ipPaidInFull irPayment) + +-- | The provider is cancelled first, because cancelling our record first would leave an address the buyer can still pay into with nothing watching it. +cancelInvoiceHandler :: WebEnv -> InvoiceId -> Respond -> IO ResponseReceived +cancelInvoiceHandler env@WebEnv {weStore} invId respond = + getInvoice weStore invId >>= \case + Nothing -> respond notFound + Just InvoiceRow {irStatus} | irStatus /= ISOpen -> respond (jsonError status409 "not_open") + Just row | funded row -> respond (jsonError status409 "funded") + Just InvoiceRow {irProvider, irProviderRef} -> case providerNamed env irProvider of + Nothing -> respond providerUnavailable + Just Provider {pCancelInvoice} -> + pCancelInvoice irProviderRef >>= \case + Left (ProviderError e) -> do + logError $ "cancel order " <> irProviderRef <> ": " <> e + respond providerUnavailable + Right () -> finish + where + funded InvoiceRow {irPayment} = maybe False paymentHolds irPayment + finish = do + now <- getCurrentTime + cancelled <- cancelOpenInvoice weStore invId now + when cancelled $ atomically $ publish (weWaiters env) invId ISExpired + getInvoice weStore invId >>= \case + Nothing -> respond notFound + Just row -> respond (jsonResponse status200 (invoiceView (confirmationsFor env) row)) + +paidParam :: Request -> Maybe (Text, Bool) +paidParam req = case lookup "seenPaid" (queryString req) of + Just raw -> (\seen -> (seen, fullParam)) <$> either (const Nothing) Just (decodeUtf8' (fromMaybe "" raw)) + Nothing -> Nothing + where + fullParam = lookup "seenFull" (queryString req) == Just (Just "1") + +waitParam :: Request -> Maybe InvoiceStatus +waitParam req = case lookup "wait" (queryString req) of + Just (Just raw) -> either (const Nothing) textToInvoiceStatus (decodeUtf8' raw) + _ -> Nothing + +-- | Greenfield reports no confirmation count, so this comes from the store's speed policy. +confirmationsFor :: WebEnv -> Maybe Int +confirmationsFor WebEnv {weConfig} = speedPolicyConfirmations . bSpeedPolicy <$> btcpay weConfig + +-- | BTCPay's own store setting, whose numbering is not in speed order. +speedPolicyConfirmations :: SpeedPolicy -> Int +speedPolicyConfirmations = \case + HighSpeed -> 0 + MediumSpeed -> 1 + LowMediumSpeed -> 2 + LowSpeed -> 6 + +invoiceView :: Maybe Int -> InvoiceRow -> J.Value +invoiceView confirmations InvoiceRow {irBadgeType, irMonths, irAmount, irCurrency, irDestination, irExpiresAt, irStatus, irPayment} = + J.object $ + [ "status" .= invoiceStatusText irStatus, + "badgeType" .= (textEncode irBadgeType :: Text), + "months" .= irMonths, + "amount" .= amountJSON irAmount, + "currency" .= irCurrency, + "expiresAt" .= irExpiresAt + ] + <> destinationPairs confirmations irDestination + <> paidPairs + where + paidPairs = maybe [] paymentPairs irPayment + paymentPairs p = + concat + [ maybe [] (\a -> ["amountPaid" .= amountJSON a]) (ipAmount p), + maybe [] (\a -> ["cryptoAmountPaid" .= a]) (ipCryptoPaid p), + maybe [] (\a -> ["cryptoAmountDue" .= a]) (ipCryptoDue p), + ["paidInFull" .= ipPaidInFull p], + ["settledAt" .= ipUpdatedAt p | irStatus == ISPaid] + ] + +destinationPairs :: Maybe Int -> ServicePaymentDestination -> [Pair] +destinationPairs confirmations = \case + SPDCard _ url -> ["clientSecret" .= url] + SPDCrypto currency address cryptoAmount -> + [ "address" .= address, + "cryptoAmount" .= cryptoAmount, + "cryptoCurrency" .= cryptoCurrencyText currency + ] + <> maybe [] (\n -> ["requiredConfirmations" .= n]) confirmations + +amountJSON :: CurrencyAmount -> J.Value +amountJSON (CurrencyAmount a) = J.toJSON a + +data CreateRequest = CreateRequest + { crPriceId :: BadgePriceId, + crOfferId :: Maybe BadgeOfferId, + crMethod :: ServicePaymentMethod, + crCodeHash :: ByteString + } + +createInvoiceHandler :: WebEnv -> Request -> Respond -> IO ResponseReceived +createInvoiceHandler env@WebEnv {weStore} req respond = + readBoundedBody maxBodyBytes req >>= \case + Nothing -> refuse "body over the size limit" badRequest + Just body -> case parseCreateRequest body of + Nothing -> refuse "malformed body" badRequest + Just cr@CreateRequest {crPriceId, crOfferId, crMethod, crCodeHash} -> do + (prices, offers) <- readCatalogRows weStore + case priceOffer prices offers crPriceId crOfferId of + Left reason -> refuse (tshow reason) catalogChanged + Right priced -> + codeHashExists weStore crCodeHash >>= \case + True -> refuse "code hash already sold" codeConflict + False -> case providerFor env crMethod of + Nothing -> refuse ("no provider configured for " <> tshow crMethod) providerUnavailable + Just provider -> createAtProvider env provider cr priced respond + where + refuse :: Text -> Response -> IO ResponseReceived + refuse why response = logInfo ("POST /api/invoice refused: " <> why) >> respond response + +-- | Once the provider call succeeds an invoice exists at BTCPay, and any path below that does not write our rows leaves it stranded. +createAtProvider :: WebEnv -> Provider -> CreateRequest -> PricedOffer -> Respond -> IO ResponseReceived +createAtProvider WebEnv {weStore, weConfig} provider CreateRequest {crPriceId, crOfferId, crMethod, crCodeHash} priced respond = + do + invId <- newInvoiceId + -- truncate once, so the response, the row and the provider get the same value + now <- truncateToSecond <$> getCurrentTime + let expiresAt = addUTCTime (invoiceWindow weConfig crMethod) now + draft = OrderDraft {odAmount = poAmount priced, odCurrency = poCurrency priced} + pCreateInvoice provider crMethod draft >>= \case + Left (ProviderError e) -> failed ("provider refused to create an invoice: " <> e) providerUnavailable + Right ProviderInvoice {piProviderRef, piDestination} -> do + let atProvider = "providerRef " <> piProviderRef + ni = + NewInvoice + { niInvoiceId = invId, + niProviderRef = piProviderRef, + niCodeHash = crCodeHash, + niPriceId = crPriceId, + niOfferId = crOfferId, + niBadgeType = poBadgeType priced, + niMonths = poMonths priced, + niPrice = poPrice priced, + niAmount = poAmount priced, + niCurrency = poCurrency priced, + niProvider = pProvider provider, + niDestination = piDestination, + niExpiresAt = expiresAt, + niCreatedAt = now + } + createInvoiceRows weStore ni >>= \case + Left CECodeConflict -> logInfo ("POST /api/invoice: code hash lost the race, invoice abandoned at the provider (" <> atProvider <> ")") >> respond codeConflict + Left e -> failed ("invoice rows not written, invoice abandoned at the provider (" <> atProvider <> "): " <> tshow e) internalError + Right () -> respond (jsonResponse status200 (createdInvoice invId priced expiresAt piDestination)) + where + failed :: Text -> Response -> IO ResponseReceived + failed why response = logError ("POST /api/invoice failed: " <> why) >> respond response + +createdInvoice :: InvoiceId -> PricedOffer -> UTCTime -> ServicePaymentDestination -> J.Value +createdInvoice (InvoiceId invId) PricedOffer {poBadgeType, poMonths, poAmount, poCurrency} expiresAt destination = + J.object $ + [ "invoiceId" .= invId, + "badgeType" .= (textEncode poBadgeType :: Text), + "months" .= poMonths, + "amount" .= amountJSON poAmount, + "currency" .= poCurrency, + "expiresAt" .= expiresAt + ] + <> destinationPairs Nothing destination + +secondsPerMinute :: Int +secondsPerMinute = 60 + +-- | The expiry shown and stored must come from the same key the provider sets the invoice's real expiry from. +invoiceWindow :: ServiceConfig -> ServicePaymentMethod -> NominalDiffTime +invoiceWindow cfg = \case + SPMCard CPStripe -> minutes (maybe defaultSessionMinutes sSessionMinutes (stripe cfg)) + SPMCrypto _ -> minutes (maybe defaultExpiryMinutes bExpiryMinutes (btcpay cfg)) + where + minutes n = fromIntegral (secondsPerMinute * n) + +providerFor :: WebEnv -> ServicePaymentMethod -> Maybe Provider +providerFor env method = providerNamed env (providerOf method) + +providerNamed :: WebEnv -> PaymentProvider -> Maybe Provider +providerNamed WebEnv {weProviders} provider = find ((== provider) . pProvider) weProviders + +providerOf :: ServicePaymentMethod -> PaymentProvider +providerOf = \case + SPMCard CPStripe -> PPStripe + SPMCrypto _ -> PPCrypto + +-- | Returns the bytes exactly as they arrived, since the webhook signature is over those bytes. +readBoundedBody :: Int -> Request -> IO (Maybe LB.ByteString) +readBoundedBody cap req = go 0 [] + where + go :: Int -> [ByteString] -> IO (Maybe LB.ByteString) + go read' acc = do + chunk <- getRequestBodyChunk req + if BS.null chunk + then pure (Just (LB.fromChunks (reverse acc))) + else + let read'' = read' + BS.length chunk + in if read'' > cap then pure Nothing else go read'' (chunk : acc) + +parseCreateRequest :: LB.ByteString -> Maybe CreateRequest +parseCreateRequest body = case J.decode body of + Just (J.Object o) -> do + crPriceId <- BadgePriceId <$> textField o "priceId" + crOfferId <- offerField o + crMethod <- methodFromText =<< textField o "method" + crCodeHash <- parseCodeHash =<< textField o "codeHash" + pure CreateRequest {crPriceId, crOfferId, crMethod, crCodeHash} + _ -> Nothing + where + textField o k = case KM.lookup k o of + Just (J.String t) | not (T.null t) -> Just t + _ -> Nothing + offerField o = case KM.lookup "offerId" o of + Nothing -> Just Nothing + Just J.Null -> Just Nothing + Just (J.String t) | not (T.null t) -> Just (Just (BadgeOfferId t)) + _ -> Nothing + +methodFromText :: Text -> Maybe ServicePaymentMethod +methodFromText = \case + "card" -> Just (SPMCard CPStripe) + "btc" -> Just (SPMCrypto CCBtc) + "xmr" -> Just (SPMCrypto CCXmr) + _ -> Nothing + +-- | The last character of a 43-character base64 string has two bits no digest byte uses, so a lax +-- decoder would accept four spellings of the same digest, which the re-encode check rejects. +parseCodeHash :: Text -> Maybe ByteString +parseCodeHash t + | T.length t /= codeHashChars = Nothing + | otherwise = case B64U.decode (encodeUtf8 (t <> "=")) of + Right bytes | BS.length bytes == sha256Bytes, canonical bytes == t -> Just bytes + _ -> Nothing + where + canonical = T.filter (/= '=') . safeDecodeUtf8 . B64U.encode + +-- | A provider retries a delivery it sees fail, so anything thrown below is caught and answered 200 anyway. +webhookHandler :: WebEnv -> PaymentProvider -> Text -> Request -> Respond -> IO ResponseReceived +webhookHandler env@WebEnv {weStore, weHints} provider route req respond = + E.try deliver >>= \case + Right received -> pure received + Left e -> case E.fromException e of + Just async' -> E.throwIO (async' :: E.SomeAsyncException) + Nothing -> do + logError (route <> ": the delivery could not be handled, so no read was queued: " <> tshow (e :: E.SomeException)) + respond webhookOk + where + deliver :: IO ResponseReceived + deliver = case providerNamed env provider of + Nothing -> do + logWarn (route <> ": no " <> tshow provider <> " adapter is configured, so every delivery is refused") + respond webhookRefused + Just p -> + readBoundedBody maxWebhookBytes req >>= \case + Nothing -> logInfo (route <> ": body over the " <> tshow maxWebhookBytes <> "-byte cap") >> respond webhookTooLarge + Just body -> do + now <- getCurrentTime + -- a provider signs the exact bytes it sent, so parsing and re-encoding here would fail the signature on every event + case pVerifyWebhook p now (requestHeaders req) (LB.toStrict body) of + Left (WebhookError e) -> refuse e + Left (WebhookStale e) -> logWarn (route <> " refused: " <> e) >> respond webhookRefused + Right Nothing -> ignore body "nothing this service acts on" + Right (Just ref) -> resolve body ref + refuse :: Text -> IO ResponseReceived + refuse why = logInfo (route <> " refused: " <> why) >> respond webhookRefused + ignore :: LB.ByteString -> Text -> IO ResponseReceived + ignore body why = logEvent body why >> respond webhookOk + logEvent :: LB.ByteString -> Text -> IO () + logEvent body what = case eventTypeOf body of + Just t -> logInfo (route <> ": " <> t <> ", " <> what) + Nothing -> logWarn (route <> ": " <> noEventType <> ", " <> what) + noEventType :: Text + noEventType = "a verified payload with no readable \"type\"" + resolve :: LB.ByteString -> Text -> IO ResponseReceived + resolve body ref = + getInvoiceByProviderRef weStore ref >>= \case + Nothing -> ignore body ("no invoice holds provider_ref " <> ref) + Just InvoiceRow {irProvider} + -- provider_ref is unique table-wide, not per provider, so without this a collision could credit the wrong order + | irProvider /= provider -> ignore body ("provider_ref " <> ref <> " belongs to " <> tshow irProvider) + | otherwise -> queue body ref + queue :: LB.ByteString -> Text -> IO ResponseReceived + queue body ref = do + queued <- queueReadHint weHints ref + if queued + then logEvent body ("queued a read of " <> ref) + else -- not an error, because the next pass finds this invoice anyway + logWarn (route <> ": " <> fromMaybe noEventType (eventTypeOf body) <> ", the read queue is full, so the read of " <> ref <> " waits for the next pass") + respond webhookOk + +-- | For the log only. A rename of the @type@ field would make every delivery look successful. +eventTypeOf :: LB.ByteString -> Maybe Text +eventTypeOf body = case J.decode body of + Just (J.Object o) | Just (J.String t) <- KM.lookup "type" o -> Just t + _ -> Nothing + +webSettings :: ListenerConfig -> Warp.Settings +webSettings ListenerConfig {lHost, lPort} = + Warp.setHost (fromString (T.unpack lHost)) + . Warp.setPort lPort + . Warp.setOnExceptionResponse (const internalError) + $ Warp.defaultSettings + +runWebListener :: WebEnv -> IO () +runWebListener env = do + warnIfHeaderTrustIsExposed (listenerConfig env) + Warp.runSettings (webSettings (listenerConfig env)) (webApp env) + +warnIfHeaderTrustIsExposed :: ListenerConfig -> IO () +warnIfHeaderTrustIsExposed ListenerConfig {lHost, lTrustForwardedFor} = + when (lTrustForwardedFor && not (isLoopbackHost lHost)) $ + logWarn ("badge service: trust_forwarded_for is on and the listener binds " <> lHost <> "; every caller can then choose its own rate limit bucket unless a proxy sets the header") + +isLoopbackHost :: Text -> Bool +isLoopbackHost h = h `elem` ["127.0.0.1", "::1", "localhost"] diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-expired.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-expired.json new file mode 100644 index 0000000000..7f5cee8aab --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-expired.json @@ -0,0 +1,36 @@ +{ + "_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status Expired -- the payment window closed -- from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.", + "response": { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": ["BTC-CHAIN"], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "GmpP7VDp3jj7HZeJVQpSNP", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Expired", + "additionalStatus": "None", + "availableStatusesForManualMarking": ["Settled", "Invalid"], + "archived": false + } +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-invalid.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-invalid.json new file mode 100644 index 0000000000..d1edee72a0 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-invalid.json @@ -0,0 +1,36 @@ +{ + "_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status Invalid -- the invoice was marked invalid, which this design treats as expired -- from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.", + "response": { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": ["BTC-CHAIN"], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "GmpP7VDp3jj7HZeJVQpSNP", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Invalid", + "additionalStatus": "None", + "availableStatusesForManualMarking": ["Settled", "Invalid"], + "archived": false + } +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-alien-method.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-alien-method.json new file mode 100644 index 0000000000..3339eb44a8 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-alien-method.json @@ -0,0 +1,133 @@ +{ + "_fixture": "The same list response carrying an invoice paid over Lightning (BTC-LN), which this build does not know: switching Lightning on at the store is all it takes. Hand-written from the documented schema. The alien entry must be SKIPPED and the healthy one in the SAME response must still settle.", + "response": [ + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "LightningInvoiceRefDD", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/LightningInvoiceRefDD", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Settled", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "paymentMethods": [ + { + "paymentMethodId": "BTC-LN", + "currency": "BTC", + "destination": "lnbc500u1pexampleinvoicebolt11string", + "paymentLink": "lightning:lnbc500u1pexampleinvoicebolt11string", + "rate": "108000.12", + "paymentMethodPaid": "0.00050000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "d7a41e0b93c85f26a1804dbe73f52c9a6081ef34d25b7a90c6183fe45b027dac", + "receivedDate": 1700003600, + "value": "0.00050000", + "fee": "0.00000000", + "status": "Settled", + "destination": "lnbc500u1pexampleinvoicebolt11string" + } + ], + "additionalData": {} + } + ] + }, + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "SettledInvoiceRefAAAA", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/SettledInvoiceRefAAAA", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Settled", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "paymentMethods": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000", + "rate": "108000.12", + "paymentMethodPaid": "0.00050000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0", + "receivedDate": 1700003600, + "value": "0.00050000", + "fee": "0.00000000", + "status": "Settled", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + } + ], + "additionalData": {} + } + ] + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-alien-status.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-alien-status.json new file mode 100644 index 0000000000..28aad30892 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-alien-status.json @@ -0,0 +1,133 @@ +{ + "_fixture": "The same list response carrying an invoice whose `status` this build does not know -- the store enumerates every invoice, ours and not. Hand-written from the documented schema, since a real BTCPay will not produce a status on demand. The alien entry must be SKIPPED and the healthy one in the SAME response must still settle.", + "response": [ + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "AlienStatusInvoiceRef", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/AlienStatusInvoiceRef", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Frobnicated", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "paymentMethods": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000", + "rate": "108000.12", + "paymentMethodPaid": "0.00050000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0", + "receivedDate": 1700003600, + "value": "0.00050000", + "fee": "0.00000000", + "status": "Settled", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + } + ], + "additionalData": {} + } + ] + }, + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "SettledInvoiceRefAAAA", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/SettledInvoiceRefAAAA", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Settled", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "paymentMethods": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000", + "rate": "108000.12", + "paymentMethodPaid": "0.00050000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0", + "receivedDate": 1700003600, + "value": "0.00050000", + "fee": "0.00000000", + "status": "Settled", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + } + ], + "additionalData": {} + } + ] + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-no-id.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-no-id.json new file mode 100644 index 0000000000..9a8ed8c97f --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-no-id.json @@ -0,0 +1,132 @@ +{ + "_fixture": "A list response carrying an entry with no `id`. Hand-written from the documented schema, where `id` is required. This must FAIL the pass: an invoice the pass cannot even name has no ref to skip and none to log.", + "response": [ + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "SettledInvoiceRefAAAA", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/SettledInvoiceRefAAAA", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Settled", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "paymentMethods": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000", + "rate": "108000.12", + "paymentMethodPaid": "0.00050000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0", + "receivedDate": 1700003600, + "value": "0.00050000", + "fee": "0.00000000", + "status": "Settled", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + } + ], + "additionalData": {} + } + ] + }, + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/x", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Settled", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "paymentMethods": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000", + "rate": "108000.12", + "paymentMethodPaid": "0.00050000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0", + "receivedDate": 1700003600, + "value": "0.00050000", + "fee": "0.00000000", + "status": "Settled", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + } + ], + "additionalData": {} + } + ] + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-no-payment-methods.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-no-payment-methods.json new file mode 100644 index 0000000000..02ffeed945 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list-no-payment-methods.json @@ -0,0 +1,43 @@ +{ + "_fixture": "A list response with `paymentMethods` ABSENT, which is what a server that did not honour includePaymentMethods sends. Hand-written from the documented schema. This must FAIL the pass: skipping it would answer `Right []` and report health for as long as nothing ever settled.", + "response": [ + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "NoMethodsInvoiceRefEE", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/NoMethodsInvoiceRefEE", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Settled", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list.json new file mode 100644 index 0000000000..95d477f14f --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-list.json @@ -0,0 +1,188 @@ +{ + "_fixture": "GET /api/v1/stores/{storeId}/invoices?includePaymentMethods=true&startDate=... -- InvoiceData[] with the payment methods inline, from BTCPay's DOCUMENTED Greenfield schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. Three invoices: one Settled, one Processing, and one New with nothing received -- the last of which the pass must report as unmoved rather than omit by accident.", + "response": [ + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "SettledInvoiceRefAAAA", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/SettledInvoiceRefAAAA", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Settled", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "paymentMethods": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000", + "rate": "108000.12", + "paymentMethodPaid": "0.00050000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0", + "receivedDate": 1700003600, + "value": "0.00050000", + "fee": "0.00000000", + "status": "Settled", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + } + ], + "additionalData": {} + } + ] + }, + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "XMR-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "ProcessingInvoiceRefB", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/ProcessingInvoiceRefB", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Processing", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "paymentMethods": [ + { + "paymentMethodId": "XMR-CHAIN", + "currency": "XMR", + "destination": "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + "paymentLink": "monero:44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A?tx_amount=0.32095000", + "rate": "168.25", + "paymentMethodPaid": "0.32095000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.32095000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "6f2c1b0a9e8d7c6b5a4938271605f4e3d2c1b0a9e8d7c6b5a4938271605f4e3d", + "receivedDate": 1700003000, + "value": "0.32095000", + "fee": "0.00000000", + "status": "Processing", + "destination": "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A" + } + ], + "additionalData": {} + } + ] + }, + { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC-CHAIN" + ], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "UntouchedInvoiceRefCC", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/UntouchedInvoiceRefCC", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "New", + "additionalStatus": "None", + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "paymentMethods": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000", + "rate": "108000.12", + "paymentMethodPaid": "0.00000000", + "totalPaid": "0.00000000", + "due": "0.00050000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [], + "additionalData": {} + } + ] + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-new.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-new.json new file mode 100644 index 0000000000..94df911cf4 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-new.json @@ -0,0 +1,36 @@ +{ + "_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status New, from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.", + "response": { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": ["BTC-CHAIN"], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "GmpP7VDp3jj7HZeJVQpSNP", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "New", + "additionalStatus": "None", + "availableStatusesForManualMarking": ["Settled", "Invalid"], + "archived": false + } +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-processing.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-processing.json new file mode 100644 index 0000000000..1dd94d651c --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-processing.json @@ -0,0 +1,36 @@ +{ + "_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status Processing -- InvoiceProcessing has fired: the payment is seen and is waiting for the confirmations `checkout.speedPolicy` requires -- from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.", + "response": { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": ["BTC-CHAIN"], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "GmpP7VDp3jj7HZeJVQpSNP", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Processing", + "additionalStatus": "None", + "availableStatusesForManualMarking": ["Settled", "Invalid"], + "archived": false + } +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/invoice-settled.json b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-settled.json new file mode 100644 index 0000000000..fd80a7296b --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/invoice-settled.json @@ -0,0 +1,36 @@ +{ + "_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status Settled -- the invoice is paid in full -- from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.", + "response": { + "metadata": {}, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": ["BTC-CHAIN"], + "defaultPaymentMethod": null, + "expirationMinutes": 60, + "monitoringMinutes": 4320, + "paymentTolerance": 0.5, + "redirectURL": null, + "redirectAutomatically": false, + "requiresRefundEmail": false, + "defaultLanguage": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + }, + "id": "GmpP7VDp3jj7HZeJVQpSNP", + "storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT", + "amount": "54.00", + "currency": "USD", + "type": "Standard", + "checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP", + "createdTime": 1700000000, + "expirationTime": 1700003600, + "monitoringExpiration": 1700259200, + "status": "Settled", + "additionalStatus": "None", + "availableStatusesForManualMarking": ["Settled", "Invalid"], + "archived": false + } +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-btc.json b/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-btc.json new file mode 100644 index 0000000000..83c03dcfd4 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-btc.json @@ -0,0 +1,29 @@ +{ + "_fixture": "GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods -- InvoicePaymentMethodDataModel[], from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. `totalPaid` is deliberately NOT equal to `paymentMethodPaid` -- at this rate reading the wrong one would report $1,080,001.20 instead of $54.00. The fake patches `paymentMethodPaid`, and serves `payments` as [] when nothing was paid.", + "response": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000", + "rate": "108000.12", + "paymentMethodPaid": "0.00050000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0", + "receivedDate": 1700003600, + "value": "0.00050000", + "fee": "0.00000000", + "status": "Settled", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + } + ], + "additionalData": {} + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-no-amount.json b/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-no-amount.json new file mode 100644 index 0000000000..d1c4129ce2 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-no-amount.json @@ -0,0 +1,19 @@ +{ + "_fixture": "GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods -- a BTC-CHAIN method with no `amount` -- the crypto figure the payment screen renders, absent. Hand-written from the documented schema; a real BTCPay will not produce it on demand. A create that cannot learn where to pay must be a ProviderError naming the missing field, and write nothing -- the invoice exists at the provider and is abandoned there.", + "response": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000", + "rate": "108000.12", + "paymentMethodPaid": "0.00000000", + "totalPaid": "0.00000000", + "due": "0.00050000", + "networkFee": "0.00000500", + "activated": true, + "payments": [], + "additionalData": {} + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-no-destination.json b/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-no-destination.json new file mode 100644 index 0000000000..4dc67e6170 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-no-destination.json @@ -0,0 +1,18 @@ +{ + "_fixture": "GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods -- a BTC-CHAIN method with no `destination` yet -- the shape a payment method has before it is activated and given an address. Hand-written from the documented schema; a real BTCPay will not produce it on demand. A create that cannot learn where to pay must be a ProviderError naming the missing field, and write nothing -- the invoice exists at the provider and is abandoned there.", + "response": [ + { + "paymentMethodId": "BTC-CHAIN", + "currency": "BTC", + "rate": "108000.12", + "paymentMethodPaid": "0.00000000", + "totalPaid": "0.00000000", + "due": "0.00050000", + "amount": "0.00050000", + "networkFee": "0.00000500", + "activated": false, + "payments": [], + "additionalData": {} + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-xmr.json b/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-xmr.json new file mode 100644 index 0000000000..c68256f3d7 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/payment-methods-xmr.json @@ -0,0 +1,29 @@ +{ + "_fixture": "GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods -- InvoicePaymentMethodDataModel[] for the Monero PLUGIN, from the same documented shape. NOT captured from a live instance -- the plugin is the likeliest of these files to diverge, XMR coming from btcpayserver-monero-plugin rather than BTCPay itself, and the likeliest to need replacing with a recorded body. `totalPaid` is deliberately NOT equal to `paymentMethodPaid`.", + "response": [ + { + "paymentMethodId": "XMR-CHAIN", + "currency": "XMR", + "destination": "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + "paymentLink": "monero:44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A?tx_amount=0.32095000", + "rate": "168.25", + "paymentMethodPaid": "0.32095000", + "totalPaid": "9.99999999", + "due": "0.00000000", + "amount": "0.32095000", + "networkFee": "0.00000500", + "activated": true, + "payments": [ + { + "id": "6f2c1b0a9e8d7c6b5a4938271605f4e3d2c1b0a9e8d7c6b5a4938271605f4e3d", + "receivedDate": 1700003600, + "value": "0.32095000", + "fee": "0.00000000", + "status": "Settled", + "destination": "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A" + } + ], + "additionalData": {} + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/btcpay/store-payment-methods.json b/apps/simplex-badge-service/test-fixtures/btcpay/store-payment-methods.json new file mode 100644 index 0000000000..836303b943 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/btcpay/store-payment-methods.json @@ -0,0 +1,21 @@ +{ + "_fixture": "GET /api/v1/stores/{storeId}/payment-methods -- StorePaymentMethodDataList, from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming, where a payment method id is CODE-CHAIN). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line.", + "response": [ + { + "paymentMethodId": "BTC-CHAIN", + "enabled": true, + "config": { + "accountDerivation": "xpub661MyMwAqRbcExampleAccountDerivation-[p2wpkh]", + "label": "", + "accountKeyPath": "" + } + }, + { + "paymentMethodId": "XMR-CHAIN", + "enabled": true, + "config": { + "accountIndex": 0 + } + } + ] +} diff --git a/apps/simplex-badge-service/test-fixtures/stripe/intent-canceled.json b/apps/simplex-badge-service/test-fixtures/stripe/intent-canceled.json new file mode 100644 index 0000000000..eafb1c0c61 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/stripe/intent-canceled.json @@ -0,0 +1,12 @@ +{ + "_fixture": "GET /v1/payment_intents/{id} for a canceled intent (status canceled), from Stripe's documented API schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. A canceled intent captured nothing, so amount_received is zero.", + "response": { + "id": "pi_test_canceled", + "object": "payment_intent", + "amount": 5400, + "amount_received": 0, + "currency": "usd", + "status": "canceled", + "latest_charge": null + } +} diff --git a/apps/simplex-badge-service/test-fixtures/stripe/intent-list-cursor-gap.json b/apps/simplex-badge-service/test-fixtures/stripe/intent-list-cursor-gap.json new file mode 100644 index 0000000000..f9e6f9bd21 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/stripe/intent-list-cursor-gap.json @@ -0,0 +1,12 @@ +{ + "_fixture": "GET /v1/payment_intents?created[gte]=&limit=, a page whose LAST row carries no id, from Stripe's documented API schema. NOT captured from a live instance. Served with a small page size so the id-less row ends a page while more remain, leaving no starting_after to continue from. pi_test_a settles, pi_test_c sits on the unreachable next page.", + "response": { + "object": "list", + "has_more": false, + "data": [ + {"id": "pi_test_a", "object": "payment_intent", "status": "succeeded", "amount": 5400, "amount_received": 5400, "currency": "usd"}, + {"object": "payment_intent", "status": "requires_payment_method", "amount": 5400, "amount_received": 0, "currency": "usd"}, + {"id": "pi_test_c", "object": "payment_intent", "status": "canceled", "amount": 5400, "amount_received": 0, "currency": "usd"} + ] + } +} diff --git a/apps/simplex-badge-service/test-fixtures/stripe/intent-list.json b/apps/simplex-badge-service/test-fixtures/stripe/intent-list.json new file mode 100644 index 0000000000..4a0e477218 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/stripe/intent-list.json @@ -0,0 +1,13 @@ +{ + "_fixture": "GET /v1/payment_intents?created[gte]=&limit=, a page of PaymentIntents created within the settle window, from Stripe's documented API schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. List rows carry NO expanded latest_charge, so a succeeded row dates its settlement at read time. Rows exercise the mapping: pi_test_open still open (no signal), pi_test_a succeeded, pi_test_b canceled, and one row with no id for the skip path. The fake serves data verbatim and paginates when _paging is set.", + "response": { + "object": "list", + "has_more": false, + "data": [ + {"id": "pi_test_open", "object": "payment_intent", "status": "requires_payment_method", "amount": 5400, "amount_received": 0, "currency": "usd"}, + {"id": "pi_test_a", "object": "payment_intent", "status": "succeeded", "amount": 5400, "amount_received": 5400, "currency": "usd"}, + {"id": "pi_test_b", "object": "payment_intent", "status": "canceled", "amount": 5400, "amount_received": 0, "currency": "usd"}, + {"object": "payment_intent", "status": "requires_payment_method", "amount": 5400, "amount_received": 0, "currency": "usd"} + ] + } +} diff --git a/apps/simplex-badge-service/test-fixtures/stripe/intent-open.json b/apps/simplex-badge-service/test-fixtures/stripe/intent-open.json new file mode 100644 index 0000000000..ac0cd42547 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/stripe/intent-open.json @@ -0,0 +1,13 @@ +{ + "_fixture": "POST /v1/payment_intents and GET /v1/payment_intents/{id} for an unpaid intent (status requires_payment_method), from Stripe's documented API schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches id, client_secret, status, amount_received and currency; everything else is served as written.", + "response": { + "id": "pi_test_open", + "object": "payment_intent", + "amount": 5400, + "amount_received": 0, + "currency": "usd", + "client_secret": "pi_test_open_secret", + "status": "requires_payment_method", + "latest_charge": null + } +} diff --git a/apps/simplex-badge-service/test-fixtures/stripe/intent-succeeded.json b/apps/simplex-badge-service/test-fixtures/stripe/intent-succeeded.json new file mode 100644 index 0000000000..cde8176601 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/stripe/intent-succeeded.json @@ -0,0 +1,18 @@ +{ + "_fixture": "GET /v1/payment_intents/{id}?expand[]=latest_charge for a paid intent (status succeeded), from Stripe's documented API schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches id, status, amount_received and currency; latest_charge.created dates the settlement.", + "response": { + "id": "pi_test_settled", + "object": "payment_intent", + "amount": 5400, + "amount_received": 5400, + "currency": "usd", + "status": "succeeded", + "latest_charge": { + "id": "ch_test_x", + "object": "charge", + "created": 1700000000, + "paid": true, + "amount_captured": 5400 + } + } +} diff --git a/apps/simplex-badge-service/web/.gitignore b/apps/simplex-badge-service/web/.gitignore new file mode 100644 index 0000000000..939e8224c9 --- /dev/null +++ b/apps/simplex-badge-service/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +dist/ diff --git a/apps/simplex-badge-service/web/README.md b/apps/simplex-badge-service/web/README.md new file mode 100644 index 0000000000..0004b44944 --- /dev/null +++ b/apps/simplex-badge-service/web/README.md @@ -0,0 +1,301 @@ +# SimpleX badge codes — web + +The buyer-facing page for badge codes: choose a tier and duration, pay by +card, BTC or XMR, and receive a code. Design and rationale are in +[`plans/badges-codes/2026-08-27-badge-codes.md`](../../../plans/badges-codes/2026-08-27-badge-codes.md); +the implementation plan for this app is +[`plans/badges-codes/2026-08-28-web-implementation.md`](../../../plans/badges-codes/2026-08-28-web-implementation.md). +This file covers how to build, test and run it. + +No runtime or dev dependencies beyond `typescript` and `@types/node`. +Stripe.js is loaded at runtime from `js.stripe.com`, never bundled or +installed. + +## Build and test + +``` +npm install +npm run build +npm test +``` + +`npm run build` clears `build/` and runs `tsc` into it (gitignored), then +`build.js`, which content-hashes the compiled modules, `styles.css` and the images, +copies them to `dist/assets//`, and writes that hash into +`public/index.html` and `public/sw.js`. `dist/` (gitignored) is the deploy +tree: `index.html`, `sw.js` and `assets//`, nothing else. + +`npm test` compiles and runs the suite (`node --test` over +compiled output) but does **not** run `build.js`. One of those tests is a +tripwire: it recomputes the hash from the freshly compiled modules and fails +if the hash named in `public/index.html` or `public/sw.js` does not match. + +**If you edit anything under `src/`, run `npm run build` and commit the +regenerated `public/index.html` and `public/sw.js`.** Skipping this is the +most likely way to break the suite — `npm test` will tell you, but the fix is +`npm run build`, not the test. + +Both scripts clear `build/` first. `tsc` leaves the output of a module you rename or delete +behind, `build.js` hashes every `.js` it finds there, and the result is a phantom module in the +build that the tripwire would tell you to commit. + +## Layout + +Only `src/main.ts` and `src/stripe.ts` touch `window`, `location`, `history` +or `localStorage`. `src/screens.ts` builds the DOM, and `src/qr.ts` and +`src/icons.ts` reach for `document` only through `createElementNS`, to build +inline SVG. Nothing else outside those five modules touches the DOM. Every other module +is plain TypeScript, tested directly in Node. + +| Module | Owns | +|---|---| +| `domain.ts` | The words the app is written in: steps, themes, order statuses, methods, chains | +| `catalog.ts` | Prices and offers compiled into the page at build time | +| `codes.ts` | The badge code alphabet and check character | +| `parse.ts` | Readers for data the page did not produce, each answering the value or undefined | +| `format.ts` | Money, countdowns and elapsed time as the words a screen prints | +| `store.ts` | The three `localStorage` keys — session, orders and the chosen theme | +| `order.ts` | What an order's state means: what to keep from a response, which screen it selects, and its history row | +| `routing.ts` | Reading `?order=` and the store into what to render | +| `api.ts` | The three routes — create, cancel and read — and the long-poll wait loop, with `fetch`, sleep and the clock injected | +| `flow.ts` | Payment flow control logic — pure, no DOM, no globals | +| `stripe.ts` | Loading Stripe.js, mounting the Payment Element, confirming, and the no-key stand-in | +| `icons.ts` | The badge art, the payment marks and the hamburger, built with `createElementNS`. The brand mark is NOT here: it is a served file | +| `qr.ts` | A QR encoder written for this page, with no dependency, no network and no raster | +| `screens.ts` | Every screen of the spec, and the header and its menu, built node by node — markup is never assigned from a string | +| `main.ts` | Wiring: DOM events in, `flow.ts` calls out, `screens.ts` renders | + +## Running the real service against this build + +**`mock/server.py` is not the service.** The service is the Haskell +executable `simplex-badge-service`, in `apps/simplex-badge-service/`, and +`dist/` is the directory its listener serves. Nothing about the page changes +between the two — the service matches this build, never the reverse. + +``` +# 1. build the page. dist/ is the deploy tree: index.html, sw.js, assets// +cd apps/simplex-badge-service/web +npm install && npm run build +cd ../../.. + +# 2. configuration, from the committed example. Copy it IN +# PLACE, beside the example: that path is the one .gitignore covers, and this +# file is about to hold a real api_key and webhook_secret. +cd apps/simplex-badge-service +cp badge_service.ini.example badge_service.ini +cd ../.. +# static_dir = ./apps/simplex-badge-service/web/dist <- the build above +# [btcpay] = a real store's host, api_key, store_id and webhook_secret, +# or delete the whole section to disable BTC and XMR (the provider-unavailable screen) +# [issuer] = uncomment the section and put a real issuer secret in private_key, +# with its index in index. It has to be a key whose public half already +# ships in the apps: startup checks the secret against `badgePublicKeys` +# at that index, so a fresh `simplex-chat badge keygen` pair is refused. + +# 3. run it. Without --service-config the web listener does not start at all. +# An issuer key is required even though nothing in the checkout path uses it: +# the same process also answers redemption requests, and it refuses to start +# without a key it could sign a credential with. Put it in the ini's [issuer] +# section, or pass --issuer-key-idx and --issuer-secret, which override it. +# `simplex-chat badge keygen` prints a pair. +cabal run simplex-badge-service -- \ + --service-config apps/simplex-badge-service/badge_service.ini +``` + +Then open the `[listener]` host and port — `http://127.0.0.1:8080/` as the +example ships. `static_dir` is relative to the working directory, so run from +the repository root or make it absolute. + +**The executable is the whole badge service, not just this listener.** +`badgeService` starts `simplexChatCore` unconditionally and the web listener +is one lane beside the chat one, so a local run opens or creates a chat +database (`printDbOpts` names the path at startup) and does agent network +work. `--no-address` skips creating the service's contact address on first +start, which a run of the web listener alone does not need. + +*These three steps are read out of `Options.hs`, `Service.hs`, `Config.hs` and +`badge_service.ini.example` rather than from a run — driving them end to end +needs a chat database and a real BTCPay store.* + +What the mock cannot stand in for: + +| | `mock/server.py` | `simplex-badge-service` | +|---|---|---| +| Invoices | invented in memory | created at BTCPay over Greenfield | +| Payment detected by | `POST /control/settle/:id`, an endpoint that exists nowhere else | a poller reading the provider, which is the only thing that carries authority | +| Webhooks | none | `POST /webhooks/btcpay`, signature-verified, a latency hint and nothing more | +| Persistence | none | the chat database, under `sx_badge_service_*` | +| Codes | invented | written unpaid at checkout, marked paid by settlement | + +The Haskell side's own end-to-end coverage of that lane — checkout, payment, +polling, partial payment, expiry, late settlement and replay, all against a +fake Greenfield — is `tests/Bots/BadgeService/WebTests.hs`: + +``` +cabal test --test-options='-m "/SimpleX badge service/"' +``` + +## Running the mock + +`mock/server.py` stands in for the Haskell service, Stripe and BTCPay, so the +whole browser flow can be driven with no real backend. **It is a browser-only +test fixture and not the service**: no signatures, no persistence, no real +money, no provider, and not a specification of what ships. Standard library +only. + +``` +python3 mock/server.py --port 8099 +``` + +It serves `public/` and `dist/`, and adds: + +| Endpoint | Behaviour | +|---|---| +| `POST /api/invoice` | Creates an invoice. Rejects a repeated `codeHash` with `409 code_conflict`. | +| `GET /api/invoice/:id?wait=&seenPaid=
&seenFull=<0\|1>` | Long-polls: holds while the invoice's status is still `` **and** its payment is the one the page says it has rendered, up to `MOCK_HOLD_SECONDS` (default 30). A payment the page has not seen answers at once — the provider's verdict counts as much as the figure, since Monero reports an invoice as confirming while its figures are still zero. A request that omits `seenPaid` holds on the status alone. | +| `POST /api/invoice/:id/cancel` | Expires an open invoice. Refuses a settled one with `409 not_open` and a funded one with `409 funded`, as the service does. | +| `POST /control/settle/:id` | Marks the invoice paid — stands in for a provider webhook. | +| `POST /control/expire/:id` | Marks it expired. | +| `POST /control/partial/:id` | Records a partial payment, half the amount due, and the remainder the provider would still ask for. | +| `POST /control/confirming/:id` | Records the full amount as arrived with the invoice still open — the screen that waits for confirmations. | +| `POST /control/verdict/:id` | Records the provider's verdict alone, with no figure — how Monero reports a payment it is still confirming. | + +### Driving a purchase by hand + +``` +python3 mock/server.py --port 8099 & + +curl -s -X POST http://localhost:8099/api/invoice -H 'content-type: application/json' \ + -d '{"codeHash":"<43 base64url chars>","priceId":"price_supporter","offerId":"offer_3m","method":"btc"}' +# => {"invoiceId": "...", "status": "open", ...} + +curl -s -X POST http://localhost:8099/control/settle/ +# => {"ok": true, "status": "paid"} + +curl -s "http://localhost:8099/api/invoice/?wait=open" +# returns immediately once settled, with status "paid" +``` + +A GET with `wait=open` held while `open` is still current unblocks the +instant `/control/settle` (or `/control/expire`, `/control/partial`) fires, +which is what the page's own wait loop relies on — no polling on a timer. + +Card invoices (`"method":"card"`) get a `clientSecret` in the response +instead of an address; BTC and XMR get `address` and `cryptoAmount`. + +### The Stripe key + +The publishable key lives in a `` element +in `public/index.html`, committed empty. `mock/server.py` substitutes +`$STRIPE_PUBLISHABLE_KEY` into the served page and refuses to start if it is +set to anything but a `pk_`-prefixed key (a secret or restricted key would +otherwise be baked into a page anyone can read). + +With no key set, the card path renders a labelled development stand-in +instead of a Stripe Payment Element: its button does what a successful +confirm does, and settling it calls the mock's `/control/settle` directly. +**This stand-in cannot appear when a key is set** — the code path that +builds it is unreachable once Stripe.js has actually loaded. To see the real +Payment Element, set a test key: + +``` +STRIPE_PUBLISHABLE_KEY=pk_test_... python3 mock/server.py --port 8099 +``` + +## What is not verified here + +The test suite runs in Node, so it asserts structure rather than rendering: +CSS is checked by parsing `styles.css`, `inert` by the attribute, the QR by +decoding the path the encoder produced, and the service worker by driving +`public/sw.js` in a Node `vm`. + +The screens themselves **have** been rendered and reviewed, in both themes at +desktop and phone widths, using headless Chromium driven by Playwright +installed outside this package — the whole purchase was walked through, +settled against the mock, and photographed. Nothing in `package.json` +changed; do the same rather than trusting the suite for anything visual. + +What still cannot be checked anywhere here: + +- A real QR scan with a phone camera. +- Stripe.js loading, mounting a Payment Element and confirming a payment, + which need a browser **and** a Stripe account. The SDK method names come + from the spec and every test drives a fake, so a wrong name stays green here + and fails in production. +- A real service worker installing and serving the precache with the network + genuinely off. +- System fonts: headless Chromium substitutes DejaVu, so type metrics differ + from a real machine. + +Before shipping a change that touches any of these, check it by hand in a +real browser: the wizard's two panels travel together for the length of a +step and neither one blinks out, panels ahead of the buyer are unreachable by +Tab, the menu closes on Escape and hands focus back to its button, both +themes are drawn from the menu's own control as well as from the operating +system, offline reload actually serves the cached build, and (with a test +key) the Payment Element mounts and confirms. + +## What is not implemented + +The web manifest is absent. The design calls it optional: offline support +needs the service worker, not the manifest. + +The asset table is otherwise built. The brand mark is the difference worth +knowing about: `public/img/wordmark-*.svg` and `public/img/symbol-*.svg` are +the official files copied out of `website/` and `media-logos/`, served under +the build hash and drawn by the stylesheet — NOT path data transcribed into a +module. `design.test.ts` compares each one against its source byte for byte, +so a hand-edited mark fails the suite. The one edit is the dark wordmark, +which is the light file with its `#030749` lettering set to white, exactly as +simplex.chat's own dark header shows it. + +## Content Security Policy + +This repository has no CSP — it belongs on the listener that serves the app +in production, not in this app. Without one, in +particular without `script-src https://js.stripe.com`, card payments break +silently and nothing in this test suite would catch it. The policy the spec +specifies: + +``` +default-src 'self'; +script-src 'self' https://js.stripe.com https://*.js.stripe.com; +frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; +connect-src 'self' https://api.stripe.com; +img-src 'self' https://*.stripe.com; +frame-ancestors 'self' https://simplex.chat https://*.simplex.chat +``` + +`frame-ancestors` is what lets the site embed this app in an iframe while every other origin is +refused (clickjacking). Drop the `simplex.chat` entries to forbid embedding entirely. + +## Embedding in the site + +The app runs standalone or inside an iframe on simplex.chat. It detects the frame +(`window.self !== window.top`), points the wordmark at the site's own home on the top window, and +speaks a small theme protocol so one switch drives both (`src/embed.ts`): + +- On load the frame posts `{ type: "simplex-embed-ready", theme }` to its parent. +- The host posts `{ type: "simplex-theme", theme }` (theme is `light` | `dark` | `system`) to drive + the frame; the frame applies it only from a trusted origin (`https://simplex.chat` or a subdomain). +- When the buyer uses the in-frame theme control, the frame echoes the same message back so the + host's own control stays in step. + +The host page hides its own navbar and frames the app full-bleed: + +```html + + +``` diff --git a/apps/simplex-badge-service/web/build.js b/apps/simplex-badge-service/web/build.js new file mode 100644 index 0000000000..41f8003c2b --- /dev/null +++ b/apps/simplex-badge-service/web/build.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// The build hash is written back into public/index.html and public/sw.js, so a served shell can never name a build other than the one on disk. + +import { createHash } from "node:crypto"; +import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = fileURLToPath(new URL("./", import.meta.url)); +export const paths = { + compiled: `${root}build/src`, + stylesheet: `${root}public/styles.css`, + images: `${root}public/img`, + fonts: `${root}public/fonts`, + indexHtml: `${root}public/index.html`, + worker: `${root}public/sw.js`, + site: `${root}dist`, +}; + +/** This is long enough to avoid a collision and short enough to read in a URL. */ +export const HASH_CHARS = 16; +export const ASSET_PATTERN = new RegExp(`/assets/[0-9a-f]{${HASH_CHARS}}/`, "g"); +export const BUILD_PATTERN = /const BUILD = "[0-9a-f]+";/; + +/** The sourceMappingURL comment is removed because the maps are not copied and would 404 on every devtools open. */ +export function served(source) { + return source.replace(/\n?\/\/# sourceMappingURL=.*\n?$/, "\n"); +} + +export function hashOf(files) { + const digest = createHash("sha256"); + for (const [name, content] of [...files].sort((a, b) => a[0].localeCompare(b[0]))) { + digest.update(name); + digest.update("\0"); + digest.update(content); + } + return digest.digest("hex").slice(0, HASH_CHARS); +} + +const IMAGE_TYPES = [".png", ".svg"]; +const FONT_TYPES = [".woff2"]; + +/** Images and fonts sit flat beside styles.css so that a url(hero-light.png) or url(Manrope-Regular.woff2) reference in the stylesheet resolves. */ +/** + * This runs as a classic non-module script so that it executes before the first paint. + * The CSP allows script-src 'self' but not inline, so it must be served as a file rather than inlined. + */ +const INIT_JS = `(function () { + var r = document.documentElement; + try { + var raw = localStorage.getItem("sb.theme.v1"); + var t = raw ? JSON.parse(raw) : "system"; + if (t !== "light" && t !== "dark" && t !== "system") t = "system"; + if (t === "system") r.removeAttribute("data-theme"); else r.setAttribute("data-theme", t); + r.style.colorScheme = (t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches)) ? "dark" : "light"; + } catch (e) {} + try { + var h = location.hash; + var landing = (h === "" || h === "#" || h === "#/") && location.search.indexOf("order=") < 0; + if (!landing) r.classList.add("sb-booting"); + } catch (e) {} +})(); +`; + +export function assets(compiled = paths.compiled, stylesheet = paths.stylesheet, images = paths.images, fonts = paths.fonts) { + const modules = readdirSync(compiled).filter((f) => f.endsWith(".js")).sort(); + if (modules.length === 0) throw new Error("build: build/src holds no modules — run tsc first"); + const pictures = readdirSync(images).filter((f) => IMAGE_TYPES.some((t) => f.endsWith(t))).sort(); + const faces = readdirSync(fonts).filter((f) => FONT_TYPES.some((t) => f.endsWith(t))).sort(); + return [ + ...modules.map((name) => [name, served(readFileSync(`${compiled}/${name}`, "utf8"))]), + ["styles.css", readFileSync(stylesheet, "utf8")], + ["init.js", INIT_JS], + ...pictures.map((name) => [name, readFileSync(`${images}/${name}`)]), + ...faces.map((name) => [name, readFileSync(`${fonts}/${name}`)]), + ]; +} + +/** This throws rather than returning the text unchanged, because a silent no-op would ship a shell that still names the previous build. */ +export function retarget(text, pattern, replacement, what) { + if (!new RegExp(pattern.source).test(text)) throw new Error(`build: no ${what} to rewrite`); + return text.replace(pattern, replacement); +} + +export function withBuild(html, build) { + return retarget(html, ASSET_PATTERN, `/assets/${build}/`, "asset path"); +} + +export async function prerenderShell() { + const dom = await import("./build/test/stub-dom.js"); + const prevDoc = Object.getOwnPropertyDescriptor(globalThis, "document"); + const prevNav = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + dom.installDocument(); + try { + const screens = await import("./build/src/screens.js"); + const noop = () => {}; + const chromeHtml = screens.chrome({ + theme: "system", onNewPurchase: noop, onHistory: noop, onTheme: noop, onToggle: noop, onHome: noop, + }).node.serialize(); + const landingHtml = screens.landing({ onStart: noop }).serialize(); + return { chromeHtml, appHtml: `
${landingHtml}
` }; + } finally { + if (prevDoc) Object.defineProperty(globalThis, "document", prevDoc); else delete globalThis.document; + if (prevNav) Object.defineProperty(globalThis, "navigator", prevNav); else delete globalThis.navigator; + } +} + +export const SHELL_SLOTS = /** @type {const} */ ([ + ["chrome", /()[\s\S]*?()/], + ["app", /()[\s\S]*?()/], +]); + +/** The markers are kept so the next build can find the slots again. */ +export function injectShell(html, shell) { + let out = html; + for (const [slot, pattern] of SHELL_SLOTS) { + const body = slot === "chrome" ? shell.chromeHtml : shell.appHtml; + out = retarget(out, pattern, (_m, open, close) => `${open}${body}${close}`, `${slot} shell slot`); + } + return out; +} + +export function withBuildId(js, build) { + return retarget(js, BUILD_PATTERN, `const BUILD = "${build}";`, "BUILD constant"); +} + +function put(file, content) { + let before = null; + try { before = readFileSync(file, "utf8"); } catch { /* the file may not exist yet */ } + if (before === content) return false; + writeFileSync(file, content); + return true; +} + +export async function assemble() { + const files = assets(); + const build = hashOf(files); + const shell = await prerenderShell(); + + // The whole site directory is removed first so a stale build hash cannot remain beside the current one. + rmSync(paths.site, { recursive: true, force: true }); + mkdirSync(`${paths.site}/assets/${build}`, { recursive: true }); + for (const [name, content] of files) writeFileSync(`${paths.site}/assets/${build}/${name}`, content); + + // The shell is injected before the build hash is rewritten, because the injected markup carries no asset URLs while the hash rewrite still finds the ones in the head. + const indexSource = withBuild(injectShell(readFileSync(paths.indexHtml, "utf8"), shell), build); + const moved = [ + put(paths.indexHtml, indexSource), + put(paths.worker, withBuildId(readFileSync(paths.worker, "utf8"), build)), + ].some(Boolean); + copyFileSync(paths.indexHtml, `${paths.site}/index.html`); + copyFileSync(paths.worker, `${paths.site}/sw.js`); + + return { build, files: files.length, moved }; +} + +if (process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url) { + const { build, files, moved } = await assemble(); + console.log(`build ${build}: ${files} files in dist/assets/${build}/, with index.html and sw.js`); + if (moved) console.log("build: public/index.html and public/sw.js now name this build — commit them"); +} diff --git a/apps/simplex-badge-service/web/check-tests.js b/apps/simplex-badge-service/web/check-tests.js new file mode 100644 index 0000000000..101e0262f3 --- /dev/null +++ b/apps/simplex-badge-service/web/check-tests.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; + +const testDir = 'build/test'; + +if (!fs.existsSync(testDir)) { + console.error(`Error: test directory not found: ${testDir}`); + process.exit(1); +} + +const files = fs.readdirSync(testDir, { recursive: true }); +const testFiles = files.filter(f => typeof f === 'string' && f.endsWith('.test.js')); + +if (testFiles.length === 0) { + console.error('Error: No test files found in build/test/'); + process.exit(1); +} + +console.log(`Found ${testFiles.length} test file(s)`); diff --git a/apps/simplex-badge-service/web/mock/server.py b/apps/simplex-badge-service/web/mock/server.py new file mode 100644 index 0000000000..af7612a084 --- /dev/null +++ b/apps/simplex-badge-service/web/mock/server.py @@ -0,0 +1,323 @@ +"""Stands in for the Haskell service AND for Stripe and BTCPay, so the whole +browser flow can be driven without any of them. A test fixture: no signatures, +no persistence, no money, and not a specification of the real service. + +Standard library only. Threaded, because the wait endpoint holds a connection. + +Environment: + MOCK_HOLD_SECONDS how long GET /api/invoice/:id?wait= holds (default 30) + STRIPE_PUBLISHABLE_KEY substituted into the + served index.html. Public by design, but still not + committed: unset, the page has NO card form and + renders the development stand-in instead, whose + button does what a successful confirm does and whose + settling is POST /control/settle/ below. + Set it to a `pk_test_...` key to drive the real + Stripe path; a secret key here is refused at start. +""" +import json, os, re, secrets, sys, threading +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse, parse_qs + +ROOT = Path(__file__).resolve().parent.parent +HOLD_SECONDS = float(os.environ.get("MOCK_HOLD_SECONDS", "30")) +STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "").strip() +KEY_META = re.compile(r'(]*content=")[^"]*(")') + +CATALOG = { + "price_supporter": {"badgeType": "supporter", "monthPrice": 700}, + "price_legend": {"badgeType": "legend", "monthPrice": 7000}, +} +OFFERS = { + "offer_3m": {"months": 3, "free": 1}, + "offer_12m": {"months": 12, "discount": 50}, + "offer_3m_s": {"months": 3, "free": 1}, + "offer_12m_s": {"months": 12, "discount": 50}, +} +MIME = {".html": "text/html", ".css": "text/css", ".js": "text/javascript", + ".svg": "image/svg+xml", ".json": "application/json", ".webmanifest": "application/manifest+json"} +# BTCPay's default speed policy asks for one confirmation. +REQUIRED_CONFIRMATIONS = 1 +ADDRESSES = {"btc": "bc1qexampleaddress0k3jq2wvcgmqz", "xmr": "48HqK2XmVexampleAddress9fRtWc"} + +LOCK = threading.Lock() +INVOICES = {} +HASHES = {} +EVENTS = {} + + +def now_iso(): + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def total(price_id, offer_id): + price = CATALOG.get(price_id) + if price is None: + return None + if not offer_id: + return {"months": 1, "amount": price["monthPrice"]} + offer = OFFERS.get(offer_id) + if offer is None: + return None + gross = price["monthPrice"] * offer["months"] + if "free" in offer: + amount = price["monthPrice"] * (offer["months"] - offer["free"]) + else: + amount = (gross * (100 - offer["discount"])) // 100 + return {"months": offer["months"], "amount": amount} + + +def payment_mark(inv): + """The figure the page shows and the verdict it shows it under, which together decide + which screen it is on.""" + return (inv.get("cryptoAmountPaid") or "", inv.get("paidInFull") is True) + + +def swap_event(invoice_id): + """Called under LOCK; returns the event to fire once it is released. A fresh Event for + whoever parks next, so a waiter that read the pre-change status but calls wait() after we + fire this one still catches its own (already-set) event instead of racing a clear() on a + shared one and missing the wake.""" + old = EVENTS.get(invoice_id) + EVENTS[invoice_id] = threading.Event() + return old + + +def public_view(inv): + """What the browser may see. Note what is absent: no code, no code hash — the service + never has the code.""" + view = { + "status": inv["status"], "badgeType": inv["badgeType"], "months": inv["months"], + "amount": inv["amount"], "currency": inv["currency"], + "expiresAt": inv["expiresAt"], + } + for k in ("amountPaid", "cryptoAmountPaid", "cryptoAmountDue", "settledAt"): + if inv.get(k) is not None: + view[k] = inv[k] + if inv.get("paidInFull") is not None: + view["paidInFull"] = inv["paidInFull"] + if inv["method"] == "card": + view["clientSecret"] = inv["clientSecret"] + else: + view["address"] = inv["address"] + view["cryptoAmount"] = inv["cryptoAmount"] + view["cryptoCurrency"] = inv["method"] + view["requiredConfirmations"] = REQUIRED_CONFIRMATIONS + return view + + +def with_publishable_key(html): + """The publishable key is compiled into the page. Here it comes + from the environment, so nothing that could be a real key is ever written + back into public/index.html. Unset leaves the committed empty value, which + is what selects the development stand-in.""" + if not STRIPE_PUBLISHABLE_KEY: + return html + text, found = KEY_META.subn(lambda m: m.group(1) + STRIPE_PUBLISHABLE_KEY + m.group(2), html.decode()) + if found != 1: + raise RuntimeError("mock: the shell has no stripe-publishable-key meta element to fill") + return text.encode() + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def _send(self, status, payload, ctype="application/json"): + body = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + try: + self.send_response(status) + self.send_header("content-type", ctype) + self.send_header("content-length", str(len(body))) + self.send_header("cache-control", "no-store") + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + # A client that drops during a long poll leaves the invoice untouched and the page + # reissues on its next load, so there is nothing to retry. + self.close_connection = True + + def _read_json(self): + length = int(self.headers.get("content-length") or 0) + try: + return json.loads(self.rfile.read(length) or b"{}") + except Exception: + return None + + def do_POST(self): + path = urlparse(self.path).path + + if path.startswith("/control/"): + parts = path.strip("/").split("/") + if len(parts) != 3: + return self._send(400, {"error": "bad_request"}) + _, action, invoice_id = parts + with LOCK: + inv = INVOICES.get(invoice_id) + if inv is None: + return self._send(404, {"error": "not_found"}) + if action == "settle": + inv["status"] = "paid" + inv["amountPaid"] = inv["amount"] + inv["settledAt"] = now_iso() + inv["paidInFull"] = True + if inv["method"] != "card": + inv["cryptoAmountPaid"] = inv["cryptoAmount"] + inv["cryptoAmountDue"] = "0.000" + elif action == "expire": + inv["status"] = "expired" + elif action == "confirming": + inv["amountPaid"] = inv["amount"] + inv["paidInFull"] = True + if inv["method"] != "card": + inv["cryptoAmountPaid"] = inv["cryptoAmount"] + inv["cryptoAmountDue"] = "0.000" + elif action == "verdict": + # Monero reports the payment as paid in full while its figures are still zero. + inv["paidInFull"] = True + elif action == "partial": + inv["amountPaid"] = inv["amount"] // 2 + inv["paidInFull"] = False + if inv["method"] != "card": + inv["cryptoAmountPaid"] = "0.734" + inv["cryptoAmountDue"] = "0.752" + else: + return self._send(400, {"error": "bad_request"}) + status = inv["status"] + old_event = swap_event(invoice_id) + if old_event is not None: + old_event.set() + return self._send(200, {"ok": True, "status": status}) + + if path.startswith("/api/invoice/") and path.endswith("/cancel"): + invoice_id = path[len("/api/invoice/"):-len("/cancel")] + with LOCK: + inv = INVOICES.get(invoice_id) + if inv is None: + return self._send(404, {"error": "not_found"}) + if inv["status"] != "open": + return self._send(409, {"error": "not_open"}) + if payment_mark(inv) != ("", False) or inv.get("amountPaid"): + # Cancelling a funded invoice would strand what the buyer already sent. + return self._send(409, {"error": "funded"}) + inv["status"] = "expired" + payload = {"invoiceId": invoice_id, **public_view(inv)} + old_event = swap_event(invoice_id) + if old_event is not None: + old_event.set() + return self._send(200, payload) + + if path == "/api/invoice": + body = self._read_json() + if not body or not isinstance(body.get("codeHash"), str) or not body["codeHash"]: + return self._send(400, {"error": "bad_request"}) + if body.get("method") not in ("card", "btc", "xmr"): + return self._send(400, {"error": "bad_request"}) + with LOCK: + if body["codeHash"] in HASHES: + return self._send(409, {"error": "code_conflict"}) + t = total(body.get("priceId"), body.get("offerId")) + if t is None: + return self._send(400, {"error": "catalog_changed"}) + invoice_id = secrets.token_urlsafe(16) + is_card = body["method"] == "card" + inv = { + "invoiceId": invoice_id, "method": body["method"], "status": "open", + "badgeType": CATALOG[body["priceId"]]["badgeType"], "months": t["months"], + "amount": t["amount"], "currency": "usd", + "expiresAt": (datetime.now(timezone.utc) + timedelta(hours=1)) + .replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "clientSecret": f"cs_test_{secrets.token_hex(12)}" if is_card else None, + "address": None if is_card else ADDRESSES[body["method"]], + "cryptoAmount": None if is_card else "1.482", + } + INVOICES[invoice_id] = inv + HASHES[body["codeHash"]] = invoice_id + EVENTS[invoice_id] = threading.Event() + payload = {"invoiceId": invoice_id, **public_view(inv)} + return self._send(200, payload) + + return self._send(405, {"error": "bad_request"}) + + def do_GET(self): + parsed = urlparse(self.path) + # An empty `seenPaid=` means the page rendered no figure, which differs from the parameter + # being absent, so blank values are kept. + path, query = parsed.path, parse_qs(parsed.query, keep_blank_values=True) + + if path.startswith("/api/invoice/"): + invoice_id = path[len("/api/invoice/"):] + with LOCK: + inv = INVOICES.get(invoice_id) + if inv is None: + return self._send(404, {"error": "not_found"}) + current = inv["status"] + held = payment_mark(inv) + event = EVENTS.get(invoice_id) + wait = (query.get("wait") or [None])[0] + # A payment recorded before this request arrived cannot fire the event this request + # would wait on, so holding then would strand the buyer on the payment screen. + seen = ((query.get("seenPaid") or [""])[0], (query.get("seenFull") or ["0"])[0] == "1") + unseen = "seenPaid" in query and seen != held + if wait is not None and wait == current and not unseen and event is not None: + # The event is never cleared; settle, expire and partial replace it with a fresh + # one under the lock, so a set event always means a change happened after this + # reference was taken. + event.wait(timeout=HOLD_SECONDS) + with LOCK: + inv = INVOICES[invoice_id] + payload = {"invoiceId": invoice_id, **public_view(inv)} + return self._send(200, payload) + + rel = "index.html" if path == "/" else path.lstrip("/") + for base in ("public", "dist"): + base_resolved = (ROOT / base).resolve() + candidate = (base_resolved / rel).resolve() + try: + candidate.relative_to(base_resolved) + except ValueError: + # The path escaped this base directory, so move to the next one. + continue + if candidate.is_file(): + ctype = MIME.get(candidate.suffix, "application/octet-stream") + body = candidate.read_bytes() + if candidate.name == "index.html": + body = with_publishable_key(body) + return self._send(200, body, ctype) + return self._send(404, {"error": "not_found"}) + + +class Server(ThreadingHTTPServer): + daemon_threads = True + + def handle_error(self, request, client_address): + # socketserver's default prints a traceback for a dropped client, which is routine under + # a long poll, so suppress it while letting anything else surface. + if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)): + return + super().handle_error(request, client_address) + + +def main(): + port = 8099 + if "--port" in sys.argv: + port = int(sys.argv[sys.argv.index("--port") + 1]) + if STRIPE_PUBLISHABLE_KEY and not STRIPE_PUBLISHABLE_KEY.startswith("pk_"): + # A secret or restricted key (sk_, rk_) would be written into the page for anyone to read, + # so only a publishable key (pk_) is allowed. + sys.exit("mock: STRIPE_PUBLISHABLE_KEY must be a publishable key (pk_...)") + server = Server(("127.0.0.1", port), Handler) + print(f"mock badge service on http://localhost:{port}", flush=True) + print("stripe: " + (f"publishable key {STRIPE_PUBLISHABLE_KEY[:11]}… — the real card form" + if STRIPE_PUBLISHABLE_KEY + else "no STRIPE_PUBLISHABLE_KEY — the card path renders the development stand-in"), + flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/apps/simplex-badge-service/web/package-lock.json b/apps/simplex-badge-service/web/package-lock.json new file mode 100644 index 0000000000..4c40bd373f --- /dev/null +++ b/apps/simplex-badge-service/web/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "simplex-badge-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplex-badge-web", + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^6.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/apps/simplex-badge-service/web/package.json b/apps/simplex-badge-service/web/package.json new file mode 100644 index 0000000000..eb42e5c0cb --- /dev/null +++ b/apps/simplex-badge-service/web/package.json @@ -0,0 +1,14 @@ +{ + "name": "simplex-badge-web", + "private": true, + "type": "module", + "scripts": { + "build": "rm -rf build && tsc -p tsconfig.json && tsc -p tsconfig.test.json && node build.js", + "test": "rm -rf build && tsc -p tsconfig.json && tsc -p tsconfig.test.json && node check-tests.js && TZ=UTC node --test 'build/test/**/*.test.js'", + "mock": "python3 mock/server.py" + }, + "devDependencies": { + "typescript": "^6.0.0", + "@types/node": "^24.0.0" + } +} diff --git a/apps/simplex-badge-service/web/public/fonts/GT-Walsheim-LC-Bold.woff2 b/apps/simplex-badge-service/web/public/fonts/GT-Walsheim-LC-Bold.woff2 new file mode 100644 index 0000000000..da6193cb44 Binary files /dev/null and b/apps/simplex-badge-service/web/public/fonts/GT-Walsheim-LC-Bold.woff2 differ diff --git a/apps/simplex-badge-service/web/public/fonts/Manrope-Bold.woff2 b/apps/simplex-badge-service/web/public/fonts/Manrope-Bold.woff2 new file mode 100644 index 0000000000..969c36f982 Binary files /dev/null and b/apps/simplex-badge-service/web/public/fonts/Manrope-Bold.woff2 differ diff --git a/apps/simplex-badge-service/web/public/fonts/Manrope-Medium.woff2 b/apps/simplex-badge-service/web/public/fonts/Manrope-Medium.woff2 new file mode 100644 index 0000000000..8c9a66a07c Binary files /dev/null and b/apps/simplex-badge-service/web/public/fonts/Manrope-Medium.woff2 differ diff --git a/apps/simplex-badge-service/web/public/fonts/Manrope-Regular.woff2 b/apps/simplex-badge-service/web/public/fonts/Manrope-Regular.woff2 new file mode 100644 index 0000000000..d91a1b707b Binary files /dev/null and b/apps/simplex-badge-service/web/public/fonts/Manrope-Regular.woff2 differ diff --git a/apps/simplex-badge-service/web/public/fonts/Manrope-SemiBold.woff2 b/apps/simplex-badge-service/web/public/fonts/Manrope-SemiBold.woff2 new file mode 100644 index 0000000000..03a79b412b Binary files /dev/null and b/apps/simplex-badge-service/web/public/fonts/Manrope-SemiBold.woff2 differ diff --git a/apps/simplex-badge-service/web/public/img/hero-dark.png b/apps/simplex-badge-service/web/public/img/hero-dark.png new file mode 100644 index 0000000000..07362c870d Binary files /dev/null and b/apps/simplex-badge-service/web/public/img/hero-dark.png differ diff --git a/apps/simplex-badge-service/web/public/img/hero-light.png b/apps/simplex-badge-service/web/public/img/hero-light.png new file mode 100644 index 0000000000..2f36b060c7 Binary files /dev/null and b/apps/simplex-badge-service/web/public/img/hero-light.png differ diff --git a/apps/simplex-badge-service/web/public/img/symbol-dark.svg b/apps/simplex-badge-service/web/public/img/symbol-dark.svg new file mode 100644 index 0000000000..fa598acf3d --- /dev/null +++ b/apps/simplex-badge-service/web/public/img/symbol-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/simplex-badge-service/web/public/img/symbol-light.svg b/apps/simplex-badge-service/web/public/img/symbol-light.svg new file mode 100644 index 0000000000..d8b5951a0b --- /dev/null +++ b/apps/simplex-badge-service/web/public/img/symbol-light.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/simplex-badge-service/web/public/img/wordmark-dark.svg b/apps/simplex-badge-service/web/public/img/wordmark-dark.svg new file mode 100644 index 0000000000..3ada958e11 --- /dev/null +++ b/apps/simplex-badge-service/web/public/img/wordmark-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/simplex-badge-service/web/public/img/wordmark-light.svg b/apps/simplex-badge-service/web/public/img/wordmark-light.svg new file mode 100644 index 0000000000..b845ff219d --- /dev/null +++ b/apps/simplex-badge-service/web/public/img/wordmark-light.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/simplex-badge-service/web/public/index.html b/apps/simplex-badge-service/web/public/index.html new file mode 100644 index 0000000000..dcf5e7d4b2 --- /dev/null +++ b/apps/simplex-badge-service/web/public/index.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + +Support SimpleX + + + + + + + + + + +
+

Support SimpleX

Get a badge to send larger files (2‑5GB) that stay available longer (7‑21 days), and to show it on your profile.

You pay once for the months you choose. No subscription, no account.

Invest $100+ in SimpleX Chat and get a free supporter badge.

Learn more on
+ + + + + diff --git a/apps/simplex-badge-service/web/public/styles.css b/apps/simplex-badge-service/web/public/styles.css new file mode 100644 index 0000000000..2fee434fcd --- /dev/null +++ b/apps/simplex-badge-service/web/public/styles.css @@ -0,0 +1,1252 @@ +/* The colours are the website's, as its blog pages use them (website/tailwind.config.js, + website/src/css/blog.css and website/src/_includes/layouts/article.html): the page's ground, + the article's card, the heading gradient, and the link and button colours, in both themes. + Every other value below is the published SimpleX design's own; where one is not, the line says why. */ +:root { + /* Tell the UA which ground to paint before the first message and on a reload's blank frame, and + which form controls and scrollbars to render; the dark blocks flip it with the palette. */ + color-scheme: light; + /* primary-light and its hover step; white on it, as on the site's buttons. Pressed is the + accent one small step darker rather than the site's lighter, greyer #407AD2: a press should + read as the same button under the finger, not a paler one. */ + --accent: #0053D0; + --accent-hover: #1661D1; + --accent-pressed: #004ABB; + --on-accent: #FFFFFF; + --ink: #0D1B3A; /* simplex.domains' ink: the navy the page's blues are built on */ + --bg: #F3FAFF; /* secondary-bg-light: the blog page's ground */ + --surface: #FFFFFF; /* the article's card */ + --menu: #FFFFFF; + --line: rgba(0, 83, 208, .14); /* simplex.domains' card line: the accent, faint, so it sits in the page's blue */ + /* one faint wash of the accent over the flat ground, and nothing else */ + --page: radial-gradient(50% 50% at 50% 50%, rgba(0, 83, 208, .06) 0%, rgba(243, 250, 255, 0) 100%); + + /* The one secondary text colour, simplex.domains' own: the ink at 65%, so it stays in the + page's hue. 5.3:1 on #F3FAFF, and set at 500 wherever it is used. */ + --ink-soft: rgba(13, 27, 58, .651); + /* The ink at 4%: the menu's hover, the landing screen's info panel, and a card the catalog + cannot price. */ + --tint: rgba(13, 27, 58, .04); + + /* the blog's heading gradient, top to bottom */ + --title: linear-gradient(to bottom, #53C1FF -50%, #0053D0 160%); + + /* Settled, waiting and failed, each a foreground on its own ground. */ + --ok-fg: #1c7c3f; + --ok-bg: #e8f5ec; + --ok-line: #bfe3cc; + --warn-fg: #8a6100; + --warn-bg: #fff8e6; + --warn-line: #f0dcae; + --danger-fg: #b3261e; + --danger-bg: #fdecec; + --danger-line: #f3c9c6; + + /* the code screen's code frame, in the accent */ + --code-bg: rgba(0, 83, 208, .06); + --code-line: rgba(0, 83, 208, .35); + + /* A primary button with nothing to do: a flat bar with a washed label, legible + at 4.0:1. See `.primary[disabled]` for why it is not the accent faded. */ + --disabled: rgba(13, 27, 58, .07); + --on-disabled: rgba(13, 27, 58, .50); + + /* Switched with the theme rather than tinted by it. The dark wordmark is the + same file with its `#030749` set to white. */ + --hero: url(hero-light.png); + --wordmark: url(wordmark-light.svg); + + /* a card is a 1px border at radius 20 with no shadow, and every button a pill; the + shadows on the page are the menu's and the primary button's */ + --r-card: 20px; + --r-pill: 9999px; + --shadow-pop: 0 2px 6px rgba(0, 0, 0, .06), 0 16px 40px rgba(0, 0, 0, .12); + /* the primary button: a touch of light across its top and a soft shadow in the accent */ + --sheen: linear-gradient(to bottom, rgba(255, 255, 255, .16), rgba(255, 255, 255, 0) 60%); + --shadow-button: 0 4px 12px rgba(0, 83, 208, .18); + --shadow-button-hover: 0 6px 16px rgba(0, 83, 208, .24); + + /* The faces simplex.domains sets: GT Walsheim for the heading, Manrope for everything else. + Both are served under the build hash (see the @font-face rules below) and precached. */ + --display: "GT Walsheim", Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font: Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* The four Manrope weights the sheet uses and the one GT Walsheim weight, relative to this file, + so they sit under the build hash with it. `swap`, so text is never invisible while a face loads. */ +@font-face { font-family: Manrope; src: url(Manrope-Regular.woff2) format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } +@font-face { font-family: Manrope; src: url(Manrope-Medium.woff2) format("woff2"); font-weight: 500; font-style: normal; font-display: swap; } +@font-face { font-family: Manrope; src: url(Manrope-SemiBold.woff2) format("woff2"); font-weight: 600; font-style: normal; font-display: swap; } +@font-face { font-family: Manrope; src: url(Manrope-Bold.woff2) format("woff2"); font-weight: 700; font-style: normal; font-display: swap; } +@font-face { font-family: "GT Walsheim"; src: url(GT-Walsheim-LC-Bold.woff2) format("woff2"); font-weight: 700; font-style: normal; font-display: swap; } + +/* The dark palette is written twice on purpose: the media query claims + everything that is not an explicit light, and the attribute rule claims an + explicit dark, which is how `system`, `light` and `dark` all win where they + should. Both blocks must declare the same tokens and values, and + `design.test.ts` fails if they diverge. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + /* primary-dark, with its hover and pressed steps, and the page's ground on it, as on the + site's dark buttons */ + --accent: #70F0F9; + --accent-hover: #66D9E2; + --accent-pressed: #5CD3DC; + --on-accent: #000832; + --ink: #EAF2FF; /* simplex.domains' dark ink */ + --bg: #000832; /* primary-bg-dark: the blog page's ground */ + --surface: #0B2A59; /* secondary-bg-dark: the blog list's card, a clear step off the ground */ + --menu: #0B2A59; + --line: rgba(112, 240, 249, .18); /* simplex.domains' dark card line: the accent, faint */ + --page: radial-gradient(50% 50% at 50% 50%, rgba(112, 240, 249, .05) 0%, rgba(0, 8, 50, 0) 100%); + + --ink-soft: rgba(234, 242, 255, .735); + --tint: rgba(255, 255, 255, .06); + + --title: linear-gradient(to bottom, #70F0F9 0%, #70F0F9 33%, #50D8F1 100%); + + /* The light theme's three hues, lightened to sit on #000832, each with its + own ground at a low alpha of itself. */ + --ok-fg: #7FE3A6; + --ok-bg: rgba(127, 227, 166, .12); + --ok-line: rgba(127, 227, 166, .28); + --warn-fg: #F5C86A; + --warn-bg: rgba(245, 200, 106, .12); + --warn-line: rgba(245, 200, 106, .28); + --danger-fg: #FF9F98; + --danger-bg: rgba(255, 159, 152, .12); + --danger-line: rgba(255, 159, 152, .28); + + --code-bg: rgba(112, 240, 249, .12); + --code-line: rgba(112, 240, 249, .45); + + --disabled: rgba(255, 255, 255, .09); + --on-disabled: rgba(255, 255, 255, .45); + + --hero: url(hero-dark.png); + --wordmark: url(wordmark-dark.svg); + + --shadow-pop: 0 2px 8px rgba(0, 0, 0, .50), 0 16px 40px rgba(0, 0, 0, .55); + /* White barely shows on the light cyan, so the dark sheen also deepens the pill's foot. */ + --sheen: linear-gradient(to bottom, rgba(255, 255, 255, .30), rgba(255, 255, 255, 0) 50%, rgba(0, 8, 50, .16) 100%); + --shadow-button: 0 6px 16px rgba(112, 240, 249, .16); + --shadow-button-hover: 0 8px 20px rgba(112, 240, 249, .22); + } +} + +/* The same palette again, for the menu's explicit Dark. */ +:root[data-theme="dark"] { + color-scheme: dark; + /* primary-dark, with its hover and pressed steps, and the page's ground on it, as on the + site's dark buttons */ + --accent: #70F0F9; + --accent-hover: #66D9E2; + --accent-pressed: #5CD3DC; + --on-accent: #000832; + --ink: #EAF2FF; /* simplex.domains' dark ink */ + --bg: #000832; /* primary-bg-dark: the blog page's ground */ + --surface: #0B2A59; /* secondary-bg-dark: the blog list's card, a clear step off the ground */ + --menu: #0B2A59; + --line: rgba(112, 240, 249, .18); /* simplex.domains' dark card line: the accent, faint */ + --page: radial-gradient(50% 50% at 50% 50%, rgba(112, 240, 249, .05) 0%, rgba(0, 8, 50, 0) 100%); + + --ink-soft: rgba(234, 242, 255, .735); + --tint: rgba(255, 255, 255, .06); + + --title: linear-gradient(to bottom, #70F0F9 0%, #70F0F9 33%, #50D8F1 100%); + + /* The light theme's three hues, lightened to sit on #000832, each with its + own ground at a low alpha of itself. */ + --ok-fg: #7FE3A6; + --ok-bg: rgba(127, 227, 166, .12); + --ok-line: rgba(127, 227, 166, .28); + --warn-fg: #F5C86A; + --warn-bg: rgba(245, 200, 106, .12); + --warn-line: rgba(245, 200, 106, .28); + --danger-fg: #FF9F98; + --danger-bg: rgba(255, 159, 152, .12); + --danger-line: rgba(255, 159, 152, .28); + + --code-bg: rgba(112, 240, 249, .12); + --code-line: rgba(112, 240, 249, .45); + + --disabled: rgba(255, 255, 255, .09); + --on-disabled: rgba(255, 255, 255, .45); + + --hero: url(hero-dark.png); + --wordmark: url(wordmark-dark.svg); + + --shadow-pop: 0 2px 8px rgba(0, 0, 0, .50), 0 16px 40px rgba(0, 0, 0, .55); + /* White barely shows on the light cyan, so the dark sheen also deepens the pill's foot. */ + --sheen: linear-gradient(to bottom, rgba(255, 255, 255, .30), rgba(255, 255, 255, 0) 50%, rgba(0, 8, 50, .16) 100%); + --shadow-button: 0 6px 16px rgba(112, 240, 249, .16); + --shadow-button-hover: 0 8px 20px rgba(112, 240, 249, .22); +} + +* { box-sizing: border-box; } + +/* Reserve the scrollbar's width always, so a panel whose height changes (the Payment Element + autofilling or a 3DS challenge resizing it) cannot toggle the scrollbar and jog the centred + column sideways, which `#app`'s overflow-x then clips. */ +html { scrollbar-gutter: stable; } + +/* The page is one column of the viewport's height: the header, then the screen, which is at least + the rest of the viewport tall (see `.panel`), then the footer below the fold, where a scroll + reaches it. `100vh` is the fallback for a browser without dynamic viewport units. */ +body { + margin: 0; + display: flex; + flex-direction: column; + /* The gutter is here, so `#app` is exactly the 640px content column and + nothing inside it has to subtract padding to reach that width. */ + padding: 0 20px; + min-height: 100vh; + min-height: 100dvh; + background: var(--bg); + /* Fixed, so the wash does not tile or scroll away on a long screen. */ + background-image: var(--page); + background-attachment: fixed; + color: var(--ink); + font-family: var(--font); + /* the scale: 1rem/1.25 body, .875 small, .75 extra-small, larger steps below */ + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +#app { width: 100%; max-width: 640px; margin: 0 auto; overflow-x: hidden; flex: 1 0 auto; } +/* The header's height, which every screen's minimum height is the viewport less: `.chrome-bar`'s + 56px box, its paddings inside it. */ +:root { --chrome-h: 56px; } + +/* The shell ships a static copy of the first screen (see index.html), so the first paint is the real + landing, not an empty page, and `main.ts` swaps in its own identical markup with no flash. Embedded, + the site owns the navbar, so the shell's copy of it is hidden there as the built one would be. */ +.embedded #chrome { display: none; } + +/* A reload of any screen but the landing can't be prerendered (the screen depends on runtime state), + so `init.js` marks it `sb-booting` before the first paint: the shell's landing is held hidden over + the themed ground, and `main.ts` clears the mark once it has painted the real screen, which then + fades in rather than replacing a flash of the landing. The landing route is never marked, so its + prerendered first paint stays instant. */ +#app { transition: opacity .2s ease; } +.sb-booting #app { opacity: 0; } + +/* Embedded in the site: the site's fixed navbar sits above the frame, so pad the top to clear it, + and drop the page's own wash so the flat background the site hands in shows through cleanly. The + site carries its own footer below the frame, so ours would only double the contact link and leave + its accent line at the frame's edge — hide it embedded. */ +/* Standalone, the body fills the viewport so the footer sits at the bottom; embedded, the host sizes + the frame to the body, so a `100vh` floor would make the body's height depend on the frame height + the host derived from it — a loop that ratchets the frame taller and drops the site's footer as the + zoom shifts. Drop the floor so the body is exactly its content. */ +.embedded body { padding-top: 54px; background-image: none; min-height: 0; display: block; } +.embedded .panel { min-height: 0; } +.embedded footer { display: none; } + +/* Embedded in the site's dark theme, the standalone grey surfaces read as off against the navy the + site hands in. Follow the site's own dark-card idiom instead: a faint white veil and hairline on + the navy ground, so a panel is a lighter navy rather than a grey block. Both selectors, to match + `system` dark (media query) and the explicit Dark choice (data-theme). */ +@media (prefers-color-scheme: dark) { + :root.embedded:not([data-theme="light"]) { + --surface: rgba(255, 255, 255, .05); + --menu: rgba(255, 255, 255, .07); + --line: rgba(255, 255, 255, .22); + } +} +:root.embedded[data-theme="dark"] { + --surface: rgba(255, 255, 255, .05); + --menu: rgba(255, 255, 255, .07); + --line: rgba(255, 255, 255, .22); +} + +/* ------------------------------------------------------------------- chrome */ + +/* The wordmark on the left, and on the right a circular icon button holding the + hamburger. It spans the page rather than the 640 column, so the column reads + as content inside a site rather than as the whole site. */ +.chrome-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 56px; + max-width: 1120px; + margin: 0 auto; + padding: 8px 4px; +} + +/* The official wordmark, drawn from the file. The URL is relative, so it + resolves inside /assets// beside this sheet and is precached with + everything else. */ +.brand { + display: block; + width: 132px; + aspect-ratio: 161 / 40; + background-image: var(--wordmark); + background-size: contain; + background-position: left center; + background-repeat: no-repeat; + /* A link with no text: the accessible name is on the element. */ + text-indent: -9999px; + overflow: hidden; +} + +.menu-wrap { position: relative; } + +/* 40 rather than the reference's 32, which is under every platform's minimum + touch target and this is the header's only control. */ +.menu-button { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: 50%; + /* The hairline colour as a fill, which on both grounds is the reference's own + circle without a fourth grey. */ + background: var(--line); + color: var(--ink); + cursor: pointer; +} +.menu-button:hover, .menu-button[aria-expanded="true"] { color: var(--accent); } +.bars { display: block; width: 20px; height: 20px; } + +/* The panel, anchored under the button and right-aligned to it, one step off + the page ground, with a hairline and the page's only drop shadow. */ +.menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 20; + width: 320px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--menu); + box-shadow: var(--shadow-pop); + text-align: left; + overflow: hidden; +} +.menu[hidden] { display: none; } + +/* Full-bleed hairlines between sections, so the padding is on the section and + never on the panel. */ +.menu-section { padding: 6px; } +.menu-section + .menu-section { border-top: 1px solid var(--line); } + +.menu-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 10px; +} +.menu-label { font-size: .875rem; font-weight: 700; color: var(--ink); } + +/* the segmented setting: three values in one pill, the chosen one in the + accent with white on it. */ +.segmented { + display: flex; + padding: 2px; + border-radius: var(--r-pill); + background: var(--tint); +} +.segment { + padding: 5px 11px; + border: 0; + border-radius: var(--r-pill); + background: none; + color: var(--ink-soft); + font: inherit; + font-size: .875rem; + font-weight: 700; + cursor: pointer; +} +.segment:hover { color: var(--ink); } +.segment[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); } + +.menu-item { + display: block; + width: 100%; + padding: 10px; + border: 0; + border-radius: 10px; + background: none; + color: var(--ink); + font: inherit; + font-size: .875rem; + font-weight: 500; + text-align: left; + cursor: pointer; +} +.menu-item[hidden] { display: none; } +.menu-item:hover { background: var(--tint); color: var(--accent); } +.menu-item.danger { color: var(--danger-fg); } +.menu-item.danger:hover { background: var(--danger-bg); color: var(--danger-fg); } + +/* the footer, on every screen: a hairline across the column, then the + contact link centred under it in the accent. */ +footer { + flex: none; + width: 100%; + max-width: 640px; + margin: 0 auto; + text-align: center; +} +footer a { + display: block; + border-top: 1px solid var(--line); + padding: 22px 0 34px; + color: var(--accent); + font-size: .875rem; + text-decoration: none; +} +footer a:hover { text-decoration: underline; } + +/* The keyboard's equivalent of the hover underline, declared rather than left + to the user agent, whose thin dark outline all but disappears on the accent + button and on the dark ground. `:focus-visible`, so a mouse press leaves + nothing behind. */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +/* On the accent ground itself an accent ring is invisible, so it inverts. */ +.primary:not(.outline):focus-visible { outline-color: var(--ink); } + +/* -------------------------------------------------------------- the wizard */ + +/* the horizontal track, in two elements, because one cannot clip and travel + at the same time. `.track` clips and is the height of the panel in view; + `.rail` is the flex row of four panels and is what translates. Both animate, + so the outgoing panel travels off one edge while the incoming one arrives at + the other. `main.ts` writes the height and the transform, and sets `--slide` + to 0ms for any move that is not a step. + + `overflow: hidden` is also what makes this a stepper rather than a carousel: + there is no scroll to drag. Unreached panels carry `inert`, so Tab cannot + enter them either. */ +.track { + overflow: hidden; + transition: height var(--slide, 320ms) cubic-bezier(.22, .61, .36, 1); +} +.rail { + display: flex; + align-items: flex-start; + width: 100%; + transition: transform var(--slide, 320ms) cubic-bezier(.22, .61, .36, 1); +} +/* Below the column width each panel is the viewport width: the same track is + the phone flow, with only the padding and the type scale changing. */ +.panel { + position: relative; + flex: 0 0 100%; + min-width: 0; + padding: 12px 0 22px; + text-align: center; + /* A screen is a column at least as tall as the viewport under the header, so that its button + stands at the same place on every screen - near the bottom, where a thumb reaches it - and the + content between the heading and the button is spread over the height rather than stacked at + the top. A screen taller than the viewport grows and scrolls, as the payment screens do. */ + display: flex; + flex-direction: column; + min-height: calc(100vh - var(--chrome-h)); + min-height: calc(100dvh - var(--chrome-h)); +} +/* On a wide screen the button needs no thumb, so it stands higher off the foot of the viewport and + the page reads as balanced rather than bottom-heavy. Up to a 1080px-tall viewport the space under + it grows with the height; beyond that the content stops descending - its foot stays 927px under + the header, where a 1080px viewport puts it - and the whole surplus goes under it. */ +@media (min-width: 700px) { + .panel { + padding-bottom: max(clamp(40px, 9vh, 110px), calc(100vh - var(--chrome-h) - 927px)); + padding-bottom: max(clamp(40px, 9vh, 110px), calc(100dvh - var(--chrome-h) - 927px)); + } +} +/* What each screen spreads: the hero, the choice cards, the summary. `auto` above and nothing + below, so the free height is shared between the space over the block and the space under it, + which the button's own `auto` margin takes. */ +.panel > .hero, .panel > .choices, .panel > .rows { margin-top: auto; } +/* The button is the last thing before the invest block, and sinks to the foot of the screen. Where + a screen keeps notes above its button, the notes sink with it and the button follows them. */ +.panel > .primary { margin-top: auto; } +.panel > .notes { margin-top: auto; } +.notes + .primary { margin-top: 20px; } +.notes .muted { margin: 4px auto 0; max-width: 480px; } +/* The checkout's note slot holds one line's height whether or not the line is shown, so choosing a + method moves nothing above or below it. */ +.notes.slot { min-height: calc(4px + .875rem * 1.5); } +.notes .half { display: inline-block; } +@media (prefers-reduced-motion: reduce) { + .track, .rail { transition: none; } +} + +/* Every payment screen and `#/codes` takes the same shell as a wizard panel. + The 20px a trailing button adds is the difference between a footer rule under + a button and one under a block of text. */ +.panel > button:last-child { margin-bottom: 20px; } + +/* ------------------------------------------------------------------ headings */ + +h1 { + font-family: var(--display); + font-size: 2.25rem; + line-height: 3rem; + font-weight: 700; + letter-spacing: -.02em; + color: var(--ink); + margin: 12px 0 0; +} +/* The heading is the gradient, clipped to its own glyphs, in both themes. + Guarded, because the fill is transparent: a browser that cannot clip a + background to text would render every heading invisible. */ +@supports ((background-clip: text) or (-webkit-background-clip: text)) { + h1 { + background-image: var(--title); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } +} +/* the payment screen and the code screen take one step down from the landing screen's. */ +h1.tight { font-size: 1.875rem; line-height: 2.5rem; margin-top: 15px; } + +.lede { + font-size: 1.125rem; + line-height: 1.5; + font-weight: 500; + color: var(--ink-soft); + margin: 10px 0 0; +} +h1 + .lede { margin-top: 10px; } +h1.tight + .lede { margin-top: 6px; } +/* Two ledes are two sentences of one paragraph, a line height apart rather than + a paragraph gap. */ +.lede + .lede { margin-top: 0; } +.lede .line { display: block; } +.lede .half { display: inline-block; } + +.muted { color: var(--ink-soft); font-size: .875rem; font-weight: 500; margin: 14px 0 0; } +/* A narrower measure than the column, so the closing footnote breaks in two rather than + reading as a caption that ran on. Scoped to a direct child, so it cannot reach the same + class inside a left-aligned field and indent the line it holds. */ +.panel > p.muted { max-width: 332px; margin-left: auto; margin-right: auto; } +/* Shown beside the status while the browser is offline, not instead of it. */ +.offline { color: var(--warn-fg); } +.center { text-align: center; } + +/* Figures that are compared down a column, or that change in place while the + buyer is looking at them. The default proportional digits are narrower on 1 + than on 0, so the payment screen's held-rate clock and the rate-limited screen's countdown shifted every second + they ticked, and the duration list's three prices did not line up under one another. + `.notice .title` is the rate-limited screen's countdown, which is the only figure that line ever + holds. */ +.row, .choice .price, .rate, .notice .title, .entry .meta { + font-variant-numeric: tabular-nums; +} + +/* Back is out of the flow, so the heading is the first thing in every panel and stands at one + height whether or not there is a Back. On a desktop `top` centres it on the heading's first + line, at the left of the column, as a navigation bar draws it. On a phone the heading is nearly + as wide as the column, so Back is above it and the heading's margin holds that line open on + every screen. The chevron hangs a little into the gutter so the word aligns with the column's edge. */ +.back { + position: absolute; + left: -5px; + top: 36px; + display: flex; + align-items: center; + gap: 1px; + color: var(--accent); + background: none; + border: 0; + font: inherit; + font-size: 1rem; + font-weight: 500; + text-align: left; + cursor: pointer; + padding: 0; +} +.back .chevron { width: 22px; height: 22px; display: block; } + +/* -------------------------------------------------------------------- buttons */ + +/* Spans the column because it is the column's one action. */ +.primary { + display: block; + width: 100%; + min-height: 48px; + padding: 8px 16px; + border: 0; + border-radius: var(--r-pill); + background: var(--accent); + background-image: var(--sheen); + box-shadow: var(--shadow-button); + color: var(--on-accent); + font: inherit; + font-size: 1rem; + font-weight: 700; + line-height: 1.25; + cursor: pointer; + margin: 24px 0 0; + transition: background-color 120ms linear, box-shadow 120ms linear; +} +.primary:not([disabled]):not(.copied):hover { background-color: var(--accent-hover); box-shadow: var(--shadow-button-hover); } +.primary:not([disabled]):not(.copied):active { background-color: var(--accent-pressed); box-shadow: none; } +/* A different shape, not a faded one: the accent at reduced opacity still reads + as the live button, so a Continue that cannot continue looks broken rather + than not-yet-answered. */ +.primary[disabled] { + background: var(--disabled); + box-shadow: none; + color: var(--on-disabled); + cursor: default; +} + +/* The same button as an outline, which is what the closed-window screen's [ New invoice ] and the code screen's + [ Copy code ] are. */ +.primary.outline { + background: none; + box-shadow: none; + color: var(--accent); + border: 1.5px solid var(--accent); +} +.primary.outline:not([disabled]):not(.copied):hover { background: var(--tint); color: var(--accent-hover); } + +/* A minimum height and not a fixed one: at the 320px floor a two-line + label would otherwise put its second line outside the border. */ +.secondary { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 48px; + padding: 8px 16px; + border: 1px solid var(--line); + border-radius: var(--r-pill); + background: var(--surface); + color: var(--accent); + font: inherit; + font-size: 1rem; + font-weight: 700; + text-align: center; + text-decoration: none; + cursor: pointer; + margin: 12px 0 0; + line-height: 1.25; +} +.secondary:not(.copied):hover { border-color: var(--accent); } + +/* the payment screen's Copy, which lives inside the address panel rather than under it. */ +.secondary.inline { + display: inline; + width: auto; + min-height: 0; + height: auto; + padding: 0; + border: 0; + background: none; + font-size: .875rem; + line-height: inherit; + margin: 0; +} +.secondary.inline:not(.copied):hover { text-decoration: underline; } + +/* A copy that landed, confirmed on the control itself for two seconds. + Every hover rule above excludes it: the pointer is still on the button that + was just pressed, and a hover colour would overwrite the confirmation. */ +.copied { color: var(--ok-fg); border-color: var(--ok-line); } +.primary.copied { background: none; box-shadow: none; color: var(--ok-fg); border-color: var(--ok-fg); } +.secondary.inline.copied { text-decoration: none; } +/* The failure line only: a success never lands here, so this is empty until + something goes wrong, and empty it takes up no room. */ +.copy-status:empty, .cancel-status:empty { display: none; } + +.link { + background: none; + border: 0; + padding: 0; + font: inherit; + font-size: .875rem; + color: var(--accent); + cursor: pointer; + text-decoration: none; +} +.link:hover { text-decoration: underline; } +.link.danger { color: var(--danger-fg); } +.link[disabled] { color: var(--ink-soft); cursor: default; text-decoration: none; } +p.row-line { margin: 16px 0 0; } + +/* --------------------------------------------------------------- the choices */ + +/* the tier list's tiers and the duration list's durations stay side by side at every width: values being + compared must stay comparable. Only the type scale shrinks. */ +.choices { display: flex; gap: 14px; margin: 26px 0 0; align-items: stretch; } +.choice { + /* A column, explicitly top-aligned: a ` + : '' + 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 | +| `