Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: consolidate QR code scanning methods #1426

Merged
merged 1 commit into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions app/src/androidTest/java/com/geeksville/mesh/ChannelSetTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.geeksville.mesh
import android.net.Uri
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.model.primaryChannel
import com.geeksville.mesh.model.shouldAddChannels
import com.geeksville.mesh.model.toChannelSet
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
Expand Down Expand Up @@ -47,18 +46,16 @@ class ChannelSetTest {
fun handleAddInFragment() {
val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESBggBQANIAQ?add=true")
val cs = url.toChannelSet()
val shouldAdd = url.shouldAddChannels()
Assert.assertEquals("LongFast", cs.primaryChannel!!.name)
Assert.assertTrue(shouldAdd)
Assert.assertEquals("Custom", cs.primaryChannel!!.name)
Assert.assertFalse(cs.hasLoraConfig())
}

/** properly parse channel config when `?add=true` is in the query parameters */
@Test
fun handleAddInQueryParams() {
val url = Uri.parse("https://meshtastic.org/e/?add=true#CgMSAQESBggBQANIAQ")
val cs = url.toChannelSet()
val shouldAdd = url.shouldAddChannels()
Assert.assertEquals("LongFast", cs.primaryChannel!!.name)
Assert.assertTrue(shouldAdd)
Assert.assertEquals("Custom", cs.primaryChannel!!.name)
Assert.assertFalse(cs.hasLoraConfig())
}
}
125 changes: 54 additions & 71 deletions app/src/main/java/com/geeksville/mesh/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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()
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 9 additions & 25 deletions app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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()
}

/**
Expand Down
30 changes: 10 additions & 20 deletions app/src/main/java/com/geeksville/mesh/model/UIState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down
17 changes: 2 additions & 15 deletions app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
Loading