mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Add Contact Sharing via QR code (#1859)
This commit is contained in:
parent
38b9515fca
commit
c99fe642b5
11 changed files with 454 additions and 58 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
284
app/src/main/java/com/geeksville/mesh/ui/ContactSharing.kt
Normal file
284
app/src/main/java/com/geeksville/mesh/ui/ContactSharing.kt
Normal 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()
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ fun NodeItem(
|
|||
onAction = onAction,
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
firmwareVersion = thisNode?.metadata?.firmwareVersion
|
||||
)
|
||||
}
|
||||
NodeKeyStatusIcon(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue