diff --git a/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt b/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt index 876630f43..936f1d379 100644 --- a/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt +++ b/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt @@ -40,11 +40,6 @@ abstract class GeeksvilleApplication : lateinit var analytics: AnalyticsProvider } - val isGooglePlayAvailable: Boolean - get() { - return false - } - // / Are we running inside the testlab? val isInTestLab: Boolean get() { @@ -57,19 +52,8 @@ abstract class GeeksvilleApplication : abstract val analyticsPrefs: AnalyticsPrefs - var isAnalyticsAllowed: Boolean - get() = analyticsPrefs.analyticsAllowed - set(value) { - analyticsPrefs.analyticsAllowed = value - - // Change the flag with the providers - analytics.setEnabled(value && !isInTestLab) // Never do analytics in the test lab - } - - @Suppress("UnusedParameter") - fun askToRate(activity: AppCompatActivity) { - // No-op for F-Droid version - } + @Suppress("EmptyFunctionBlock", "UnusedParameter") + fun askToRate(application: AppCompatActivity) {} override fun onCreate() { super.onCreate() @@ -80,13 +64,9 @@ abstract class GeeksvilleApplication : val nopAnalytics = NopAnalytics(this) analytics = nopAnalytics - isAnalyticsAllowed = false } } -val Context.isGooglePlayAvailable: Boolean - get() = false - @Suppress("UnusedParameter") fun setAttributes(deviceVersion: String, deviceHardware: DeviceHardware) { // No-op for F-Droid version @@ -100,3 +80,6 @@ fun AddNavigationTracking(navController: NavHostController) { debug("Navigation changed to: ${destination.route}") } } + +val Context.isAnalyticsAvailable: Boolean + get() = false diff --git a/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt index ff6ce2f30..997df57a0 100644 --- a/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -17,69 +17,16 @@ package com.geeksville.mesh -import android.os.Debug -import com.geeksville.mesh.android.BuildUtils.isEmulator import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.prefs.AnalyticsPrefs -import com.geeksville.mesh.util.Exceptions -import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.google.firebase.crashlytics.setCustomKeys import dagger.hilt.android.HiltAndroidApp -import timber.log.Timber import javax.inject.Inject @HiltAndroidApp class MeshUtilApplication : GeeksvilleApplication() { - @Inject override lateinit var analyticsPrefs: AnalyticsPrefs override fun onCreate() { super.onCreate() - - // We default to off in the manifest - we turn on here if the user approves - // leave off when running in the debugger - if (!isEmulator && (!BuildConfig.DEBUG || !Debug.isDebuggerConnected())) { - val crashlytics = FirebaseCrashlytics.getInstance() - crashlytics.setUserId(analyticsPrefs.installId) // be able to group all bugs per anonymous user - - fun sendCrashReports() { - if (isAnalyticsAllowed) { - crashlytics.sendUnsentReports() - } - } - - // Send any old reports if user approves - sendCrashReports() - - // Attach to our exception wrapper - Exceptions.reporter = { exception, _, _ -> - crashlytics.recordException(exception) - sendCrashReports() // Send the new report - } - Timber.plant(CrashlyticsTree()) - } - } -} - -class CrashlyticsTree : Timber.Tree() { - - companion object { - private const val KEY_PRIORITY = "priority" - private const val KEY_TAG = "tag" - private const val KEY_MESSAGE = "message" - } - - override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - FirebaseCrashlytics.getInstance().setCustomKeys { - key(KEY_PRIORITY, priority) - key(KEY_TAG, tag ?: "No Tag") - key(KEY_MESSAGE, message) - } - - if (t == null) { - FirebaseCrashlytics.getInstance().recordException(Exception(message)) - } else { - FirebaseCrashlytics.getInstance().recordException(t) - } } } diff --git a/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt b/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt index 83b4163b4..513c27328 100644 --- a/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt +++ b/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt @@ -19,6 +19,7 @@ package com.geeksville.mesh.android import android.app.Application import android.content.Context +import android.content.SharedPreferences import android.provider.Settings import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable @@ -52,6 +53,11 @@ import com.geeksville.mesh.model.DeviceHardware import com.geeksville.mesh.util.exceptionReporter import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailabilityLight +import com.google.firebase.Firebase +import com.google.firebase.analytics.analytics +import com.google.firebase.crashlytics.crashlytics +import com.google.firebase.crashlytics.setCustomKeys +import com.google.firebase.initialize import com.suddenh4x.ratingdialog.AppRating import io.opentracing.util.GlobalTracer import timber.log.Timber @@ -76,29 +82,6 @@ abstract class GeeksvilleApplication : abstract val analyticsPrefs: AnalyticsPrefs - var isAnalyticsAllowed: Boolean - get() = analyticsPrefs.analyticsAllowed - set(value) { - analyticsPrefs.analyticsAllowed = value - val newConsent = - if (value && !isInTestLab) { - TrackingConsent.GRANTED - } else { - TrackingConsent.NOT_GRANTED - } - - info(if (value) "Analytics enabled" else "Analytics disabled") - - if (Datadog.isInitialized()) { - Datadog.setTrackingConsent(newConsent) - } else { - initDatadog() - } - - // Change the flag with the providers - analytics.setEnabled(value && !isInTestLab) // Never do analytics in the test lab - } - private val minimumLaunchTimes: Int = 10 private val minimumDays: Int = 10 private val minimumLaunchTimesToShowAgain: Int = 5 @@ -130,19 +113,71 @@ abstract class GeeksvilleApplication : } } + lateinit var analyticsPrefsChangedListener: SharedPreferences.OnSharedPreferenceChangeListener + override fun onCreate() { super.onCreate() - - val firebaseAnalytics = FirebaseAnalytics(analyticsPrefs.installId) - analytics = firebaseAnalytics - - // Set analytics per prefs - isAnalyticsAllowed = isAnalyticsAllowed initDatadog() + initCrashlytics() + updateAnalyticsConsent() + // listen for changes to analytics prefs + analyticsPrefsChangedListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == "allowed") { + updateAnalyticsConsent() + } + } + getSharedPreferences("analytics-prefs", MODE_PRIVATE) + .registerOnSharedPreferenceChangeListener(analyticsPrefsChangedListener) } private val sampleRate = 100f + private fun initCrashlytics() { + analytics = FirebaseAnalytics(analyticsPrefs.installId) + Firebase.initialize(this) + Firebase.crashlytics.setUserId(analyticsPrefs.installId) + Timber.plant(CrashlyticsTree()) + } + + private fun updateAnalyticsConsent() { + if (!isAnalyticsAvailable || isInTestLab) { + info("Analytics not available") + return + } + val isAnalyticsAllowed = analyticsPrefs.analyticsAllowed + info(if (isAnalyticsAllowed) "Analytics enabled" else "Analytics disabled") + Datadog.setTrackingConsent(if (isAnalyticsAllowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED) + + analytics.setEnabled(isAnalyticsAllowed) + Firebase.crashlytics.isCrashlyticsCollectionEnabled = isAnalyticsAllowed + Firebase.analytics.setAnalyticsCollectionEnabled(isAnalyticsAllowed) + Firebase.crashlytics.sendUnsentReports() + } + + private class CrashlyticsTree : Timber.Tree() { + + companion object { + private const val KEY_PRIORITY = "priority" + private const val KEY_TAG = "tag" + private const val KEY_MESSAGE = "message" + } + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + Firebase.crashlytics.setCustomKeys { + key(KEY_PRIORITY, priority) + key(KEY_TAG, tag ?: "No Tag") + key(KEY_MESSAGE, message) + } + + if (t == null) { + Firebase.crashlytics.recordException(Exception(message)) + } else { + Firebase.crashlytics.recordException(t) + } + } + } + private fun initDatadog() { val logger = Logger.Builder() @@ -161,13 +196,9 @@ abstract class GeeksvilleApplication : .setCrashReportsEnabled(true) .setUseDeveloperModeWhenDebuggable(true) .build() - val consent = - if (isAnalyticsAllowed && !isInTestLab) { - TrackingConsent.GRANTED - } else { - TrackingConsent.NOT_GRANTED - } + val consent = TrackingConsent.PENDING Datadog.initialize(this, configuration, consent) + Datadog.setUserInfo(analyticsPrefs.installId) val rumConfiguration = RumConfiguration.Builder(BuildConfig.datadogApplicationId) @@ -207,12 +238,17 @@ fun setAttributes(firmwareVersion: String, deviceHardware: DeviceHardware) { GlobalRumMonitor.get().addAttribute("device_hardware", deviceHardware.hwModelSlug) } -val Context.isGooglePlayAvailable: Boolean +private val Context.isGooglePlayAvailable: Boolean get() = GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(this).let { it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID } +private val isDatadogAvailable: Boolean = Datadog.isInitialized() + +val Context.isAnalyticsAvailable: Boolean + get() = isDatadogAvailable && isGooglePlayAvailable + @OptIn(ExperimentalTrackingApi::class) @Composable fun AddNavigationTracking(navController: NavHostController) { diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt index bbd169372..1afeeb1cd 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt @@ -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() } 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 2c016c183..26445b48a 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,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) }, ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt index 8827002bd..20a8f3ac6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt @@ -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), - ) - } - } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/components/SettingsItem.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/components/SettingsItem.kt index c410377ab..5617cfb7a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/components/SettingsItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/components/SettingsItem.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfig.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfig.kt index 2e95605b2..d7ef8119a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfig.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfig.kt @@ -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, + ) + } + } + } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfigViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfigViewModel.kt index 158da8568..e9ac7234a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfigViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfigViewModel.kt @@ -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 = 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) } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5381e7cff..a5907036b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,7 +100,7 @@ Send You haven\'t yet paired a Meshtastic compatible radio with this phone. Please pair a device and set your username.\n\nThis open-source application is in development, if you find problems please post on our forum: https://github.com/orgs/meshtastic/discussions.\n\nFor more information see our web page - www.meshtastic.org. You - Anonymous usage statistics and crash reports. + Allow analytics and crash reporting. Accept Cancel Clear changes @@ -798,4 +798,5 @@ URL Template https://a.tile.openstreetmap.org/{z}/{x}/{y}.png track point + Phone Settings