diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 969d81aa70..2e02059559 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -606,6 +606,7 @@ firmware_too_old
firmware_update_almost_there
firmware_update_alpha
firmware_update_archive_missing_target
+firmware_update_available
firmware_update_battery_low
firmware_update_checking
firmware_update_confirm_file_button
@@ -645,6 +646,10 @@ firmware_update_nightly
firmware_update_no_device
firmware_update_node_info_missing
firmware_update_not_found_in_release
+firmware_update_notification_android
+firmware_update_notification_flasher
+firmware_update_open
+firmware_update_open_flasher
firmware_update_ota_failed
firmware_update_ota_unsupported_reason
firmware_update_rak4631_bootloader_hint
@@ -1776,8 +1781,10 @@ wind_direction
wind_gust
wind_lull
wind_speed
+### WRITE ###
write_nfc
write_nfc_failed
+write_nfc_subtext
write_nfc_success
write_nfc_text
you
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 49fd280ca8..15d32d7789 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1826,11 +1826,14 @@
Wind Gust
Wind Lull
Wind Speed
+
Write to NFC tag
- Couldn\'t write to tag. Use a writable, empty NFC tag and hold it steady.
+ Couldn't write to tag. Use a writable, empty NFC tag and hold it steady.
+ Writing saves this share link to a physical NFC tag. Anyone can tap the tag with their phone to open it.
Written to tag
Hold a writable NFC tag against the back of your phone.
You
简体中文
繁體中文
+
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
index b4573aa28b..6871668703 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
@@ -116,11 +116,10 @@ internal class MessagingControllerImpl(
Logger.w { "importContact rejected: missing node_num or user (node_num=${contact.node_num})" }
return
}
- // Importing a contact (e.g. scanning their QR code) is itself an act of manual verification,
- // so mark it verified regardless of the incoming flag.
- val verifiedContact = contact.copy(manually_verified = true)
- commandSender.sendAdmin(myNum) { AdminMessage(add_contact = verifiedContact) }
- nodeManager.handleReceivedUser(contact.node_num, user, manuallyVerified = true)
+ // Cross-platform policy: honor the verification state encoded by the sharer as-is
+ // (see meshtastic/design standards/audits/nfc-alignment-audit.md).
+ commandSender.sendAdmin(myNum) { AdminMessage(add_contact = contact) }
+ nodeManager.handleReceivedUser(contact.node_num, user, manuallyVerified = contact.manually_verified)
}
private companion object {
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
index caa0c77af8..babba159cf 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
@@ -393,13 +393,42 @@ class RadioControllerImplTest {
@Test
fun importContactSendsAdminAndUpdatesNodeManager() = runTest {
val controller = createController()
- // A QR-scanned contact arrives with manually_verified = false (proto default).
+ // A scanned contact arrives with manually_verified = false (proto default).
val contact = SharedContact(node_num = 42, user = User(id = "!0000002a", long_name = "Test"))
+ var sentMessage: AdminMessage? = null
+ everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } calls
+ {
+ @Suppress("UNCHECKED_CAST")
+ sentMessage = (it.args[3] as () -> AdminMessage)()
+ }
+
+ controller.importContact(contact)
+
+ verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) }
+ // The verification state encoded by the sharer is honored as-is, not forced to true.
+ assertEquals(false, sentMessage?.add_contact?.manually_verified)
+ verify { nodeManager.handleReceivedUser(42, any(), any(), false) }
+ }
+
+ @Test
+ fun importContactPreservesEncodedVerificationState() = runTest {
+ val controller = createController()
+ // A contact shared as already verified stays verified on import.
+ val contact =
+ SharedContact(node_num = 42, user = User(id = "!0000002a", long_name = "Test"), manually_verified = true)
+
+ var sentMessage: AdminMessage? = null
+ everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } calls
+ {
+ @Suppress("UNCHECKED_CAST")
+ sentMessage = (it.args[3] as () -> AdminMessage)()
+ }
+
controller.importContact(contact)
verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) }
- // Importing is an act of manual verification, so the node is recorded as verified.
+ assertEquals(true, sentMessage?.add_contact?.manually_verified)
verify { nodeManager.handleReceivedUser(42, any(), any(), true) }
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt
index dc22912b44..f94e134e4f 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt
@@ -32,6 +32,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
@@ -54,6 +55,7 @@ import org.meshtastic.core.ui.util.annotatedStringFromHtml
* @param titleRes The title string resource of the dialog.
* @param message Optional plain text message.
* @param messageRes Optional string resource message.
+ * @param messageColor Optional color for the plain text message; [Color.Unspecified] uses the default text color.
* @param html Optional HTML formatted message.
* @param icon Optional leading icon.
* @param text Optional custom composable content for the body.
@@ -75,6 +77,7 @@ fun MeshtasticDialog(
titleRes: StringResource? = null,
message: String? = null,
messageRes: StringResource? = null,
+ messageColor: Color = Color.Unspecified,
html: String? = null,
icon: ImageVector? = null,
text: @Composable (() -> Unit)? = null,
@@ -149,7 +152,7 @@ fun MeshtasticDialog(
} else if (htmlAnnotated != null) {
Text(text = htmlAnnotated)
} else if (messageText != null) {
- Text(text = messageText)
+ Text(text = messageText, color = messageColor)
}
if (choices.isNotEmpty()) {
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt
index f596bf31e7..106050d1e9 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt
@@ -49,11 +49,13 @@ import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.qr_code
import org.meshtastic.core.resources.write_nfc
import org.meshtastic.core.resources.write_nfc_failed
+import org.meshtastic.core.resources.write_nfc_subtext
import org.meshtastic.core.resources.write_nfc_success
import org.meshtastic.core.resources.write_nfc_text
import org.meshtastic.core.ui.icon.Copy
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nfc
+import org.meshtastic.core.ui.theme.SemanticColors
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNfcWriterProvider
import org.meshtastic.core.ui.util.SetScreenBrightness
@@ -102,6 +104,7 @@ fun QrDialog(title: String, uriString: String, onDismiss: () -> Unit) {
onDismiss = { writeSucceeded = null },
titleRes = Res.string.write_nfc,
messageRes = if (success) Res.string.write_nfc_success else Res.string.write_nfc_failed,
+ messageColor = if (success) SemanticColors.Success else MaterialTheme.colorScheme.error,
onConfirm = { writeSucceeded = null },
confirmTextRes = Res.string.okay,
)
@@ -152,6 +155,15 @@ fun QrDialog(title: String, uriString: String, onDismiss: () -> Unit) {
Icon(imageVector = MeshtasticIcons.Copy, contentDescription = stringResource(Res.string.copy))
}
}
+
+ if (nfcSupported) {
+ Text(
+ text = stringResource(Res.string.write_nfc_subtext),
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
},
)
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/share/SharedContactDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/share/SharedContactDialog.kt
index a2886fe771..b838b2fffa 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/share/SharedContactDialog.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/share/SharedContactDialog.kt
@@ -16,15 +16,24 @@
*/
package org.meshtastic.core.ui.share
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.compareUsers
import org.meshtastic.core.model.util.userFieldsToString
import org.meshtastic.core.resources.Res
@@ -34,6 +43,8 @@ import org.meshtastic.core.resources.import_label
import org.meshtastic.core.resources.import_shared_contact
import org.meshtastic.core.resources.public_key_changed
import org.meshtastic.core.ui.component.MeshtasticDialog
+import org.meshtastic.core.ui.component.NodeChip
+import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
@@ -46,13 +57,41 @@ fun SharedContactDialog(
) {
val unfilteredNodes by viewModel.unfilteredNodes.collectAsStateWithLifecycle()
- val nodeNum = sharedContact.node_num
- val node = unfilteredNodes.find { it.num == nodeNum }
+ val node = unfilteredNodes.find { it.num == sharedContact.node_num }
+ SharedContactDialogContent(
+ sharedContact = sharedContact,
+ node = node,
+ onDismiss = onDismiss,
+ onImport = {
+ viewModel.addSharedContact(sharedContact)
+ onDismiss()
+ },
+ )
+}
+
+/**
+ * Stateless content of [SharedContactDialog]. [node] is the matching node already in the local database, or null when
+ * the contact is unknown.
+ */
+@Composable
+fun SharedContactDialogContent(
+ sharedContact: SharedContact,
+ node: Node?,
+ onDismiss: () -> Unit,
+ onImport: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
MeshtasticDialog(
+ modifier = modifier,
titleRes = Res.string.import_shared_contact,
text = {
Column {
+ // Node identity badge: the same node identity chip used across the app (Design Standards §1).
+ val chipNode = node ?: Node(num = sharedContact.node_num, user = sharedContact.user ?: User())
+ Box(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), contentAlignment = Alignment.Center) {
+ NodeChip(node = chipNode)
+ }
if (node != null) {
Text(text = stringResource(Res.string.import_known_shared_contact_text))
if ((node.user.public_key.size) > 0 && node.user.public_key != sharedContact.user?.public_key) {
@@ -71,9 +110,27 @@ fun SharedContactDialog(
dismissText = stringResource(Res.string.cancel),
onDismiss = onDismiss,
confirmText = stringResource(Res.string.import_label),
- onConfirm = {
- viewModel.addSharedContact(sharedContact)
- onDismiss()
- },
+ onConfirm = onImport,
)
}
+
+// Public because the screenshot-tests module renders this preview as a golden test.
+@Suppress("MagicNumber", "PreviewPublic")
+@Preview(showBackground = true, name = "Shared Contact Import Alert")
+@Composable
+fun PreviewSharedContactImportAlert() {
+ AppTheme {
+ Box(modifier = Modifier.fillMaxSize()) {
+ SharedContactDialogContent(
+ sharedContact =
+ SharedContact(
+ node_num = 13444,
+ user = User(id = "!00003484", long_name = "John Doe", short_name = "JD"),
+ ),
+ node = null,
+ onDismiss = {},
+ onImport = {},
+ )
+ }
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertPreviews.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertPreviews.kt
index 93aa3503a2..dc4363f872 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertPreviews.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertPreviews.kt
@@ -20,18 +20,24 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.preview_custom_composable_line_one
import org.meshtastic.core.resources.preview_custom_composable_line_two
+import org.meshtastic.core.resources.write_nfc
+import org.meshtastic.core.resources.write_nfc_failed
+import org.meshtastic.core.resources.write_nfc_success
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.theme.SemanticColors
/** A helper component that renders an [AlertManager.AlertData] using the same logic as MainScreen. */
@Composable
@@ -114,6 +120,44 @@ fun PreviewMultipleChoiceAlert() {
}
}
+// Public because the screenshot-tests module renders this preview as a golden test.
+@Suppress("PreviewPublic")
+@Preview(showBackground = true, name = "NFC Write Success Alert")
+@Composable
+fun PreviewNfcWriteSuccessAlert() {
+ AppTheme {
+ Box(modifier = Modifier.fillMaxSize()) {
+ MeshtasticDialog(
+ onDismiss = {},
+ titleRes = Res.string.write_nfc,
+ messageRes = Res.string.write_nfc_success,
+ messageColor = SemanticColors.Success,
+ onConfirm = {},
+ confirmTextRes = Res.string.okay,
+ )
+ }
+ }
+}
+
+// Public because the screenshot-tests module renders this preview as a golden test.
+@Suppress("PreviewPublic")
+@Preview(showBackground = true, name = "NFC Write Failed Alert")
+@Composable
+fun PreviewNfcWriteFailedAlert() {
+ AppTheme {
+ Box(modifier = Modifier.fillMaxSize()) {
+ MeshtasticDialog(
+ onDismiss = {},
+ titleRes = Res.string.write_nfc,
+ messageRes = Res.string.write_nfc_failed,
+ messageColor = MaterialTheme.colorScheme.error,
+ onConfirm = {},
+ confirmTextRes = Res.string.okay,
+ )
+ }
+ }
+}
+
@Preview(showBackground = true, name = "Composable Content Alert")
@Composable
fun PreviewComposableAlert() {
diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/core/AlertScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/core/AlertScreenshotTests.kt
index a2f03961ce..c695855d70 100644
--- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/core/AlertScreenshotTests.kt
+++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/core/AlertScreenshotTests.kt
@@ -19,10 +19,13 @@ package org.meshtastic.screenshots.core
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.android.tools.screenshot.PreviewTest
+import org.meshtastic.core.ui.share.PreviewSharedContactImportAlert
import org.meshtastic.core.ui.util.PreviewComposableAlert
import org.meshtastic.core.ui.util.PreviewHtmlAlert
import org.meshtastic.core.ui.util.PreviewIconAlert
import org.meshtastic.core.ui.util.PreviewMultipleChoiceAlert
+import org.meshtastic.core.ui.util.PreviewNfcWriteFailedAlert
+import org.meshtastic.core.ui.util.PreviewNfcWriteSuccessAlert
import org.meshtastic.core.ui.util.PreviewTextAlert
@PreviewTest
@@ -59,3 +62,24 @@ fun ScreenshotMultipleChoiceAlert() {
fun ScreenshotComposableAlert() {
PreviewComposableAlert()
}
+
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotNfcWriteSuccessAlert() {
+ PreviewNfcWriteSuccessAlert()
+}
+
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotNfcWriteFailedAlert() {
+ PreviewNfcWriteFailedAlert()
+}
+
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotSharedContactImportAlert() {
+ PreviewSharedContactImportAlert()
+}
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteFailedAlert_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteFailedAlert_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..de5c550ebc
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteFailedAlert_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteFailedAlert_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteFailedAlert_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..1380e24a6a
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteFailedAlert_Light_b29dc7a7_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteSuccessAlert_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteSuccessAlert_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..9e65639ba5
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteSuccessAlert_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteSuccessAlert_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteSuccessAlert_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..b54f04e9b4
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotNfcWriteSuccessAlert_Light_b29dc7a7_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotSharedContactImportAlert_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotSharedContactImportAlert_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..6d0858dc3a
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotSharedContactImportAlert_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotSharedContactImportAlert_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotSharedContactImportAlert_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..d346c9f715
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/core/AlertScreenshotTestsKt/ScreenshotSharedContactImportAlert_Light_b29dc7a7_0.png differ