diff --git a/apps/ios/Shared/Views/Chat/ScanCodeView.swift b/apps/ios/Shared/Views/Chat/ScanCodeView.swift index f364b4ed0b..d74ac421ce 100644 --- a/apps/ios/Shared/Views/Chat/ScanCodeView.swift +++ b/apps/ios/Shared/Views/Chat/ScanCodeView.swift @@ -8,12 +8,13 @@ import SwiftUI import CodeScanner +import SimpleXChat struct ScanCodeView: View { @Environment(\.dismiss) var dismiss: DismissAction @Binding var connectionVerified: Bool var verify: (String?) async -> (Bool, String)? - @State private var showCodeError = false + @State private var scanAlert: SomeAlert? var body: some View { VStack(alignment: .leading) { @@ -25,26 +26,32 @@ struct ScanCodeView: View { } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .alert(isPresented: $showCodeError) { - Alert(title: Text("Incorrect security code!")) - } + .alert(item: $scanAlert) { $0.alert } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } func processQRCode(_ resp: Result) { switch resp { case let .success(r): - Task { - if let (ok, _) = await verify(r.string) { - await MainActor.run { - connectionVerified = ok - if ok { - dismiss() - } else { - showCodeError = true + let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines) + switch checkLink(trimmed) { + case .verificationCode?, nil: + // a security code (or unrecognised text): run the existing verify path + Task { + if let (ok, _) = await verify(trimmed) { + await MainActor.run { + connectionVerified = ok + if ok { + dismiss() + } else { + scanAlert = SomeAlert(alert: Alert(title: Text("Incorrect security code!")), id: "incorrectCode") + } } } } + case let type?: + // valid SimpleX code of another kind: tell the user what it is + scanAlert = SomeAlert(alert: wrongQRCodeAlert(wrongQRCodeMessage(type)), id: "wrongQRCode") } case let .failure(e): logger.error("ScanCodeView.processQRCode QR code error: \(e.localizedDescription)") diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index cb3832b727..992c4d821a 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -204,11 +204,15 @@ struct MigrateToDevice: View { ScannerInView(showQRCodeScanner: $showQRCodeScanner) { resp in switch resp { case let .success(r): - let link = r.string - if strHasSimplexFileLink(link.trimmingCharacters(in: .whitespaces)) { - migrationState = .linkDownloading(link: link.trimmingCharacters(in: .whitespaces)) - } else { + let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines) + switch checkLink(trimmed) { + case .some(.fileDescription): + migrationState = .linkDownloading(link: trimmed) + case nil: alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.") + case let type?: + // shared with the paste button, so the title is neutral ("Wrong link") + alert = .error(title: "Wrong link", error: wrongQRCodeMessage(type)) } case let .failure(e): logger.error("processQRCode QR code error: \(e.localizedDescription)") @@ -232,10 +236,14 @@ struct MigrateToDevice: View { private func pasteLinkView() -> some View { Button { if let str = UIPasteboard.general.string { - if strHasSimplexFileLink(str.trimmingCharacters(in: .whitespaces)) { - migrationState = .linkDownloading(link: str.trimmingCharacters(in: .whitespaces)) - } else { + let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines) + switch checkLink(trimmed) { + case .some(.fileDescription): + migrationState = .linkDownloading(link: trimmed) + case nil: alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.") + case let type?: + alert = .error(title: "Wrong link", error: wrongQRCodeMessage(type)) } } } label: { @@ -640,10 +648,6 @@ struct MigrateToDevice: View { dismiss() } - private func strHasSimplexFileLink(_ text: String) -> Bool { - text.starts(with: "simplex:/file") || text.starts(with: "https://simplex.chat/file") - } - private static func urlForTemporaryDatabase() -> URL { URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true)) } diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index a87b9b46f4..8d8776bf32 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -684,14 +684,20 @@ private struct ConnectView: View { private func processQRCode(_ resp: Result) { switch resp { case let .success(r): - let link = r.string - if strIsSimplexLink(r.string) { - connect(link) - } else { + let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines) + switch checkLink(trimmed) { + case .some(.connection): + connect(trimmed) + case nil: alert = .newChatSomeAlert(alert: SomeAlert( alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."), id: "processQRCode: code is not a SimpleX link" )) + case let type?: + alert = .newChatSomeAlert(alert: SomeAlert( + alert: wrongQRCodeAlert(wrongQRCodeMessage(type)), + id: "processQRCode: wrong QR code type" + )) } case let .failure(e): logger.error("processQRCode QR code error: \(e.localizedDescription)") @@ -841,16 +847,6 @@ struct InfoSheetButton: View { } } -func strIsSimplexLink(_ str: String) -> Bool { - if let parsedMd = parseSimpleXMarkdown(str), - parsedMd.count == 1, - case .simplexLink = parsedMd[0].format { - return true - } else { - return false - } -} - enum ConnectTarget { case link(text: String, linkType: SimplexLinkType, linkText: String) case name(text: String, nameInfo: SimplexNameInfo) @@ -1319,7 +1315,7 @@ func planAndConnect( if linkType == .relay { showAlert( NSLocalizedString("Relay address", comment: "alert title"), - message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect. To use it, open Network & servers, Your servers, Add server, then Chat relay, and paste the address there.", comment: "alert message") ) cleanup?() return diff --git a/apps/ios/Shared/Views/NewChat/WrongQRCode.swift b/apps/ios/Shared/Views/NewChat/WrongQRCode.swift new file mode 100644 index 0000000000..55f676ee72 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/WrongQRCode.swift @@ -0,0 +1,42 @@ +// +// WrongQRCode.swift +// SimpleX +// +// iOS mirror of the Kotlin WrongQRCode.kt (apps/multiplatform/.../views/newchat/WrongQRCode.kt). +// +// NOTE: written without an Xcode/macOS toolchain and NOT compiled here — build/verify on CI or a Mac. +// + +import Foundation +import SwiftUI +import SimpleXChat + +// Message shown when a scanner (or the Migrate paste field) is handed a valid +// SimpleX code of a kind it does not accept: name what it actually is and where +// to use it. The type comes from the core classifier (checkLink), so this is +// purely presentation — no parsing here. iOS uses %@ (not %s) for the substitution. +func wrongQRCodeMessage(_ type: ScannedLinkType) -> String { + switch type { + case let .connection(linkType): + if linkType == .relay { + return String.localizedStringWithFormat(NSLocalizedString("This is a %@. To use it, open Network & servers, Your servers, Add server, then Chat relay, and paste the address there.", comment: "wrong QR code alert"), linkType.description) + } else { + return String.localizedStringWithFormat(NSLocalizedString("This is a %@. To use it, open New chat, then scan or paste it there.", comment: "wrong QR code alert"), linkType.description) + } + case .server: + return NSLocalizedString("This is a SimpleX server address. To use it, open Network & servers, Your servers, Add server, then Scan server QR code.", comment: "wrong QR code alert") + case .fileDescription: + return NSLocalizedString("This is a link to migrate to another device. To use it, when setting up a new device, choose to migrate from another device.", comment: "wrong QR code alert") + case .desktopCtrl: + return NSLocalizedString("This is an address to connect to a desktop app. To use it, open Use from desktop and scan the QR code shown in the desktop app.", comment: "wrong QR code alert") + case .verificationCode: + return NSLocalizedString("This is a security code. To use it, open the chat, then the contact's or member's name, then Verify security code.", comment: "wrong QR code alert") + } +} + +// The shared "Wrong QR code" alert. Callers present it through their own alert +// state so it appears within the scanner's presentation (a global/root alert +// may not show over a modal scanner). +func wrongQRCodeAlert(_ message: String) -> Alert { + Alert(title: Text("Wrong QR code"), message: Text(verbatim: message)) +} diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 24ae5cffca..4513856617 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -36,6 +36,7 @@ struct ConnectDesktopView: View { case badInvitationError case badVersionError(version: String?) case desktopDisconnectedError + case wrongQRCode(message: String) case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { @@ -45,6 +46,7 @@ struct ConnectDesktopView: View { case .badInvitationError: "badInvitationError" case let .badVersionError(v): "badVersionError \(v ?? "")" case .desktopDisconnectedError: "desktopDisconnectedError" + case .wrongQRCode: "wrongQRCode" case let .error(title, _): "error \(title)" } } @@ -141,6 +143,8 @@ struct ConnectDesktopView: View { ) case .desktopDisconnectedError: Alert(title: Text("Connection terminated")) + case let .wrongQRCode(message): + wrongQRCodeAlert(message) case let .error(title, error): mkAlert(title: title, message: error) } @@ -405,7 +409,15 @@ struct ConnectDesktopView: View { private func processDesktopQRCode(_ resp: Result) { switch resp { - case let .success(r): connectDesktopAddress(r.string) + case let .success(r): + let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines) + switch checkLink(trimmed) { + case .some(.desktopCtrl), nil: + // a desktop address, or unrecognised text: let the core parse and report its own error + connectDesktopAddress(trimmed) + case let type?: + alert = .wrongQRCode(message: wrongQRCodeMessage(type)) + } case let .failure(e): errorAlert(e) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift index b2b4a64f4e..b21f708917 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift @@ -16,6 +16,7 @@ struct ScanProtocolServer: View { @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] @Binding var serverWarnings: [UserServersWarning] + @State private var scanAlert: SomeAlert? var body: some View { VStack(alignment: .leading) { @@ -30,14 +31,27 @@ struct ScanProtocolServer: View { } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .alert(item: $scanAlert) { $0.alert } } func processQRCode(_ resp: Result) { switch resp { case let .success(r): - var server: UserServer = .empty - server.server = r.string - addServer(server, $userServers, $serverErrors, $serverWarnings, dismiss) + let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines) + switch checkLink(trimmed) { + case .server?: + var server: UserServer = .empty + server.server = trimmed + addServer(server, $userServers, $serverErrors, $serverWarnings, dismiss) + case nil: + // unrecognised: scanner-local alert (deliberate iOS change from the pre-PR dismiss-then-global flow) + scanAlert = SomeAlert( + alert: mkAlert(title: "Invalid server address!", message: "Check server address and try again."), + id: "invalidServerAddress" + ) + case let type?: + scanAlert = SomeAlert(alert: wrongQRCodeAlert(wrongQRCodeMessage(type)), id: "wrongQRCode") + } case let .failure(e): logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)") dismiss() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2098113bef..9d88db279e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */; }; 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; }; + AA00000000000000000000A2 /* WrongQRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A1 /* WrongQRCode.swift */; }; 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; }; 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; }; 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; }; @@ -522,6 +523,7 @@ 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatMenuButton.swift; sourceTree = ""; }; 640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; + AA00000000000000000000A1 /* WrongQRCode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WrongQRCode.swift; sourceTree = ""; }; 640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = ""; }; 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = ""; }; 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = ""; }; @@ -988,6 +990,7 @@ children = ( 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */, 640417CC2B29B8C200CCB412 /* NewChatView.swift */, + AA00000000000000000000A1 /* WrongQRCode.swift */, 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, E5E418002F83D2CA00252B9E /* OnboardingCards.swift */, 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */, @@ -1519,6 +1522,7 @@ 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */, 8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */, 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */, + AA00000000000000000000A2 /* WrongQRCode.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, E5A0B0012F960000AAAA0001 /* YourNetwork.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 6bf46fb0dd..85153e1cea 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -187,6 +187,38 @@ struct ParsedServerAddress: Decodable { var parseError: String } +// The kind of SimpleX QR code / link a scanned string turned out to be, as +// determined by the core (chat_check_link). Public: it crosses the +// SimpleXChat -> app module boundary. Case names and the connection payload +// label must match the Haskell ScannedLinkType wire tags. +public enum ScannedLinkType: Decodable { + case connection(linkType: SimplexLinkType) + case server + case fileDescription + case desktopCtrl + case verificationCode +} + +// Wrapper stays internal — it never leaves the framework. A missing linkType +// key (the "not a SimpleX code" case) decodes to nil. +struct CheckedLink: Decodable { + var linkType: ScannedLinkType? +} + +public func checkLink(_ s: String) -> ScannedLinkType? { + var c = s.cString(using: .utf8)! + if let cjson = chat_check_link(&c) { + if let d = dataFromCString(cjson) { + do { + return try jsonDecoder.decode(CheckedLink.self, from: d).linkType + } catch { + logger.error("checkLink jsonDecoder.decode error: \(error.localizedDescription)") + } + } + } + return nil +} + public func parseSanitizeUri(_ s: String, safe: Bool) -> ParsedUri? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_uri(&c, safe ? 1 : 0) { diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 5a3541e06d..5bf702576e 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -24,6 +24,7 @@ extern char *chat_send_cmd_retry(chat_ctrl ctl, char *cmd, int retryNum); extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); extern char *chat_parse_markdown(char *str); extern char *chat_parse_server(char *str); +extern char *chat_check_link(char *str); extern char *chat_parse_uri(char *str, int safe); extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_valid_name(char *name); diff --git a/apps/ios/spec/architecture.md b/apps/ios/spec/architecture.md index 9ab3eb1fd2..8e651a0433 100644 --- a/apps/ios/spec/architecture.md +++ b/apps/ios/spec/architecture.md @@ -58,15 +58,15 @@ The app follows a strict layered model where each layer communicates only with i | State | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | `ChatModel`, `ItemsModel`, `Chat` classes | L337, L74, L1271 | | API | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | FFI bridge functions | L93 | | API | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | `ChatCommand`, `ChatResponse`, `ChatEvent` enums | L15, L649, L1055 | -| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | C header declaring Haskell exports | | +| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L50) | C header declaring Haskell exports | | | FFI | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | `APIResult`, `ChatError`, `ChatCmdProtocol` | L27, L699, L17 | | Core | `../../src/Simplex/Chat/Controller.hs` | Haskell command processor — see `processCommand` in `Controller.hs` | | --- -## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L49) +## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L50) -### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L49) +### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L50) The Haskell core exposes these C functions, declared in `SimpleXChat/SimpleX.h`: @@ -87,9 +87,10 @@ char *chat_recv_msg_wait(chat_ctrl ctl, int wait); char *chat_close_store(chat_ctrl ctl); char *chat_reopen_store(chat_ctrl ctl); -// Utility: markdown parsing, server validation, password hashing +// Utility: markdown parsing, server validation, link classification, password hashing char *chat_parse_markdown(char *str); char *chat_parse_server(char *str); +char *chat_check_link(char *str); char *chat_password_hash(char *pwd, char *salt); // File encryption/decryption @@ -327,7 +328,7 @@ Chat relays are SMP servers that forward messages to channel subscribers. They a | App state | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | L337 | | API types | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | L15 | | Shared types | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | L27 | -| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | | +| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L50) | | | NSE | [`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228) | | | Haskell core | `../../src/Simplex/Chat/Controller.hs` — see `processCommand` in `Controller.hs` | | | Chat protocol (x-events, message envelopes) | `../../src/Simplex/Chat/Protocol.hs` | | diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index fd7f71d49c..ccbc4ddb5e 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -64,6 +64,7 @@ extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); +extern char *chat_check_link(const char *str); extern char *chat_parse_uri(const char *str, const int safe); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); @@ -147,6 +148,14 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused j return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatCheckLink(JNIEnv *env, __unused jclass clazz, jstring str) { + const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_check_link(_str)); + (*env)->ReleaseStringUTFChars(env, str, _str); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, __unused jclass clazz, jstring str, jint safe) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 9844a5927d..b24a28a331 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -37,6 +37,7 @@ extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); +extern char *chat_check_link(const char *str); extern char *chat_parse_uri(const char *str, const int safe); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); @@ -157,6 +158,14 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, jclass cla return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatCheckLink(JNIEnv *env, jclass clazz, jstring str) { + const char *_str = encode_to_utf8_chars(env, str); + jstring res = decode_to_utf8_string(env, chat_check_link(_str)); + (*env)->ReleaseStringUTFChars(env, str, _str); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, jclass clazz, jstring str, jint safe) { const char *_str = encode_to_utf8_chars(env, str); 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 f9438fca32..4bb3f118fe 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 @@ -5025,6 +5025,28 @@ fun parseSanitizeUri(s: String, safe: Boolean): ParsedUri? { .getOrNull() } +// The kind of SimpleX QR code / link a scanned string turned out to be, as +// determined by the core (chat_check_link). Null result = not a SimpleX code. +// Wire tags must match Haskell ScannedLinkType (sumTypeJSON $ dropPrefix "SLT"). +@Serializable +sealed class ScannedLinkType { + @Serializable @SerialName("connection") data class Connection(val linkType: SimplexLinkType): ScannedLinkType() + @Serializable @SerialName("server") object Server: ScannedLinkType() + @Serializable @SerialName("fileDescription") object FileDescription: ScannedLinkType() + @Serializable @SerialName("desktopCtrl") object DesktopCtrl: ScannedLinkType() + @Serializable @SerialName("verificationCode") object VerificationCode: ScannedLinkType() +} + +@Serializable +data class CheckedLink(val linkType: ScannedLinkType? = null) + +fun checkLink(link: String): ScannedLinkType? { + val parsed = chatCheckLink(link) + return runCatching { json.decodeFromString(CheckedLink.serializer(), parsed) } + .onFailure { Log.d(TAG, "checkLink decode error: $it") } + .getOrNull()?.linkType +} + @Serializable data class ParsedUri(val uriInfo: UriInfo?, val parseError: String) 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 3805a8e8b7..e6daa1f52c 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 @@ -38,6 +38,7 @@ external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): St external fun chatReadFile(path: String, key: String, nonce: String): Array external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String +external fun chatCheckLink(link: String): String val chatModel: ChatModel get() = chatController.chatModel 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..10e4b8476a 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 @@ -5,10 +5,13 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import chat.simplex.common.model.ScannedLinkType +import chat.simplex.common.model.checkLink 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.common.views.newchat.showWrongQRCodeAlert import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @@ -17,15 +20,22 @@ fun ScanCodeView(verifyCode: suspend (String?) -> Boolean, close: () -> Unit) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.scan_code)) QRCodeScanner { text -> - val success = verifyCode(text) - if (success) { - close() + val trimmed = text.trim() + val type = checkLink(trimmed) + if (type != null && type != ScannedLinkType.VerificationCode) { + // valid SimpleX code of another kind: tell the user what it is + showWrongQRCodeAlert(type) + false } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.incorrect_code) - ) + // a security code (or unrecognised text): run the existing verify path + val success = verifyCode(trimmed) + if (success) { + close() + } else { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.incorrect_code)) + } + success } - success } Text(stringResource(MR.strings.scan_code_from_contacts_app), Modifier.padding(horizontal = DEFAULT_PADDING)) SectionBottomSpacer() 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..84f60e7c08 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 @@ -26,6 +26,7 @@ import chat.simplex.common.views.database.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.newchat.showWrongQRCodeAlert import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout @@ -524,31 +525,40 @@ private fun ProgressView() { } private suspend fun MutableState.checkUserLink(link: String): Boolean { - return if (strHasSimplexFileLink(link.trim())) { - val data = MigrationFileLinkData.readFromLink(link) - val hasProxyConfigured = data?.networkConfig?.hasProxyConfigured() ?: false - val networkConfig = data?.networkConfig?.transformToPlatformSupported() - // If any of iOS or Android had onion enabled, show onion screen - if (hasProxyConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) { - state = MigrationToState.Onion(link.trim(), networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode) - MigrationToDeviceState.save(MigrationToDeviceState.Onion(link.trim(), networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) - } else { - val current = getNetCfg() - state = MigrationToState.DatabaseInit(link.trim(), current.copy( - socksProxy = null, - hostMode = networkConfig?.hostMode ?: current.hostMode, - requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode - ), - networkProxy = null - ) + val trimmed = link.trim() + return when (val type = checkLink(trimmed)) { + ScannedLinkType.FileDescription -> { + val data = MigrationFileLinkData.readFromLink(trimmed) + val hasProxyConfigured = data?.networkConfig?.hasProxyConfigured() ?: false + val networkConfig = data?.networkConfig?.transformToPlatformSupported() + // If any of iOS or Android had onion enabled, show onion screen + if (hasProxyConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) { + state = MigrationToState.Onion(trimmed, networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode) + MigrationToDeviceState.save(MigrationToDeviceState.Onion(trimmed, networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) + } else { + val current = getNetCfg() + state = MigrationToState.DatabaseInit(trimmed, current.copy( + socksProxy = null, + hostMode = networkConfig?.hostMode ?: current.hostMode, + requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode + ), + networkProxy = null + ) + } + true + } + null -> { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_file_link), + text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) + ) + false + } + // shared with the paste button, so the title is neutral ("Wrong link") + else -> { + showWrongQRCodeAlert(type, title = generalGetString(MR.strings.wrong_link)) + false } - true - } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_file_link), - text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) - ) - false } } @@ -727,9 +737,6 @@ private suspend fun MutableState.cleanUpOnBack(chatReceiver: chatModel.migrationState.value = null } -private fun strHasSimplexFileLink(text: String): Boolean = - text.startsWith("simplex:/file") || text.startsWith("https://simplex.chat/file") - private fun fileForTemporaryDatabase(): File = File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory())) 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 d3bca178aa..95b6563bab 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 @@ -654,14 +654,21 @@ private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, p SectionView(stringResource(MR.strings.or_scan_qr_code), headerBottomPadding = 5.dp) { QRCodeScanner(showQRCodeScanner) { text -> - val linkVerified = verifyOnly(text) - if (!linkVerified) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_qr_code), - text = generalGetString(MR.strings.code_you_scanned_is_not_simplex_link_qr_code) - ) + val trimmed = text.trim() + when (val type = checkLink(trimmed)) { + is ScannedLinkType.Connection -> connect(rhId, trimmed, close) + null -> { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_qr_code), + text = generalGetString(MR.strings.code_you_scanned_is_not_simplex_link_qr_code) + ) + false + } + else -> { + showWrongQRCodeAlert(type) + false + } } - verifyAndConnect(rhId, text, close) } } } @@ -777,17 +784,6 @@ private fun filteredProfiles(users: List, searchTextOrPassword: String): L } } -private fun verifyOnly(text: String?): Boolean = text != null && strIsSimplexLink(text) - -private suspend fun verifyAndConnect(rhId: Long?, text: String?, close: () -> Unit): Boolean { - if (text != null && strIsSimplexLink(text)) { - return withContext(Dispatchers.Default) { - connect(rhId, text, close) - } - } - return false -} - private suspend fun connect(rhId: Long?, link: String, close: () -> Unit, cleanup: (() -> Unit)? = null): Boolean = planAndConnect( rhId, @@ -821,11 +817,6 @@ private fun createInvitation( } } -fun strIsSimplexLink(str: String): Boolean { - val parsedMd = parseToMarkdown(str) - return parsedMd != null && parsedMd.size == 1 && parsedMd[0].format is Format.SimplexLink -} - sealed class ConnectTarget { class Link(val text: String, val linkType: SimplexLinkType, val linkText: String) : ConnectTarget() class Name(val text: String, val nameInfo: SimplexNameInfo) : ConnectTarget() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/WrongQRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/WrongQRCode.kt new file mode 100644 index 0000000000..edd2b3dbdc --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/WrongQRCode.kt @@ -0,0 +1,28 @@ +package chat.simplex.common.views.newchat + +import chat.simplex.common.model.ScannedLinkType +import chat.simplex.common.model.SimplexLinkType +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR + +// Shown when a scanner (or the Migrate paste field) is handed a valid SimpleX +// code of a kind it does not accept: name what it actually is and where to use +// it. The type comes from the core classifier (checkLink), so this is purely +// presentation — no parsing here. +private fun wrongQRCodeMessage(type: ScannedLinkType): String = when (type) { + is ScannedLinkType.Connection -> + if (type.linkType == SimplexLinkType.relay) + String.format(generalGetString(MR.strings.wrong_qr_relay_address), type.linkType.description) + else + String.format(generalGetString(MR.strings.wrong_qr_connection_link), type.linkType.description) + ScannedLinkType.Server -> generalGetString(MR.strings.wrong_qr_server_address) + ScannedLinkType.FileDescription -> generalGetString(MR.strings.wrong_qr_migration_link) + ScannedLinkType.DesktopCtrl -> generalGetString(MR.strings.wrong_qr_desktop_address) + ScannedLinkType.VerificationCode -> generalGetString(MR.strings.wrong_qr_security_code) +} + +// Title defaults to "Wrong QR code"; MigrateToDevice passes the neutral "Wrong +// link" title because its path is shared with the paste button. +fun showWrongQRCodeAlert(type: ScannedLinkType, title: String = generalGetString(MR.strings.wrong_qr_code)) { + AlertManager.shared.showAlertMsg(title = title, text = wrongQRCodeMessage(type)) +} 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 86be99d1bf..82952f1748 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 @@ -33,6 +33,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.newchat.QRCodeScanner +import chat.simplex.common.views.newchat.showWrongQRCodeAlert import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR @@ -352,8 +353,16 @@ private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList) { SectionView(stringResource(MR.strings.scan_qr_code_from_desktop)) { QRCodeScanner { text -> - sessionAddress.value = text - connectDesktopAddress(sessionAddress, text) + val trimmed = text.trim() + val type = checkLink(trimmed) + if (type != null && type != ScannedLinkType.DesktopCtrl) { + showWrongQRCodeAlert(type) + false + } else { + // a desktop address, or unrecognised text: let the core parse and report + sessionAddress.value = trimmed + connectDesktopAddress(sessionAddress, trimmed) + } } } } 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..b948562fc6 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 @@ -2,11 +2,13 @@ package chat.simplex.common.views.usersettings.networkAndServers import androidx.compose.runtime.Composable import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.model.ScannedLinkType import chat.simplex.common.model.UserServer +import chat.simplex.common.model.checkLink import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.newchat.showWrongQRCodeAlert import chat.simplex.res.MR @Composable @@ -17,16 +19,24 @@ fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr)) QRCodeScanner { text -> - val res = parseServerAddress(text) - if (res != null) { - onNext(UserServer(remoteHostId = rhId, null, text, false, null, false, false)) - } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.smp_servers_invalid_address), - text = generalGetString(MR.strings.smp_servers_check_address) - ) + val trimmed = text.trim() + when (val type = checkLink(trimmed)) { + ScannedLinkType.Server -> { + onNext(UserServer(remoteHostId = rhId, null, trimmed, false, null, false, false)) + true + } + null -> { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_invalid_address), + text = generalGetString(MR.strings.smp_servers_check_address) + ) + false + } + else -> { + showWrongQRCodeAlert(type) + false + } } - res != null } } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 6989521c6b..8030438f00 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -802,6 +802,15 @@ Enable camera access Tap to scan Camera not available + + Wrong QR code + Wrong link + This is a %s. To use it, open New chat, then scan or paste it there. + This is a SimpleX server address. To use it, open Network & servers, Your servers, Add server, then Scan server QR code. + This is a link to migrate to another device. To use it, when setting up a new device, choose to migrate from another device. + This is an address to connect to a desktop app. To use it, open Use from desktop and scan the QR code shown in the desktop app. + This is a security code. To use it, open the chat, then the contact\'s or member\'s name, then Verify security code. + This is a %s. To use it, open Network & servers, Your servers, Add server, then Chat relay, and paste the address there. Permission Denied! @@ -3160,7 +3169,7 @@ Relay address - This is a chat relay address, it cannot be used to connect. + This is a chat relay address, it cannot be used to connect. To use it, open Network & servers, Your servers, Add server, then Chat relay, and paste the address there. Open channel Open new channel Your channel diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md index cfef4d06c2..df71e1d30d 100644 --- a/apps/multiplatform/spec/architecture.md +++ b/apps/multiplatform/spec/architecture.md @@ -144,8 +144,9 @@ All JNI declarations reside in [`Core.kt`](../common/src/commonMain/kotlin/chat/ | 16 | [`chatReadFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L38) | `external fun chatReadFile(path: String, key: String, nonce: String): Array` | 38 | Read and decrypt file | | 17 | [`chatEncryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39) | `external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String` | 39 | Encrypt file on disk | | 18 | [`chatDecryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L40) | `external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String` | 40 | Decrypt file on disk | +| 19 | [`chatCheckLink()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L41) | `external fun chatCheckLink(link: String): String` | 41 | Classify a scanned QR code / link into its type | -**Total: 18 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer). +**Total: 19 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer). @@ -155,8 +156,8 @@ All JNI declarations reside in [`Core.kt`](../common/src/commonMain/kotlin/chat/ | Function | Line | Purpose | |---|---|---| -| [`initChatControllerOnStart()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L51) | 51 | Entry point called during app startup; launches `initChatController` in a long-running coroutine | -| [`initChatController()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62) | 62 | Main initialization: DB migration via `chatMigrateInit`, error recovery (incomplete DB removal), sets file paths, loads active user, starts chat | +| [`initChatControllerOnStart()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L52) | 52 | Entry point called during app startup; launches `initChatController` in a long-running coroutine | +| [`initChatController()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L63) | 63 | Main initialization: DB migration via `chatMigrateInit`, error recovery (incomplete DB removal), sets file paths, loads active user, starts chat | | [`chatInitTemporaryDatabase()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L190) | 190 | Creates a temporary database for migration scenarios | | [`chatInitControllerRemovingDatabases()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L202) | 202 | Removes existing DBs and creates fresh controller (used during re-initialization) | | [`showStartChatAfterRestartAlert()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L222) | 222 | Shows confirmation dialog when chat was stopped and DB passphrase is stored | diff --git a/apps/multiplatform/spec/database.md b/apps/multiplatform/spec/database.md index f6ecedb721..78ae671c25 100644 --- a/apps/multiplatform/spec/database.md +++ b/apps/multiplatform/spec/database.md @@ -143,7 +143,7 @@ external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Ar ### Migration Flow in `initChatController` -The full initialization sequence is in [Core.kt#L62](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62): +The full initialization sequence is in [Core.kt#L63](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L63): 1. Obtain the DB encryption key from `DatabaseUtils.useDatabaseKey()`. 2. Determine the confirmation mode (default: `YesUp`; developer mode with confirm upgrades: `Error`). diff --git a/flake.nix b/flake.nix index fdd041bd88..aea25513b0 100644 --- a/flake.nix +++ b/flake.nix @@ -395,6 +395,7 @@ "chat_migrate_init" "chat_parse_markdown" "chat_parse_server" + "chat_check_link" "chat_parse_uri" "chat_password_hash" "chat_read_file" @@ -516,6 +517,7 @@ "chat_migrate_init" "chat_parse_markdown" "chat_parse_server" + "chat_check_link" "chat_parse_uri" "chat_password_hash" "chat_read_file" diff --git a/libsimplex.dll.def b/libsimplex.dll.def index ec4125193f..8274b106fb 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -12,6 +12,7 @@ EXPORTS chat_recv_msg_wait chat_parse_markdown chat_parse_server + chat_check_link chat_parse_uri chat_password_hash chat_valid_name diff --git a/plans/2026-07-09-qr-recognise-wrong-type.md b/plans/2026-07-09-qr-recognise-wrong-type.md new file mode 100644 index 0000000000..3f7f8ea161 --- /dev/null +++ b/plans/2026-07-09-qr-recognise-wrong-type.md @@ -0,0 +1,728 @@ +# Recognise the scanned QR code type in every scanner — via a core classifier + +Revision 2. The first version of this PR classified scans in the UI with ad-hoc +parsers (markdown re-parse, `xrcp:/` prefix check, digit-shape heuristic). Review +rejected that: most links can only be truly recognised by the core (Haskell), and +"looks similar to a valid link" (UI heuristics) is not "is a valid link" (core +decoder accepted it). There must be exactly one way to determine a link's type — +the same decoders that later process the link — exposed to the UI as a C function, +like the existing `chat_parse_server` / `chat_parse_markdown`. + +## Problem + +Each QR scanner is built for exactly one kind of code: + +- New chat → connection link +- Network & servers → SMP/XFTP server address +- Migrate device → migration file link +- Use from desktop → desktop session address (`xrcp:`) +- Verify security code → a contact's security code + +The first three reject everything else with their own "invalid" message; the +other two are permissive — Use from desktop forwards any scan to the core and +surfaces only the core's error, and Verify security code runs the scan through +`verifyCode` and reports "Incorrect security code!". (That difference is why +section 3 keeps those two on a two-way branch instead of adding a client-side +gate.) In none of the five cases does the user learn that the code is *valid, +just scanned in the wrong place*. + +## Design + +One core enum + one exported recognition function. Every scanner always calls it +first: + +- result is the scanner's expected type → proceed with the existing success path; +- result is another known type → alert "Wrong QR code" / "This is a X. To use + it, ." (single message string per type); +- result is unknown → that scanner's own contextual error, exactly as before the + feature (one deliberate exception: iOS ScanProtocolServer — see section 3). + +### 1. Haskell: enum + `chat_check_link` + +Extract the existing connection-link classification from `Markdown.hs` so it has +a single definition: + +```haskell +-- Simplex.Chat.Markdown — extracted from simplexUriFormat (Markdown.hs:349-370), +-- which keeps using it; this is the exact logic that labels links in chat today, +-- including group-links-as-contact-URIs via CRDataGroup client data. +simplexLinkType :: AConnectionLink -> SimplexLinkType -- XLContact|XLInvitation|XLGroup|XLChannel|XLRelay +``` + +New result type (next to `ParsedServerAddress`, `src/Simplex/Chat/Controller.hs`): + +```haskell +data ScannedLinkType -- Controller.hs needs `import Simplex.Chat.Markdown (SimplexLinkType (..))` — new import, no cycle + = SLTConnection {linkType :: SimplexLinkType} -- reuses markdown's enum, already deserialised by all UIs; payload field name is exactly `linkType` on the wire + | SLTServer -- SMP/XFTP server address + | SLTFileDescription -- migration / standalone file link + | SLTDesktopCtrl -- desktop session address (xrcp) + | SLTVerificationCode -- contact's security code +-- sumTypeJSON, dropPrefix "SLT" + +newtype CheckedLink = CheckedLink {linkType :: Maybe ScannedLinkType} +``` + +Both types derive `(Eq, Show)` (the tests compare decoded values). TH splice +placement in Controller.hs: types after `ParsedServerAddress` (~L1373, before +the file's first splice at ~L1769), `deriveJSON` splices next to +`''ParsedServerAddress`'s (~L1827) with `''ScannedLinkType`'s splice **before** +`''CheckedLink`'s (the latter's generated instance needs the former's). + +JSON encoding must follow an existing discriminated-sum convention that the +Kotlin and Swift sides already know how to decode (e.g. the one used for +`ConnectionPlan`) — do not invent a new shape. `dropPrefix` lowercases the +first char after stripping, so the five wire tags are exactly: +`connection`, `server`, `fileDescription`, `desktopCtrl`, `verificationCode`. +These must appear verbatim as Kotlin `@SerialName` values (default `"type"` +discriminator) and as the literal Swift enum case names (synthesized +decoding, `ConnectionPlan`-style — the swift-build `_owsf` wrapper is +invisible to it). `ConnectionPlan` models the JSON shape **and** the +payload-label rule (labels are module-independent); it does NOT model +API.swift's *visibility* rules, since its Swift twin lives in the app target — +section 5 mandates visibility separately. Non-Darwin wire shapes: +`{"type":"connection","linkType":"contact"}`, nullary `{"type":"server"}`. + +`chatCheckLink :: ByteString -> JSONByteString` (`src/Simplex/Chat/Mobile.hs`, +same pattern as `chatParseServer`) trims the input (ASCII-only, defensive — +the UI owns the real trim, see section 3) and tries, in order, the +decoders the core itself uses to process each kind: + +1. `strDecode @RCSignedInvitation` (Simplex.RemoteControl.Invitation — what + `/connect remote ctrl` parses, Commands.hs:5750) → `SLTDesktopCtrl`. + Unambiguous `xrcp:` scheme, safe first. +2. `strDecode @FileDescriptionURI` (Simplex.FileTransfer.Description — what + `/_download` / `/_download info` parse, Commands.hs:5758-5759) → + `SLTFileDescription`. Unambiguous `…/file#/?desc=` shape, both `simplex:` and + `https://` schemes. +3. `strDecode @AConnectionLink` (Simplex.Messaging.Agent.Protocol) → + `SLTConnection (simplexLinkType l)`. Full URIs and short links, invitation / + contact / group / channel / relay. This is exactly the link branch of + `AConnectTarget`'s `strP` (Types.hs:1828) — what `/_connect plan` + (Commands.hs:5645) parses, i.e. what `planAndConnect` accepts. (Note: + `connLinkP` / plain `/_connect` requires a *full* URI and rejects bare short + links — the classifier must NOT be anchored on it.) +4. `strDecode @AProtoServerWithAuth` (what `chat_parse_server` already runs) → + `SLTServer`. After connection links: both use URI shapes, only the connection + parser accepts `/invitation|/contact|…#` structure, and `smp://`/`xftp://` + never parse as connection links. +5. Verification-code shape → `SLTVerificationCode`. There is no parser because a + code is not encoded — it is generated: `verificationCode = T.pack . unwords . + chunks 5 . show . os2ip` (Types.hs:1901). The recogniser is the inverse of + that generator — `isVerificationCode :: ByteString -> Bool`, defined + immediately after `verificationCode` in Types.hs so shape and generator stay + together: groups of exactly 5 digits separated by exactly one space (last + group 1–5 digits, matching `unwords`'s single-space output), ≥ 32 digits + total. Last, since it is shape-based. +6. Nothing matched → `{}` — with the codebase's `defaultJSON` + (`omitNothingFields = True`) a `Nothing` field is **omitted**, not `null`. + Both UIs must decode a *missing* `linkType` key as null (Kotlin's shared + `json` already has `explicitNulls = false`; Swift optional `Decodable` + tolerates a missing key). `linkType` as a field name in both `CheckedLink` + and `SLTConnection` is fine — Controller.hs enables `DuplicateRecordFields`. + +Export + manifests (checklist verified against the existing `chat_parse_server`): + +- `Mobile.hs`: `foreign export ccall "chat_check_link" cChatCheckLink :: CString + -> IO CJSONString` (~L132) + wrapper (~L227) + pure `chatCheckLink`. + Two existing **selective** imports must be widened: `Simplex.Chat.Markdown` + (:40) gains `simplexLinkType`, and `Simplex.Messaging.Agent.Protocol` (:53) + gains `AConnectionLink`. `RCSignedInvitation` / `FileDescriptionURI` are new + imports. `Simplex.Chat.Types` (:50) is imported wholesale, so + `isVerificationCode` needs nothing; `strDecode` (:57) and + `AProtoServerWithAuth` (:59) are already in scope. +- `libsimplex.dll.def`: add `chat_check_link` once. +- `flake.nix`: add it in **both** `-optl-Wl,-u` symbol lists (L397, L518) — + `scripts/desktop/build-lib-*.sh` assert exactly this (once in .def, twice in + flake) and fail the build otherwise. (Not asserted, optional parity: + `packages/simplex-chat-nodejs/cpp/simplex.h` declares the C API for the Node + addon — only add there if the addon should expose it.) +- `apps/multiplatform/spec/architecture.md:125-148` — a maintained, numbered + table of every `external fun` in `Core.kt` (heading :125, rows 1-18 at + :129-146), each row carrying that function's **line number** and a + `…/Core.kt#L` anchor, closed by "Total: 18" at :148. It is currently + accurate, so adding `chatCheckLink` breaks it. Append the `external fun` at + the end of the **`external fun` block** (immediately after `chatDecryptFile` + at :40, i.e. as the new :41 — not at the end of the file) — that keeps the 18 + existing external-fun anchors valid (putting it next to `chatParseServer` + :32 would instead renumber rows 11-18); then add row 19 and bump the total. + Appending still shifts everything below `Core.kt:41` by one, so **also** fix + the second, separate anchor table in the same file, "Key Kotlin Functions in + Core.kt" (:154-162): `initChatControllerOnStart` L51→52 and + `initChatController` L62→63. (Its other three rows, :160-162, are already + stale by 4 — actual 186/198/218 vs cited 190/202/222 — leave them; not ours.) + One more file carries a currently-exact `Core.kt` anchor below the insertion + point: `apps/multiplatform/spec/database.md:146` (`Core.kt#L62` → `#L63`). + Every other `Core.kt#L` reference in the repo points above :41 and is safe. +- `apps/ios/spec/architecture.md:90-93` — the C-function list declared in + `SimpleX.h`; add `chat_check_link`. (That list is already abridged, omitting + `chat_parse_uri`/`chat_valid_name` — do not expand its scope, just add ours.) + The header goes 49→50 lines, so the four `SimpleX.h#L1-L49` whole-file + anchors in the same doc (:61, :67, :69, :330) become short; bump them to + `#L1-L50` while editing. (Every other whole-file range in that file is + already decayed — e.g. `API.swift#L1-L388` vs 397 actual — so don't chase + those; ours is exact today and cheap to keep exact.) +- Tests: `tests/MobileTests.hs` — one positive case per kind (invitation, + contact address, group, channel and relay short links, smp server, file link, + xrcp address, security code) + negatives (arbitrary text/URL, bare number, + empty). Relay matters: it is the one connection subtype with special + downstream handling (description-only string) and exercises + `CCTRelay → XLRelay` in the extracted `simplexLinkType`. + Assertion style: `sumTypeJSON` is CPP-conditional (`_owsf` single-field + objects under `darwin_HOST_OS && swiftJSON`, `"type"`-tagged otherwise), so + do NOT compare the C output to literal JSON strings — decode it back through + the Haskell `FromJSON` (round-trips under either flag) or provide dual CPP + fixtures like the existing `noActiveUser`/`noActiveUserSwift`. + Fixtures: connection links/short links can be reused from + MarkdownTests.hs:256-273 (`/c#`/`/r#` variants derivable); no `xrcp:` or + `…/file#/?desc=` fixtures exist in tests/ — the file link is easy to author; + a syntactically valid `RCSignedInvitation` must be generated or captured + (signatures are parsed, not verified, so a captured string works). + Three cases are **mandatory** — they guard the behaviour deltas this plan + introduces: + 1. A corrupt file link (`simplex:/file#/?desc=`) → must classify as + unknown. Guards the *narrowing* half of the `strHasSimplexFileLink` + deletion (section 3): the prefix check accepted it and failed late. + 2. A file link on a **non-`simplex.chat` https host** + (`https:///file#/?desc=…`) → must classify as + `FileDescription`. Guards the *widening* half: the deleted prefix check + rejected it, `FileDescriptionURI` accepts it and the core downloads it + identically (the URI host is decorative). + 3. An unsigned `xrcp:` invitation (no `ssig`/`idsig`) → must classify as + unknown; it fails `RCSignedInvitation`. + + Do NOT write a regression test asserting that a *trailing*-whitespace + verification code failed pre-PR — it did not; see section 3's right-strip + note. Only leading whitespace was ever broken. + + Verified separately, no test needed: a corrupt file link cannot be mistaken + for a connection short link, because the short-link type is a **single** char + — `i`, or one of `a/c/g/r` (case-insensitive; `contactTypeP` upper-cases + before matching) — followed by an optional `/` and then `#`, whereas `file` + is four chars. The two are structurally disjoint, so classifier steps 2/3 are + order-independent. + +### 2. Kotlin binding (shared by Android + desktop) + +- `common/.../platform/Core.kt`: `external fun chatCheckLink(link: String): String` + — inserted as the **last line of the `external fun` block, immediately after + `chatDecryptFile` (:40), i.e. the new :41**. Do NOT put it next to + `chatParseServer` (:32), thematically tempting though that is: every + `external fun` below :32 is hard-anchored by line number in + `spec/architecture.md` (rows 11-18 at architecture.md:139-146), and section + 1's prescribed doc edits assume the after-:40 placement. +- JNI shims, both copies: `common/src/commonMain/cpp/android/simplex-api.c` and + `common/src/commonMain/cpp/desktop/simplex-api.c` — `extern` decl + + `Java_chat_simplex_common_platform_CoreKt_chatCheckLink`, mirroring + `chatParseServer`. +- `model/SimpleXAPI.kt`, **top-level** — same file as + `ServerAddress.Companion.parseServerAddress` but NOT inside that companion + (mirroring iOS, where `parseServerAddress` is a top-level `public func`; + a companion member would force `import ...ServerAddress.Companion.checkLink` + in all five view files): + `fun checkLink(link: String): ScannedLinkType?` decoding `CheckedLink`; + `@Serializable sealed class ScannedLinkType` mirroring the JSON, with + `Connection(val linkType: SimplexLinkType)` reusing the existing + `SimplexLinkType` enum. Always reference it **qualified** as + `ScannedLinkType.Connection`: the bare name is already taken in this package + by `model.Connection` (ChatModel.kt:1991), which every consumer has in scope + via `model.*`. **Imports:** three of the five Kotlin consumers have + `chat.simplex.common.model.*` already (NewChatView.kt:35, MigrateToDevice.kt:16, + ConnectDesktopView.kt:27); two do not and need `checkLink`/`ScannedLinkType` + imported explicitly — `ScanCodeView.kt` (no `model` import at all) and + `ScanProtocolServer.kt` (whose `model` imports are single-symbol, not a + wildcard: `…parseServerAddress` at :5, deleted here, and `…UserServer` at :6, + which must be **kept** — the success path still constructs `UserServer`). + Each subclass **must** carry `@Serializable` *and* `@SerialName` with + section 1's wire tag verbatim — `connection`, `server`, `fileDescription`, + `desktopCtrl`, `verificationCode`. Without `@SerialName` kotlinx defaults to + the fully-qualified class name, so **all five** tags fail to decode at + **runtime** (not compile time), presenting as "the classifier always returns + unknown". Template to copy: the `Format` hierarchy at ChatModel.kt:4857-4883, + which annotates every subclass even where the simple name would match. + +### 3. Kotlin UI — uniform scanner flow + +`views/newchat/WrongQRCode.kt` shrinks to the alert only — no parsing: + +```kotlin +// One message string per scanned type ("This is a X. To use it, ."; +// exception: Connection(relay) shows its own relay message — see +// Strings section). Title defaults to "Wrong QR code"; MigrateToDevice passes +// the neutral "Wrong link" title (its path is shared with paste). +fun showWrongQRCodeAlert(type: ScannedLinkType, title: String = generalGetString(MR.strings.wrong_qr_code)) +``` + +Platform-neutral invariant for EVERY success branch below (both platforms): the +success path must consume the **same trimmed string** that was classified — not +the raw scan/paste text. The classifier's trimmed string never crosses the FFI +back, so **the UI owns the trim**: each site computes `trimmed` once (Kotlin +`text.trim()`, Swift `.trimmingCharacters(in: .whitespacesAndNewlines)`) and +passes that same value to `checkLink` AND onward on success; the Haskell-side +trim in `chatCheckLink` is defensive only, and must be ASCII-only +(`" \t\r\n"` — a `Char8.dropWhile isSpace` would half-strip a UTF-8 NBSP, +`0xC2 0xA0`, leaving a dangling byte; Kotlin/Swift trims strip NBSP fully, so +ASCII-only keeps the defensive trim strictly weaker than the UI trim). +Pre-PR the strict UI gates rejected whitespace-padded input, so passing raw +text onward would newly accept e.g. a padded server QR and **store** the +untrimmed string as the server address (`chat_parse_server` uses `parseAll`, so +it rejects padding on both ends — this one is a persistence bug, not a +command-parse one), or send a **leading**-whitespace link raw into +`/_connect plan`. Note the asymmetry: *trailing* whitespace is harmless on the +command paths where the scanned string is the **last token on the line**, since +`parseChatCommand` right-strips the whole command line (`Commands.hs:411`, +`B.dropWhileEnd isSpace`); there only leading whitespace breaks the `strP` after +the command prefix. That covers `/_connect plan`, `/_download info`, +`/connect remote ctrl` and `/_verify code` — but **not** `/_download`, which is +also in scope: Migrate's success path sends the stored link there +(MigrateToDevice.kt:630 → `"/_download $userId $url ${file.filePath}"`, +SimpleXAPI.kt:4161; iOS AppAPITypes.swift:411), and the URL sits *before* the +file path via `strP_`, which consumes exactly one space (`Commands.hs:5759`) — +so a trailing space there corrupts the next field. Trailing trim is therefore +required too, not optional; iOS is live-affected today because `.whitespaces` +does not strip newlines. (`/_connect plan` also stops being last-token when its +optional `resolve=`/`sig=` suffixes are non-default, SimpleXAPI.kt:4090-4093 — +not on the scanner path, but do not generalise the rule.) + +**Sites that must change** (this is the complete list, both platforms; each +passes at least one untrimmed string today): ScanProtocolServer builds +`UserServer` from raw `text`; NewChatView passes raw text to connect; +ConnectDesktopView sets raw `sessionAddress`; ScanCodeView calls +`verifyCode(text)` raw; and `checkUserLink` must compute `val trimmed = +link.trim()` **once** at the top and pass it to `checkLink` and down the +FileDescription success path. This applies to the two-way pass-throughs too, on +both platforms: `connectDesktopAddress` and `verifyCode` receive the trimmed +string. The rationale is this feature's own invariant — the classifier and the +consumer must see the same string — not any downstream bug fix (see the +pre-existing-bug note below, which is out of scope here). + +For `verifyCode` that is a deliberate small improvement over pre-PR: with a +**leading** newline the command line fails to parse entirely — `verifyCodeP` +halts at the newline (`Commands.hs:5895`) and the trailing `A.endOfInput` in +`chatCommandP`'s `choice` (`Commands.hs:5775`) then rejects the remainder, so +the core returns a bad-command error, surfaced as the same "Incorrect security +code!"; trimmed it succeeds. Trailing whitespace already worked, per the +right-strip above — do not write a regression test asserting otherwise. + +**Pre-existing bug — NOT this PR; track in a separate PR.** Independently of +this feature, Kotlin `checkUserLink` already mishandles a padded migration +link: it trims the gate (MigrateToDevice.kt:528) and the state payloads +(:534/:535/:538) but passes the **raw** link to `readFromLink` at :529, which +sends it into `/_download info`. So a **leading**-whitespace migration link +silently loses `networkConfig` on `stable` today, skipping the Onion screen and +migrating with the device's own host modes (`getNetCfg()`) instead of the +link's. This is a one-line fix (`readFromLink(link.trim())`) plus a regression +test, on its own, and does not belong in this feature PR — file it separately. +(This PR's rewrite of `checkUserLink` happens to route `trimmed` down that path +too, so it would incidentally mask the bug; keep the standalone fix + test +anyway so the networkConfig regression is covered on its own merits and can land +independently of / ahead of this feature.) iOS is not affected: its +`readFromLink` is a `nil` stub (AppAPITypes.swift:2153-2156, `standaloneFileInfo` +has no live caller), so it never reads `networkConfig` at all. + +Second invariant, **Kotlin only** (iOS scanner completions return `Void`), for +every Kotlin scanner branch below: each wrong-type and unknown alert branch +returns `false` from the `QRCodeScanner` callback — the pre-PR reject +convention. `false` means "not handled", so the code is not memoised +(`QRCodeScanner.android.kt:101-110` — `contactLink.value` is assigned only when +`onBarcode` returns true) and the same scan re-fires the alert after the +existing ~1s delay; it does NOT end the scan session, which only success side +effects do: `verifyCode()` succeeding, which dismisses the scanner modal from +VerifyCodeView.kt:45 (ScanCodeView's own `close` is `{}` at its only call site, +VerifyCodeView.kt:113), `onNext(...)` (ScanProtocolServer), +`connect(..., close)` (NewChatView), or — for ConnectDesktopView and +MigrateToDevice, which have neither — a state transition that re-composes the +view away from the scanner (`UIRemoteCtrlSessionState` written at +ConnectDesktopView.kt:502-506, read at :79 with the branches at :89/:104/:141; +`MigrationToState` written at MigrateToDevice.kt:527-538, with the scanner +composed only under `PasteOrScanLink` at :188). Do NOT add a `close()` to those +two: Migrate's `close` dismisses the whole migration modal, not the scanner. +Success branches keep their existing returns. + +Scanners whose success path needs a *valid* value use a three-way branch; +scanners whose existing path already handles arbitrary input use a two-way +branch (wrong-known-type → alert; everything else → the pre-existing path +unchanged, so pre-PR behaviour is preserved exactly). `expected` comparison is +on the top-level kind (any `Connection` subtype matches the New-chat scanner): + +- `NewChatView` (three-way) — `Connection` → call `connect()` directly + (`planAndConnect`; the core re-parses authoritatively). NOT `verifyAndConnect`: + its internal `strIsSimplexLink` markdown gate is a second, different decider — + if it disagreed with the classifier the scan would be silently swallowed with + no alert. Other known type → wrong-type alert; unknown → original "Invalid QR + code / not a SimpleX link" alert. +- `ScanProtocolServer` (three-way) — `Server` → build `UserServer` directly from + the scanned text. Do NOT keep the `parseServerAddress(text)` call: both + platforms only null-check its result and construct the server from the raw + string anyway (Kotlin ScanProtocolServer.kt:21-23, iOS + ScanProtocolServer.swift:40-43), so it would be a redundant second decider of + the type `chat_check_link` already determined. Unknown → "Invalid server + address! / Check server address…" alert — on Kotlin this **restores** the + pre-v1 alert that v1 regressed to the generic message; on iOS this is a + small **deliberate behaviour change**: pre-v1 iOS passed the string into + `addServer`, which validated it via `parseServerAddress` and on garbage + dismissed the scanner then showed a **global** "Invalid server address! / + Check server address…" alert (NewServerView.swift:118-151) — same wording, + different flow. The v1 in-scanner gate is kept so the alert shows without + dismissing the scanner, matching the other scanners. Other known type → + wrong-type alert. +- `MigrateToDevice.checkUserLink` (three-way) — `FileDescription` → existing + download path; unknown → the pre-existing "Invalid link" alert (unchanged + from pre-PR and v1); other known type → + wrong-type alert. `strHasSimplexFileLink` is deleted (both platforms): + `FileDescriptionURI` is the authoritative decoder and **supersedes** the + prefix check — it does not merely duplicate it. Declare this behaviour delta, + which cuts both ways: the prefix check was too **loose** (a `desc`-less or + corrupt `simplex:/file…` passed and failed late during download; now it is + unknown → immediate "Invalid link", which is what §1's corrupt-link test + asserts) and too **strict** (`ServiceScheme` accepts *any* https host, so + `https:///file#/?desc=…` is rejected today but decodes and + downloads identically — the URI host is decorative). Both halves of this + delta have a mandatory test in section 1. +- `ConnectDesktopView` (two-way) — other known type → wrong-type alert; + `DesktopCtrl` **or unknown** → `connectDesktopAddress` (pre-PR behaviour: the + core parses and reports its own error — no invented client-side message). The + v1 `DESKTOP_ADDRESS_SCHEME` constant is deleted. +- `ScanCodeView` (verify contact, two-way) — other known type → wrong-type + alert; `VerificationCode` **or unknown** → existing `verifyCode()` path (its + `incorrect_code` "Incorrect security code!" alert on failure — `verifyCode` + keeps running for unknown input rather than being short-circuited; same flow + as pre-PR, except it now receives the trimmed string per the invariant + above). + +Explicit deletion list (verified: no other callers remain after the above): +`isSecurityCode`, `identifyQRCode`, the `ScannedQRCode` sealed class, the +`detectSecurityCode` flag, `showNotSimplexQRCodeAlert` (NewChatView's unknown +branch uses the pre-existing `invalid_qr_code` + +`code_you_scanned_is_not_simplex_link_qr_code` strings inline, as pre-v1), and +— now orphaned by the direct-`connect()` scanner path — `verifyOnly`, +`verifyAndConnect` and `strIsSimplexLink` (Kotlin NewChatView.kt:778-787,822; +iOS NewChatView.swift:849). The core owns all recognition; expected-type +exclusion makes the `detectSecurityCode` flag unnecessary. + +### 4. Strings + +Replace v1's 11 strings with 8 — titles + one complete message per type (review: +description + "where to scan" were split into two places for no reason). The +texts below are shown unescaped; in `strings.xml` they need Android escaping — +`&` for `&` (single escape, as strings.xml:813 does — NOT `&amp;`) and +`\'` for each apostrophe (`wrong_qr_security_code` has two: `contact\'s` and +`member\'s` — escape both; an unescaped `'` is an aapt2 build error). iOS +`NSLocalizedString` literals need neither — but iOS **must** use `%@`, not +`%s`, as the substitution token: `%s` is a C `char *` in `String(format:)` and +passing a Swift `String` yields garbage or a crash. Write +`String.localizedStringWithFormat(NSLocalizedString("This is a %@.", …), +linkType.description)`, as v1's WrongQRCode.swift:29 already does. The `%s` in +the texts below is the Android form: + +- `wrong_qr_code` — "Wrong QR code" (title, kept) +- `wrong_link` — "Wrong link" (neutral title; new resource, see below) +- `wrong_qr_connection_link` — "This is a %s. To use it, open New chat, then + scan or paste it there." (`%s` = `SimplexLinkType.description`, whose values + already begin with "SimpleX" — "SimpleX contact address", "SimpleX group + link", … — so the template must NOT add its own "SimpleX") +- `wrong_qr_server_address` — "This is a SimpleX server address. To use it, open + Network & servers, Your servers, Add server, then Scan server QR code." + (The "Add server" hop is required: "Scan server QR code" is a button *inside* + that dialog — ProtocolServersView.kt:299, iOS :206 — and is the sibling of + the "Chat relay" button the relay string names. v1's text omitted the hop; + this string is authored fresh here, so fix it rather than inherit it.) +- `wrong_qr_migration_link` — "This is a link to migrate to another device. To + use it, when setting up a new device, choose to migrate from another device." +- `wrong_qr_desktop_address` — "This is an address to connect to a desktop app. + To use it, open Use from desktop and scan the QR code shown in the desktop + app." (Deliberately names **no** control label — no single label is right on + both screens the user might land on. Both platforms gate identically on + `connectRemoteViaMulticast && remoteCtrls.isNotEmpty()` + (ConnectDesktopView.kt:544-545, iOS :30) and differ only in the default + (Android `false`, SimpleXAPI.kt:255; iOS `true`, :19), so think by screen, + not platform: the **connect** screen (Android default, iOS first-time + linkers) shows the scanner under a non-tappable section header "Scan QR code + from desktop" (ConnectDesktopView.kt:355, iOS :332); the **searching** screen + (iOS returning linkers, and Android users who enable "Discover via local + network" — the toggle at ConnectDesktopView.kt:436, iOS :379, whose resource + id is the misleading `discover_on_network`, strings.xml:2830) offers a button + labelled "Scan QR code" (Kotlin :204, iOS :206). + Naming the goal covers both, and additionally tells the user *which* QR to + scan. It does not fix the leading "Use from desktop" clause on desktop, where + that screen doesn't exist at all — that mismatch is the accepted case in the + desktop note below.) +- `wrong_qr_security_code` — "This is a security code. To use it, open the chat, + then the contact's or member's name, then Verify security code." (Not + "contact's": the identical `VerifyCodeButton` also exists per group member + (GroupMemberInfoView.kt:556, iOS GroupMemberInfoView.swift:603), reached via + the member list rather than a contact name, and the classifier is shape-based + so it cannot tell the two apart. Tapping a member in the chat opens member + info directly — ChatView.kt:515, iOS ChatView.swift:198/:263 — so no + group-info hop is needed; `GroupChatInfoView` has no such button. This string + carries **two** apostrophes to escape.) + +- `wrong_qr_relay_address` — "This is a %s. To use it, open Network & servers, + Your servers, Add server, then Chat relay, and paste the address there." + (`%s` = `SimplexLinkType.description` as above, so no hardcoded literal — the + "SimpleX relay address" text stays solely in `simplex_link_relay`, + strings.xml:109, and cannot drift on retranslation.) + (Names the real labels on both platforms: `smp_servers_add` "Add server" + opens the add dialog (ProtocolServersView.kt:180-186), whose `chat_relay` + "Chat relay" button (strings.xml:3056, ProtocolServersView.kt:308; iOS + :204-208) opens `NewChatRelayView`. The trailing "paste the address there" + is load-bearing: unlike every other destination this feature names, the relay + flow has **no scanner** — `NewChatRelayView` takes the address through a + `TextEditor` only (ChatRelayView.kt:236, iOS ChatRelayView.swift:309) — so a + user holding a QR must transcribe or paste it. Do NOT say "under Chat + relays": that section lists only *existing* relays, is not an add path, and + is not rendered at all when there are none, ProtocolServersView.kt:89-91.) + Relay gets its **own** + destination rather than the generic connection-link one: New chat *rejects* + relay links (`planAndConnect` aborts with its own + `relay_address_alert_title`/`_message` — ConnectPlan.kt:33-42, iOS + NewChatView.swift:1322-1331), but a real relay add flow exists and accepts + exactly the strings the classifier labels relay — `ChatRelayView.validRelayAddress` + gates on `SimplexLinkType.relay` (ChatRelayView.kt:58-64, iOS + ChatRelayView.swift:43), reached via Your servers → Add server → Chat relay + (ProtocolServersView.kt:180-186 → :305). It is not Android-gated, so the + destination exists on desktop too. Sending relay to "open New chat…" would be + a dead end; sending it nowhere would make it the one known type the Design + section's "This is a X. To use it, ." promise doesn't cover. + (Scanning a relay QR in the New-chat scanner itself still classifies as + `Connection` → `connect()` → the existing relay alert from `planAndConnect`.) + **Also extend `relay_address_alert_message`** (strings.xml:3175, iOS literal + NewChatView.swift:1324-1327) with the same destination clause: today it reads + "This is a chat relay address, it cannot be used to connect." and names + nowhere. Without this the app answers the identical relay code two ways — + four scanners point at Your servers, while New chat (the scanner a user with + a relay QR reaches for first, and on desktop the *only* New-chat entry, via + paste) still dead-ends. One string, and it covers scan and paste on both + platforms because both funnel through `planAndConnect`. Do NOT instead + special-case relay in NewChatView's `Connection` arm: that duplicates the + decider and misses paste. + +MigrateToDevice's check path is shared with its *paste* button, so its +wrong-type alert uses a neutral title `wrong_link` — "Wrong link" — instead of +"Wrong QR code" (which would be nonsense for pasted text). The four pure +scanners keep the `wrong_qr_code` title. That makes 8 **new** strings: 2 titles ++ 5 type messages + the relay-specific message. One **existing** string is also +edited, not added: `relay_address_alert_message` gains a destination clause (see +the relay bullet above). + +Wording is device-neutral where cheap ("open", not "tap"). Two contexts where +the "To use it…" destinations don't exist yet, both accepted (the type +identification is still the alert's value): + +1. **Onboarding.** MigrateToDevice is reachable *before a profile exists* — the + entry point is `WelcomeView.kt`'s `MigrateButton` (:201-224, mounted on both + `CreateFirstProfileMobile` :242 and `CreateFirstProfileDesktop` :320), which + sets `migrationState` and shows the view; SimpleXInfo.kt:97-98/:168-169 only + *re-open* an already-started migration. Its scan and paste both route through + `checkUserLink`, and all five cross-type messages name post-onboarding + surfaces. Applies on every platform, not just desktop. +2. **Desktop**, below. + +Desktop note: the +only desktop-reachable path is Migrate's paste — `QRCodeScanner`'s desktop +actual is an empty stub so no scanner callback ever fires on desktop, and every +scanner is additionally `appPlatform.isAndroid`-gated before it is composed — +externally for ScanProtocolServer (ProtocolServersView.kt:281), +ConnectDesktopView (chatlist/UserPicker.kt:354) and Verify security code +(VerifyCodeView.kt:110, gating the button that opens the ScanCodeView modal at +:111-113); inline in the view itself for New chat (NewChatView.kt:652) and +Migrate (MigrateToDevice.kt:207, which gates only the scanner — the paste field +below it is unconditional). That is all five. So the alert shown for a desktop +paste in Migrate can name screens that sit elsewhere on desktop, or don't exist +there at all ("Use from desktop"). Accepted: the type +identification is the alert's value; per-platform navigation strings are not +worth 5 extra resources for one obscure path. + +### 5. iOS mirror + +- `SimpleXChat/SimpleX.h`: `extern char *chat_check_link(char *str);` +- `SimpleXChat/API.swift`: `checkLink(_:) -> ScannedLinkType?` + + `Decodable` enum, mirroring `parseServerAddress` (L170-188); connection + subtype reuses the existing `SimplexLinkType`. Four mandates, all iOS-only + failure modes on the platform we cannot compile locally: + - **Import:** `Views/Chat/ScanCodeView.swift` imports only `SwiftUI` and + `CodeScanner` (:9-10) — add `import SimpleXChat`. It compiles today only + because v1's helpers live in the app target; `checkLink` does not. The + other five consumers already import it. + - **Visibility:** `checkLink` and `ScannedLinkType` must be `public` — they + cross the SimpleXChat→Shared module boundary, like + `ServerAddress` (APITypes.swift:218). Do NOT copy the visibility of + `ParsedServerAddress` (API.swift:185): that wrapper is `internal` because it + never leaves the framework. The `CheckedLink` wrapper likewise stays + internal. Getting this wrong is a compile error in all six consumer files. + (Its cases inherit `public` automatically — do not write `public case`, + which Swift rejects.) + - **Case names** must be section 1's wire tags verbatim — `connection`, + `server`, `fileDescription`, `desktopCtrl`, `verificationCode` — since + synthesized `Decodable` keys off the case name. + - **Associated-value label** must be `linkType`: + `case connection(linkType: SimplexLinkType)`. Synthesized `Decodable` keys + off the label too (precedent: `AppAPITypes.swift:1410` + `case invitationLink(invitationLinkPlan: …)`). An *unlabeled* payload — as + v1 writes it at WrongQRCode.swift:19, `case connectionLink(SimplexLinkType)` + — decodes against `_0`, compiles fine, and fails at **runtime**: connection + links classify as unknown and every scanner silently loses its wrong-type + alert. +- `Views/NewChat/WrongQRCode.swift` shrinks to the message mapping + + `wrongQRCodeAlert`, which keeps its **single-argument** shape — its four + *surviving* callers (ScanCodeView.swift:42, ScanProtocolServer.swift:46, + NewChatView.swift:692, ConnectDesktopView.swift:147) all want the default + "Wrong QR code" title. (v1's fifth call, NewChatView.swift:697, is the + unknown branch replaced in the next bullet.) + The surviving message mapping **changes contract**: + `func wrongQRCodeMessage(_ type: ScannedLinkType) -> String` — it takes the + classified type, not text, and is **non-optional**. v1's `-> String?` + (WrongQRCode.swift:92) signalled "unknown", which each scanner's own branch + now handles, so all six call sites collapse to a direct call: the four + `if let msg = wrongQRCodeMessage(…)` sites (NewChatView.swift:690, + MigrateToDevice.swift:210/:239, ScanCodeView.swift:41) and the two + `?? notSimplexQRCodeMessage()` sites (ScanProtocolServer.swift:46, + ConnectDesktopView.swift:416) — whose fallback this plan deletes, so keeping + `String?` is a compile error. Relay needs no extra parameter: discriminate + internally on `case .connection(let linkType)` → `linkType == .relay` and + return §4's `wrong_qr_relay_address` message, mirroring Kotlin. Do NOT add a title parameter: iOS Migrate does not + call this helper at all — it uses its own + `MigrateToDeviceViewAlert.error(title:error:)` case (MigrateToDevice.swift:76, + rendered :179-180), so the neutral-title edit there is simply changing the + hardcoded `title: "Wrong QR code"` at :211 and :240 to `"Wrong link"`. + `identifyQRCode`, `isSecurityCode`, + `desktopAddressScheme`, `strHasSimplexFileLink`, `notSimplexQRCodeMessage`, + the `ScannedQRCode` enum (WrongQRCode.swift:18-49), the `detectSecurityCode` + parameter (:92, sole call site ScanCodeView.swift:41 — expected-type + exclusion makes it unnecessary, and leaving it keeps a second shape-based + decider alive on the one scanner that must not short-circuit) + and `strIsSimplexLink` (NewChatView.swift:849) deleted + (`MigrateToDevice.swift` gets back its own gate via `checkLink`). +- iOS NewChatView's **unknown** branch restores the pre-v1 alert — + `mkAlert(title: "Invalid QR code", message: "The code you scanned is not a + SimpleX link QR code.")` (pre-v1 NewChatView.swift:692), wrapped in its + `SomeAlert` as before — **not** `wrongQRCodeAlert`, which v1 used and which + retitles it to "Wrong QR code". This is the iOS twin of §3's Kotlin + `invalid_qr_code` restore; without it the two platforms show different titles + for the identical scan. +- The same **per-scanner branching as section 3** (three-way or two-way as + specified there — NOT uniformly three-way; a literal three-way on + ConnectDesktopView would reintroduce the v1 regression section 3 reverts, and + on ScanCodeView would **newly** short-circuit `verifyCode` — which must keep + running on unknown input, since core's `sameVerificationCode` ignores spacing + and so accepts codes the shape recogniser rejects), presented through each + view's **own** alert state (a + global alert may not show over a modal scanner). iOS ScanProtocolServer's + unknown branch shows the scanner-local alert + `scanAlert = SomeAlert(alert: mkAlert(title: "Invalid server address!", + message: "Check server address and try again."), id: "invalidServerAddress")` + — the same wording + as pre-PR's global alert (NewServerView.swift:147-150) and the twin of + Kotlin's `smp_servers_invalid_address`/`smp_servers_check_address`. **Not** + `wrongQRCodeAlert`, which v1 uses here and which retitles it "Wrong QR code" + — the same defect the NewChatView bullet above guards against. This is the + deliberate iOS behaviour change from section 3 (in-scanner alert instead of + the pre-PR route-into-`addServer` dismiss-then-global-alert flow). +- The trimmed-string invariant from section 3 applies here too; iOS + additionally has a subtle trap: `.whitespaces` excludes newlines + (MigrateToDevice.swift:207-212,236-241), so a leading-newline paste would + classify as `FileDescription` but keep the newline downstream — use + `.whitespacesAndNewlines` everywhere a string is trimmed. +- iOS Migrate has **no shared `checkUserLink`**: the scan + (MigrateToDevice.swift:204-219) and paste (:234-250) sites duplicate the gate + inline. Apply the three-way branch, the neutral "Wrong link" title and the + full trim at **both** sites (or extract a shared helper) — the scan site too + uses "Wrong link", matching Kotlin, whose shared path shows it for scans. + +## Verification / build order + +1. Haskell: build the library and run the new `MobileTests` cases (x86_64 Linux + toolchain available locally). +2. Kotlin, in this order: + a. `./gradlew :common:compileKotlinDesktop` — compile check only; it does + **not** touch native libs (common/build.gradle.kts has no cmake wiring). + b. `bash ~/build/linux.sh` — the single desktop build command. Do NOT run + `scripts/desktop/build-lib-linux.sh` by hand first: linux.sh runs it + itself behind a fingerprint gate over `src/**/*.hs` + + `libsimplex.dll.def` + `flake.nix` (all changed by this plan, so it will + rebuild), and a manual run does not update that fingerprint — you would + re-run the lib build for nothing (a relink at minimum, a full rebuild if + your `cabal.project.local` didn't already match + `scripts/cabal.project.local.linux`, which linux.sh overwrites), and the + manual run's staged libs are wiped again by the in-script run. + (`LINUX_BUILD_FORCE_REFRESH=1` forces the lib rebuild if ever needed.) + Note `build-lib-linux.sh` also deletes `libapp-lib.so` along with the + libs dir; it is regenerated by `cmakeBuildAndCopy` (a + `:desktop:compileKotlinJvm` dependency, desktop/build.gradle.kts:165-181) + during the `./gradlew createDistributable` that `make-appimage-linux.sh` + runs — i.e. it comes back only if you go through the full script. Without + it the app dies at `System.loadLibrary`, a different failure from the + stale-lib one. + c. Verify the symbol reached the **packaged** app — not the dist-newstyle or + staged copies, which can be fresh while the AppImage ships a stale lib + (linux.sh:350-360 documents this; it `rm -rf`s `release/main/app` each + run to force repackaging). Check the AppDir copy — the one + `appimagetool` packages; do NOT `find` for the name, it matches two + files (`simplex/lib/…` and `AppDir/usr/lib/…`) and a quoted command + substitution would hand `nm` one bogus argument and print nothing, + reading exactly like "symbol missing": + `nm -D apps/multiplatform/release/main/app/AppDir/usr/lib/app/resources/libsimplex.so | grep chat_check_link` + d. Exercise the Migrate-to-device **paste** path (the only desktop-reachable + `checkLink` call site — desktop scanners are stubs). A *stale* + `libsimplex` fails **lazily at the first `chatCheckLink` call**, not at + app load (`libapp-lib.so` is not linked `BIND_NOW`), so "the app + launched" proves nothing. +3. Android APK: **blocked on a fresh arm64 cross-build of `libsimplex.so`** — + the prebuilt `/home/user/libsimplex.so` lacks `chat_check_link`; there, + unlike desktop, the app fails at `System.loadLibrary("app-lib")` (bionic has + no lazy binding), so it won't even start. +4. iOS: not compiled here (no Xcode toolchain) — needs Mac/CI; wording of the + "where" messages should be checked against live iOS navigation labels. + +## Not done / out of scope + +- No "use it anyway" action on the alert — informational only. +- ConnectDesktopView's **paste** field (`DesktopAddressView`, + ConnectDesktopView.kt:369-409; iOS ConnectDesktopView.swift:337-362) keeps its + pre-PR behaviour — not classified, so a wrong-type address pasted there still + gets only the core's error while the scanner one section above names the type. + Justification: the field is behind the **developer-tools** flag + (ConnectDesktopView.kt:142, iOS :21/:163), so it is not user-reachable in a + default install, and classifying it would mean duplicating the gate — scan and + paste share the *action* (`connectDesktopAddress`, ConnectDesktopView.kt:489 / + .swift:448) but not the gate, which is inline in each callback. Note trimming + alone *would* be cheap there (one line inside the shared + `connectDesktopAddress`); deliberately deferred with the classification. +- Commit hygiene: commit ONLY the files this plan names — which now include + both `spec/architecture.md` files and `apps/multiplatform/spec/database.md` + (see section 1); they are maintained + artifacts with back-references from the code, so leaving them stale is not an + option hygiene should protect. The shared working + tree carries unrelated uncommitted modifications from other work streams + (gradle.properties / android/build.gradle.kts local build patches, + AppUpdater.kt .deb-arch filtering, tests/ChatTests/Groups.hs join-interrupt + repro) that must NOT be included. +- Scope covers scanners **plus** MigrateToDevice's paste path — `checkUserLink` + is shared by its scan and paste entry points (MigrateToDevice.kt:209-211, + 233-238; on iOS the two sites are duplicated inline — see section 5), so + pasting e.g. a contact link into Migrate now gets the classified wrong-type + alert (titled "Wrong link") instead of the bare "Invalid link" — an intended + improvement, and the reason for the neutral title. +- **New chat's paste path is NOT covered — flagged for a scope decision, not + settled.** It keeps `strConnectTarget` (NewChatView.kt:674, iOS + NewChatView.swift:651) and its existing fallback — Android "Invalid link!" + + "The text you pasted is not a SimpleX link." (`invalid_contact_link` + strings.xml:925, `the_text_you_pasted_is_not_a_link` :1003); iOS the same + without the exclamation mark (NewChatView.swift:660), a pre-existing + divergence this plan does not touch. Consequences a reviewer should weigh + before this merges: + - On Android, New chat answers the *same* server address two different ways + on one screen: the scanner names the type, the paste field 20px above says + "not a SimpleX link". + - On **desktop** the paste field is the only New-chat entry point at all + (the scanner is Android-gated at NewChatView.kt:652, `PasteLinkView` at + :648-650 is unconditional), so New chat — the highest-traffic link entry + point — gets none of this feature there. + - Extending it is one branch per platform: route the `null` arm of + `strConnectTarget` through `checkLink` before the existing fallback, + reusing the same strings and the neutral `wrong_link` title. It must stay + a *fallback*, not a replacement: `strConnectTarget` also accepts + `Format.SimplexName` (NewChatView.kt:840-842), which `checkLink` does not + classify. + Left out here only to hold the diff to what was authorised; say the word and + it's a small addition. diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6a80f51a1e..c8a88340e6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -58,6 +58,7 @@ import Simplex.Chat.Call import Simplex.Chat.Delivery import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Markdown (SimplexLinkType (..)) import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion @@ -1372,6 +1373,20 @@ data ServerAddress = ServerAddress } deriving (Show) +-- The kinds of QR code / link the app knows how to scan, for chat_check_link. +-- A scanner that rejects a scan passes the text here to learn what it actually +-- was, so it can tell the user where to use it. +data ScannedLinkType + = SLTConnection {linkType :: SimplexLinkType} -- connection link; subtype reuses markdown's enum + | SLTServer -- SMP/XFTP server address + | SLTFileDescription -- migration / standalone file link + | SLTDesktopCtrl -- desktop session address (xrcp) + | SLTVerificationCode -- contact's security code + deriving (Eq, Show) + +newtype CheckedLink = CheckedLink {linkType :: Maybe ScannedLinkType} -- Nothing = not a SimpleX QR code + deriving (Eq, Show) + data TimedMessagesEnabled = TMEEnableSetTTL Int | TMEEnableKeepTTL @@ -1826,6 +1841,10 @@ $(JQ.deriveJSON defaultJSON ''ServerAddress) $(JQ.deriveJSON defaultJSON ''ParsedServerAddress) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SLT") ''ScannedLinkType) + +$(JQ.deriveJSON defaultJSON ''CheckedLink) + $(JQ.deriveJSON defaultJSON ''ChatItemDeletion) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ServiceSub") ''ServiceSubEvent) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index e8cd381941..3362b55936 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -347,30 +347,40 @@ markdownP = mconcat <$> A.many' fragmentP isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s) noFormat = pure . unmarked simplexUriFormat :: Maybe Text -> AConnectionLink -> Format - simplexUriFormat showText = \case - ACL m (CLFull cReq) -> case cReq of - CRContactUri crData -> SimplexLink showText (linkType' crData) cLink $ uriHosts crData - CRInvitationUri crData _ -> SimplexLink showText XLInvitation cLink $ uriHosts crData + simplexUriFormat showText aLink@(ACL m cl) = case cl of + CLFull cReq -> case cReq of + CRContactUri crData -> mk $ uriHosts crData + CRInvitationUri crData _ -> mk $ uriHosts crData where - cLink = ACL m $ CLFull $ simplexConnReqUri cReq + mk = SimplexLink showText (simplexLinkType aLink) (ACL m $ CLFull $ simplexConnReqUri cReq) uriHosts ConnReqUriData {crSmpQueues} = L.map strEncodeText $ sconcat $ L.map (host . qServer) crSmpQueues - linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of - Just (CRDataGroup _) -> XLGroup - Nothing -> XLContact - ACL m (CLShort sLnk) -> case sLnk of - CSLContact _ ct srv _ -> SimplexLink showText (linkType' ct) cLink $ uriHosts srv - CSLInvitation _ srv _ _ -> SimplexLink showText XLInvitation cLink $ uriHosts srv + CLShort sLnk -> case sLnk of + CSLContact _ _ srv _ -> mk $ uriHosts srv + CSLInvitation _ srv _ _ -> mk $ uriHosts srv where - cLink = ACL m $ CLShort $ simplexShortLink sLnk + mk = SimplexLink showText (simplexLinkType aLink) (ACL m $ CLShort $ simplexShortLink sLnk) uriHosts srv = L.map strEncodeText $ host srv - linkType' = \case - CCTGroup -> XLGroup - CCTChannel -> XLChannel - CCTContact -> XLContact - CCTRelay -> XLRelay strEncodeText :: StrEncoding a => a -> Text strEncodeText = safeDecodeUtf8 . strEncode +-- The single definition of connection-link kind classification, used by the chat +-- markdown formatter (simplexUriFormat) and by the mobile link classifier +-- (chatCheckLink). Group links are contact URIs carrying CRDataGroup client data. +simplexLinkType :: AConnectionLink -> SimplexLinkType +simplexLinkType (ACL _ cl) = case cl of + CLFull cReq -> case cReq of + CRContactUri ConnReqUriData {crClientData} -> case crClientData >>= decodeJSON of + Just (CRDataGroup _) -> XLGroup + Nothing -> XLContact + CRInvitationUri {} -> XLInvitation + CLShort sLnk -> case sLnk of + CSLContact _ ct _ _ -> case ct of + CCTGroup -> XLGroup + CCTChannel -> XLChannel + CCTContact -> XLContact + CCTRelay -> XLRelay + CSLInvitation {} -> XLInvitation + parseUri :: ByteString -> Either Text U.URI parseUri s = case U.parseURI U.laxURIParserOptions s of Left e -> Left $ "Invalid URI: " <> tshow e diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 4e3dc3ab34..674d911999 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -37,7 +37,7 @@ import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEnco import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Library.Commands -import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) +import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri, simplexLinkType) import Simplex.Chat.Mobile.Badges import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -48,9 +48,10 @@ import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types +import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) -import Simplex.Messaging.Agent.Protocol (AgentErrorType) +import Simplex.Messaging.Agent.Protocol (AConnectionLink, AgentErrorType) import Simplex.Messaging.Agent.Store.Interface (closeDBStore, reopenDBStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C @@ -58,6 +59,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..)) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) +import Simplex.RemoteControl.Invitation (RCSignedInvitation) import System.IO (utf8) import System.Timeout (timeout) import qualified URI.ByteString as U @@ -131,6 +133,8 @@ foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO C foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString +foreign export ccall "chat_check_link" cChatCheckLink :: CString -> IO CJSONString + foreign export ccall "chat_parse_uri" cChatParseUri :: CString -> CInt -> IO CJSONString foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CString -> IO CString @@ -226,6 +230,10 @@ cChatParseMarkdown s = newCStringFromLazyBS . chatParseMarkdown =<< B.packCStrin cChatParseServer :: CString -> IO CJSONString cChatParseServer s = newCStringFromLazyBS . chatParseServer =<< B.packCString s +-- | classify a scanned QR code / link - returns CheckedLink JSON +cChatCheckLink :: CString -> IO CJSONString +cChatCheckLink s = newCStringFromLazyBS . chatCheckLink =<< B.packCString s + -- | parse web URI - returns ParsedUri JSON cChatParseUri :: CString -> CInt -> IO CJSONString cChatParseUri s safe = newCStringFromLazyBS . chatParseUri (safe /= 0) =<< B.packCString s @@ -380,6 +388,23 @@ chatParseServer = J.encode . toServerAddress . strDecode enc :: StrEncoding a => a -> String enc = B.unpack . strEncode +-- | Classify a scanned QR code / link into ScannedLinkType, or Nothing if it is +-- not a SimpleX code, by trying the same core decoders that later process each +-- kind. The UI owns the real trim (see the plan); this ASCII-only trim is +-- defensive so a stray edge byte never fails an otherwise-valid decode. +chatCheckLink :: ByteString -> JSONByteString +chatCheckLink = J.encode . CheckedLink . classify . trimAscii + where + isAsciiSpace c = c == ' ' || c == '\t' || c == '\r' || c == '\n' + trimAscii = B.dropWhileEnd isAsciiSpace . B.dropWhile isAsciiSpace + classify s + | Right {} <- strDecode @RCSignedInvitation s = Just SLTDesktopCtrl + | Right {} <- strDecode @FileDescriptionURI s = Just SLTFileDescription + | Right l <- strDecode @AConnectionLink s = Just $ SLTConnection $ simplexLinkType l + | Right {} <- strDecode @AProtoServerWithAuth s = Just SLTServer + | isVerificationCode s = Just SLTVerificationCode + | otherwise = Nothing + chatParseUri :: Bool -> ByteString -> JSONByteString chatParseUri safe s = J.encode $ case parseUri s of Left e -> ParsedUri Nothing e diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index dce1a2a9c9..fd90b0105c 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1904,6 +1904,20 @@ verificationCode = T.pack . unwords . chunks 5 . show . os2ip chunks _ [] = [] chunks n xs = let (h, t) = splitAt n xs in h : chunks n t +-- Recognises the textual shape of a verification code — the inverse of +-- verificationCode's `unwords . chunks 5 . show`: space-separated groups of +-- exactly 5 ASCII digits (the last group 1-5 digits), 32+ digits in total. +-- Used by chatCheckLink to classify a scanned contact security code, which has +-- no scheme to parse. Requiring the grouping keeps a bare long number out. +isVerificationCode :: ByteString -> Bool +isVerificationCode s = case B.split ' ' s of + [] -> False + gs -> all full5 (init gs) && lastOk (last gs) && sum (map B.length gs) >= 32 + where + isAsciiDigit c = c >= '0' && c <= '9' + full5 g = B.length g == 5 && B.all isAsciiDigit g + lastOk g = let n = B.length g in n >= 1 && n <= 5 && B.all isAsciiDigit g + sameVerificationCode :: Text -> Text -> Bool sameVerificationCode c1 c2 = noSpaces c1 == noSpaces c2 where diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 4e3ddbc0fa..a5add26895 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,9 +1,11 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-orphans #-} @@ -22,6 +24,7 @@ import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as BS import Data.ByteString.Internal (create) import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (getCurrentTime) import Data.Word (Word8, Word32) import Foreign.C @@ -34,7 +37,8 @@ import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEnco import JSONFixtures import Simplex.Chat import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) -import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) +import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..), CheckedLink (..), ScannedLinkType (..)) +import Simplex.Chat.Markdown (SimplexLinkType (..)) import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File @@ -43,13 +47,20 @@ import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles -import Simplex.Chat.Types (AgentUserId (..), Profile (..)) +import Simplex.Chat.Types (AgentUserId (..), Profile (..), verificationCode) +import Simplex.FileTransfer.Description (ChunkReplicaId (..), FileChunk (..), FileChunkReplica (..), FileDescription (..), FileDescriptionURI (..), FileDigest (..), FileSize (..), validateFileDescription) +import Simplex.FileTransfer.Protocol (SFileParty (..)) import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Protocol (EntityId (..)) +import Simplex.Messaging.ServiceScheme (ServiceScheme (..), SrvLoc (..)) +import Simplex.RemoteControl.Invitation (RCInvitation (..), signInvitation) +import Simplex.RemoteControl.Types (supportedRCPVRange) +import Data.Time.Clock.System (SystemTime (..)) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import System.Directory (copyFile) import System.FilePath (()) @@ -84,6 +95,7 @@ mobileTests = do describe "Parsers" $ do it "should parse server address" testChatParseServer it "should parse and sanitize URI" testChatParseUri + it "should classify scanned links" testChatCheckLink describe "Badges" $ do it "should generate key and issue badge via C API, verify credential" testBadgeKeygenIssueCApi @@ -314,6 +326,95 @@ testChatParseUri :: TestParams -> IO () testChatParseUri _ = do pure () +testChatCheckLink :: TestParams -> IO () +testChatCheckLink _ = do + -- connection links: full URIs and short links, all five subtypes reach simplexLinkType + checkLink invFull `shouldBe` Just (SLTConnection XLInvitation) + checkLink ctShort `shouldBe` Just (SLTConnection XLContact) + checkLink grFull `shouldBe` Just (SLTConnection XLGroup) + checkLink grShort `shouldBe` Just (SLTConnection XLGroup) + checkLink chShort `shouldBe` Just (SLTConnection XLChannel) + checkLink rlShort `shouldBe` Just (SLTConnection XLRelay) + -- SMP server address + checkLink smpServer `shouldBe` Just SLTServer + -- migration / standalone file link (built from a valid file description) + checkLink fileLinkSimplex `shouldBe` Just SLTFileDescription + -- mandatory: a file link on a non-simplex.chat https host still classifies as + -- FileDescription (the widening half of the strHasSimplexFileLink deletion) + checkLink fileLinkOtherHost `shouldBe` Just SLTFileDescription + -- desktop session address (a valid signed xrcp invitation, generated here) + drg <- C.newRandom + (skPub, skPriv) <- atomically $ C.generateKeyPair @'C.Ed25519 drg + (idPub, idPriv) <- atomically $ C.generateKeyPair @'C.Ed25519 drg + (dhPub, _) <- atomically $ C.generateKeyPair @'C.X25519 drg + let inv = + RCInvitation + { ca = C.KeyHash "test-ca", + host = either error id $ strDecode "localhost", + port = 5223, + v = supportedRCPVRange, + app = J.String "app", + ts = MkSystemTime 0 0, + skey = skPub, + idkey = idPub, + dh = dhPub + } + checkLink (strEncode $ signInvitation skPriv idPriv inv) `shouldBe` Just SLTDesktopCtrl + -- contact security code (generated by verificationCode); recognised by shape + checkLink (encodeUtf8 $ verificationCode $ C.sha256Hash "check-link-test") `shouldBe` Just SLTVerificationCode + -- defensive ASCII trim: surrounding whitespace does not change classification + checkLink (" " <> ctShort <> "\n") `shouldBe` Just (SLTConnection XLContact) + -- unknown / not a SimpleX code + checkLink "simplex:/file#/?desc=not-a-valid-file-description" `shouldBe` Nothing -- mandatory: corrupt file link, guards the narrowing delta + checkLink "xrcp:/not-a-signed-invitation" `shouldBe` Nothing -- mandatory: unsigned/malformed xrcp fails RCSignedInvitation + checkLink "https://example.com/page" `shouldBe` Nothing + checkLink "just some scanned text" `shouldBe` Nothing + checkLink "123456789012345678901234567890123456" `shouldBe` Nothing -- bare number: not the grouped verification-code shape + checkLink "" `shouldBe` Nothing + where + checkLink :: ByteString -> Maybe ScannedLinkType + checkLink s = case J.decode (chatCheckLink s) of + Just (CheckedLink t) -> t + Nothing -> error "chatCheckLink produced undecodable JSON" + -- fixtures reused from MarkdownTests; short-link type char a/c/g/r selects the subtype + invFull = "simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" + ctShort = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" + chShort = "simplex:/c#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" + grShort = "simplex:/g#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" + rlShort = "simplex:/r#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" + grFull = "simplex:/contact#/?v=2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" + smpServer = "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im" + -- a valid file link, built from a minimal 1-chunk file description (crypto + -- literals reused from simplexmq's FileDescriptionTests); the URI host is + -- decorative, so the same description encodes under either scheme. + fileLinkSimplex = strEncode $ FileDescriptionURI SSSimplex validFd Nothing + fileLinkOtherHost = strEncode $ FileDescriptionURI (SSAppServer (SrvLoc "example.com" "")) validFd Nothing + validFd = either error id $ validateFileDescription fileDesc + fileDesc = + FileDescription + { party = SFRecipient, + size = FileSize (8 * 1024 * 1024), + digest = FileDigest "abc", + key = either error id $ strDecode "00n8p1tJq5E-SGnHcYTOrS4A9I07gTA_WFD6MTFFFOY=", + nonce = either error id $ strDecode "dPSF-wrQpDiK_K6sYv0BDBZ9S4dg-jmu", + chunkSize = FileSize (8 * 1024 * 1024), + chunks = + [ FileChunk + { chunkNo = 1, + chunkSize = FileSize (8 * 1024 * 1024), + digest = FileDigest "ghi", + replicas = + [ FileChunkReplica + { server = "xftp://abc=@example1.com", + replicaId = ChunkReplicaId (EntityId "abc"), + replicaKey = C.APrivateAuthKey C.SEd25519 "MC4CAQAwBQYDK2VwBCIEIDfEfevydXXfKajz3sRkcQ7RPvfWUPoq6pu1TYHV1DEe" + } + ] + } + ], + redirect = Nothing + } + -- Generate a server keypair and issue a badge credential via the C FFI, -- constructing the request from the typed records, then verify the issued -- credential's BBS signature on the Haskell side.