From c99fe642b572c33a9117d9919d2219f030e809a8 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 May 2025 13:36:11 -0500
Subject: [PATCH] feat: Add Contact Sharing via QR code (#1859)
---
app/src/main/AndroidManifest.xml | 15 +-
.../java/com/geeksville/mesh/model/UIState.kt | 5 +
.../geeksville/mesh/service/MeshService.kt | 10 +
.../com/geeksville/mesh/ui/ContactSharing.kt | 284 ++++++++++++++++++
.../java/com/geeksville/mesh/ui/NodeDetail.kt | 27 +-
.../java/com/geeksville/mesh/ui/NodeItem.kt | 1 +
.../java/com/geeksville/mesh/ui/NodeScreen.kt | 133 +++++---
.../geeksville/mesh/ui/components/NodeMenu.kt | 16 +-
.../com/geeksville/mesh/ui/message/Message.kt | 11 +
.../mesh/ui/message/components/MessageList.kt | 7 +-
app/src/main/res/values/strings.xml | 3 +
11 files changed, 454 insertions(+), 58 deletions(-)
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/ContactSharing.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7fe79c0c8..49905d152 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -173,14 +173,13 @@
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index 138c7adf7..18f8eb732 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -29,6 +29,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
+import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ChannelProtos.ChannelSettings
@@ -449,6 +450,10 @@ class UIViewModel @Inject constructor(
radioConfigRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey))
}
+ fun addSharedContact(sharedContact: AdminProtos.SharedContact) = viewModelScope.launch {
+ radioConfigRepository.onServiceAction(ServiceAction.AddSharedContact(sharedContact))
+ }
+
fun requestTraceroute(destNum: Int) {
info("Requesting traceroute for '$destNum'")
try {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index 3df946dce..888c1f340 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -109,6 +109,7 @@ sealed class ServiceAction {
data class Favorite(val node: Node) : ServiceAction()
data class Ignore(val node: Node) : ServiceAction()
data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
+ data class AddSharedContact(val contact: AdminProtos.SharedContact) : ServiceAction()
}
/**
@@ -1872,9 +1873,18 @@ class MeshService : Service(), Logging {
is ServiceAction.Favorite -> favoriteNode(action.node)
is ServiceAction.Ignore -> ignoreNode(action.node)
is ServiceAction.Reaction -> sendReaction(action)
+ is ServiceAction.AddSharedContact -> importContact(action.contact)
}
}
+ private fun importContact(contact: AdminProtos.SharedContact) {
+ sendToRadio(
+ newMeshPacketTo(myNodeNum).buildAdminPacket {
+ addContact = contact
+ }
+ )
+ }
+
private fun getDeviceMetadata(destNum: Int) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(wantResponse = true) {
getDeviceMetadataRequest = true
diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactSharing.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactSharing.kt
new file mode 100644
index 000000000..3d6b9171d
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/ContactSharing.kt
@@ -0,0 +1,284 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.geeksville.mesh.ui
+
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.util.Base64
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.twotone.QrCodeScanner
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import com.geeksville.mesh.AdminProtos
+import com.geeksville.mesh.R
+import com.geeksville.mesh.android.BuildUtils.debug
+import com.geeksville.mesh.android.BuildUtils.errormsg
+import com.geeksville.mesh.android.getCameraPermissions
+import com.geeksville.mesh.model.DeviceVersion
+import com.geeksville.mesh.model.Node
+import com.geeksville.mesh.ui.components.CopyIconButton
+import com.geeksville.mesh.ui.components.SimpleAlertDialog
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.MultiFormatWriter
+import com.google.zxing.WriterException
+import com.journeyapps.barcodescanner.BarcodeEncoder
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
+import java.net.MalformedURLException
+
+@RequiresApi(Build.VERSION_CODES.M)
+@Suppress("LongMethod", "CyclomaticComplexMethod")
+@Composable
+fun AddContactFAB(
+ modifier: Modifier = Modifier,
+ onSharedContactImport: (AdminProtos.SharedContact) -> Unit = {},
+) {
+ val context = LocalContext.current
+ var contactToImport: AdminProtos.SharedContact? by remember { mutableStateOf(null) }
+
+ val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
+ if (result.contents != null) {
+ val uri = result.contents.toUri()
+ val sharedContact = try {
+ uri.toSharedContact()
+ } catch (ex: MalformedURLException) {
+ errormsg("URL was malformed: ${ex.message}")
+ null
+ }
+ if (sharedContact != null) {
+ contactToImport = sharedContact
+ }
+ }
+ }
+
+ if (contactToImport != null) {
+ SimpleAlertDialog(
+ title = R.string.import_shared_contact,
+ text = {
+ Text("$contactToImport")
+ },
+ onDismiss = {
+ contactToImport = null
+ },
+ onConfirm = {
+ onSharedContactImport(contactToImport!!)
+ contactToImport = null
+ }
+ )
+ }
+
+ fun zxingScan() {
+ debug("Starting zxing QR code scanner")
+ val zxingScan = ScanOptions()
+ zxingScan.setCameraId(CAMERA_ID)
+ zxingScan.setPrompt("")
+ zxingScan.setBeepEnabled(false)
+ zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ barcodeLauncher.launch(zxingScan)
+ }
+
+ val requestPermissionAndScanLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+ if (permissions.entries.all { it.value }) zxingScan()
+ }
+
+ var showPermissionRationale by remember { mutableStateOf(false) }
+ if (showPermissionRationale) {
+ SimpleAlertDialog(
+ title = R.string.camera_required,
+ text = R.string.why_camera_required,
+ onDismiss = {
+ debug("Camera permission denied")
+ showPermissionRationale = false
+ },
+ onConfirm = {
+ requestPermissionAndScanLauncher.launch(context.getCameraPermissions())
+ showPermissionRationale = false
+ }
+ )
+ }
+ fun requestPermissionAndScan() {
+ showPermissionRationale = true
+ }
+
+ FloatingActionButton(
+ onClick = {
+ if (context.getCameraPermissions().all {
+ context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
+ }
+ ) {
+ zxingScan()
+ } else {
+ requestPermissionAndScan()
+ }
+ },
+ modifier = modifier.padding(16.dp)
+ ) {
+ Icon(
+ imageVector = Icons.TwoTone.QrCodeScanner,
+ contentDescription = stringResource(R.string.scan_qr_code),
+ )
+ }
+}
+
+@Composable
+private fun QrCodeImage(
+ uri: Uri,
+ modifier: Modifier = Modifier,
+) = Image(
+ painter = uri.qrCode
+ ?.let { BitmapPainter(it.asImageBitmap()) }
+ ?: painterResource(id = R.drawable.qrcode),
+ contentDescription = stringResource(R.string.qr_code),
+ modifier = modifier,
+ contentScale = ContentScale.Inside,
+)
+
+@Composable
+private fun SharedContact(
+ contactUri: Uri,
+) {
+ Column {
+ QrCodeImage(
+ uri = contactUri,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp),
+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
+ ) {
+ Text(
+ text = contactUri.toString(),
+ modifier = Modifier
+ .weight(1f)
+ )
+ CopyIconButton(
+ valueToCopy = contactUri.toString(),
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ }
+}
+
+@Composable
+fun SharedContactDialog(
+ contact: Node?,
+ onDismiss: () -> Unit,
+) {
+ if (contact == null) return
+ val sharedContact =
+ AdminProtos.SharedContact.newBuilder().setUser(contact.user).setNodeNum(contact.num).build()
+ val uri = sharedContact.getSharedContactUrl()
+ SimpleAlertDialog(
+ title = R.string.share_contact,
+ text = {
+ Column {
+ Text(contact.user.longName)
+ SharedContact(
+ contactUri = uri,
+ )
+ }
+ },
+ onDismiss = onDismiss
+ )
+}
+
+@Preview
+@Composable
+private fun ShareContactPreview() {
+ SharedContact(
+ contactUri = "https://example.com".toUri(),
+ )
+}
+
+val Uri.qrCode: Bitmap?
+ get() = try {
+ val multiFormatWriter = MultiFormatWriter()
+ val bitMatrix =
+ multiFormatWriter.encode(
+ this.toString(),
+ BarcodeFormat.QR_CODE,
+ BARCODE_PIXEL_SIZE,
+ BARCODE_PIXEL_SIZE
+ )
+ val barcodeEncoder = BarcodeEncoder()
+ barcodeEncoder.createBitmap(bitMatrix)
+ } catch (ex: WriterException) {
+ errormsg("URL was too complex to render as barcode: ${ex.message}")
+ null
+ }
+
+private const val REQUIRED_MIN_FIRMWARE = "2.6.8"
+private const val BARCODE_PIXEL_SIZE = 960
+private const val MESHTASTIC_HOST = "meshtastic.org"
+private const val CONTACT_SHARE_PATH = "/v/"
+internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
+private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
+private const val CAMERA_ID = 0
+
+fun DeviceVersion.supportsQrCodeSharing(): Boolean =
+ this >= DeviceVersion(REQUIRED_MIN_FIRMWARE)
+
+@Suppress("MagicNumber")
+@Throws(MalformedURLException::class)
+fun Uri.toSharedContact(): AdminProtos.SharedContact {
+ if (fragment.isNullOrBlank() ||
+ !host.equals(MESHTASTIC_HOST, true) ||
+ !path.equals(CONTACT_SHARE_PATH, true)
+ ) {
+ throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
+ }
+ val url = AdminProtos.SharedContact.parseFrom(Base64.decode(fragment!!, BASE64FLAGS))
+ return url.toBuilder().build()
+ }
+
+fun AdminProtos.SharedContact.getSharedContactUrl(): Uri {
+ val bytes = this.toByteArray() ?: ByteArray(0)
+ val enc = Base64.encodeToString(bytes, BASE64FLAGS)
+ return "$URL_PREFIX$enc".toUri()
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt
index 619c7160d..8f2afcd2f 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt
@@ -53,6 +53,7 @@ import androidx.compose.material.icons.filled.Route
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Scale
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SignalCellularAlt
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Thermostat
@@ -67,7 +68,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -144,12 +147,21 @@ fun NodeDetailScreen(
if (state.node != null) {
val node = state.node ?: return
uiViewModel.setTitle(node.user.longName)
+ var share by remember { mutableStateOf(false) }
+ if (share) {
+ SharedContactDialog(node) {
+ share = false
+ }
+ }
NodeDetailList(
node = node,
metricsState = state,
onNavigate = onNavigate,
modifier = modifier,
- metricsAvailability = availabilities
+ metricsAvailability = availabilities,
+ onShared = {
+ share = true
+ }
)
} else {
Box(
@@ -161,13 +173,15 @@ fun NodeDetailScreen(
}
}
+@Suppress("LongMethod")
@Composable
private fun NodeDetailList(
modifier: Modifier = Modifier,
node: Node,
metricsState: MetricsState,
onNavigate: (Route) -> Unit = {},
- metricsAvailability: BooleanArray
+ metricsAvailability: BooleanArray,
+ onShared: () -> Unit = {}
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
@@ -186,6 +200,15 @@ private fun NodeDetailList(
}
}
+ item {
+ NavCard(
+ title = stringResource(id = R.string.share_contact),
+ icon = Icons.Default.Share,
+ enabled = true,
+ onClick = onShared
+ )
+ }
+
if (node.hasEnvironmentMetrics) {
item {
PreferenceCategory(stringResource(R.string.environment))
diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt
index 801a2245b..c056c8290 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt
@@ -160,6 +160,7 @@ fun NodeItem(
onAction = onAction,
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
+ firmwareVersion = thisNode?.metadata?.firmwareVersion
)
}
NodeKeyStatusIcon(
diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt
index 1a1fcf684..90699a5f2 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt
@@ -17,9 +17,12 @@
package com.geeksville.mesh.ui
+import android.os.Build
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -29,18 +32,23 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.DataPacket
+import com.geeksville.mesh.model.DeviceVersion
+import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.components.NodeFilterTextField
import com.geeksville.mesh.ui.components.NodeMenuAction
import com.geeksville.mesh.ui.components.rememberTimeTickWithLifecycle
@OptIn(ExperimentalFoundationApi::class)
-@Suppress("LongMethod")
+@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NodeScreen(
model: UIViewModel = hiltViewModel(),
@@ -57,54 +65,87 @@ fun NodeScreen(
val currentTimeMillis = rememberTimeTickWithLifecycle()
val connectionState by model.connectionState.collectAsStateWithLifecycle()
- LazyColumn(
- state = listState,
- modifier = Modifier.fillMaxSize(),
+ var showSharedContact: Node? by remember { mutableStateOf(null) }
+ if (showSharedContact != null) {
+ SharedContactDialog(
+ contact = showSharedContact,
+ onDismiss = { showSharedContact = null }
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
) {
- stickyHeader {
- NodeFilterTextField(
- modifier = Modifier
- .fillMaxWidth()
- .background(MaterialTheme.colorScheme.background)
- .padding(8.dp),
- filterText = state.filter,
- onTextChange = model::setNodeFilterText,
- currentSortOption = state.sort,
- onSortSelect = model::setSortOption,
- includeUnknown = state.includeUnknown,
- onToggleIncludeUnknown = model::toggleIncludeUnknown,
- showDetails = state.showDetails,
- onToggleShowDetails = model::toggleShowDetails,
- )
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ stickyHeader {
+ NodeFilterTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(8.dp),
+ filterText = state.filter,
+ onTextChange = model::setNodeFilterText,
+ currentSortOption = state.sort,
+ onSortSelect = model::setSortOption,
+ includeUnknown = state.includeUnknown,
+ onToggleIncludeUnknown = model::toggleIncludeUnknown,
+ showDetails = state.showDetails,
+ onToggleShowDetails = model::toggleShowDetails,
+ )
+ }
+
+ items(nodes, key = { it.num }) { node ->
+ NodeItem(
+ modifier = Modifier.animateContentSize(),
+ thisNode = ourNode,
+ thatNode = node,
+ gpsFormat = state.gpsFormat,
+ distanceUnits = state.distanceUnits,
+ tempInFahrenheit = state.tempInFahrenheit,
+ onAction = { menuItem ->
+ when (menuItem) {
+ is NodeMenuAction.Remove -> model.removeNode(node.num)
+ is NodeMenuAction.Ignore -> model.ignoreNode(node)
+ is NodeMenuAction.Favorite -> model.favoriteNode(node)
+ is NodeMenuAction.DirectMessage -> {
+ val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC
+ val channel =
+ if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
+ navigateToMessages("$channel${node.user.id}")
+ }
+
+ is NodeMenuAction.RequestUserInfo -> model.requestUserInfo(node.num)
+ is NodeMenuAction.RequestPosition -> model.requestPosition(node.num)
+ is NodeMenuAction.TraceRoute -> model.requestTraceroute(node.num)
+ is NodeMenuAction.MoreDetails -> navigateToNodeDetails(node.num)
+ is NodeMenuAction.Share -> showSharedContact = node
+ }
+ },
+ expanded = state.showDetails,
+ currentTimeMillis = currentTimeMillis,
+ isConnected = connectionState.isConnected(),
+ )
+ }
}
- items(nodes, key = { it.num }) { node ->
- NodeItem(
- modifier = Modifier.animateContentSize(),
- thisNode = ourNode,
- thatNode = node,
- gpsFormat = state.gpsFormat,
- distanceUnits = state.distanceUnits,
- tempInFahrenheit = state.tempInFahrenheit,
- onAction = { menuItem ->
- when (menuItem) {
- is NodeMenuAction.Remove -> model.removeNode(node.num)
- is NodeMenuAction.Ignore -> model.ignoreNode(node)
- is NodeMenuAction.Favorite -> model.favoriteNode(node)
- is NodeMenuAction.DirectMessage -> {
- val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC
- val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
- navigateToMessages("$channel${node.user.id}")
- }
- is NodeMenuAction.RequestUserInfo -> model.requestUserInfo(node.num)
- is NodeMenuAction.RequestPosition -> model.requestPosition(node.num)
- is NodeMenuAction.TraceRoute -> model.requestTraceroute(node.num)
- is NodeMenuAction.MoreDetails -> navigateToNodeDetails(node.num)
- }
- },
- expanded = state.showDetails,
- currentTimeMillis = currentTimeMillis,
- isConnected = connectionState.isConnected(),
+ val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
+ val shareCapable = firmwareVersion.supportsQrCodeSharing()
+
+ AnimatedVisibility(
+ modifier = Modifier.align(androidx.compose.ui.Alignment.BottomEnd),
+ visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
+ !listState.isScrollInProgress &&
+ shareCapable
+ ) {
+ @Suppress("NewApi")
+ AddContactFAB(
+ onSharedContactImport = { contact ->
+ model.addSharedContact(contact)
+ }
)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt
index 8e25b1e35..11191112a 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt
@@ -35,7 +35,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
+import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.Node
+import com.geeksville.mesh.ui.supportsQrCodeSharing
@Suppress("LongMethod")
@Composable
@@ -44,7 +46,8 @@ fun NodeMenu(
showFullMenu: Boolean = false,
onDismissRequest: () -> Unit,
expanded: Boolean = false,
- onAction: (NodeMenuAction) -> Unit
+ onAction: (NodeMenuAction) -> Unit,
+ firmwareVersion: String? = null,
) {
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
@@ -179,6 +182,15 @@ fun NodeMenu(
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
}
+ val firmware = DeviceVersion(firmwareVersion ?: "0.0.0")
+ if (firmware.supportsQrCodeSharing()) {
+ DropdownMenuItem(
+ onClick = {
+ onDismissRequest()
+ onAction(NodeMenuAction.Share(node))
+ },
+ text = { Text(stringResource(R.string.share_contact)) }
+ )
DropdownMenuItem(
onClick = {
onDismissRequest()
@@ -186,6 +198,7 @@ fun NodeMenu(
},
text = { Text(stringResource(R.string.more_details)) }
)
+ }
}
}
@@ -198,4 +211,5 @@ sealed class NodeMenuAction {
data class RequestPosition(val node: Node) : NodeMenuAction()
data class TraceRoute(val node: Node) : NodeMenuAction()
data class MoreDetails(val node: Node) : NodeMenuAction()
+ data class Share(val node: Node) : NodeMenuAction()
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt
index 03d598120..4626b8fa3 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt
@@ -74,8 +74,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.QuickChatAction
+import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getChannel
+import com.geeksville.mesh.ui.SharedContactDialog
import com.geeksville.mesh.ui.components.NodeKeyStatusIcon
import com.geeksville.mesh.ui.components.NodeMenuAction
import com.geeksville.mesh.ui.message.components.MessageList
@@ -203,6 +205,14 @@ internal fun MessageScreen(
}
) { padding ->
if (messages.isNotEmpty()) {
+ var sharedContact: Node? by remember { mutableStateOf(null) }
+ if (sharedContact != null) {
+ SharedContactDialog(
+ contact = sharedContact,
+ onDismiss = { sharedContact = null }
+ )
+ }
+
MessageList(
modifier = Modifier.padding(padding),
messages = messages,
@@ -228,6 +238,7 @@ internal fun MessageScreen(
is NodeMenuAction.RequestPosition -> viewModel.requestPosition(action.node.num)
is NodeMenuAction.TraceRoute -> viewModel.requestTraceroute(action.node.num)
is NodeMenuAction.MoreDetails -> navigateToNodeDetails(action.node.num)
+ is NodeMenuAction.Share -> sharedContact = action.node
}
}
)
diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt
index 4d3ca5b6f..dbf736d56 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt
@@ -35,6 +35,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -155,6 +156,9 @@ internal fun MessageList(
value += uuid
}
+ val ourNode by viewModel.ourNodeInfo.collectAsState()
+ val firmwareVersion = ourNode?.metadata?.firmwareVersion
+
LazyColumn(
modifier = modifier.fillMaxSize(),
state = listState,
@@ -191,7 +195,8 @@ internal fun MessageList(
showFullMenu = true,
onDismissRequest = { expandedNodeMenu = false },
expanded = expandedNodeMenu,
- onAction = onNodeMenuAction
+ onAction = onNodeMenuAction,
+ firmwareVersion = firmwareVersion
)
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c66e078fd..4609c1c3f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -610,4 +610,7 @@
Set Region
Unmute
Dynamic
+ Scan QR Code
+ Share Contact
+ Import Shared Contact?