mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 02:01:35 -04:00
refactor(analytics): reduce tracking footprint (#4649)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user