From 3115bbe58daf0ebe104b3875b244f0da8944d32f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 3 Jun 2025 10:39:58 -0500 Subject: [PATCH] feat: add MQTT Map Reporting consent (#2006) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../compose/MapReportingPreferenceTest.kt | 109 ++++++++++++ .../components/PositionPrecisionPreference.kt | 6 +- .../ui/common/components/SwitchPreference.kt | 87 ++++----- .../components/MQTTConfigItemList.kt | 72 +++++--- .../components/MapReportingPreference.kt | 165 ++++++++++++++++++ app/src/main/res/values/strings.xml | 5 + 6 files changed, 360 insertions(+), 84 deletions(-) create mode 100644 app/src/androidTest/java/com/geeksville/mesh/compose/MapReportingPreferenceTest.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/MapReportingPreference.kt diff --git a/app/src/androidTest/java/com/geeksville/mesh/compose/MapReportingPreferenceTest.kt b/app/src/androidTest/java/com/geeksville/mesh/compose/MapReportingPreferenceTest.kt new file mode 100644 index 000000000..343dcaebb --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/compose/MapReportingPreferenceTest.kt @@ -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 . + */ + +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) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/components/PositionPrecisionPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/common/components/PositionPrecisionPreference.kt index d1c23955e..547dd4bc4 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/common/components/PositionPrecisionPreference.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/common/components/PositionPrecisionPreference.kt @@ -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()) diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/components/SwitchPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/common/components/SwitchPreference.kt index e13f188e3..2c016c183 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/common/components/SwitchPreference.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/common/components/SwitchPreference.kt @@ -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() { diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/MQTTConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/MQTTConfigItemList.kt index 609fb8cac..f459331f9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/MQTTConfigItemList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/MQTTConfigItemList.kt @@ -15,10 +15,12 @@ * along with this program. If not, see . */ +@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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/MapReportingPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/MapReportingPreference.kt new file mode 100644 index 000000000..b183d2130 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/MapReportingPreference.kt @@ -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 . + */ + +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 + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9048bd7b9..59923c12a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -662,4 +662,9 @@ Nodes Set your region Reply + 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. + Consent to Share Unencrypted Node Data via MQTT + By enabling this feature, you acknowledge and expressly consent to the transmission of your device’s 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. + I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT + I agree.