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 }