refactor(analytics): consolidate consent logic, move to Settings (#2885)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-27 21:21:06 -05:00 committed by GitHub
parent 86ce659bc6
commit ad736116a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 172 additions and 214 deletions

View file

@ -20,7 +20,8 @@ package com.geeksville.mesh.repository.radio
import com.geeksville.mesh.android.Logging
/**
* An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP probably)
* An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP
* probably)
*/
abstract class StreamInterface(protected val service: RadioInterfaceService) :
Logging,
@ -46,12 +47,16 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) :
onDeviceDisconnect(true)
}
/** Tell MeshService our device has gone away, but wait for it to come back
/**
* Tell MeshService our device has gone away, but wait for it to come back
*
* @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the manager callbacks
* */
* @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the
* manager callbacks
*/
protected open fun onDeviceDisconnect(waitForStopped: Boolean) {
service.onDisconnect(isPermanent = true) // if USB device disconnects it is definitely permanently gone, not sleeping)
service.onDisconnect(
isPermanent = true,
) // if USB device disconnects it is definitely permanently gone, not sleeping)
}
protected open fun connect() {
@ -85,14 +90,12 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) :
/** Print device serial debug output somewhere */
private fun debugOut(b: Byte) {
when (val c = b.toChar()) {
'\r' -> {
} // ignore
'\r' -> {} // ignore
'\n' -> {
debug("DeviceLog: $debugLineBuf")
debugLineBuf.clear()
}
else ->
debugLineBuf.append(c)
else -> debugLineBuf.append(c)
}
}
@ -133,16 +136,19 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) :
// We've read our header, do one big read for the packet itself
packetLen = (msb shl 8) or lsb
if (packetLen > MAX_TO_FROM_RADIO_SIZE) {
lostSync() // If packet len is too long, the bytes must have been corrupted, start looking for START1 again
lostSync() // If packet len is too long, the bytes must have been corrupted, start looking for
// START1 again
} else if (packetLen == 0) {
deliverPacket() // zero length packets are valid and should be delivered immediately (because there won't be a next byte of payload)
deliverPacket() // zero length packets are valid and should be delivered immediately (because there
// won't be a next byte of payload)
}
}
else -> {
// We are looking at the packet bytes now
rxPacket[ptr - 4] = c
// Note: we have to check if ptr +1 is equal to packet length (for example, for a 1 byte packetlen, this code will be run with ptr of4
// Note: we have to check if ptr +1 is equal to packet length (for example, for a 1 byte packetlen, this
// code will be run with ptr of4
if (ptr - 4 + 1 == packetLen) {
deliverPacket()
}

View file

@ -17,9 +17,13 @@
package com.geeksville.mesh.ui.common.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Switch
@ -30,6 +34,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SwitchPreference(
modifier: Modifier = Modifier,
@ -40,46 +45,47 @@ fun SwitchPreference(
onCheckedChange: (Boolean) -> Unit,
padding: PaddingValues? = null,
containerColor: Color? = null,
loading: Boolean = false,
) {
ListItem(
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(
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 = null,
)
AnimatedContent(targetState = loading) { loading ->
if (loading) {
CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Switch(enabled = enabled, checked = checked, onCheckedChange = null)
}
}
},
supportingContent = {
if (summary.isNotEmpty()) {
Text(
text = summary,
modifier = Modifier.padding(bottom = 16.dp),
)
Text(text = summary, modifier = Modifier.padding(bottom = 16.dp))
}
},
headlineContent = {
Text(
text = title,
)
}
headlineContent = { Text(text = title) },
)
}

View file

@ -39,7 +39,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
@ -49,7 +48,6 @@ import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.Usb
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
@ -71,7 +69,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -83,10 +80,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.gpsDisabled
import com.geeksville.mesh.android.isGooglePlayAvailable
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.DeviceListEntry
@ -141,7 +135,6 @@ fun ConnectionsScreen(
val connectionState by uiViewModel.connectionState.collectAsStateWithLifecycle(ConnectionState.DISCONNECTED)
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
val context = LocalContext.current
val app = (context.applicationContext as GeeksvilleApplication)
val info by uiViewModel.myNodeInfo.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothEnabled by bluetoothViewModel.enabled.collectAsStateWithLifecycle(false)
@ -425,42 +418,6 @@ fun ConnectionsScreen(
LaunchedEffect(Unit) { uiViewModel.suppressNoPairedWarning() }
}
// Analytics Okay Checkbox
val isGooglePlayAvailable = context.isGooglePlayAvailable
val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable
if (isGooglePlayAvailable) {
var loading by remember { mutableStateOf(false) }
LaunchedEffect(isAnalyticsAllowed) { loading = false }
Row(
modifier =
Modifier.fillMaxWidth()
.toggleable(
value = isAnalyticsAllowed,
onValueChange = {
debug("User changed analytics to $it")
app.isAnalyticsAllowed = it
loading = true
},
role = Role.Checkbox,
enabled = isGooglePlayAvailable && !loading,
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
enabled = isGooglePlayAvailable,
checked = isAnalyticsAllowed,
onCheckedChange = null,
)
Text(
text = stringResource(R.string.analytics_okay),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
}

View file

@ -17,28 +17,23 @@
package com.geeksville.mesh.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
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.graphics.vector.ImageVector
@ -67,6 +62,7 @@ fun SettingsItem(
}
/** A toggleable settings switch item. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsItemSwitch(
checked: Boolean,
@ -120,17 +116,14 @@ private fun ClickableWrapper(enabled: Boolean, onClick: () -> Unit, content: @Co
/** The row content to display for a settings item. */
@Composable
private fun Content(leading: @Composable () -> Unit, text: String, trailing: @Composable RowScope.() -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 16.dp),
) {
leading()
Text(text = text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.wrapContentWidth())
Spacer(modifier = Modifier.weight(1f))
trailing()
}
private fun Content(leading: @Composable () -> Unit, text: String, trailing: @Composable () -> Unit) {
ListItem(
modifier = Modifier.padding(horizontal = 8.dp),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
headlineContent = { Text(text) },
leadingContent = { leading() },
trailingContent = { trailing() },
)
}
@Composable

View file

@ -39,6 +39,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.KeyboardArrowRight
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.twotone.Warning
@ -79,6 +80,7 @@ import com.geeksville.mesh.ui.common.components.TitledCard
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
import com.geeksville.mesh.ui.settings.components.SettingsItem
import com.geeksville.mesh.ui.settings.components.SettingsItemSwitch
import com.geeksville.mesh.ui.settings.radio.components.EditDeviceProfileDialog
import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialog
import kotlinx.coroutines.delay
@ -201,6 +203,7 @@ fun RadioConfigScreen(
deviceProfile = null
showEditDeviceProfileDialog = true
},
onToggleAnalytics = { viewModel.toggleAnalytics() },
onNavigate = onNavigate,
)
}
@ -286,6 +289,7 @@ private fun RadioConfigItemList(
onRouteClick: (Enum<*>) -> Unit = {},
onImport: () -> Unit = {},
onExport: () -> Unit = {},
onToggleAnalytics: () -> Unit = {},
onNavigate: (Route) -> Unit,
) {
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
@ -364,6 +368,18 @@ private fun RadioConfigItemList(
)
}
}
item {
if (state.analyticsAvailable) {
TitledCard(title = stringResource(R.string.phone_settings), modifier = Modifier.padding(top = 16.dp)) {
SettingsItemSwitch(
text = stringResource(R.string.analytics_okay),
checked = state.analyticsEnabled,
leadingIcon = Icons.Default.BugReport,
onClick = onToggleAnalytics,
)
}
}
}
}
}

