feat(sharing): Refactor QR/NFC scanning with ML Kit and CameraX (#4471)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-05 22:16:16 -06:00 committed by GitHub
parent 3971c0a9f4
commit 96551761c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1455 additions and 464 deletions

View file

@ -14,6 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("detekt:ALL")
package org.meshtastic.feature.node.list
import androidx.compose.animation.core.animateFloatAsState
@ -57,16 +59,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_favorite
import org.meshtastic.core.strings.channel_invalid
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.node_count_template
@ -79,7 +84,9 @@ import org.meshtastic.core.ui.component.AddContactFAB
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
@ -90,10 +97,13 @@ import org.meshtastic.proto.SharedContact
@Composable
fun NodeListScreen(
navigateToNodeDetails: (Int) -> Unit,
onNavigateToChannels: () -> Unit = {},
viewModel: NodeListViewModel = hiltViewModel(),
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
@ -119,6 +129,10 @@ fun NodeListScreen(
val isScrollInProgress by remember {
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
}
val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle()
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelSet() }) }
Scaffold(
topBar = {
MainAppBar(
@ -142,7 +156,10 @@ fun NodeListScreen(
visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable,
alignment = Alignment.BottomEnd,
),
onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
onResult = { uri ->
viewModel.handleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
},
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
)
},
) { contentPadding ->

View file

@ -16,9 +16,12 @@
*/
package org.meshtastic.feature.node.list
import android.net.Uri
import android.os.RemoteException
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -32,9 +35,12 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@ -45,7 +51,7 @@ class NodeListViewModel
constructor(
private val savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
val nodeActions: NodeActions,
val nodeFilterPreferences: NodeFilterPreferences,
@ -62,6 +68,9 @@ constructor(
private val _sharedContactRequested: MutableStateFlow<SharedContact?> = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
private val _requestChannelSet = MutableStateFlow<ChannelSet?>(null)
val requestChannelSet = _requestChannelSet.asStateFlow()
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
@ -155,6 +164,38 @@ constructor(
_sharedContactRequested.value = sharedContact
}
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
if (uri.path?.contains("/v/") == true) {
runCatching { _sharedContactRequested.value = uri.toSharedContact() }
.onFailure { ex ->
Logger.e(ex) { "Shared contact error" }
onInvalid()
}
} else {
runCatching { _requestChannelSet.value = uri.toChannelSet() }
.onFailure { ex ->
Logger.e(ex) { "Channel url error" }
onInvalid()
}
}
}
fun clearRequestChannelSet() {
_requestChannelSet.value = null
}
fun setChannels(channelSet: ChannelSet) = viewModelScope.launch {
radioConfigRepository.replaceAllSettings(channelSet.settings)
val newLoraConfig = channelSet.lora_config
if (newLoraConfig != null) {
try {
serviceRepository.meshService?.setConfig(Config(lora = newLoraConfig).encode())
} catch (ex: RemoteException) {
Logger.e(ex) { "Set config error" }
}
}
}
fun favoriteNode(node: Node) = viewModelScope.launch { nodeActions.favoriteNode(node) }
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }