From 5d95dca354dc12c952de12c8c80b049ea95dd9ac Mon Sep 17 00:00:00 2001
From: Phil Oliver <3497406+poliver@users.noreply.github.com>
Date: Fri, 3 Oct 2025 06:42:52 -0400
Subject: [PATCH] Fix shared contact deeplink (#3302)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/detekt-baseline.xml | 2 +
.../java/com/geeksville/mesh/MainActivity.kt | 6 +-
.../java/com/geeksville/mesh/model/UIState.kt | 14 +++-
.../main/java/com/geeksville/mesh/ui/Main.kt | 6 ++
.../geeksville/mesh/ui/node/NodeListScreen.kt | 6 +-
.../mesh/ui/sharing/ContactSharing.kt | 41 +----------
.../mesh/ui/sharing/SharedContactDialog.kt | 72 +++++++++++++++++++
.../mesh/ui/sharing/SharedContactViewModel.kt | 53 ++++++++++++++
core/strings/src/main/res/values/strings.xml | 1 +
feature/node/detekt-baseline.xml | 1 -
.../feature/node/list/NodeListViewModel.kt | 3 -
11 files changed, 153 insertions(+), 52 deletions(-)
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/sharing/SharedContactDialog.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/sharing/SharedContactViewModel.kt
diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 4af55d5f5..571bc53f6 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -66,6 +66,7 @@
FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt
ForbiddenComment:SafeBluetooth.kt$SafeBluetooth$// TODO: display some kind of UI about restarting BLE
LambdaParameterEventTrailing:Channel.kt$onConfirm
+ LambdaParameterEventTrailing:ContactSharing.kt$onSharedContactRequested
LambdaParameterEventTrailing:Message.kt$onClick
LambdaParameterEventTrailing:Message.kt$onSendMessage
LambdaParameterEventTrailing:MessageList.kt$onReply
@@ -178,6 +179,7 @@
ModifierMissing:SecurityConfigItemList.kt$SecurityConfigScreen
ModifierMissing:SettingsScreen.kt$SettingsScreen
ModifierMissing:Share.kt$ShareScreen
+ ModifierMissing:SharedContactDialog.kt$SharedContactDialog
ModifierMissing:SignalMetrics.kt$SignalMetricsScreen
ModifierMissing:TopLevelNavIcon.kt$TopLevelNavIcon
ModifierNotUsedAtRoot:DeviceMetrics.kt$modifier = modifier.weight(weight = Y_AXIS_WEIGHT)
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index c1ffaaed5..2e32a4a20 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -42,7 +42,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.MainScreen
import com.geeksville.mesh.ui.intro.AppIntroductionScreen
-import com.geeksville.mesh.ui.sharing.toSharedContact
import dagger.hilt.android.AndroidEntryPoint
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
@@ -118,9 +117,8 @@ class MainActivity : AppCompatActivity() {
Timber.d("App link data is a channel set")
model.requestChannelUrl(it)
} else if (it.path?.startsWith("/v/") == true || it.path?.startsWith("/V/") == true) {
- val sharedContact = it.toSharedContact()
- Timber.d("App link data is a shared contact: ${sharedContact.user.longName}")
- model.setSharedContactRequested(sharedContact)
+ Timber.d("App link data is a shared contact")
+ model.setSharedContactRequested(it)
} else {
Timber.d("App link data is not a channel set")
}
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 e15419249..85751f9ff 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -43,6 +43,7 @@ import com.geeksville.mesh.copy
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshServiceNotifications
+import com.geeksville.mesh.ui.sharing.toSharedContact
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -297,8 +298,17 @@ constructor(
val sharedContactRequested: StateFlow
get() = _sharedContactRequested.asStateFlow()
- fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
- _sharedContactRequested.value = sharedContact
+ fun setSharedContactRequested(url: Uri) {
+ runCatching { _sharedContactRequested.value = url.toSharedContact() }
+ .onFailure { ex ->
+ Timber.e(ex, "Shared contact error")
+ showSnackBar(R.string.contact_invalid)
+ }
+ }
+
+ /** Called immediately after activity observes requestChannelUrl */
+ fun clearSharedContactRequested() {
+ _sharedContactRequested.value = null
}
// Connection state to our radio device
diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
index 30fe3b5a7..fc2ce0701 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -91,6 +91,7 @@ import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
import com.geeksville.mesh.ui.connections.DeviceType
import com.geeksville.mesh.ui.connections.components.TopLevelNavIcon
import com.geeksville.mesh.ui.metrics.annotateTraceroute
+import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@@ -139,6 +140,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
val navController = rememberNavController()
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
+ val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
@@ -150,6 +152,10 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
}
if (connectionState == ConnectionState.CONNECTED) {
+ sharedContactRequested?.let {
+ SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() })
+ }
+
requestChannelSet?.let { newChannelSet ->
ScannedQrCodeDialog(newChannelSet, onDismiss = { uIViewModel.clearRequestChannelUrl() })
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeListScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeListScreen.kt
index 7167d9750..ace46d5dd 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeListScreen.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeListScreen.kt
@@ -109,17 +109,15 @@ fun NodeListScreen(viewModel: NodeListViewModel = hiltViewModel(), navigateToNod
floatingActionButton = {
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
val shareCapable = firmwareVersion.supportsQrCodeSharing()
- val scannedContact: AdminProtos.SharedContact? by
+ val sharedContact: AdminProtos.SharedContact? by
viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
AddContactFAB(
- unfilteredNodes = unfilteredNodes,
- scannedContact = scannedContact,
+ sharedContact = sharedContact,
modifier =
Modifier.animateFloatingActionButton(
visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable,
alignment = Alignment.BottomEnd,
),
- onSharedContactImport = { contact -> viewModel.addSharedContact(contact) },
onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
)
},
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt
index 8d9c8eae0..5a1550d3d 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt
@@ -30,9 +30,7 @@ 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.HorizontalDivider
import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -72,17 +70,14 @@ import java.net.MalformedURLException
* requests using Accompanist Permissions.
*
* @param modifier Modifier for this composable.
- * @param onSharedContactImport Callback invoked when a shared contact is successfully imported.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun AddContactFAB(
- unfilteredNodes: List,
- scannedContact: AdminProtos.SharedContact?,
+ sharedContact: AdminProtos.SharedContact?,
modifier: Modifier = Modifier,
- onSharedContactImport: (AdminProtos.SharedContact) -> Unit = {},
- onSharedContactRequested: (AdminProtos.SharedContact?) -> Unit = {},
+ onSharedContactRequested: (AdminProtos.SharedContact?) -> Unit,
) {
val barcodeLauncher =
rememberLauncherForActivityResult(ScanContract()) { result ->
@@ -101,37 +96,7 @@ fun AddContactFAB(
}
}
- scannedContact?.let { contactToImport ->
- val nodeNum = contactToImport.nodeNum
- val node = unfilteredNodes.find { it.num == nodeNum }
- SimpleAlertDialog(
- title = R.string.import_shared_contact,
- text = {
- Column {
- if (node != null) {
- Text(text = stringResource(R.string.import_known_shared_contact_text))
- if (node.user.publicKey.size() > 0 && node.user.publicKey != contactToImport.user?.publicKey) {
- Text(
- text = stringResource(R.string.public_key_changed),
- color = MaterialTheme.colorScheme.error,
- )
- }
- HorizontalDivider()
- Text(text = compareUsers(node.user, contactToImport.user))
- } else {
- Text(text = userFieldsToString(contactToImport.user))
- }
- }
- },
- dismissText = stringResource(R.string.cancel),
- onDismiss = { onSharedContactRequested(null) },
- confirmText = stringResource(R.string.import_label),
- onConfirm = {
- onSharedContactImport(contactToImport)
- onSharedContactRequested(null)
- },
- )
- }
+ sharedContact?.let { SharedContactDialog(sharedContact = it, onDismiss = { onSharedContactRequested(null) }) }
fun zxingScan() {
Timber.d("Starting zxing QR code scanner")
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/SharedContactDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/SharedContactDialog.kt
new file mode 100644
index 000000000..8eabdd3ae
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/SharedContactDialog.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.sharing
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.geeksville.mesh.AdminProtos
+import org.meshtastic.core.strings.R
+import org.meshtastic.core.ui.component.SimpleAlertDialog
+
+/** A dialog for importing a shared contact that was scanned from a QR code. */
+@Composable
+fun SharedContactDialog(
+ sharedContact: AdminProtos.SharedContact,
+ onDismiss: () -> Unit,
+ viewModel: SharedContactViewModel = hiltViewModel(),
+) {
+ val unfilteredNodes by viewModel.unfilteredNodes.collectAsStateWithLifecycle()
+
+ val nodeNum = sharedContact.nodeNum
+ val node = unfilteredNodes.find { it.num == nodeNum }
+
+ SimpleAlertDialog(
+ title = R.string.import_shared_contact,
+ text = {
+ Column {
+ if (node != null) {
+ Text(text = stringResource(R.string.import_known_shared_contact_text))
+ if (node.user.publicKey.size() > 0 && node.user.publicKey != sharedContact.user?.publicKey) {
+ Text(
+ text = stringResource(R.string.public_key_changed),
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ HorizontalDivider()
+ Text(text = compareUsers(node.user, sharedContact.user))
+ } else {
+ Text(text = userFieldsToString(sharedContact.user))
+ }
+ }
+ },
+ dismissText = stringResource(R.string.cancel),
+ onDismiss = onDismiss,
+ confirmText = stringResource(R.string.import_label),
+ onConfirm = {
+ viewModel.addSharedContact(sharedContact)
+ onDismiss()
+ },
+ )
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/SharedContactViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/SharedContactViewModel.kt
new file mode 100644
index 000000000..3d0c17c75
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/SharedContactViewModel.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.sharing
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.geeksville.mesh.AdminProtos
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.service.ServiceAction
+import org.meshtastic.core.service.ServiceRepository
+import javax.inject.Inject
+
+@HiltViewModel
+class SharedContactViewModel
+@Inject
+constructor(
+ nodeRepository: NodeRepository,
+ private val serviceRepository: ServiceRepository,
+) : ViewModel() {
+
+ val unfilteredNodes: StateFlow> =
+ nodeRepository
+ .getNodes()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = emptyList(),
+ )
+
+ fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
+ viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.ImportContact(sharedContact)) }
+}
diff --git a/core/strings/src/main/res/values/strings.xml b/core/strings/src/main/res/values/strings.xml
index a231f5a4d..09a95805a 100644
--- a/core/strings/src/main/res/values/strings.xml
+++ b/core/strings/src/main/res/values/strings.xml
@@ -214,6 +214,7 @@
Service notifications
About
This Channel URL is invalid and can not be used
+ This contact is invalid and can not be added
Debug Panel
Decoded Payload:
Export Logs
diff --git a/feature/node/detekt-baseline.xml b/feature/node/detekt-baseline.xml
index ac9bafa3a..0d1144994 100644
--- a/feature/node/detekt-baseline.xml
+++ b/feature/node/detekt-baseline.xml
@@ -14,6 +14,5 @@
ParameterNaming:NodeFilterTextField.kt$onToggleShowIgnored
PreviewPublic:NodeItem.kt$NodeInfoPreview
PreviewPublic:NodeItem.kt$NodeInfoSimplePreview
- TooManyFunctions:NodeListViewModel.kt$NodeListViewModel : ViewModel
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
index d43a5d1c9..e7cc0657b 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
@@ -161,9 +161,6 @@ constructor(
uiPreferencesDataSource.setNodeSort(sort.ordinal)
}
- fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
- viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.ImportContact(sharedContact)) }
-
fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
_sharedContactRequested.value = sharedContact
}