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?