View file

@ -43,7 +43,10 @@ import com.geeksville.mesh.ModuleConfigProtos
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.Position
import com.geeksville.mesh.R
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.isAnalyticsAvailable
import com.geeksville.mesh.android.prefs.AnalyticsPrefs
import com.geeksville.mesh.android.prefs.MapConsentPrefs
import com.geeksville.mesh.config
import com.geeksville.mesh.database.entity.MyNodeEntity
@ -92,6 +95,8 @@ data class RadioConfigState(
val ringtone: String = "",
val cannedMessageMessages: String = "",
val responseState: ResponseState<Boolean> = ResponseState.Empty,
val analyticsAvailable: Boolean = true,
val analyticsEnabled: Boolean = false,
)
@HiltViewModel
@ -103,6 +108,7 @@ constructor(
private val radioConfigRepository: RadioConfigRepository,
private val locationRepository: LocationRepository,
private val mapConsentPrefs: MapConsentPrefs,
private val analyticsPrefs: AnalyticsPrefs,
) : ViewModel(),
Logging {
private val meshService: IMeshService?
@ -159,6 +165,8 @@ constructor(
}
.launchIn(viewModelScope)
_radioConfigState.update { it.copy(analyticsAvailable = (app as GeeksvilleApplication).isAnalyticsAvailable) }
debug("RadioConfigViewModel created")
}
@ -695,4 +703,9 @@ constructor(
requestIds.update { it.apply { remove(data.requestId) } }
}
}
fun toggleAnalytics() {
analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
_radioConfigState.update { it.copy(analyticsEnabled = analyticsPrefs.analyticsAllowed) }
}
}