mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
519 lines
20 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|