Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -1826,11 +1826,14 @@
<string name="wind_gust">Wind Gust</string>
<string name="wind_lull">Wind Lull</string>
<string name="wind_speed">Wind Speed</string>
<!-- WRITE -->
<string name="write_nfc">Write to NFC tag</string>
<string name="write_nfc_failed">Couldn\'t write to tag. Use a writable, empty NFC tag and hold it steady.</string>
<string name="write_nfc_failed">Couldn't write to tag. Use a writable, empty NFC tag and hold it steady.</string>
<string name="write_nfc_subtext">Writing saves this share link to a physical NFC tag. Anyone can tap the tag with their phone to open it.</string>
<string name="write_nfc_success">Written to tag</string>
<string name="write_nfc_text">Hold a writable NFC tag against the back of your phone.</string>
<string name="you">You</string>
<string name="zh_CN" translatable="false">简体中文</string>
<string name="zh_TW" translatable="false">繁體中文</string>
</resources>

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
}
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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) {
Expand All @@ -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 = {},
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.