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
@@ -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`).