feat: add MQTT Map Reporting consent (#2006)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2025-06-03 10:39:58 -05:00
committed by GitHub
parent e4313e0bd3
commit 3115bbe58d
6 changed files with 360 additions and 84 deletions

View File

@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 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.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.radioconfig.components.MapReportingPreference
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MapReportingPreferenceTest {
@get:Rule
val composeTestRule = createComposeRule()
private fun getString(id: Int): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
var mapReportingEnabled = false
var shouldReportLocation = false
var positionPrecision = 5
var positionReportingInterval = 60
var mapReportingEnabledChanged = { enabled: Boolean ->
mapReportingEnabled = enabled
}
var shouldReportLocationChanged = { enabled: Boolean ->
shouldReportLocation = enabled
}
var positionPrecisionChanged = { precision: Int ->
positionPrecision = precision
}
var positionReportingIntervalChanged = { interval: Int ->
positionReportingInterval = interval
}
private fun testMapReportingPreference() = composeTestRule.setContent {
Column {
MapReportingPreference(
mapReportingEnabled = mapReportingEnabled,
shouldReportLocation = shouldReportLocation,
positionPrecision = positionPrecision,
onMapReportingEnabledChanged = mapReportingEnabledChanged,
onShouldReportLocationChanged = shouldReportLocationChanged,
onPositionPrecisionChanged = positionPrecisionChanged,
publishIntervalSecs = positionReportingInterval,
onPublishIntervalSecsChanged = positionReportingIntervalChanged,
enabled = true,
focusManager = LocalFocusManager.current,
)
}
}
@Test
fun testMapReportingPreference_showsText() {
composeTestRule.apply {
testMapReportingPreference()
// Verify that the dialog title is displayed
onNodeWithText(getString(R.string.map_reporting)).assertIsDisplayed()
onNodeWithText(getString(R.string.map_reporting_summary)).assertIsDisplayed()
}
}
@Test
fun testMapReportingPreference_toggleMapReporting() {
composeTestRule.apply {
testMapReportingPreference()
onNodeWithText(getString(R.string.i_agree)).assertIsNotDisplayed()
onNodeWithText(getString(R.string.map_reporting)).performClick()
Assert.assertFalse(mapReportingEnabled)
Assert.assertFalse(shouldReportLocation)
onNodeWithText(getString(R.string.i_agree)).assertIsDisplayed()
onNodeWithText(getString(R.string.i_agree)).performClick()
Assert.assertTrue(shouldReportLocation)
Assert.assertTrue(mapReportingEnabled)
onNodeWithText(getString(R.string.map_reporting)).performClick()
onNodeWithText(getString(R.string.i_agree)).assertIsNotDisplayed()
Assert.assertTrue(shouldReportLocation)
Assert.assertFalse(mapReportingEnabled)
}
}
}

View File

@@ -39,9 +39,9 @@ import kotlin.math.roundToInt
private const val PositionEnabled = 32
private const val PositionDisabled = 0
const val PositionPrecisionMin = 10
const val PositionPrecisionMax = 19
const val PositionPrecisionDefault = 13
const val PositionPrecisionMin = 12
const val PositionPrecisionMax = 15
const val PositionPrecisionDefault = 14
@Suppress("MagicNumber")
fun precisionBitsToMeters(bits: Int): Double = 23905787.925008 * 0.5.pow(bits.toDouble())

View File

@@ -17,18 +17,14 @@
package com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
@@ -36,78 +32,57 @@ import androidx.compose.ui.unit.dp
@Composable
fun SwitchPreference(
modifier: Modifier = Modifier,
title: String,
summary: String,
summary: String = "",
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
padding: PaddingValues? = null,
containerColor: Color? = null,
) {
val color = if (enabled) {
Color.Unspecified
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
ListItem(
modifier = modifier,
colors = ListItemDefaults.colors().copy(
headlineColor = if (enabled) {
ListItemDefaults.colors().headlineColor
} else {
ListItemDefaults.colors().headlineColor.copy(alpha = 0.5f)
},
supportingTextColor = if (enabled) {
ListItemDefaults.colors().supportingTextColor
} else {
ListItemDefaults.colors().supportingTextColor.copy(alpha = 0.5f)
},
containerColor = containerColor ?: ListItemDefaults.colors().containerColor,
),
modifier = (padding?.let { Modifier.padding(it) } ?: modifier).toggleable(
value = checked,
enabled = enabled,
onValueChange = onCheckedChange,
),
trailingContent = {
Switch(
enabled = enabled,
checked = checked,
onCheckedChange = onCheckedChange,
onCheckedChange = null,
)
},
supportingContent = {
Text(
text = summary,
modifier = Modifier.padding(bottom = 16.dp),
color = color,
)
if (summary.isNotEmpty()) {
Text(
text = summary,
modifier = Modifier.padding(bottom = 16.dp),
)
}
},
headlineContent = {
Text(
text = title,
color = color,
)
}
)
}
@Composable
fun SwitchPreference(
title: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
padding: PaddingValues = PaddingValues(horizontal = 16.dp),
) {
Row(
modifier = modifier
.fillMaxWidth()
.size(48.dp)
.padding(padding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) {
Color.Unspecified
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
Switch(
enabled = enabled,
checked = checked,
onCheckedChange = onCheckedChange,
)
}
}
@Preview(showBackground = true)
@Composable
private fun SwitchPreferencePreview() {

View File

@@ -15,10 +15,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("LongMethod")
package com.geeksville.mesh.ui.radioconfig.components
import android.content.Context
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -29,12 +31,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.MQTTConfig
@@ -43,17 +46,20 @@ import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.common.components.EditPasswordPreference
import com.geeksville.mesh.ui.common.components.EditTextPreference
import com.geeksville.mesh.ui.common.components.PositionPrecisionPreference
import com.geeksville.mesh.ui.common.components.PreferenceCategory
import com.geeksville.mesh.ui.common.components.PreferenceFooter
import com.geeksville.mesh.ui.common.components.SwitchPreference
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
const val MapConsentPreferencesKey = "map_consent_preferences"
@Composable
fun MQTTConfigScreen(
viewModel: RadioConfigViewModel = hiltViewModel(),
) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val destNum = destNode?.num
if (state.responseState.isWaiting()) {
PacketResponseStateDialog(
@@ -63,6 +69,7 @@ fun MQTTConfigScreen(
}
MQTTConfigItemList(
nodeNum = destNum,
mqttConfig = state.moduleConfig.mqtt,
enabled = state.connected,
onSaveClicked = { mqttInput ->
@@ -74,12 +81,22 @@ fun MQTTConfigScreen(
@Composable
fun MQTTConfigItemList(
nodeNum: Int? = 0,
mqttConfig: MQTTConfig,
enabled: Boolean,
onSaveClicked: (MQTTConfig) -> Unit,
) {
val focusManager = LocalFocusManager.current
var mqttInput by rememberSaveable { mutableStateOf(mqttConfig) }
val sharedPrefs = LocalContext.current.getSharedPreferences(
MapConsentPreferencesKey, Context.MODE_PRIVATE
)
if (!mqttInput.mapReportSettings.shouldReportLocation) {
val settings = mqttInput.mapReportSettings.copy {
this.shouldReportLocation = sharedPrefs.getBoolean(nodeNum.toString(), false)
}
mqttInput = mqttInput.copy { mapReportSettings = settings }
}
LazyColumn(
modifier = Modifier.fillMaxSize()
@@ -191,40 +208,45 @@ fun MQTTConfigItemList(
)
}
item { HorizontalDivider() }
// mqtt map reporting opt in
item { PreferenceCategory(text = stringResource(R.string.map_reporting)) }
item {
PositionPrecisionPreference(
title = stringResource(R.string.map_reporting),
enabled = enabled,
value = mqttInput.mapReportSettings.positionPrecision,
onValueChanged = {
val settings = mqttInput.mapReportSettings.copy { positionPrecision = it }
MapReportingPreference(
mapReportingEnabled = mqttInput.mapReportingEnabled,
onMapReportingEnabledChanged = {
mqttInput = mqttInput.copy { mapReportingEnabled = it }
},
shouldReportLocation = mqttInput.mapReportSettings.shouldReportLocation,
onShouldReportLocationChanged = {
sharedPrefs.edit { putBoolean(nodeNum.toString(), it) }
val settings =
mqttInput.mapReportSettings.copy { this.shouldReportLocation = it }
mqttInput = mqttInput.copy {
mapReportingEnabled = settings.positionPrecision > 0
mapReportSettings = settings
}
},
modifier = Modifier.padding(horizontal = 16.dp)
positionPrecision = mqttInput.mapReportSettings.positionPrecision,
onPositionPrecisionChanged = {
val settings = mqttInput.mapReportSettings.copy { positionPrecision = it }
mqttInput = mqttInput.copy {
mapReportSettings = settings
}
},
enabled = enabled,
focusManager = focusManager
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.map_reporting_interval_seconds),
value = mqttInput.mapReportSettings.publishIntervalSecs,
enabled = enabled && mqttInput.mapReportingEnabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val settings = mqttInput.mapReportSettings.copy { publishIntervalSecs = it }
mqttInput = mqttInput.copy { mapReportSettings = settings }
},
)
}
item {
val consentValid = if (mqttInput.mapReportingEnabled) {
mqttInput.mapReportSettings.shouldReportLocation
} else {
true
}
PreferenceFooter(
enabled = enabled && mqttInput != mqttConfig,
enabled = enabled && mqttInput != mqttConfig && consentValid,
onCancelClicked = {
focusManager.clearFocus()
mqttInput = mqttConfig

View File

@@ -0,0 +1,165 @@
/*
* Copyright (c) 2025 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.radioconfig.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.common.components.EditTextPreference
import com.geeksville.mesh.ui.common.components.PositionPrecisionMax
import com.geeksville.mesh.ui.common.components.PositionPrecisionMin
import com.geeksville.mesh.ui.common.components.SwitchPreference
import com.geeksville.mesh.ui.common.components.precisionBitsToMeters
import com.geeksville.mesh.util.DistanceUnit
import com.geeksville.mesh.util.toDistanceString
import kotlin.math.roundToInt
@Suppress("LongMethod")
@Composable
fun MapReportingPreference(
mapReportingEnabled: Boolean = false,
onMapReportingEnabledChanged: (Boolean) -> Unit = {},
shouldReportLocation: Boolean = false,
onShouldReportLocationChanged: (Boolean) -> Unit = {},
positionPrecision: Int = 14,
onPositionPrecisionChanged: (Int) -> Unit = {},
publishIntervalSecs: Int = 3600,
onPublishIntervalSecsChanged: (Int) -> Unit = {},
enabled: Boolean,
focusManager: FocusManager
) {
Column {
var showMapReportingWarning by rememberSaveable { mutableStateOf(mapReportingEnabled) }
LaunchedEffect(mapReportingEnabled) {
showMapReportingWarning = mapReportingEnabled
}
SwitchPreference(
title = stringResource(R.string.map_reporting),
summary = stringResource(R.string.map_reporting_summary),
checked = showMapReportingWarning,
enabled = enabled,
onCheckedChange = { checked ->
showMapReportingWarning = checked
if (checked && shouldReportLocation) {
onMapReportingEnabledChanged(true)
} else if (!checked) {
onMapReportingEnabledChanged(false)
}
}
)
AnimatedVisibility(showMapReportingWarning) {
Card(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.map_reporting_consent_header),
modifier = Modifier.padding(16.dp),
)
HorizontalDivider()
Text(
stringResource(R.string.map_reporting_consent_text),
modifier = Modifier.padding(16.dp)
)
SwitchPreference(
title = stringResource(R.string.i_agree),
summary = stringResource(R.string.i_agree_to_share_my_location),
checked = shouldReportLocation,
enabled = enabled,
onCheckedChange = { checked ->
if (checked) {
onMapReportingEnabledChanged(true)
onShouldReportLocationChanged(true)
} else {
onShouldReportLocationChanged(false)
}
},
containerColor = CardDefaults.cardColors().containerColor,
)
if (shouldReportLocation && mapReportingEnabled) {
Slider(
modifier = Modifier.Companion.padding(horizontal = 16.dp),
value = positionPrecision.toFloat(),
onValueChange = { onPositionPrecisionChanged(it.roundToInt()) },
enabled = enabled,
valueRange = PositionPrecisionMin.toFloat()..PositionPrecisionMax.toFloat(),
steps = PositionPrecisionMax - PositionPrecisionMin - 1,
)
val precisionMeters = precisionBitsToMeters(positionPrecision).toInt()
val unit = DistanceUnit.Companion.getFromLocale()
Text(
text = precisionMeters.toDistanceString(unit),
modifier = Modifier.Companion.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp
),
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
overflow = TextOverflow.Companion.Ellipsis,
maxLines = 1,
)
EditTextPreference(
modifier = Modifier.Companion.padding(bottom = 16.dp),
title = stringResource(R.string.map_reporting_interval_seconds),
value = publishIntervalSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = onPublishIntervalSecsChanged,
)
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun MapReportingPreview() {
val focusManager = LocalFocusManager.current
MapReportingPreference(
mapReportingEnabled = true,
onMapReportingEnabledChanged = {},
shouldReportLocation = true,
onShouldReportLocationChanged = {},
positionPrecision = 5,
onPositionPrecisionChanged = {},
enabled = true,
focusManager = focusManager
)
}

View File

@@ -662,4 +662,9 @@
<string name="nodes">Nodes</string>
<string name="set_your_region">Set your region</string>
<string name="reply">Reply</string>
<string name="map_reporting_summary">Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, long and short name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name.</string>
<string name="map_reporting_consent_header">Consent to Share Unencrypted Node Data via MQTT</string>
<string name="map_reporting_consent_text">By enabling this feature, you acknowledge and expressly consent to the transmission of your devices real-time geographic location over the MQTT protocol without encryption. This location data may be used for purposes such as live map reporting, device tracking, and related telemetry functions.</string>
<string name="i_agree_to_share_my_location">I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT</string>
<string name="i_agree">I agree.</string>
</resources>