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:
Narasimha-sc
2026-07-10 14:27:45 +00:00
parent a4e3a1ea11
commit eb6d8768eb
15 changed files with 378 additions and 35 deletions
@@ -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 */,
@@ -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
}
@@ -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 =
@@ -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)
}
}
}
@@ -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)
)
}
@@ -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
}
}
}
}
@@ -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 &amp; 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.