diff --git a/apps/ios/Shared/Views/Chat/ScanCodeView.swift b/apps/ios/Shared/Views/Chat/ScanCodeView.swift index f364b4ed0b..85226a2b8e 100644 --- a/apps/ios/Shared/Views/Chat/ScanCodeView.swift +++ b/apps/ios/Shared/Views/Chat/ScanCodeView.swift @@ -13,7 +13,7 @@ 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,9 +25,7 @@ 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) } @@ -40,8 +38,10 @@ struct ScanCodeView: View { connectionVerified = ok if ok { dismiss() + } else if let msg = wrongQRCodeMessage(r.string, detectSecurityCode: false) { + scanAlert = SomeAlert(alert: wrongQRCodeAlert(msg), id: "wrongQRCode") } else { - showCodeError = true + scanAlert = SomeAlert(alert: Alert(title: Text("Incorrect security code!")), id: "incorrectCode") } } } diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index cb3832b727..be089685d6 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -207,6 +207,8 @@ struct MigrateToDevice: View { let link = r.string if strHasSimplexFileLink(link.trimmingCharacters(in: .whitespaces)) { migrationState = .linkDownloading(link: link.trimmingCharacters(in: .whitespaces)) + } else if let msg = wrongQRCodeMessage(link) { + alert = .error(title: "Wrong QR code", error: msg) } else { alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.") } @@ -234,6 +236,8 @@ struct MigrateToDevice: View { if let str = UIPasteboard.general.string { if strHasSimplexFileLink(str.trimmingCharacters(in: .whitespaces)) { migrationState = .linkDownloading(link: str.trimmingCharacters(in: .whitespaces)) + } else if let msg = wrongQRCodeMessage(str) { + alert = .error(title: "Wrong QR code", error: msg) } else { alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.") } @@ -640,10 +644,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..65b6beacbf 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -687,9 +687,14 @@ private struct ConnectView: View { let link = r.string if strIsSimplexLink(r.string) { connect(link) + } else if let msg = wrongQRCodeMessage(link) { + alert = .newChatSomeAlert(alert: SomeAlert( + alert: wrongQRCodeAlert(msg), + id: "processQRCode: wrong QR code type" + )) } else { alert = .newChatSomeAlert(alert: SomeAlert( - alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."), + alert: wrongQRCodeAlert(notSimplexQRCodeMessage()), id: "processQRCode: code is not a SimpleX link" )) } diff --git a/apps/ios/Shared/Views/NewChat/WrongQRCode.swift b/apps/ios/Shared/Views/NewChat/WrongQRCode.swift new file mode 100644 index 0000000000..07baafbcc5 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/WrongQRCode.swift @@ -0,0 +1,107 @@ +// +// 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. The "where to scan" wording should be reviewed against the real iOS navigation labels. +// + +import Foundation +import SwiftUI +import SimpleXChat + +// The kinds of QR code the app knows how to scan, apart from a contact's security code (which has +// no scheme and cannot be recognised by shape). Each scanner expects one of these; when a scanner's +// own parser rejects a scan, identifyQRCode reports which of the others it turned out to be. +private enum ScannedQRCode { + case connectionLink(SimplexLinkType) + case serverAddress + case migrationLink + case desktopAddress + case securityCode + + // "This is a ." — names what was actually scanned. + var typeDescription: String { + switch self { + case let .connectionLink(linkType): + return String.localizedStringWithFormat(NSLocalizedString("This is a %@.", comment: "wrong QR code alert"), linkType.description) + case .serverAddress: return NSLocalizedString("This is a SimpleX server address.", comment: "wrong QR code alert") + case .migrationLink: return NSLocalizedString("This is a link to migrate to another device.", comment: "wrong QR code alert") + case .desktopAddress: return NSLocalizedString("This is an address to connect to a desktop app.", comment: "wrong QR code alert") + case .securityCode: return NSLocalizedString("This is a contact's security code.", comment: "wrong QR code alert") + } + } + + // "." — the screen that accepts this kind, or nil when there is no scanner for + // it (a relay address is a connection link but cannot be used to connect from New chat). + var whereToScan: String? { + switch self { + case let .connectionLink(linkType): + return linkType == .relay ? nil : NSLocalizedString("To use it, tap New chat, then scan or paste it there.", comment: "wrong QR code alert") + case .serverAddress: return NSLocalizedString("To use it, open Network & servers, Your servers, then Scan server QR code.", comment: "wrong QR code alert") + case .migrationLink: return NSLocalizedString("To use it, when setting up a new device, choose to migrate from another device.", comment: "wrong QR code alert") + case .desktopAddress: return NSLocalizedString("To use it, open Use from desktop, then Scan QR code from desktop.", comment: "wrong QR code alert") + case .securityCode: return NSLocalizedString("To use it, open the chat, tap the contact's name, then Verify security code.", comment: "wrong QR code alert") + } + } +} + +// The QR-encoded form of a desktop session address always starts with this scheme +// (single slash; see simplexmq RCSignedInvitation encoding). +let desktopAddressScheme = "xrcp:/" + +func strHasSimplexFileLink(_ text: String) -> Bool { + text.starts(with: "simplex:/file") || text.starts(with: "https://simplex.chat/file") +} + +// The "megaparser": try every known parser and return the first match, or nil when the scan is not +// a SimpleX QR code at all. No new parsing logic — the same parsers each scanner already uses. +private func identifyQRCode(_ text: String, detectSecurityCode: Bool) -> ScannedQRCode? { + let t = text.trimmingCharacters(in: .whitespacesAndNewlines) + if let md = parseSimpleXMarkdown(t), md.count == 1, + case let .simplexLink(_, linkType, _, _) = md[0].format { + return .connectionLink(linkType) + } + if parseServerAddress(t) != nil { return .serverAddress } + if strHasSimplexFileLink(t) { return .migrationLink } + if t.hasPrefix(desktopAddressScheme) { return .desktopAddress } + if detectSecurityCode && isSecurityCode(t) { return .securityCode } + return nil +} + +// A contact's security/verification code has no scheme; the QR encodes it verbatim. The core builds +// it as `verificationCode = unwords . chunks 5 . show . os2ip` — the decimal digits of a hash in +// space-separated groups of 5 (e.g. "61889 38426 ... 25"), ~77 digits. Recognise that exact shape: +// 2+ whitespace-separated groups of ASCII digits, 32+ digits total. Requiring the grouping keeps a +// bare long number from matching. Checked last, after every scheme-based parser. +private func isSecurityCode(_ t: String) -> Bool { + // ASCII whitespace only, to match the Kotlin `\s+` split (Java \s does not include + // Unicode spaces like NBSP) — both platforms must classify identically. + let groups = t.split(whereSeparator: { $0.isWhitespace && $0.isASCII }) + return groups.count >= 2 + && groups.reduce(0) { $0 + $1.count } >= 32 + && groups.allSatisfy { $0.allSatisfy { $0.isNumber && $0.isASCII } } +} + +// For a scan the current screen rejected: "This is X.\n\nTo use it, scan it there." when it is +// recognised as another known kind, or nil when it is not a SimpleX QR code at all (the caller +// then shows its own "unknown" message). Each caller presents this through its OWN alert state so +// the alert appears within the scanner's presentation (a global/root alert may not show over it). +func wrongQRCodeMessage(_ text: String, detectSecurityCode: Bool = true) -> String? { + guard let qr = identifyQRCode(text, detectSecurityCode: detectSecurityCode) else { return nil } + return [qr.typeDescription, qr.whereToScan].compactMap { $0 }.joined(separator: "\n\n") +} + +// The shared "not a SimpleX QR code" message for the unrecognised case, for scanners that have no +// more specific wording of their own. +func notSimplexQRCodeMessage() -> String { + NSLocalizedString("The code you scanned is not a SimpleX link QR 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..97c1dda87a 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,12 @@ struct ConnectDesktopView: View { private func processDesktopQRCode(_ resp: Result) { switch resp { - case let .success(r): connectDesktopAddress(r.string) + case let .success(r): + if r.string.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix(desktopAddressScheme) { + connectDesktopAddress(r.string) + } else { + alert = .wrongQRCode(message: wrongQRCodeMessage(r.string) ?? notSimplexQRCodeMessage()) + } 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..6ff6994f48 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,22 @@ 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) + if parseServerAddress(r.string) != nil { + var server: UserServer = .empty + server.server = r.string + addServer(server, $userServers, $serverErrors, $serverWarnings, dismiss) + } else { + scanAlert = SomeAlert( + alert: wrongQRCodeAlert(wrongQRCodeMessage(r.string) ?? notSimplexQRCodeMessage()), + 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 fa4e21fbfc..85130e28df 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/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..af0eeabc6e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -9,6 +9,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.newchat.showWrongQRCodeAlert import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @@ -21,9 +22,9 @@ fun ScanCodeView(verifyCode: suspend (String?) -> Boolean, close: () -> Unit) { if (success) { close() } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.incorrect_code) - ) + showWrongQRCodeAlert(text, detectSecurityCode = false) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.incorrect_code)) + } } success } 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..cdaff3144c 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 @@ -544,10 +545,12 @@ private suspend fun MutableState.checkUserLink(link: String): } true } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_file_link), - text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) - ) + showWrongQRCodeAlert(link) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_file_link), + text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) + ) + } false } } @@ -727,7 +730,7 @@ private suspend fun MutableState.cleanUpOnBack(chatReceiver: chatModel.migrationState.value = null } -private fun strHasSimplexFileLink(text: String): Boolean = +internal fun strHasSimplexFileLink(text: String): Boolean = text.startsWith("simplex:/file") || text.startsWith("https://simplex.chat/file") private fun fileForTemporaryDatabase(): File = 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..79460aa693 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,12 @@ 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) - ) + if (verifyOnly(text)) { + verifyAndConnect(rhId, text, close) + } else { + showWrongQRCodeAlert(text) + false } - verifyAndConnect(rhId, text, close) } } } 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..9b8f4a1b4d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/WrongQRCode.kt @@ -0,0 +1,95 @@ +package chat.simplex.common.views.newchat + +import chat.simplex.common.model.* +import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.migration.strHasSimplexFileLink +import chat.simplex.res.MR + +// The QR-encoded form of a desktop session address always starts with this scheme +// (single slash; see simplexmq RCSignedInvitation encoding). +const val DESKTOP_ADDRESS_SCHEME = "xrcp:/" + +// The kinds of QR code the app knows how to scan. Each scanner expects one of these; when a +// scanner's own parser rejects a scan, identifyQRCode reports which of the others it turned out to +// be. A security code has no scheme, so it is recognised by shape (see isSecurityCode) — every +// scanner detects it except the security-code scanner itself (see detectSecurityCode). +private sealed class ScannedQRCode { + data class ConnectionLink(val linkType: SimplexLinkType): ScannedQRCode() + object ServerAddress: ScannedQRCode() + object MigrationLink: ScannedQRCode() + object DesktopAddress: ScannedQRCode() + object SecurityCode: ScannedQRCode() + + // "This is a ." — names what was actually scanned. + val description: String get() = when (this) { + is ConnectionLink -> String.format(generalGetString(MR.strings.wrong_qr_is_connection_link), linkType.description) + ServerAddress -> generalGetString(MR.strings.wrong_qr_is_server_address) + MigrationLink -> generalGetString(MR.strings.wrong_qr_is_migration_link) + DesktopAddress -> generalGetString(MR.strings.wrong_qr_is_desktop_address) + SecurityCode -> generalGetString(MR.strings.wrong_qr_is_security_code) + } + + // "." — the screen that accepts this kind, or null when there is no scanner for + // it (a relay address is a connection link but cannot be used to connect from New chat). + val whereToScan: String? get() = when (this) { + is ConnectionLink -> if (linkType == SimplexLinkType.relay) null else generalGetString(MR.strings.wrong_qr_where_connection_link) + ServerAddress -> generalGetString(MR.strings.wrong_qr_where_server_address) + MigrationLink -> generalGetString(MR.strings.wrong_qr_where_migration_link) + DesktopAddress -> generalGetString(MR.strings.wrong_qr_where_desktop_address) + SecurityCode -> generalGetString(MR.strings.wrong_qr_where_security_code) + } +} + +// The "megaparser": try every known parser and return the first match, or null when the scan is not +// a SimpleX QR code at all. The scanner that already rejected this text passes it here, so the kind +// it expected either does not match (a link is not a server address) or was a false negative worth +// reporting anyway. No new parsing logic — these are the same parsers each scanner already uses. +private fun identifyQRCode(text: String, detectSecurityCode: Boolean): ScannedQRCode? { + val t = text.trim() + val md = parseToMarkdown(t) + val linkType = if (md?.size == 1) (md[0].format as? Format.SimplexLink)?.linkType else null + return when { + linkType != null -> ScannedQRCode.ConnectionLink(linkType) + parseServerAddress(t) != null -> ScannedQRCode.ServerAddress + strHasSimplexFileLink(t) -> ScannedQRCode.MigrationLink + t.startsWith(DESKTOP_ADDRESS_SCHEME) -> ScannedQRCode.DesktopAddress + detectSecurityCode && isSecurityCode(t) -> ScannedQRCode.SecurityCode + else -> null + } +} + +// A contact's security/verification code has no scheme; the QR encodes it verbatim. The core builds +// it as `verificationCode = unwords . chunks 5 . show . os2ip` — the decimal digits of a hash in +// space-separated groups of 5 (e.g. "61889 38426 ... 25"), ~77 digits. Recognise that exact shape: +// 2+ whitespace-separated groups of ASCII digits, 32+ digits total. Requiring the grouping keeps a +// bare long number from matching. Checked last, after every scheme-based parser. +private fun isSecurityCode(t: String): Boolean { + val groups = t.split(Regex("\\s+")).filter { it.isNotEmpty() } + return groups.size >= 2 && + groups.sumOf { it.length } >= 32 && + groups.all { g -> g.all { it in '0'..'9' } } +} + +// Shown by every scanner when a scan is not the kind that screen expects: name what was actually +// scanned and where to scan it. When it is not a recognisable SimpleX QR code, fall back to +// [orElse] — a unified "not a SimpleX QR code" message by default, which the security-code scanner +// overrides with its own "incorrect code" (there the scan is the right kind but the wrong value). +fun showWrongQRCodeAlert(text: String, detectSecurityCode: Boolean = true, orElse: () -> Unit = ::showNotSimplexQRCodeAlert) { + val qr = identifyQRCode(text, detectSecurityCode) + if (qr == null) { + orElse() + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.wrong_qr_code), + text = listOfNotNull(qr.description, qr.whereToScan).joinToString("\n\n") + ) + } +} + +private fun showNotSimplexQRCodeAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.wrong_qr_code), + text = generalGetString(MR.strings.code_you_scanned_is_not_simplex_link_qr_code) + ) +} 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..32afa3295b 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 @@ -32,7 +32,9 @@ import chat.simplex.common.platform.* 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.DESKTOP_ADDRESS_SCHEME 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 +354,13 @@ private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList) { SectionView(stringResource(MR.strings.scan_qr_code_from_desktop)) { QRCodeScanner { text -> - sessionAddress.value = text - connectDesktopAddress(sessionAddress, text) + if (text.trim().startsWith(DESKTOP_ADDRESS_SCHEME)) { + sessionAddress.value = text + connectDesktopAddress(sessionAddress, text) + } else { + showWrongQRCodeAlert(text) + false + } } } } 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..34bf8e8c11 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt @@ -7,6 +7,7 @@ import chat.simplex.common.model.UserServer import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.newchat.showWrongQRCodeAlert import chat.simplex.res.MR @Composable @@ -21,10 +22,7 @@ fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) { 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) - ) + showWrongQRCodeAlert(text) } 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 6befeecb3b..b26dd3d483 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -783,6 +783,18 @@ Enable camera access Tap to scan Camera not available + + Wrong QR code + This is a %s. + This is a SimpleX server address. + This is a link to migrate to another device. + This is an address to connect to a desktop app. + This is a contact\'s security code. + To use it, tap New chat, then scan or paste it there. + To use it, open Network & servers, Your servers, then Scan server QR code. + To use it, when setting up a new device, choose to migrate from another device. + To use it, open Use from desktop, then Scan QR code from desktop. + To use it, open the chat, tap the contact\'s name, then Verify security code. Permission Denied! 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..1c870f1427 --- /dev/null +++ b/plans/2026-07-09-qr-recognise-wrong-type.md @@ -0,0 +1,95 @@ +# Recognise the wrong QR code type in every scanner + +Supersedes the earlier `plan-unify-qr-scanning.md` / `-impl.md` (PR #7044), which +proposed replacing every screen's parser with a single universal `QRCodeType` +classifier. That was more change than the goal needs. This plan is the minimal +version: keep every scanner's parser and success path; only add a fallback when a +scan is rejected. + +## Problem + +Each QR scanner accepts exactly one kind of code and silently rejects everything +else with a generic "invalid" message: + +- New chat → connection link +- Network & servers → SMP/XFTP server address +- Migrate device → migration file link +- Use from desktop → desktop address (`xrcp:/…`) +- Verify security code → a contact's security code + +If you scan a valid code on the wrong screen (e.g. a contact link while verifying a +security code) you get "Invalid …" with no hint that the code is fine, just scanned +in the wrong place. + +## Approach + +When a scanner's own parser rejects a scan, run a small fallback that tries the +*other* known parsers and reports what the code actually is. No parser is replaced; +no scanner signature changes; the fallback lives in one shared file per platform. + +- `identifyQRCode(text)` — the "megaparser". Tries, in order, the parsers each + scanner already uses: connection link (markdown `SimplexLink`, whole-string), + server address (`parseServerAddress`), migration file link + (`strHasSimplexFileLink`), desktop address (`xrcp:/` prefix). Returns the first + match or "none". Because the calling scanner's own parser already failed, this + naturally reports "any option except the one expected". +- On a match: alert **"Wrong QR code"** with `"This is a .\n\n."` +- On no match: **"Wrong QR code" / "The code you scanned is not a SimpleX link QR + code."**, except the security-code screen ("Incorrect security code!" — there the + code is the right kind but the wrong value) and migration ("Invalid link", shared + with its paste path). + +A contact's security code has no scheme, so it is matched by shape (`isSecurityCode`: +2+ space-separated groups of digits, 32+ digits total — the `unwords . chunks 5 . +show . os2ip` form). It is detected on every scanner **except** the security-code +scanner itself, which passes `detectSecurityCode = false`: there a scan of the right +shape but wrong value must stay "Incorrect security code!", not "scan it there". + +## Files + +Kotlin (Android + desktop), shared `commonMain`: +- `views/newchat/WrongQRCode.kt` (new) — `ScannedQRCode`, `identifyQRCode`, + `showWrongQRCodeAlert(text, orElse)`, `DESKTOP_ADDRESS_SCHEME`. +- `views/newchat/NewChatView.kt`, `chat/ScanCodeView.kt`, + `migration/MigrateToDevice.kt` (makes `strHasSimplexFileLink` `internal`), + `remote/ConnectDesktopView.kt`, `usersettings/networkAndServers/ScanProtocolServer.kt` + — failure branch calls `showWrongQRCodeAlert`; desktop gains a `xrcp:/` pre-check. +- `resources/MR/base/strings.xml` — 11 new strings; none removed. + +iOS mirror (same structure): +- `Views/NewChat/WrongQRCode.swift` (new) — `identifyQRCode`, `wrongQRCodeMessage`, + `notSimplexQRCodeMessage`, `wrongQRCodeAlert`, `desktopAddressScheme`, + `strHasSimplexFileLink` (moved out of `MigrateToDevice.swift`). +- The five scanner views present the wrong-type alert through **their own** alert + state (`SomeAlert` / view alert enum), not a global manager, so it shows within + the scanner's own presentation. Server and desktop gain a local pre-check + (`parseServerAddress` / `xrcp:` prefix) so they too report the wrong type. + `project.pbxproj` registers the new file. + +## Parser ordering (correctness) + +Markdown is checked first, then server / migration / desktop. Verified safe against +`src/Simplex/Chat/Markdown.hs`: a `SimplexLink` is only produced for connection-request +URIs (`http/https/simplex:` that parse as `AConnectionLink`); file links have no +markdown handling, and `xrcp:`/`smp:` are not treated as URIs. So migration, desktop +and server codes never match the markdown branch — order does not misclassify. + +## Edge cases + +- Relay links classify as a connection link; the "where to scan" line is omitted + (a relay address can't be used to connect from New chat), so the alert shows only + "This is a SimpleX relay address." +- A SimpleX *name* is not a `SimplexLink`, so it stays "unknown" on the connection + scanner — same as before (names only ever worked via paste). +- Android: the failure branch returns `false` (unchanged), so the scanner keeps + scanning with the existing 1s debounce; iOS scanners are `.oncePerCode`. + +## Verification + +- Kotlin: `./gradlew :common:compileKotlinDesktop` — builds clean. +- iOS: **not compiled** in this environment (no Xcode toolchain) — needs a Mac/CI + build. "Where to scan" wording should be checked against the live iOS navigation. + +## Not done / out of scope + +- No "Connect" action on the alert — informational only.