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