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 use it, instead
of a generic "invalid" message.

Link recognition is done in the core: a new chat_check_link export
classifies a scanned string using the same decoders the views use to
process the links (connection link, server address, migration file link,
desktop address, security code), returning a typed result. Each scanner
calls checkLink first; the expected type proceeds to that scanner's normal
success path, a recognised-but-wrong type shows a shared wrong-type alert
saying what it is and where to use it, and an unrecognised string falls
back to that scanner's own contextual error. Mirrored on Android, desktop
and iOS.

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-21 12:33:37 +00:00
parent 2ea5940e81
commit fd34525aa3
31 changed files with 1261 additions and 142 deletions
+19 -12
View File
@@ -8,12 +8,13 @@
import SwiftUI
import CodeScanner
import SimpleXChat
struct ScanCodeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var connectionVerified: Bool
var verify: (String?) async -> (Bool, String)?
@State private var showCodeError = false
@State private var scanAlert: SomeAlert?
var body: some View {
VStack(alignment: .leading) {
@@ -25,26 +26,32 @@ struct ScanCodeView: View {
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(isPresented: $showCodeError) {
Alert(title: Text("Incorrect security code!"))
}
.alert(item: $scanAlert) { $0.alert }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
Task {
if let (ok, _) = await verify(r.string) {
await MainActor.run {
connectionVerified = ok
if ok {
dismiss()
} else {
showCodeError = true
let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines)
switch checkLink(trimmed) {
case .verificationCode?, nil:
// a security code (or unrecognised text): run the existing verify path
Task {
if let (ok, _) = await verify(trimmed) {
await MainActor.run {
connectionVerified = ok
if ok {
dismiss()
} else {
scanAlert = SomeAlert(alert: Alert(title: Text("Incorrect security code!")), id: "incorrectCode")
}
}
}
}
case let type?:
// valid SimpleX code of another kind: tell the user what it is
scanAlert = SomeAlert(alert: wrongQRCodeAlert(wrongQRCodeMessage(type)), id: "wrongQRCode")
}
case let .failure(e):
logger.error("ScanCodeView.processQRCode QR code error: \(e.localizedDescription)")
@@ -204,11 +204,15 @@ struct MigrateToDevice: View {
ScannerInView(showQRCodeScanner: $showQRCodeScanner) { resp in
switch resp {
case let .success(r):
let link = r.string
if strHasSimplexFileLink(link.trimmingCharacters(in: .whitespaces)) {
migrationState = .linkDownloading(link: link.trimmingCharacters(in: .whitespaces))
} else {
let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines)
switch checkLink(trimmed) {
case .some(.fileDescription):
migrationState = .linkDownloading(link: trimmed)
case nil:
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
case let type?:
// shared with the paste button, so the title is neutral ("Wrong link")
alert = .error(title: "Wrong link", error: wrongQRCodeMessage(type))
}
case let .failure(e):
logger.error("processQRCode QR code error: \(e.localizedDescription)")
@@ -232,10 +236,14 @@ struct MigrateToDevice: View {
private func pasteLinkView() -> some View {
Button {
if let str = UIPasteboard.general.string {
if strHasSimplexFileLink(str.trimmingCharacters(in: .whitespaces)) {
migrationState = .linkDownloading(link: str.trimmingCharacters(in: .whitespaces))
} else {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
switch checkLink(trimmed) {
case .some(.fileDescription):
migrationState = .linkDownloading(link: trimmed)
case nil:
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
case let type?:
alert = .error(title: "Wrong link", error: wrongQRCodeMessage(type))
}
}
} label: {
@@ -640,10 +648,6 @@ struct MigrateToDevice: View {
dismiss()
}
private func strHasSimplexFileLink(_ text: String) -> Bool {
text.starts(with: "simplex:/file") || text.starts(with: "https://simplex.chat/file")
}
private static func urlForTemporaryDatabase() -> URL {
URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true))
}
+11 -15
View File
@@ -684,14 +684,20 @@ private struct ConnectView: View {
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
let link = r.string
if strIsSimplexLink(r.string) {
connect(link)
} else {
let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines)
switch checkLink(trimmed) {
case .some(.connection):
connect(trimmed)
case nil:
alert = .newChatSomeAlert(alert: SomeAlert(
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."),
id: "processQRCode: code is not a SimpleX link"
))
case let type?:
alert = .newChatSomeAlert(alert: SomeAlert(
alert: wrongQRCodeAlert(wrongQRCodeMessage(type)),
id: "processQRCode: wrong QR code type"
))
}
case let .failure(e):
logger.error("processQRCode QR code error: \(e.localizedDescription)")
@@ -841,16 +847,6 @@ struct InfoSheetButton<Content: View>: View {
}
}
func strIsSimplexLink(_ str: String) -> Bool {
if let parsedMd = parseSimpleXMarkdown(str),
parsedMd.count == 1,
case .simplexLink = parsedMd[0].format {
return true
} else {
return false
}
}
enum ConnectTarget {
case link(text: String, linkType: SimplexLinkType, linkText: String)
case name(text: String, nameInfo: SimplexNameInfo)
@@ -1319,7 +1315,7 @@ func planAndConnect(
if linkType == .relay {
showAlert(
NSLocalizedString("Relay address", comment: "alert title"),
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect. To use it, open Network & servers, Your servers, Add server, then Chat relay, and paste the address there.", comment: "alert message")
)
cleanup?()
return
@@ -0,0 +1,42 @@
//
// WrongQRCode.swift
// SimpleX
//
// iOS mirror of the Kotlin WrongQRCode.kt (apps/multiplatform/.../views/newchat/WrongQRCode.kt).
//
// NOTE: written without an Xcode/macOS toolchain and NOT compiled here build/verify on CI or a Mac.
//
import Foundation
import SwiftUI
import SimpleXChat
// Message shown when a scanner (or the Migrate paste field) is handed a valid
// SimpleX code of a kind it does not accept: name what it actually is and where
// to use it. The type comes from the core classifier (checkLink), so this is
// purely presentation no parsing here. iOS uses %@ (not %s) for the substitution.
func wrongQRCodeMessage(_ type: ScannedLinkType) -> String {
switch type {
case let .connection(linkType):
if linkType == .relay {
return String.localizedStringWithFormat(NSLocalizedString("This is a %@. To use it, open Network & servers, Your servers, Add server, then Chat relay, and paste the address there.", comment: "wrong QR code alert"), linkType.description)
} else {
return String.localizedStringWithFormat(NSLocalizedString("This is a %@. To use it, open New chat, then scan or paste it there.", comment: "wrong QR code alert"), linkType.description)
}
case .server:
return NSLocalizedString("This is a SimpleX server address. To use it, open Network & servers, Your servers, Add server, then Scan server QR code.", comment: "wrong QR code alert")
case .fileDescription:
return NSLocalizedString("This is a link to migrate to another device. To use it, when setting up a new device, choose to migrate from another device.", comment: "wrong QR code alert")
case .desktopCtrl:
return NSLocalizedString("This is an address to connect to a desktop app. To use it, open Use from desktop and scan the QR code shown in the desktop app.", comment: "wrong QR code alert")
case .verificationCode:
return NSLocalizedString("This is a security code. To use it, open the chat, then the contact's or member's name, then Verify security code.", comment: "wrong QR code alert")
}
}
// The shared "Wrong QR code" alert. Callers present it through their own alert
// state so it appears within the scanner's presentation (a global/root alert
// may not show over a modal scanner).
func wrongQRCodeAlert(_ message: String) -> Alert {
Alert(title: Text("Wrong QR code"), message: Text(verbatim: message))
}
@@ -36,6 +36,7 @@ struct ConnectDesktopView: View {
case badInvitationError
case badVersionError(version: String?)
case desktopDisconnectedError
case wrongQRCode(message: String)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
@@ -45,6 +46,7 @@ struct ConnectDesktopView: View {
case .badInvitationError: "badInvitationError"
case let .badVersionError(v): "badVersionError \(v ?? "")"
case .desktopDisconnectedError: "desktopDisconnectedError"
case .wrongQRCode: "wrongQRCode"
case let .error(title, _): "error \(title)"
}
}
@@ -141,6 +143,8 @@ struct ConnectDesktopView: View {
)
case .desktopDisconnectedError:
Alert(title: Text("Connection terminated"))
case let .wrongQRCode(message):
wrongQRCodeAlert(message)
case let .error(title, error):
mkAlert(title: title, message: error)
}
@@ -405,7 +409,15 @@ struct ConnectDesktopView: View {
private func processDesktopQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r): connectDesktopAddress(r.string)
case let .success(r):
let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines)
switch checkLink(trimmed) {
case .some(.desktopCtrl), nil:
// a desktop address, or unrecognised text: let the core parse and report its own error
connectDesktopAddress(trimmed)
case let type?:
alert = .wrongQRCode(message: wrongQRCodeMessage(type))
}
case let .failure(e): errorAlert(e)
}
}
@@ -16,6 +16,7 @@ struct ScanProtocolServer: View {
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
@State private var scanAlert: SomeAlert?
var body: some View {
VStack(alignment: .leading) {
@@ -30,14 +31,27 @@ struct ScanProtocolServer: View {
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $scanAlert) { $0.alert }
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
var server: UserServer = .empty
server.server = r.string
addServer(server, $userServers, $serverErrors, $serverWarnings, dismiss)
let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines)
switch checkLink(trimmed) {
case .server?:
var server: UserServer = .empty
server.server = trimmed
addServer(server, $userServers, $serverErrors, $serverWarnings, dismiss)
case nil:
// unrecognised: scanner-local alert (deliberate iOS change from the pre-PR dismiss-then-global flow)
scanAlert = SomeAlert(
alert: mkAlert(title: "Invalid server address!", message: "Check server address and try again."),
id: "invalidServerAddress"
)
case let type?:
scanAlert = SomeAlert(alert: wrongQRCodeAlert(wrongQRCodeMessage(type)), id: "wrongQRCode")
}
case let .failure(e):
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
@@ -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 */,
+32
View File
@@ -187,6 +187,38 @@ struct ParsedServerAddress: Decodable {
var parseError: String
}
// The kind of SimpleX QR code / link a scanned string turned out to be, as
// determined by the core (chat_check_link). Public: it crosses the
// SimpleXChat -> app module boundary. Case names and the connection payload
// label must match the Haskell ScannedLinkType wire tags.
public enum ScannedLinkType: Decodable {
case connection(linkType: SimplexLinkType)
case server
case fileDescription
case desktopCtrl
case verificationCode
}
// Wrapper stays internal it never leaves the framework. A missing linkType
// key (the "not a SimpleX code" case) decodes to nil.
struct CheckedLink: Decodable {
var linkType: ScannedLinkType?
}
public func checkLink(_ s: String) -> ScannedLinkType? {
var c = s.cString(using: .utf8)!
if let cjson = chat_check_link(&c) {
if let d = dataFromCString(cjson) {
do {
return try jsonDecoder.decode(CheckedLink.self, from: d).linkType
} catch {
logger.error("checkLink jsonDecoder.decode error: \(error.localizedDescription)")
}
}
}
return nil
}
public func parseSanitizeUri(_ s: String, safe: Bool) -> ParsedUri? {
var c = s.cString(using: .utf8)!
if let cjson = chat_parse_uri(&c, safe ? 1 : 0) {
+1
View File
@@ -24,6 +24,7 @@ extern char *chat_send_cmd_retry(chat_ctrl ctl, char *cmd, int retryNum);
extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait);
extern char *chat_parse_markdown(char *str);
extern char *chat_parse_server(char *str);
extern char *chat_check_link(char *str);
extern char *chat_parse_uri(char *str, int safe);
extern char *chat_password_hash(char *pwd, char *salt);
extern char *chat_valid_name(char *name);
+6 -5
View File
@@ -58,15 +58,15 @@ The app follows a strict layered model where each layer communicates only with i
| State | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | `ChatModel`, `ItemsModel`, `Chat` classes | L337, L74, L1271 |
| API | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | FFI bridge functions | L93 |
| API | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | `ChatCommand`, `ChatResponse`, `ChatEvent` enums | L15, L649, L1055 |
| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | C header declaring Haskell exports | |
| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L50) | C header declaring Haskell exports | |
| FFI | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | `APIResult<R>`, `ChatError`, `ChatCmdProtocol` | L27, L699, L17 |
| Core | `../../src/Simplex/Chat/Controller.hs` | Haskell command processor — see `processCommand` in `Controller.hs` | |
---
## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L49)
## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L50)
### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L49)
### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L50)
The Haskell core exposes these C functions, declared in `SimpleXChat/SimpleX.h`:
@@ -87,9 +87,10 @@ char *chat_recv_msg_wait(chat_ctrl ctl, int wait);
char *chat_close_store(chat_ctrl ctl);
char *chat_reopen_store(chat_ctrl ctl);
// Utility: markdown parsing, server validation, password hashing
// Utility: markdown parsing, server validation, link classification, password hashing
char *chat_parse_markdown(char *str);
char *chat_parse_server(char *str);
char *chat_check_link(char *str);
char *chat_password_hash(char *pwd, char *salt);
// File encryption/decryption
@@ -327,7 +328,7 @@ Chat relays are SMP servers that forward messages to channel subscribers. They a
| App state | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | L337 |
| API types | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | L15 |
| Shared types | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | L27 |
| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | |
| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L50) | |
| NSE | [`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228) | |
| Haskell core | `../../src/Simplex/Chat/Controller.hs` — see `processCommand` in `Controller.hs` | |
| Chat protocol (x-events, message envelopes) | `../../src/Simplex/Chat/Protocol.hs` | |
@@ -64,6 +64,7 @@ extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_check_link(const char *str);
extern char *chat_parse_uri(const char *str, const int safe);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name);
@@ -147,6 +148,14 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused j
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatCheckLink(JNIEnv *env, __unused jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_check_link(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, __unused jclass clazz, jstring str, jint safe) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
@@ -37,6 +37,7 @@ extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_check_link(const char *str);
extern char *chat_parse_uri(const char *str, const int safe);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name);
@@ -157,6 +158,14 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, jclass cla
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatCheckLink(JNIEnv *env, jclass clazz, jstring str) {
const char *_str = encode_to_utf8_chars(env, str);
jstring res = decode_to_utf8_string(env, chat_check_link(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, jclass clazz, jstring str, jint safe) {
const char *_str = encode_to_utf8_chars(env, str);
@@ -5025,6 +5025,28 @@ fun parseSanitizeUri(s: String, safe: Boolean): ParsedUri? {
.getOrNull()
}
// The kind of SimpleX QR code / link a scanned string turned out to be, as
// determined by the core (chat_check_link). Null result = not a SimpleX code.
// Wire tags must match Haskell ScannedLinkType (sumTypeJSON $ dropPrefix "SLT").
@Serializable
sealed class ScannedLinkType {
@Serializable @SerialName("connection") data class Connection(val linkType: SimplexLinkType): ScannedLinkType()
@Serializable @SerialName("server") object Server: ScannedLinkType()
@Serializable @SerialName("fileDescription") object FileDescription: ScannedLinkType()
@Serializable @SerialName("desktopCtrl") object DesktopCtrl: ScannedLinkType()
@Serializable @SerialName("verificationCode") object VerificationCode: ScannedLinkType()
}
@Serializable
data class CheckedLink(val linkType: ScannedLinkType? = null)
fun checkLink(link: String): ScannedLinkType? {
val parsed = chatCheckLink(link)
return runCatching { json.decodeFromString(CheckedLink.serializer(), parsed) }
.onFailure { Log.d(TAG, "checkLink decode error: $it") }
.getOrNull()?.linkType
}
@Serializable
data class ParsedUri(val uriInfo: UriInfo?, val parseError: String)
@@ -38,6 +38,7 @@ external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): St
external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>
external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String
external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String
external fun chatCheckLink(link: String): String
val chatModel: ChatModel
get() = chatController.chatModel
@@ -5,10 +5,13 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import chat.simplex.common.model.ScannedLinkType
import chat.simplex.common.model.checkLink
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.common.views.newchat.showWrongQRCodeAlert
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
@@ -17,15 +20,22 @@ fun ScanCodeView(verifyCode: suspend (String?) -> Boolean, close: () -> Unit) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.scan_code))
QRCodeScanner { text ->
val success = verifyCode(text)
if (success) {
close()
val trimmed = text.trim()
val type = checkLink(trimmed)
if (type != null && type != ScannedLinkType.VerificationCode) {
// valid SimpleX code of another kind: tell the user what it is
showWrongQRCodeAlert(type)
false
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.incorrect_code)
)
// a security code (or unrecognised text): run the existing verify path
val success = verifyCode(trimmed)
if (success) {
close()
} else {
AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.incorrect_code))
}
success
}
success
}
Text(stringResource(MR.strings.scan_code_from_contacts_app), Modifier.padding(horizontal = DEFAULT_PADDING))
SectionBottomSpacer()
@@ -26,6 +26,7 @@ import chat.simplex.common.views.database.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.common.views.newchat.showWrongQRCodeAlert
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout
@@ -524,31 +525,40 @@ private fun ProgressView() {
}
private suspend fun MutableState<MigrationToState?>.checkUserLink(link: String): Boolean {
return if (strHasSimplexFileLink(link.trim())) {
val data = MigrationFileLinkData.readFromLink(link)
val hasProxyConfigured = data?.networkConfig?.hasProxyConfigured() ?: false
val networkConfig = data?.networkConfig?.transformToPlatformSupported()
// If any of iOS or Android had onion enabled, show onion screen
if (hasProxyConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) {
state = MigrationToState.Onion(link.trim(), networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode)
MigrationToDeviceState.save(MigrationToDeviceState.Onion(link.trim(), networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode))
} else {
val current = getNetCfg()
state = MigrationToState.DatabaseInit(link.trim(), current.copy(
socksProxy = null,
hostMode = networkConfig?.hostMode ?: current.hostMode,
requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode
),
networkProxy = null
)
val trimmed = link.trim()
return when (val type = checkLink(trimmed)) {
ScannedLinkType.FileDescription -> {
val data = MigrationFileLinkData.readFromLink(trimmed)
val hasProxyConfigured = data?.networkConfig?.hasProxyConfigured() ?: false
val networkConfig = data?.networkConfig?.transformToPlatformSupported()
// If any of iOS or Android had onion enabled, show onion screen
if (hasProxyConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) {
state = MigrationToState.Onion(trimmed, networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode)
MigrationToDeviceState.save(MigrationToDeviceState.Onion(trimmed, networkConfig.legacySocksProxy, networkConfig.networkProxy, networkConfig.hostMode, networkConfig.requiredHostMode))
} else {
val current = getNetCfg()
state = MigrationToState.DatabaseInit(trimmed, current.copy(
socksProxy = null,
hostMode = networkConfig?.hostMode ?: current.hostMode,
requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode
),
networkProxy = null
)
}
true
}
null -> {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_file_link),
text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link)
)
false
}
// shared with the paste button, so the title is neutral ("Wrong link")
else -> {
showWrongQRCodeAlert(type, title = generalGetString(MR.strings.wrong_link))
false
}
true
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_file_link),
text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link)
)
false
}
}
@@ -727,9 +737,6 @@ private suspend fun MutableState<MigrationToState?>.cleanUpOnBack(chatReceiver:
chatModel.migrationState.value = null
}
private fun strHasSimplexFileLink(text: String): Boolean =
text.startsWith("simplex:/file") || text.startsWith("https://simplex.chat/file")
private fun fileForTemporaryDatabase(): File =
File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory()))
@@ -654,14 +654,21 @@ 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)
)
val trimmed = text.trim()
when (val type = checkLink(trimmed)) {
is ScannedLinkType.Connection -> connect(rhId, trimmed, close)
null -> {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_qr_code),
text = generalGetString(MR.strings.code_you_scanned_is_not_simplex_link_qr_code)
)
false
}
else -> {
showWrongQRCodeAlert(type)
false
}
}
verifyAndConnect(rhId, text, close)
}
}
}
@@ -777,17 +784,6 @@ private fun filteredProfiles(users: List<User>, searchTextOrPassword: String): L
}
}
private fun verifyOnly(text: String?): Boolean = text != null && strIsSimplexLink(text)
private suspend fun verifyAndConnect(rhId: Long?, text: String?, close: () -> Unit): Boolean {
if (text != null && strIsSimplexLink(text)) {
return withContext(Dispatchers.Default) {
connect(rhId, text, close)
}
}
return false
}
private suspend fun connect(rhId: Long?, link: String, close: () -> Unit, cleanup: (() -> Unit)? = null): Boolean =
planAndConnect(
rhId,
@@ -821,11 +817,6 @@ private fun createInvitation(
}
}
fun strIsSimplexLink(str: String): Boolean {
val parsedMd = parseToMarkdown(str)
return parsedMd != null && parsedMd.size == 1 && parsedMd[0].format is Format.SimplexLink
}
sealed class ConnectTarget {
class Link(val text: String, val linkType: SimplexLinkType, val linkText: String) : ConnectTarget()
class Name(val text: String, val nameInfo: SimplexNameInfo) : ConnectTarget()
@@ -0,0 +1,28 @@
package chat.simplex.common.views.newchat
import chat.simplex.common.model.ScannedLinkType
import chat.simplex.common.model.SimplexLinkType
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
// Shown when a scanner (or the Migrate paste field) is handed a valid SimpleX
// code of a kind it does not accept: name what it actually is and where to use
// it. The type comes from the core classifier (checkLink), so this is purely
// presentation — no parsing here.
private fun wrongQRCodeMessage(type: ScannedLinkType): String = when (type) {
is ScannedLinkType.Connection ->
if (type.linkType == SimplexLinkType.relay)
String.format(generalGetString(MR.strings.wrong_qr_relay_address), type.linkType.description)
else
String.format(generalGetString(MR.strings.wrong_qr_connection_link), type.linkType.description)
ScannedLinkType.Server -> generalGetString(MR.strings.wrong_qr_server_address)
ScannedLinkType.FileDescription -> generalGetString(MR.strings.wrong_qr_migration_link)
ScannedLinkType.DesktopCtrl -> generalGetString(MR.strings.wrong_qr_desktop_address)
ScannedLinkType.VerificationCode -> generalGetString(MR.strings.wrong_qr_security_code)
}
// Title defaults to "Wrong QR code"; MigrateToDevice passes the neutral "Wrong
// link" title because its path is shared with the paste button.
fun showWrongQRCodeAlert(type: ScannedLinkType, title: String = generalGetString(MR.strings.wrong_qr_code)) {
AlertManager.shared.showAlertMsg(title = title, text = wrongQRCodeMessage(type))
}
@@ -33,6 +33,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.common.views.newchat.showWrongQRCodeAlert
import chat.simplex.common.views.usersettings.PreferenceToggle
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
@@ -352,8 +353,16 @@ private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList<Remot
private fun ScanDesktopAddressView(sessionAddress: MutableState<String>) {
SectionView(stringResource(MR.strings.scan_qr_code_from_desktop)) {
QRCodeScanner { text ->
sessionAddress.value = text
connectDesktopAddress(sessionAddress, text)
val trimmed = text.trim()
val type = checkLink(trimmed)
if (type != null && type != ScannedLinkType.DesktopCtrl) {
showWrongQRCodeAlert(type)
false
} else {
// a desktop address, or unrecognised text: let the core parse and report
sessionAddress.value = trimmed
connectDesktopAddress(sessionAddress, trimmed)
}
}
}
}
@@ -2,11 +2,13 @@ package chat.simplex.common.views.usersettings.networkAndServers
import androidx.compose.runtime.Composable
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.model.ScannedLinkType
import chat.simplex.common.model.UserServer
import chat.simplex.common.model.checkLink
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.common.views.newchat.showWrongQRCodeAlert
import chat.simplex.res.MR
@Composable
@@ -17,16 +19,24 @@ fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr))
QRCodeScanner { text ->
val res = parseServerAddress(text)
if (res != null) {
onNext(UserServer(remoteHostId = rhId, null, text, false, null, false, false))
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_invalid_address),
text = generalGetString(MR.strings.smp_servers_check_address)
)
val trimmed = text.trim()
when (val type = checkLink(trimmed)) {
ScannedLinkType.Server -> {
onNext(UserServer(remoteHostId = rhId, null, trimmed, false, null, false, false))
true
}
null -> {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_invalid_address),
text = generalGetString(MR.strings.smp_servers_check_address)
)
false
}
else -> {
showWrongQRCodeAlert(type)
false
}
}
res != null
}
}
}
@@ -802,6 +802,15 @@
<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_link">Wrong link</string>
<string name="wrong_qr_connection_link">This is a %s. To use it, open New chat, then scan or paste it there.</string>
<string name="wrong_qr_server_address">This is a SimpleX server address. To use it, open Network &amp; servers, Your servers, Add server, then Scan server QR code.</string>
<string name="wrong_qr_migration_link">This is a link to migrate to another device. To use it, when setting up a new device, choose to migrate from another device.</string>
<string name="wrong_qr_desktop_address">This is an address to connect to a desktop app. To use it, open Use from desktop and scan the QR code shown in the desktop app.</string>
<string name="wrong_qr_security_code">This is a security code. To use it, open the chat, then the contact\'s or member\'s name, then Verify security code.</string>
<string name="wrong_qr_relay_address">This is a %s. To use it, open Network &amp; servers, Your servers, Add server, then Chat relay, and paste the address there.</string>
<!-- GetImageView -->
<string name="toast_permission_denied">Permission Denied!</string>
@@ -3160,7 +3169,7 @@
<!-- ConnectPlan.kt channel-related -->
<string name="relay_address_alert_title">Relay address</string>
<string name="relay_address_alert_message">This is a chat relay address, it cannot be used to connect.</string>
<string name="relay_address_alert_message">This is a chat relay address, it cannot be used to connect. To use it, open Network &amp; servers, Your servers, Add server, then Chat relay, and paste the address there.</string>
<string name="connect_plan_open_channel">Open channel</string>
<string name="connect_plan_open_new_channel">Open new channel</string>
<string name="connect_plan_this_is_your_link_for_channel">Your channel</string>
+4 -3
View File
@@ -144,8 +144,9 @@ All JNI declarations reside in [`Core.kt`](../common/src/commonMain/kotlin/chat/
| 16 | [`chatReadFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L38) | `external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>` | 38 | Read and decrypt file |
| 17 | [`chatEncryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39) | `external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String` | 39 | Encrypt file on disk |
| 18 | [`chatDecryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L40) | `external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String` | 40 | Decrypt file on disk |
| 19 | [`chatCheckLink()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L41) | `external fun chatCheckLink(link: String): String` | 41 | Classify a scanned QR code / link into its type |
**Total: 18 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer).
**Total: 19 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer).
<a id="initChatControllerOnStart"></a>
<a id="chatInitTemporaryDatabase"></a>
@@ -155,8 +156,8 @@ All JNI declarations reside in [`Core.kt`](../common/src/commonMain/kotlin/chat/
| Function | Line | Purpose |
|---|---|---|
| [`initChatControllerOnStart()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L51) | 51 | Entry point called during app startup; launches `initChatController` in a long-running coroutine |
| [`initChatController()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62) | 62 | Main initialization: DB migration via `chatMigrateInit`, error recovery (incomplete DB removal), sets file paths, loads active user, starts chat |
| [`initChatControllerOnStart()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L52) | 52 | Entry point called during app startup; launches `initChatController` in a long-running coroutine |
| [`initChatController()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L63) | 63 | Main initialization: DB migration via `chatMigrateInit`, error recovery (incomplete DB removal), sets file paths, loads active user, starts chat |
| [`chatInitTemporaryDatabase()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L190) | 190 | Creates a temporary database for migration scenarios |
| [`chatInitControllerRemovingDatabases()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L202) | 202 | Removes existing DBs and creates fresh controller (used during re-initialization) |
| [`showStartChatAfterRestartAlert()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L222) | 222 | Shows confirmation dialog when chat was stopped and DB passphrase is stored |
+1 -1
View File
@@ -143,7 +143,7 @@ external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Ar
### Migration Flow in `initChatController`
The full initialization sequence is in [Core.kt#L62](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62):
The full initialization sequence is in [Core.kt#L63](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L63):
1. Obtain the DB encryption key from `DatabaseUtils.useDatabaseKey()`.
2. Determine the confirmation mode (default: `YesUp`; developer mode with confirm upgrades: `Error`).