mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor: organize ui screens to separate packages (#1982)
This commit is contained in:
parent
32d9f29d7e
commit
ad1897c564
108 changed files with 475 additions and 569 deletions
575
app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
Normal file
575
app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
/*
|
||||
* 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.sharing
|
||||
|
||||
import android.content.ClipData
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Check
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.ContentCopy
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
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.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
|
||||
import com.geeksville.mesh.ChannelProtos
|
||||
import com.geeksville.mesh.ConfigProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.analytics.DataPair
|
||||
import com.geeksville.mesh.android.BuildUtils.debug
|
||||
import com.geeksville.mesh.android.BuildUtils.errormsg
|
||||
import com.geeksville.mesh.android.GeeksvilleApplication
|
||||
import com.geeksville.mesh.android.getCameraPermissions
|
||||
import com.geeksville.mesh.android.hasCameraPermission
|
||||
import com.geeksville.mesh.channelSet
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.ChannelOption
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.model.getChannelUrl
|
||||
import com.geeksville.mesh.model.qrCode
|
||||
import com.geeksville.mesh.model.toChannelSet
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.common.components.AdaptiveTwoPane
|
||||
import com.geeksville.mesh.ui.common.components.DropDownPreference
|
||||
import com.geeksville.mesh.ui.common.components.PreferenceFooter
|
||||
import com.geeksville.mesh.ui.common.components.dragContainer
|
||||
import com.geeksville.mesh.ui.common.components.dragDropItemsIndexed
|
||||
import com.geeksville.mesh.ui.common.components.rememberDragDropState
|
||||
import com.geeksville.mesh.ui.radioconfig.components.ChannelCard
|
||||
import com.geeksville.mesh.ui.radioconfig.components.ChannelSelection
|
||||
import com.geeksville.mesh.ui.radioconfig.components.EditChannelDialog
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun ChannelScreen(
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val enabled = connectionState == MeshService.ConnectionState.CONNECTED && !viewModel.isManaged
|
||||
|
||||
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||
var channelSet by remember(channels) { mutableStateOf(channels) }
|
||||
var showChannelEditor by rememberSaveable { mutableStateOf(false) }
|
||||
var showSendDialog by remember { mutableStateOf(false) }
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
var showScanDialog by remember { mutableStateOf(false) }
|
||||
val isEditing = channelSet != channels || showChannelEditor
|
||||
|
||||
/* Holds selections made by the user for QR generation. */
|
||||
val channelSelections = rememberSaveable(
|
||||
saver = listSaver(
|
||||
save = { it.toList() },
|
||||
restore = { it.toMutableStateList() }
|
||||
)
|
||||
) { mutableStateListOf(elements = Array(size = 8, init = { true })) }
|
||||
|
||||
val selectedChannelSet = channelSet.copy {
|
||||
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
|
||||
settings.clear()
|
||||
settings.addAll(result)
|
||||
}
|
||||
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
|
||||
|
||||
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
viewModel.requestChannelUrl(result.contents.toUri())
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSettingsList(update: MutableList<ChannelProtos.ChannelSettings>.() -> Unit) {
|
||||
try {
|
||||
val list = channelSet.settingsList.toMutableList()
|
||||
list.update()
|
||||
channelSet = channelSet.copy {
|
||||
settings.clear()
|
||||
settings.addAll(list)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
errormsg("Error updating ChannelSettings list:", ex)
|
||||
}
|
||||
}
|
||||
|
||||
fun zxingScan() {
|
||||
debug("Starting zxing QR code scanner")
|
||||
val zxingScan = ScanOptions()
|
||||
zxingScan.setCameraId(0)
|
||||
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()
|
||||
}
|
||||
|
||||
if (showScanDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
debug("Camera permission denied")
|
||||
showScanDialog = false
|
||||
},
|
||||
title = { Text(text = stringResource(id = R.string.camera_required)) },
|
||||
text = { Text(text = stringResource(id = R.string.why_camera_required)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { requestPermissionAndScanLauncher.launch(context.getCameraPermissions()) }) {
|
||||
Text(text = stringResource(id = R.string.accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { debug("Camera permission denied") }) {
|
||||
Text(text = stringResource(id = R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Send new channel settings to the device
|
||||
fun installSettings(newChannelSet: ChannelSet) {
|
||||
// Try to change the radio, if it fails, tell the user why and throw away their edits
|
||||
try {
|
||||
viewModel.setChannels(newChannelSet)
|
||||
// Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc)
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("ignoring channel problem", ex)
|
||||
|
||||
channelSet = channels // Throw away user edits
|
||||
|
||||
// Tell the user to try again
|
||||
viewModel.showSnackbar(R.string.cant_change_no_radio)
|
||||
} finally {
|
||||
showChannelEditor = false
|
||||
}
|
||||
}
|
||||
|
||||
fun installSettings(
|
||||
newChannel: ChannelProtos.ChannelSettings,
|
||||
newLoRaConfig: ConfigProtos.Config.LoRaConfig
|
||||
) {
|
||||
val newSet = channelSet {
|
||||
settings.add(newChannel)
|
||||
loraConfig = newLoRaConfig
|
||||
}
|
||||
installSettings(newSet)
|
||||
}
|
||||
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
channelSet = channels // throw away any edits
|
||||
showResetDialog = false
|
||||
},
|
||||
title = { Text(text = stringResource(id = R.string.reset_to_defaults)) },
|
||||
text = { Text(text = stringResource(id = R.string.are_you_sure_change_default)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
debug("Switching back to default channel")
|
||||
installSettings(
|
||||
Channel.default.settings,
|
||||
Channel.default.loraConfig.copy {
|
||||
region = viewModel.region
|
||||
txEnabled = viewModel.txEnabled
|
||||
}
|
||||
)
|
||||
showResetDialog = false
|
||||
}) { Text(text = stringResource(id = R.string.apply)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
channelSet = channels // throw away any edits
|
||||
showResetDialog = false
|
||||
}) { Text(text = stringResource(id = R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSendDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showSendDialog = false
|
||||
showChannelEditor = false
|
||||
channelSet = channels
|
||||
},
|
||||
title = { Text(text = stringResource(id = R.string.change_channel)) },
|
||||
text = { Text(text = stringResource(id = R.string.are_you_sure_channel)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
installSettings(channelSet)
|
||||
showSendDialog = false
|
||||
}) { Text(text = stringResource(id = R.string.accept)) }
|
||||
installSettings(channelSet)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
|
||||
|
||||
if (showEditChannelDialog != null) {
|
||||
val index = showEditChannelDialog ?: return
|
||||
EditChannelDialog(
|
||||
channelSettings = with(channelSet) {
|
||||
if (settingsCount > index) getSettings(index) else channelSettings { }
|
||||
},
|
||||
modemPresetName = modemPresetName,
|
||||
onAddClick = {
|
||||
with(channelSet) {
|
||||
if (settingsCount > index) {
|
||||
channelSet = copy { settings[index] = it }
|
||||
} else {
|
||||
channelSet = copy { settings.add(it) }
|
||||
}
|
||||
}
|
||||
showEditChannelDialog = null
|
||||
},
|
||||
onDismissRequest = { showEditChannelDialog = null }
|
||||
)
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState = rememberDragDropState(listState) { fromIndex, toIndex ->
|
||||
updateSettingsList { add(toIndex, removeAt(fromIndex)) }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.dragContainer(
|
||||
dragDropState = dragDropState,
|
||||
haptics = LocalHapticFeedback.current,
|
||||
),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
if (!showChannelEditor) {
|
||||
item {
|
||||
ChannelListView(
|
||||
enabled = enabled,
|
||||
channelSet = channelSet,
|
||||
modemPresetName = modemPresetName,
|
||||
channelSelections = channelSelections,
|
||||
onClick = { showChannelEditor = true }
|
||||
)
|
||||
EditChannelUrl(
|
||||
enabled = enabled,
|
||||
channelUrl = selectedChannelSet.getChannelUrl(),
|
||||
onConfirm = viewModel::requestChannelUrl
|
||||
)
|
||||
}
|
||||
} else {
|
||||
dragDropItemsIndexed(
|
||||
items = channelSet.settingsList,
|
||||
dragDropState = dragDropState,
|
||||
) { index, channel, isDragging ->
|
||||
ChannelCard(
|
||||
index = index,
|
||||
title = channel.name.ifEmpty { modemPresetName },
|
||||
enabled = enabled,
|
||||
onEditClick = { showEditChannelDialog = index },
|
||||
onDeleteClick = { updateSettingsList { removeAt(index) } }
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
channelSet = channelSet.copy {
|
||||
settings.add(channelSettings { psk = Channel.default.settings.psk })
|
||||
}
|
||||
showEditChannelDialog = channelSet.settingsList.lastIndex
|
||||
},
|
||||
enabled = enabled && viewModel.maxChannels > channelSet.settingsCount,
|
||||
) { Text(text = stringResource(R.string.add)) }
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
DropDownPreference(
|
||||
title = stringResource(id = R.string.channel_options),
|
||||
enabled = enabled,
|
||||
items = ChannelOption.entries
|
||||
.map { it.modemPreset to stringResource(it.configRes) },
|
||||
selectedItem = channelSet.loraConfig.modemPreset,
|
||||
onItemSelected = {
|
||||
val lora = channelSet.loraConfig.copy { modemPreset = it }
|
||||
channelSet = channelSet.copy { loraConfig = lora }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
if (isEditing) {
|
||||
PreferenceFooter(
|
||||
enabled = enabled,
|
||||
onCancelClicked = {
|
||||
focusManager.clearFocus()
|
||||
showChannelEditor = false
|
||||
channelSet = channels
|
||||
},
|
||||
onSaveClicked = {
|
||||
focusManager.clearFocus()
|
||||
showSendDialog = true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
PreferenceFooter(
|
||||
enabled = enabled,
|
||||
negativeText = R.string.reset,
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
showResetDialog = true
|
||||
},
|
||||
positiveText = R.string.scan,
|
||||
onPositiveClicked = {
|
||||
focusManager.clearFocus()
|
||||
if (context.hasCameraPermission()) zxingScan() else showScanDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun EditChannelUrl(
|
||||
enabled: Boolean,
|
||||
channelUrl: Uri,
|
||||
modifier: Modifier = Modifier,
|
||||
onConfirm: (Uri) -> Unit
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var valueState by remember(channelUrl) { mutableStateOf(channelUrl) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
// Trigger dialog automatically when users paste a new valid URL
|
||||
LaunchedEffect(valueState, isError) {
|
||||
if (!isError && valueState != channelUrl) {
|
||||
onConfirm(valueState)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = valueState.toString(),
|
||||
onValueChange = {
|
||||
isError = runCatching {
|
||||
valueState = it.toUri()
|
||||
valueState.toChannelSet()
|
||||
}.isFailure
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.url)) },
|
||||
isError = isError,
|
||||
trailingIcon = {
|
||||
val label = stringResource(R.string.url)
|
||||
val isUrlEqual = valueState == channelUrl
|
||||
IconButton(onClick = {
|
||||
when {
|
||||
isError -> {
|
||||
isError = false
|
||||
valueState = channelUrl
|
||||
}
|
||||
|
||||
!isUrlEqual -> {
|
||||
onConfirm(valueState)
|
||||
valueState = channelUrl
|
||||
}
|
||||
|
||||
else -> {
|
||||
// track how many times users share channels
|
||||
GeeksvilleApplication.analytics.track(
|
||||
"share", DataPair("content_type", "channel")
|
||||
)
|
||||
coroutineScope.launch {
|
||||
clipboardManager.setClipEntry(
|
||||
ClipEntry(
|
||||
ClipData.newPlainText(
|
||||
label,
|
||||
valueState.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = when {
|
||||
isError -> Icons.TwoTone.Close
|
||||
!isUrlEqual -> Icons.TwoTone.Check
|
||||
else -> Icons.TwoTone.ContentCopy
|
||||
},
|
||||
contentDescription = when {
|
||||
isError -> stringResource(R.string.copy)
|
||||
!isUrlEqual -> stringResource(R.string.send)
|
||||
else -> stringResource(R.string.copy)
|
||||
},
|
||||
tint = if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrCodeImage(
|
||||
enabled: Boolean,
|
||||
channelSet: ChannelSet,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Image(
|
||||
painter = channelSet.qrCode
|
||||
?.let { BitmapPainter(it.asImageBitmap()) }
|
||||
?: painterResource(id = R.drawable.qrcode),
|
||||
contentDescription = stringResource(R.string.qr_code),
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Inside,
|
||||
alpha = if (enabled) 1.0f else 0.7f
|
||||
// colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ChannelListView(
|
||||
enabled: Boolean,
|
||||
channelSet: ChannelSet,
|
||||
modemPresetName: String,
|
||||
channelSelections: SnapshotStateList<Boolean>,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
val selectedChannelSet = channelSet.copy {
|
||||
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
|
||||
settings.clear()
|
||||
settings.addAll(result)
|
||||
}
|
||||
|
||||
AdaptiveTwoPane(
|
||||
first = {
|
||||
channelSet.settingsList.forEachIndexed { index, channel ->
|
||||
ChannelSelection(
|
||||
index = index,
|
||||
title = channel.name.ifEmpty { modemPresetName },
|
||||
enabled = enabled,
|
||||
isSelected = channelSelections[index],
|
||||
onSelected = {
|
||||
if (it || selectedChannelSet.settingsCount > 1) {
|
||||
channelSelections[index] = it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(text = stringResource(R.string.edit)) }
|
||||
},
|
||||
second = {
|
||||
QrCodeImage(
|
||||
enabled = enabled,
|
||||
channelSet = selectedChannelSet,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewScreenSizes
|
||||
@Composable
|
||||
private fun ChannelScreenPreview() {
|
||||
ChannelListView(
|
||||
enabled = true,
|
||||
channelSet = channelSet {
|
||||
settings.add(Channel.default.settings)
|
||||
loraConfig = Channel.default.loraConfig
|
||||
},
|
||||
modemPresetName = Channel.default.name,
|
||||
channelSelections = listOf(true).toMutableStateList(),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
/*
|
||||
* 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.sharing
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.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.collectAsState
|
||||
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.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 androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.geeksville.mesh.AdminProtos
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
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.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.common.components.CopyIconButton
|
||||
import com.geeksville.mesh.ui.common.components.SimpleAlertDialog
|
||||
import com.google.protobuf.ByteString
|
||||
import com.google.protobuf.Descriptors
|
||||
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
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun AddContactFAB(
|
||||
modifier: Modifier = Modifier,
|
||||
model: UIViewModel = hiltViewModel(),
|
||||
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) {
|
||||
val nodeNum = contactToImport?.nodeNum
|
||||
val nodes by model.unfilteredNodeList.collectAsState()
|
||||
val node = nodes.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 = {
|
||||
contactToImport = null
|
||||
},
|
||||
confirmText = stringResource(R.string.import_label),
|
||||
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 = 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()
|
||||
}
|
||||
|
||||
fun compareUsers(oldUser: MeshProtos.User, newUser: MeshProtos.User): String {
|
||||
val changes = mutableListOf<String>()
|
||||
|
||||
// Iterate over all fields in the User message descriptor
|
||||
for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) {
|
||||
val fieldName = fieldDescriptor.name
|
||||
val oldValue =
|
||||
if (oldUser.hasField(fieldDescriptor)) oldUser.getField(fieldDescriptor) else null
|
||||
val newValue =
|
||||
if (newUser.hasField(fieldDescriptor)) newUser.getField(fieldDescriptor) else null
|
||||
|
||||
if (oldValue != newValue) {
|
||||
val oldValueString = valueToString(oldValue, fieldDescriptor)
|
||||
val newValueString = valueToString(newValue, fieldDescriptor)
|
||||
changes.add("$fieldName: $oldValueString -> $newValueString")
|
||||
}
|
||||
}
|
||||
|
||||
return if (changes.isEmpty()) {
|
||||
"No changes detected."
|
||||
} else {
|
||||
"Changes:\n" + changes.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fun userFieldsToString(user: MeshProtos.User): String {
|
||||
val fieldLines = mutableListOf<String>()
|
||||
|
||||
for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) {
|
||||
val fieldName = fieldDescriptor.name
|
||||
if (user.hasField(fieldDescriptor)) {
|
||||
val value = user.getField(fieldDescriptor)
|
||||
val valueString =
|
||||
valueToString(value, fieldDescriptor) // Using the helper from previous example
|
||||
fieldLines.add("$fieldName: $valueString")
|
||||
} else if (fieldDescriptor.isRepeated || fieldDescriptor.hasDefaultValue() || fieldDescriptor.isOptional) {
|
||||
val defaultValue = fieldDescriptor.defaultValue
|
||||
val valueString = if (fieldDescriptor.isRepeated) {
|
||||
"[]" // Empty list
|
||||
} else if (user.hasField(fieldDescriptor)) {
|
||||
valueToString(
|
||||
user.getField(fieldDescriptor),
|
||||
fieldDescriptor
|
||||
)
|
||||
} else {
|
||||
valueToString(defaultValue, fieldDescriptor)
|
||||
}
|
||||
|
||||
fieldLines.add("$fieldName: $valueString")
|
||||
}
|
||||
}
|
||||
return if (fieldLines.isEmpty()) {
|
||||
"User object has no fields set."
|
||||
} else {
|
||||
fieldLines.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun valueToString(value: Any?, fieldDescriptor: Descriptors.FieldDescriptor): String {
|
||||
if (value == null) {
|
||||
return "null"
|
||||
}
|
||||
return when (fieldDescriptor.type) {
|
||||
Descriptors.FieldDescriptor.Type.BYTES -> {
|
||||
// For ByteString, you might want to display it as hex or Base64
|
||||
// For simplicity, here we'll just show its size.
|
||||
if (value is ByteString) {
|
||||
Base64.encodeToString(value.toByteArray(), Base64.DEFAULT).trim()
|
||||
} else {
|
||||
value.toString().trim()
|
||||
}
|
||||
}
|
||||
// Add more custom formatting for other types if needed
|
||||
else -> value.toString().trim()
|
||||
}
|
||||
}
|
||||
122
app/src/main/java/com/geeksville/mesh/ui/sharing/Share.kt
Normal file
122
app/src/main/java/com/geeksville/mesh/ui/sharing/Share.kt
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* 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.sharing
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Contact
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.ui.contact.ContactItem
|
||||
|
||||
@Composable
|
||||
fun ShareScreen(
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
|
||||
|
||||
ShareScreen(
|
||||
contacts = contactList,
|
||||
onConfirm = onConfirm,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShareScreen(
|
||||
contacts: List<Contact>,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var selectedContact by remember { mutableStateOf("") }
|
||||
|
||||
Column {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
items(contacts, key = { it.contactKey }) { contact ->
|
||||
val selected = contact.contactKey == selectedContact
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
selected = selected,
|
||||
onClick = { selectedContact = contact.contactKey },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onConfirm(selectedContact)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
enabled = selectedContact.isNotEmpty(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.Send,
|
||||
contentDescription = stringResource(id = R.string.share)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewScreenSizes
|
||||
@Composable
|
||||
private fun ShareScreenPreview() {
|
||||
AppTheme {
|
||||
ShareScreen(
|
||||
contacts = listOf(
|
||||
Contact(
|
||||
contactKey = "0^all",
|
||||
shortName = stringResource(R.string.some_username),
|
||||
longName = stringResource(R.string.unknown_username),
|
||||
lastMessageTime = "3 minutes ago",
|
||||
lastMessageText = stringResource(R.string.sample_message),
|
||||
unreadCount = 2,
|
||||
messageCount = 10,
|
||||
isMuted = true,
|
||||
isUnmessageable = false,
|
||||
),
|
||||
),
|
||||
onConfirm = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue