Skip to content

Commit

Permalink
refactor: consolidate QR code scanning methods
Browse files Browse the repository at this point in the history
  • Loading branch information
andrekir committed Nov 21, 2024
1 parent 75003bb commit f73d909
Show file tree
Hide file tree
Showing 44 changed files with 89 additions and 226 deletions.
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

0 comments on commit f73d909

Please sign in to comment.