mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: consolidate QR code scanning methods
This commit is contained in:
parent
75003bb6f0
commit
f73d909cd0
44 changed files with 89 additions and 226 deletions
|
|
@ -2,7 +2,8 @@ package com.geeksville.mesh
|
|||
|
||||
import android.app.Activity
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.content.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
|
|
@ -22,25 +23,47 @@ import androidx.activity.viewModels
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.geeksville.mesh.android.*
|
||||
import com.geeksville.mesh.android.BindFailedException
|
||||
import com.geeksville.mesh.android.GeeksvilleApplication
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.android.ServiceClient
|
||||
import com.geeksville.mesh.android.getBluetoothPermissions
|
||||
import com.geeksville.mesh.android.getNotificationPermissions
|
||||
import com.geeksville.mesh.android.hasBluetoothPermission
|
||||
import com.geeksville.mesh.android.hasNotificationPermission
|
||||
import com.geeksville.mesh.android.permissionMissing
|
||||
import com.geeksville.mesh.android.rationaleDialog
|
||||
import com.geeksville.mesh.android.shouldShowRequestPermissionRationale
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.databinding.ActivityMainBinding
|
||||
import com.geeksville.mesh.model.BluetoothViewModel
|
||||
import com.geeksville.mesh.model.DeviceVersion
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.model.primaryChannel
|
||||
import com.geeksville.mesh.model.shouldAddChannels
|
||||
import com.geeksville.mesh.model.toChannelSet
|
||||
import com.geeksville.mesh.service.*
|
||||
import com.geeksville.mesh.ui.*
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.service.MeshServiceNotifications
|
||||
import com.geeksville.mesh.service.ServiceRepository
|
||||
import com.geeksville.mesh.service.startService
|
||||
import com.geeksville.mesh.ui.ChannelFragment
|
||||
import com.geeksville.mesh.ui.ContactsFragment
|
||||
import com.geeksville.mesh.ui.DebugFragment
|
||||
import com.geeksville.mesh.ui.QuickChatSettingsFragment
|
||||
import com.geeksville.mesh.ui.SettingsFragment
|
||||
import com.geeksville.mesh.ui.UsersFragment
|
||||
import com.geeksville.mesh.ui.components.ScannedQrCodeDialog
|
||||
import com.geeksville.mesh.ui.map.MapFragment
|
||||
import com.geeksville.mesh.ui.navigateToMessages
|
||||
import com.geeksville.mesh.ui.navigateToNavGraph
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
import com.geeksville.mesh.util.Exceptions
|
||||
import com.geeksville.mesh.util.LanguageUtils
|
||||
import com.geeksville.mesh.util.getPackageInfoCompat
|
||||
|
|
@ -222,6 +245,25 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
override fun onTabReselected(tab: TabLayout.Tab?) { }
|
||||
})
|
||||
|
||||
binding.composeView.setContent {
|
||||
val connState by model.connectionState.collectAsStateWithLifecycle()
|
||||
val channels by model.channels.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by model.requestChannelSet.collectAsStateWithLifecycle()
|
||||
|
||||
AppTheme {
|
||||
if (connState.isConnected()) {
|
||||
if (requestChannelSet != null) {
|
||||
ScannedQrCodeDialog(
|
||||
channels = channels,
|
||||
incoming = requestChannelSet!!,
|
||||
onDismiss = model::clearRequestChannelUrl,
|
||||
onConfirm = model::setChannels,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any intent
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
|
@ -253,8 +295,6 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private var requestedChannelUrl: Uri? = null
|
||||
|
||||
// Handle any intents that were passed into us
|
||||
private fun handleIntent(intent: Intent) {
|
||||
val appLinkAction = intent.action
|
||||
|
|
@ -263,10 +303,12 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
when (appLinkAction) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
debug("Asked to open a channel URL - ask user if they want to switch to that channel. If so send the config to the radio")
|
||||
requestedChannelUrl = appLinkData
|
||||
|
||||
// if the device is connected already, process it now
|
||||
perhapsChangeChannel()
|
||||
try {
|
||||
appLinkData?.let { model.requestChannelSet(it.toChannelSet()) }
|
||||
} catch (ex: Throwable) {
|
||||
errormsg("Channel url error: ${ex.message}")
|
||||
showSnackbar("${getString(R.string.channel_invalid)}: ${ex.message}")
|
||||
}
|
||||
|
||||
// We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel
|
||||
}
|
||||
|
|
@ -355,11 +397,6 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
|
||||
if (curVer < MeshService.minDeviceVersion) {
|
||||
showAlert(R.string.firmware_too_old, R.string.firmware_old)
|
||||
} else {
|
||||
// If our app is too old/new, we probably don't understand the new DeviceConfig messages, so we don't read them until here
|
||||
|
||||
// we have a connection to our device now, do the channel change
|
||||
perhapsChangeChannel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -407,51 +444,6 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth")
|
||||
private fun perhapsChangeChannel(url: Uri? = requestedChannelUrl) {
|
||||
// if the device is connected already, process it now
|
||||
if (url != null && model.isConnected()) {
|
||||
requestedChannelUrl = null
|
||||
try {
|
||||
val channels = url.toChannelSet()
|
||||
val shouldAdd = url.shouldAddChannels()
|
||||
val primary = channels.primaryChannel
|
||||
if (primary == null) {
|
||||
showSnackbar(R.string.channel_invalid)
|
||||
} else {
|
||||
val dialogMessage = if (!shouldAdd) {
|
||||
getString(R.string.do_you_want_switch).format(primary.name)
|
||||
} else {
|
||||
resources.getQuantityString(
|
||||
R.plurals.add_channel_from_qr,
|
||||
channels.settingsCount,
|
||||
channels.settingsCount
|
||||
)
|
||||
}
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.new_channel_rcvd)
|
||||
.setMessage(dialogMessage)
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
// Do nothing
|
||||
}
|
||||
.setPositiveButton(R.string.accept) { _, _ ->
|
||||
debug("Setting channel from URL")
|
||||
try {
|
||||
model.setChannels(channels, !shouldAdd)
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("Couldn't change channel ${ex.message}")
|
||||
showSnackbar(R.string.cant_change_no_radio)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
errormsg("Channel url error: ${ex.message}")
|
||||
showSnackbar("${getString(R.string.channel_invalid)}: ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
return try {
|
||||
super.dispatchTouchEvent(ev)
|
||||
|
|
@ -562,15 +554,6 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
}
|
||||
}
|
||||
|
||||
// Call perhapsChangeChannel() whenever [requestChannelUrl] updates with a non-null value
|
||||
model.requestChannelUrl.observe(this) { url ->
|
||||
url?.let {
|
||||
requestedChannelUrl = url
|
||||
model.clearRequestChannelUrl()
|
||||
perhapsChangeChannel()
|
||||
}
|
||||
}
|
||||
|
||||
// Call showSnackbar() whenever [snackbarText] updates with a non-null value
|
||||
model.snackbarText.observe(this) { text ->
|
||||
if (text is Int) showSnackbar(text)
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ import com.journeyapps.barcodescanner.BarcodeEncoder
|
|||
import java.net.MalformedURLException
|
||||
import kotlin.jvm.Throws
|
||||
|
||||
internal const val URL_PREFIX = "https://meshtastic.org/e/#"
|
||||
private const val MESHTASTIC_DOMAIN = "meshtastic.org"
|
||||
private const val MESHTASTIC_CHANNEL_CONFIG_PATH = "/e/"
|
||||
private const val MESHTASTIC_HOST = "meshtastic.org"
|
||||
private const val MESHTASTIC_PATH = "/e/"
|
||||
internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$MESHTASTIC_PATH#"
|
||||
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
|
||||
|
||||
/**
|
||||
|
|
@ -23,37 +23,21 @@ private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PAD
|
|||
@Throws(MalformedURLException::class)
|
||||
fun Uri.toChannelSet(): ChannelSet {
|
||||
if (fragment.isNullOrBlank() ||
|
||||
!host.equals(MESHTASTIC_DOMAIN, true) ||
|
||||
!path.equals(MESHTASTIC_CHANNEL_CONFIG_PATH, true)
|
||||
!host.equals(MESHTASTIC_HOST, true) ||
|
||||
!path.equals(MESHTASTIC_PATH, true)
|
||||
) {
|
||||
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
|
||||
}
|
||||
|
||||
// Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment.
|
||||
// This gracefully handles those cases until the newer version are generally available/used.
|
||||
return ChannelSet.parseFrom(Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS))
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a [Boolean] if the URL indicates the associated [ChannelSet] should be added to the
|
||||
* existing configuration.
|
||||
* @throws MalformedURLException when not recognized as a valid Meshtastic URL
|
||||
*/
|
||||
@Throws(MalformedURLException::class)
|
||||
fun Uri.shouldAddChannels(): Boolean {
|
||||
if (fragment.isNullOrBlank() ||
|
||||
!host.equals(MESHTASTIC_DOMAIN, true) ||
|
||||
!path.equals(MESHTASTIC_CHANNEL_CONFIG_PATH, true)
|
||||
) {
|
||||
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
|
||||
}
|
||||
|
||||
// Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment.
|
||||
// This gracefully handles those cases until the newer version are generally available/used.
|
||||
return fragment?.substringAfter('?', "")
|
||||
val url = ChannelSet.parseFrom(Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS))
|
||||
val shouldAdd = fragment?.substringAfter('?', "")
|
||||
?.takeUnless { it.isBlank() }
|
||||
?.equals("add=true")
|
||||
?: getBooleanQueryParameter("add", false)
|
||||
|
||||
return url.toBuilder().apply { if (shouldAdd) clearLoraConfig() }.build()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -451,18 +451,18 @@ class UIViewModel @Inject constructor(
|
|||
val connectionState get() = radioConfigRepository.connectionState
|
||||
fun isConnected() = connectionState.value != MeshService.ConnectionState.DISCONNECTED
|
||||
|
||||
private val _requestChannelUrl = MutableLiveData<Uri?>(null)
|
||||
val requestChannelUrl: LiveData<Uri?> get() = _requestChannelUrl
|
||||
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
|
||||
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?> get() = _requestChannelSet
|
||||
|
||||
fun setRequestChannelUrl(channelUrl: Uri) {
|
||||
_requestChannelUrl.value = channelUrl
|
||||
fun requestChannelSet(channelSet: AppOnlyProtos.ChannelSet) {
|
||||
_requestChannelSet.value = channelSet
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after activity observes requestChannelUrl
|
||||
*/
|
||||
fun clearRequestChannelUrl() {
|
||||
_requestChannelUrl.value = null
|
||||
_requestChannelSet.value = null
|
||||
}
|
||||
|
||||
fun showSnackbar(resString: Any) {
|
||||
|
|
@ -538,24 +538,14 @@ class UIViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Set the radio config (also updates our saved copy in preferences). By default, this will replace
|
||||
* all channels in the existing radio config. Otherwise, it will append all [ChannelSettings] that
|
||||
* are unique in [channelSet] to the existing radio config.
|
||||
* Set the radio config (also updates our saved copy in preferences).
|
||||
*/
|
||||
fun setChannels(channelSet: AppOnlyProtos.ChannelSet, overwrite: Boolean = true) = viewModelScope.launch {
|
||||
val newRadioSettings: List<ChannelSettings> = if (overwrite) {
|
||||
channelSet.settingsList
|
||||
} else {
|
||||
// To guarantee consistent ordering, using a LinkedHashSet which iterates through it's
|
||||
// entries according to the order an item was *first* inserted.
|
||||
// https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/
|
||||
LinkedHashSet(channels.value.settingsList + channelSet.settingsList).toList()
|
||||
}
|
||||
fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch {
|
||||
getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel)
|
||||
radioConfigRepository.replaceAllSettings(channelSet.settingsList)
|
||||
|
||||
getChannelList(newRadioSettings, channels.value.settingsList).forEach(::setChannel)
|
||||
radioConfigRepository.replaceAllSettings(newRadioSettings)
|
||||
val newConfig = config { lora = channelSet.loraConfig }
|
||||
if (overwrite && config.lora != newConfig.lora) setConfig(newConfig)
|
||||
if (config.lora != newConfig.lora) setConfig(newConfig)
|
||||
}
|
||||
|
||||
val provideLocation = object : MutableLiveData<Boolean>(preferences.getBoolean("provide-location", false)) {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ import com.geeksville.mesh.service.MeshService
|
|||
import com.geeksville.mesh.ui.components.AdaptiveTwoPane
|
||||
import com.geeksville.mesh.ui.components.DropDownPreference
|
||||
import com.geeksville.mesh.ui.components.PreferenceFooter
|
||||
import com.geeksville.mesh.ui.components.ScannedQrCodeDialog
|
||||
import com.geeksville.mesh.ui.components.config.ChannelCard
|
||||
import com.geeksville.mesh.ui.components.config.ChannelSelection
|
||||
import com.geeksville.mesh.ui.components.config.EditChannelDialog
|
||||
|
|
@ -147,11 +146,10 @@ fun ChannelScreen(
|
|||
val channelUrl = channelSet.getChannelUrl()
|
||||
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
|
||||
|
||||
var scannedChannelSet by remember { mutableStateOf<ChannelSet?>(null) }
|
||||
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
try {
|
||||
scannedChannelSet = Uri.parse(result.contents).toChannelSet()
|
||||
viewModel.requestChannelSet(Uri.parse(result.contents).toChannelSet())
|
||||
} catch (ex: Throwable) {
|
||||
errormsg("Channel url error: ${ex.message}")
|
||||
viewModel.showSnackbar(R.string.channel_invalid)
|
||||
|
|
@ -266,17 +264,6 @@ fun ChannelScreen(
|
|||
.show()
|
||||
}
|
||||
|
||||
if (scannedChannelSet != null) {
|
||||
val incoming = scannedChannelSet ?: return
|
||||
/* Prompt the user to modify channels after scanning a QR code. */
|
||||
ScannedQrCodeDialog(
|
||||
channels = channels,
|
||||
incoming = incoming,
|
||||
onDismiss = { scannedChannelSet = null },
|
||||
onConfirm = { newChannelSet -> installSettings(newChannelSet) }
|
||||
)
|
||||
}
|
||||
|
||||
var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
|
||||
|
||||
if (showEditChannelDialog != null) {
|
||||
|
|
@ -375,7 +362,7 @@ fun ChannelScreen(
|
|||
IconButton(onClick = {
|
||||
when {
|
||||
isError -> valueState = channelUrl
|
||||
!isUrlEqual -> viewModel.setRequestChannelUrl(channelUrl)
|
||||
!isUrlEqual -> viewModel.requestChannelSet(channels)
|
||||
else -> {
|
||||
// track how many times users share channels
|
||||
GeeksvilleApplication.analytics.track(
|
||||
|
|
|
|||
|
|
@ -51,13 +51,16 @@ fun ScannedQrCodeDialog(
|
|||
onDismiss: () -> Unit,
|
||||
onConfirm: (ChannelSet) -> Unit
|
||||
) {
|
||||
var shouldReplace by remember { mutableStateOf(true) }
|
||||
var shouldReplace by remember { mutableStateOf(incoming.hasLoraConfig()) }
|
||||
|
||||
val channelSet = remember(shouldReplace) {
|
||||
if (shouldReplace) {
|
||||
incoming
|
||||
} else {
|
||||
channels.copy {
|
||||
// To guarantee consistent ordering, using a LinkedHashSet which iterates through
|
||||
// it's entries according to the order an item was *first* inserted.
|
||||
// https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/
|
||||
val result = LinkedHashSet(settings + incoming.settingsList)
|
||||
settings.clear()
|
||||
settings.addAll(result)
|
||||
|
|
@ -134,6 +137,7 @@ fun ScannedQrCodeDialog(
|
|||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.weight(1f),
|
||||
enabled = incoming.hasLoraConfig(),
|
||||
colors = if (shouldReplace) selectedColors else unselectedColors,
|
||||
) { Text(text = stringResource(R.string.replace)) }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue