feat: Add Contact Sharing via QR code (#1859)

This commit is contained in:
James Rich 2025-05-20 13:36:11 -05:00 committed by GitHub
parent 38b9515fca
commit c99fe642b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 454 additions and 58 deletions

View file

@ -173,14 +173,13 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="meshtastic.org"
android:pathPrefix="/e/" />
<data
android:scheme="https"
android:host="meshtastic.org"
android:pathPrefix="/E/" />
<data android:scheme="https" />
<data android:scheme="http" />
<data android:host="meshtastic.org" />
<data android:pathPrefix="/e/" />
<data android:pathPrefix="/E/" />
<data android:pathPrefix="/v/" />
<data android:pathPrefix="/V/" />
</intent-filter>
<intent-filter>

View file

@ -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 {

View file

@ -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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}

View file

@ -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<Boolean>(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))

View file

@ -160,6 +160,7 @@ fun NodeItem(
onAction = onAction,
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
firmwareVersion = thisNode?.metadata?.firmwareVersion
)
}
NodeKeyStatusIcon(

View file

@ -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)
}
)
}
}

View file

@ -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()
}

View file

@ -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
}
}
)

View file

@ -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
)
}
}

View file

@ -610,4 +610,7 @@
<string name="set_region">Set Region</string>
<string name="unmute">Unmute</string>
<string name="dynamic">Dynamic</string>
<string name="scan_qr_code">Scan QR Code</string>
<string name="share_contact">Share Contact</string>
<string name="import_shared_contact">Import Shared Contact?</string>
</resources>