refactor(analytics): reduce tracking footprint (#4649)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-02-25 20:44:13 -06:00
committed by GitHub
parent ceeb28945d
commit 85c6ed61bb
4 changed files with 109 additions and 163 deletions

View File

@@ -135,6 +135,21 @@
android:name="google_analytics_default_allow_ad_personalization_signals"
android:value="false" />
<!-- Disable SSAID collection to reduce PII footprint -->
<meta-data
android:name="google_analytics_ssaid_collection_enabled"
android:value="false" />
<!-- Disable automatic screen reporting to reduce background noise -->
<meta-data
android:name="google_analytics_automatic_screen_reporting_enabled"
android:value="false" />
<!-- Set default analytics storage consent to denied for Advanced Consent Mode -->
<meta-data
android:name="google_analytics_default_allow_analytics_storage"
android:value="false" />
<!-- This is the public API for doing mesh radio operations from android apps -->
<service
android:name="com.geeksville.mesh.service.MeshService"

View File

@@ -1,119 +0,0 @@
/*
* Copyright (c) 2026 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.widget
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
@OptIn(ExperimentalCoroutinesApi::class)
class LocalStatsWidgetStateProviderTest {
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val nodeDbFlow = MutableStateFlow<Map<Int, Node>>(emptyMap())
private val localStatsFlow = MutableStateFlow(LocalStats())
private val ourNodeInfoFlow = MutableStateFlow<Node?>(null)
private val serviceRepository = mockk<ServiceRepository>(relaxed = true)
private val nodeRepository = mockk<NodeRepository>(relaxed = true)
@Before
fun setUp() {
mockkStatic("org.meshtastic.core.resources.ContextExtKt")
mockkStatic("org.meshtastic.core.model.util.TimeUtilsKt")
coEvery { getStringSuspend(any()) } returns "Mock String"
coEvery { getStringSuspend(any(), *anyVararg()) } returns "Mock Formatted String"
every { onlineTimeThreshold() } returns 0
// Explicitly return flows from mocks
every { serviceRepository.connectionState } returns connectionStateFlow
every { nodeRepository.nodeDBbyNum } returns nodeDbFlow
every { nodeRepository.localStats } returns localStatsFlow
every { nodeRepository.ourNodeInfo } returns ourNodeInfoFlow
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `initial state reflects disconnected status`() = runTest {
val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
val state = provider.state.first()
assertEquals(ConnectionState.Disconnected, state.connectionState)
assertFalse(state.showContent)
}
@Test
fun `connected state shows content and maps node info`() = runTest {
connectionStateFlow.value = ConnectionState.Connected
ourNodeInfoFlow.value =
Node(
num = 123,
user = User(short_name = "ABC"),
deviceMetrics = DeviceMetrics(battery_level = 85, channel_utilization = 12.5f),
)
val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
val state =
provider.state.first { (it.connectionState == ConnectionState.Connected) && (it.nodeShortName == "ABC") }
assertTrue(state.showContent)
assertEquals("ABC", state.nodeShortName)
assertEquals("85%", state.batteryValue)
}
@Test
fun `node count and update timestamp are populated`() = runTest {
connectionStateFlow.value = ConnectionState.Connected
nodeDbFlow.value = mapOf(1 to Node(num = 1, lastHeard = 1000))
val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
val state = provider.state.first { it.nodeCountText == "1/1" }
assertEquals("1/1", state.nodeCountText)
assertEquals("Mock Formatted String", state.updatedText)
}
}

View File

@@ -30,7 +30,6 @@ import com.datadog.android.Datadog
import com.datadog.android.DatadogSite
import com.datadog.android.compose.ExperimentalTrackingApi
import com.datadog.android.compose.NavigationViewTrackingEffect
import com.datadog.android.compose.enableComposeActionTracking
import com.datadog.android.core.configuration.Configuration
import com.datadog.android.log.Logger
import com.datadog.android.log.Logs
@@ -46,6 +45,8 @@ import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailabilityLight
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics.ConsentStatus
import com.google.firebase.analytics.FirebaseAnalytics.ConsentType
import com.google.firebase.analytics.analytics
import com.google.firebase.crashlytics.crashlytics
import com.google.firebase.crashlytics.setCustomKeys
@@ -64,16 +65,22 @@ import co.touchlab.kermit.Logger as KermitLogger
/**
* Google Play Services specific implementation of [PlatformAnalytics]. This helper initializes and manages Firebase and
* Datadog services, and subscribes to analytics preference changes to update consent accordingly.
*
* This implementation delays initialization of SDKs until user consent is granted to reduce tracking "noise" and
* respect privacy-focused environments.
*/
class GooglePlatformAnalytics
@Inject
constructor(
@ApplicationContext private val context: Context,
analyticsPrefs: AnalyticsPrefs,
private val analyticsPrefs: AnalyticsPrefs,
) : PlatformAnalytics {
private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
private var datadogLogger: Logger? = null
private var isFirebaseInitialized = false
private val isInTestLab: Boolean
get() {
val testLabSetting = Settings.System.getString(context.contentResolver, "firebase.test.lab")
@@ -83,22 +90,16 @@ constructor(
companion object {
private const val TAG = "GooglePlatformAnalytics"
private const val SERVICE_NAME = "org.meshtastic"
private const val KEY_PRIORITY = "priority"
private const val KEY_TAG = "tag"
private const val KEY_MESSAGE = "message"
}
init {
initDatadog(context as Application)
initCrashlytics(context as Application)
val datadogLogger =
Logger.Builder()
.setService(SERVICE_NAME)
.setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID
.setRemoteSampleRate(sampleRate)
.setBundleWithTraceEnabled(true)
.setBundleWithRumEnabled(true)
.build()
// Setup Kermit log writers immediately, they will handle delayed SDK initialization gracefully.
val writers = buildList {
add(DatadogLogWriter(datadogLogger))
add(DatadogLogWriter())
add(CrashlyticsLogWriter())
if (BuildConfig.DEBUG) {
add(co.touchlab.kermit.LogcatWriter())
@@ -117,6 +118,30 @@ constructor(
.launchIn(ProcessLifecycleOwner.get().lifecycleScope)
}
/**
* Ensures that Datadog and Firebase SDKs are initialized if allowed. This is called lazily when consent is granted.
*/
private fun ensureInitialized() {
if (!analyticsPrefs.analyticsAllowed || isInTestLab) return
if (!Datadog.isInitialized()) {
initDatadog(context as Application)
datadogLogger =
Logger.Builder()
.setService(SERVICE_NAME)
.setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID
.setRemoteSampleRate(sampleRate)
.setBundleWithTraceEnabled(true)
.setBundleWithRumEnabled(true)
.build()
}
if (!isFirebaseInitialized) {
initCrashlytics(context as Application)
isFirebaseInitialized = true
}
}
private fun initDatadog(application: Application) {
val configuration =
Configuration.Builder(
@@ -135,13 +160,11 @@ constructor(
val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackAnonymousUser(true)
.trackBackgroundEvents(true)
.trackFrustrations(true)
.trackBackgroundEvents(false) // Disable background noise
.trackFrustrations(false) // Disable click-tracking based frustration detection
.trackLongTasks()
.trackNonFatalAnrs(true)
.trackUserInteractions()
.setSessionSampleRate(sampleRate)
.enableComposeActionTracking()
.build()
Rum.enable(rumConfiguration)
@@ -153,12 +176,24 @@ constructor(
GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
// Session Replay disabled to reduce PII collection as requested
// Session Replay disabled to reduce PII collection
}
private fun initCrashlytics(application: Application) {
Firebase.initialize(application)
// User ID tracking disabled to avoid collecting Unique Identifier PII
// Deny all ad-related consent types by default to minimize tracking noise
Firebase.analytics.setConsent(
mapOf(
ConsentType.AD_STORAGE to ConsentStatus.DENIED,
ConsentType.AD_USER_DATA to ConsentStatus.DENIED,
ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED,
ConsentType.ANALYTICS_STORAGE to ConsentStatus.DENIED,
),
)
// Explicitly disable analytics collection until we confirm user consent
Firebase.analytics.setAnalyticsCollectionEnabled(false)
}
/**
@@ -167,18 +202,39 @@ constructor(
* @param allowed True if analytics are allowed, false otherwise.
*/
fun updateAnalyticsConsent(allowed: Boolean) {
if (!isPlatformServicesAvailable || isInTestLab) {
KermitLogger.i { "Analytics not available or in test lab, consent update skipped." }
return
}
KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" }
Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED)
Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed
Firebase.analytics.setAnalyticsCollectionEnabled(allowed)
if (isInTestLab) return
if (allowed) {
Firebase.crashlytics.sendUnsentReports()
ensureInitialized()
}
KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" }
if (Datadog.isInitialized()) {
Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED)
}
if (isFirebaseInitialized) {
Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed
Firebase.analytics.setAnalyticsCollectionEnabled(allowed)
if (allowed) {
Firebase.crashlytics.sendUnsentReports()
// Ensure ad-related PII collection remains disabled even if analytics is allowed
Firebase.analytics.setUserProperty("allow_personalized_ads", "false")
}
// Manage Analytics Storage consent for Advanced Consent Mode
val consentStatus = if (allowed) ConsentStatus.GRANTED else ConsentStatus.DENIED
Firebase.analytics.setConsent(
mapOf(
ConsentType.ANALYTICS_STORAGE to consentStatus,
// Keep ad-related types explicitly denied
ConsentType.AD_STORAGE to ConsentStatus.DENIED,
ConsentType.AD_USER_DATA to ConsentStatus.DENIED,
ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED,
),
)
}
}
@@ -206,20 +262,12 @@ constructor(
it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID
}
private val isDatadogAvailable: Boolean
get() = Datadog.isInitialized()
override val isPlatformServicesAvailable: Boolean
get() = isGooglePlayAvailable && isDatadogAvailable
private class CrashlyticsLogWriter : LogWriter() {
companion object {
private const val KEY_PRIORITY = "priority"
private const val KEY_TAG = "tag"
private const val KEY_MESSAGE = "message"
}
get() = isGooglePlayAvailable
private inner class CrashlyticsLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
if (!isFirebaseInitialized) return
if (!Firebase.crashlytics.isCrashlyticsCollectionEnabled) return
// Add the log to the Crashlytics log buffer so it appears in reports
@@ -244,8 +292,9 @@ constructor(
}
}
private class DatadogLogWriter(private val datadogLogger: Logger) : LogWriter() {
private inner class DatadogLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val logger = datadogLogger ?: return
val datadogPriority =
when (severity) {
Severity.Verbose -> android.util.Log.VERBOSE
@@ -255,7 +304,7 @@ constructor(
Severity.Error -> android.util.Log.ERROR
Severity.Assert -> android.util.Log.ASSERT
}
datadogLogger.log(datadogPriority, message, throwable, mapOf("tag" to tag))
logger.log(datadogPriority, message, throwable, mapOf("tag" to tag))
}
}
@@ -266,6 +315,7 @@ constructor(
}
override fun track(event: String, vararg properties: DataPair) {
if (!isFirebaseInitialized) return
val bundle = Bundle()
properties.forEach {
when (it.value) {

View File

@@ -60,7 +60,7 @@ constructor(
@AppSharedPreferences appPrefs: SharedPreferences,
) : AnalyticsPrefs {
override var analyticsAllowed: Boolean by
PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, true)
PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, false)
private var _installId: String? by NullableStringPrefDelegate(appPrefs, "appPrefs_install_id", null)