mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-28 12:09:41 +00:00
Recognise wrong QR code type in every scanner
When a scanner is shown a QR code of a different kind than it expects, it now tells the user what they actually scanned and where to scan it, instead of a generic "invalid" message. On parse failure each scanner falls back to a shared classifier (identifyQRCode / wrongQRCodeMessage) that tries the other known parsers — connection link, server address, migration link, desktop address — and shows "This is a X. To use it, ...", or the existing "not a SimpleX code" message when nothing matches. Each screen keeps its own parser and success path; only the failure branch changed. The wrong-type alert is presented through each view's own alert state so it shows within the scanner's presentation. Mirrored on Android, desktop and iOS. Plan: plans/2026-07-09-qr-recognise-wrong-type.md. Note: the iOS Swift was written without an Xcode toolchain and is not yet compiled — needs a Mac/CI build to verify.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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 <kind>." — 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")
|
||||
}
|
||||
}
|
||||
|
||||
// "<Where to scan it>." — 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))
|
||||
}
|
||||
@@ -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<ScanResult, ScanError>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ScanResult, ScanError>) {
|
||||
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()
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; };
|
||||
AA00000000000000000000A1 /* WrongQRCode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WrongQRCode.swift; sourceTree = "<group>"; };
|
||||
640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = "<group>"; };
|
||||
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = "<group>"; };
|
||||
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
+4
-3
@@ -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
|
||||
}
|
||||
|
||||
+8
-5
@@ -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<MigrationToState?>.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<MigrationToState?>.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 =
|
||||
|
||||
+5
-7
@@ -654,14 +654,12 @@ private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState<Boolean>, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+95
@@ -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 <kind>." — 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)
|
||||
}
|
||||
|
||||
// "<Where to scan it>." — 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)
|
||||
)
|
||||
}
|
||||
+9
-2
@@ -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<Remot
|
||||
private fun ScanDesktopAddressView(sessionAddress: MutableState<String>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -783,6 +783,18 @@
|
||||
<string name="enable_camera_access">Enable camera access</string>
|
||||
<string name="tap_to_scan">Tap to scan</string>
|
||||
<string name="camera_not_available">Camera not available</string>
|
||||
<!-- wrong QR code type scanned -->
|
||||
<string name="wrong_qr_code">Wrong QR code</string>
|
||||
<string name="wrong_qr_is_connection_link">This is a %s.</string>
|
||||
<string name="wrong_qr_is_server_address">This is a SimpleX server address.</string>
|
||||
<string name="wrong_qr_is_migration_link">This is a link to migrate to another device.</string>
|
||||
<string name="wrong_qr_is_desktop_address">This is an address to connect to a desktop app.</string>
|
||||
<string name="wrong_qr_is_security_code">This is a contact\'s security code.</string>
|
||||
<string name="wrong_qr_where_connection_link">To use it, tap New chat, then scan or paste it there.</string>
|
||||
<string name="wrong_qr_where_server_address">To use it, open Network & servers, Your servers, then Scan server QR code.</string>
|
||||
<string name="wrong_qr_where_migration_link">To use it, when setting up a new device, choose to migrate from another device.</string>
|
||||
<string name="wrong_qr_where_desktop_address">To use it, open Use from desktop, then Scan QR code from desktop.</string>
|
||||
<string name="wrong_qr_where_security_code">To use it, open the chat, tap the contact\'s name, then Verify security code.</string>
|
||||
|
||||
<!-- GetImageView -->
|
||||
<string name="toast_permission_denied">Permission Denied!</string>
|
||||
|
||||
@@ -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 <kind>.\n\n<where to scan it>."`
|
||||
- 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.
|
||||
Reference in New Issue
Block a user