Meshtastic-Android/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt

519 lines
20 KiB
Kotlin

/*
* Copyright (c) 2024 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
import android.net.InetAddresses
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.Editable
import android.util.Patterns
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.RadioButton
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.asLiveData
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.android.*
import com.geeksville.mesh.databinding.SettingsFragmentBinding
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.RegionInfo
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.util.exceptionToSnackbar
import com.geeksville.mesh.util.onEditorAction
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class SettingsFragment : ScreenFragment("Settings"), Logging {
private var _binding: SettingsFragmentBinding? = null
// This property is only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
private val scanModel: BTScanModel by activityViewModels()
private val bluetoothViewModel: BluetoothViewModel by activityViewModels()
private val model: UIViewModel by activityViewModels()
@Inject
internal lateinit var locationRepository: LocationRepository
private val hasGps by lazy { requireContext().hasGps() }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = SettingsFragmentBinding.inflate(inflater, container, false)
return binding.root
}
/**
* Pull the latest device info from the model and into the GUI
*/
private fun updateNodeInfo() {
val connectionState = model.connectionState.value
val isConnected = connectionState == MeshService.ConnectionState.CONNECTED
binding.nodeSettings.visibility = if (isConnected) View.VISIBLE else View.GONE
binding.provideLocationCheckbox.visibility = if (isConnected) View.VISIBLE else View.GONE
binding.usernameEditText.isEnabled = isConnected && !model.isManaged
if (hasGps) {
binding.provideLocationCheckbox.isEnabled = true
} else {
binding.provideLocationCheckbox.isChecked = false
binding.provideLocationCheckbox.isEnabled = false
}
// update the region selection from the device
val region = model.region
val spinner = binding.regionSpinner
spinner.onItemSelectedListener = null
debug("current region is $region")
var regionIndex = regions.indexOfFirst { it.regionCode == region }
if (regionIndex == -1) { // Not found, probably because the device has a region our app doesn't yet understand. Punt and say Unset
regionIndex = ConfigProtos.Config.LoRaConfig.RegionCode.UNSET_VALUE
}
// We don't want to be notified of our own changes, so turn off listener while making them
spinner.setSelection(regionIndex, false)
spinner.onItemSelectedListener = regionSpinnerListener
spinner.isEnabled = !model.isManaged
// Update the status string (highest priority messages first)
val regionUnset = region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET
val info = model.myNodeInfo.value
when (connectionState) {
MeshService.ConnectionState.CONNECTED ->
if (regionUnset) R.string.must_set_region else R.string.connected_to
MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected
MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping
else -> null
}?.let {
val firmwareString = info?.firmwareString ?: getString(R.string.unknown)
scanModel.setErrorText(getString(it, firmwareString))
}
}
private val regionSpinnerListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>,
view: View,
position: Int,
id: Long
) {
val item = RegionInfo.entries[position]
val asProto = item.regionCode
exceptionToSnackbar(requireView()) {
debug("regionSpinner onItemSelected $asProto")
if (asProto != model.region) model.region = asProto
}
updateNodeInfo() // We might have just changed Unset to set
}
override fun onNothingSelected(parent: AdapterView<*>) {
// TODO("Not yet implemented")
}
}
private val regions = RegionInfo.entries
private fun initCommonUI() {
val requestLocationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) {
model.provideLocation.value = true
model.meshService?.startProvideLocation()
} else {
debug("User denied location permission")
model.showSnackbar(getString(R.string.why_background_required))
}
bluetoothViewModel.permissionsUpdated()
}
// init our region spinner
val spinner = binding.regionSpinner
val regionAdapter = object : ArrayAdapter<RegionInfo>(
requireContext(),
android.R.layout.simple_spinner_item,
regions
) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getView(position, convertView, parent)
(view as? TextView)?.text = regions[position].name
return view
}
override fun getDropDownView(
position: Int,
convertView: View?,
parent: ViewGroup
): View {
val view = super.getDropDownView(position, convertView, parent)
(view as? TextView)?.text = regions[position].description
return view
}
}
regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = regionAdapter
model.ourNodeInfo.asLiveData().observe(viewLifecycleOwner) { node ->
binding.usernameEditText.setText(node?.user?.longName.orEmpty())
}
scanModel.devices.observe(viewLifecycleOwner) { devices ->
updateDevicesButtons(devices)
}
// Only let user edit their name or set software update while connected to a radio
model.connectionState.asLiveData().observe(viewLifecycleOwner) {
updateNodeInfo()
}
model.localConfig.asLiveData().observe(viewLifecycleOwner) {
if (model.isConnected()) updateNodeInfo()
}
// Also watch myNodeInfo because it might change later
model.myNodeInfo.asLiveData().observe(viewLifecycleOwner) {
updateNodeInfo()
}
scanModel.errorText.observe(viewLifecycleOwner) { errMsg ->
if (errMsg != null) {
binding.scanStatusText.text = errMsg
}
}
var scanDialog: AlertDialog? = null
scanModel.scanResult.observe(viewLifecycleOwner) { results ->
val devices = results.values.ifEmpty { return@observe }
scanDialog?.dismiss()
scanDialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Select a Bluetooth device")
.setSingleChoiceItems(
devices.map { it.name }.toTypedArray(),
-1
) { dialog, position ->
val selectedDevice = devices.elementAt(position)
scanModel.onSelected(selectedDevice)
scanModel.clearScanResults()
dialog.dismiss()
scanDialog = null
}
.setPositiveButton(R.string.cancel) { dialog, _ ->
scanModel.clearScanResults()
dialog.dismiss()
scanDialog = null
}
.show()
}
// show the spinner when [spinner] is true
scanModel.spinner.observe(viewLifecycleOwner) { show ->
binding.changeRadioButton.isEnabled = !show
binding.scanProgressBar.visibility = if (show) View.VISIBLE else View.GONE
}
binding.usernameEditText.onEditorAction(EditorInfo.IME_ACTION_DONE) {
debug("received IME_ACTION_DONE")
val n = binding.usernameEditText.text.toString().trim()
if (n.isNotEmpty()) model.setOwner(n)
requireActivity().hideKeyboard()
}
// Observe receivingLocationUpdates state and update provideLocationCheckbox
locationRepository.receivingLocationUpdates.asLiveData().observe(viewLifecycleOwner) {
binding.provideLocationCheckbox.isChecked = it
}
binding.provideLocationCheckbox.setOnCheckedChangeListener { view, isChecked ->
// Don't check the box until the system setting changes
view.isChecked = isChecked && requireContext().hasLocationPermission()
if (view.isPressed) { // We want to ignore changes caused by code (as opposed to the user)
debug("User changed location tracking to $isChecked")
model.provideLocation.value = isChecked
if (isChecked && !view.isChecked) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.background_required)
.setMessage(R.string.why_background_required)
.setNeutralButton(R.string.cancel) { _, _ ->
debug("User denied background permission")
}
.setPositiveButton(getString(R.string.accept)) { _, _ ->
// Make sure we have location permission (prerequisite)
if (!requireContext().hasLocationPermission()) {
requestLocationPermissionLauncher.launch(requireContext().getLocationPermissions())
}
}
.show()
}
}
if (view.isChecked) {
checkLocationEnabled(getString(R.string.location_disabled))
model.meshService?.startProvideLocation()
} else {
model.meshService?.stopProvideLocation()
}
}
val app = (requireContext().applicationContext as GeeksvilleApplication)
val isGooglePlayAvailable = requireContext().isGooglePlayAvailable()
val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable
// Set analytics checkbox
binding.analyticsOkayCheckbox.isEnabled = isGooglePlayAvailable
binding.analyticsOkayCheckbox.isChecked = isAnalyticsAllowed
binding.analyticsOkayCheckbox.setOnCheckedChangeListener { _, isChecked ->
debug("User changed analytics to $isChecked")
app.isAnalyticsAllowed = isChecked
binding.reportBugButton.isEnabled = isAnalyticsAllowed
}
// report bug button only enabled if analytics is allowed
binding.reportBugButton.isEnabled = isAnalyticsAllowed
binding.reportBugButton.setOnClickListener(::showReportBugDialog)
}
@Suppress("UNUSED_PARAMETER")
private fun showReportBugDialog(view: View) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.report_a_bug)
.setMessage(getString(R.string.report_bug_text))
.setNeutralButton(R.string.cancel) { _, _ ->
debug("Decided not to report a bug")
}
.setPositiveButton(getString(R.string.report)) { _, _ ->
reportError("Clicked Report A Bug")
model.showSnackbar("Bug report sent!")
}
.show()
}
private var tapCount = 0
private var lastTapTime: Long = 0
private fun addDeviceButton(device: BTScanModel.DeviceListEntry, enabled: Boolean) {
val b = RadioButton(requireActivity())
b.text = device.name
b.id = View.generateViewId()
b.isEnabled = enabled
b.isChecked = device.fullAddress == scanModel.selectedNotNull
binding.deviceRadioGroup.addView(b)
b.setOnClickListener {
if (device.fullAddress == "n") {
val currentTapTime = System.currentTimeMillis()
if (currentTapTime - lastTapTime > TAP_THRESHOLD) {
tapCount = 0
}
lastTapTime = currentTapTime
tapCount++
if (tapCount >= TAP_TRIGGER) {
model.showSnackbar("Demo Mode enabled")
scanModel.showMockInterface()
}
}
if (!device.bonded) { // If user just clicked on us, try to bond
binding.scanStatusText.setText(R.string.starting_pairing)
}
b.isChecked = scanModel.onSelected(device)
}
}
private fun addManualDeviceButton() {
val deviceSelectIPAddress = binding.radioButtonManual
val inputIPAddress = binding.editManualAddress
deviceSelectIPAddress.isEnabled = inputIPAddress.text.isIPAddress()
deviceSelectIPAddress.setOnClickListener {
deviceSelectIPAddress.isChecked = scanModel.onSelected(BTScanModel.DeviceListEntry("", "t" + inputIPAddress.text, true))
}
binding.deviceRadioGroup.addView(deviceSelectIPAddress)
binding.deviceRadioGroup.addView(inputIPAddress)
inputIPAddress.doAfterTextChanged {
deviceSelectIPAddress.isEnabled = inputIPAddress.text.isIPAddress()
}
}
private fun updateDevicesButtons(devices: MutableMap<String, BTScanModel.DeviceListEntry>?) {
// Remove the old radio buttons and repopulate
binding.deviceRadioGroup.removeAllViews()
if (devices == null) return
var hasShownOurDevice = false
devices.values.forEach { device ->
if (device.fullAddress == scanModel.selectedNotNull) {
hasShownOurDevice = true
}
addDeviceButton(device, true)
}
// The selected device is not in the scan; it is either offline, or it doesn't advertise
// itself (most BLE devices don't advertise when connected).
// Show it in the list, greyed out based on connection status.
if (!hasShownOurDevice) {
// Note: we pull this into a tempvar, because otherwise some other thread can change selectedAddress after our null check
// and before use
val curAddr = scanModel.selectedAddress
if (curAddr != null) {
val curDevice = BTScanModel.DeviceListEntry(curAddr.substring(1), curAddr, false)
addDeviceButton(curDevice, model.isConnected())
}
}
addManualDeviceButton()
// get rid of the warning text once at least one device is paired.
// If we are running on an emulator, always leave this message showing so we can test the worst case layout
val curRadio = scanModel.selectedAddress
if (curRadio != null && curRadio != "m") {
binding.warningNotPaired.visibility = View.GONE
} else if (bluetoothViewModel.enabled.value == true) {
binding.warningNotPaired.visibility = View.VISIBLE
scanModel.setErrorText(getString(R.string.not_paired_yet))
}
}
// per https://developer.android.com/guide/topics/connectivity/bluetooth/find-ble-devices
private var scanning = false
private fun scanLeDevice() {
if (!checkBTEnabled()) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) checkLocationEnabled()
if (!scanning) { // Stops scanning after a pre-defined scan period.
Handler(Looper.getMainLooper()).postDelayed({
scanning = false
scanModel.stopScan()
}, SCAN_PERIOD)
scanning = true
scanModel.startScan()
} else {
scanning = false
scanModel.stopScan()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initCommonUI()
val requestPermissionAndScanLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) {
info("Bluetooth permissions granted")
scanLeDevice()
} else {
warn("Bluetooth permissions denied")
model.showSnackbar(requireContext().permissionMissing)
}
bluetoothViewModel.permissionsUpdated()
}
binding.changeRadioButton.setOnClickListener {
debug("User clicked changeRadioButton")
val bluetoothPermissions = requireContext().getBluetoothPermissions()
if (bluetoothPermissions.isEmpty()) {
scanLeDevice()
} else {
requireContext().rationaleDialog(
shouldShowRequestPermissionRationale(bluetoothPermissions)
) {
requestPermissionAndScanLauncher.launch(bluetoothPermissions)
}
}
}
}
// If the user has not turned on location access throw up a warning
private fun checkLocationEnabled(
// Default warning valid only for classic bluetooth scan
warningReason: String = getString(R.string.location_disabled_warning)
) {
if (requireContext().gpsDisabled()) {
warn("Telling user we need location access")
model.showSnackbar(warningReason)
}
}
private fun checkBTEnabled(): Boolean = (bluetoothViewModel.enabled.value == true).also { enabled ->
if (!enabled) {
warn("Telling user bluetooth is disabled")
model.showSnackbar(R.string.bluetooth_disabled)
}
}
override fun onResume() {
super.onResume()
// Warn user if BLE device is selected but BLE disabled
if (scanModel.selectedBluetooth) checkBTEnabled()
// Warn user if provide location is selected but location disabled
if (binding.provideLocationCheckbox.isChecked) {
checkLocationEnabled(getString(R.string.location_disabled))
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val SCAN_PERIOD: Long = 10000 // Stops scanning after 10 seconds
private const val TAP_TRIGGER: Int = 7
private const val TAP_THRESHOLD: Long = 500 // max 500 ms between taps
}
private fun Editable.isIPAddress(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
InetAddresses.isNumericAddress(this.toString())
} else {
@Suppress("DEPRECATION")
Patterns.IP_ADDRESS.matcher(this).matches()
}
}
}