mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: migrate core modules to Kotlin Multiplatform and consolidat… (#4735)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
f3775a601c
commit
cffbd08806
265 changed files with 1383 additions and 1340 deletions
|
|
@ -0,0 +1,315 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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 org.meshtastic.app.analytics
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import co.touchlab.kermit.LogWriter
|
||||
import co.touchlab.kermit.Severity
|
||||
import com.datadog.android.Datadog
|
||||
import com.datadog.android.DatadogSite
|
||||
import com.datadog.android.core.configuration.Configuration
|
||||
import com.datadog.android.log.Logger
|
||||
import com.datadog.android.log.Logs
|
||||
import com.datadog.android.log.LogsConfiguration
|
||||
import com.datadog.android.privacy.TrackingConsent
|
||||
import com.datadog.android.rum.GlobalRumMonitor
|
||||
import com.datadog.android.rum.Rum
|
||||
import com.datadog.android.rum.RumConfiguration
|
||||
import com.datadog.android.trace.Trace
|
||||
import com.datadog.android.trace.TraceConfiguration
|
||||
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
|
||||
import com.google.firebase.initialize
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.opentelemetry.api.GlobalOpenTelemetry
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.meshtastic.app.BuildConfig
|
||||
import org.meshtastic.core.repository.AnalyticsPrefs
|
||||
import org.meshtastic.core.repository.DataPair
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import javax.inject.Inject
|
||||
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,
|
||||
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")
|
||||
return "true" == testLabSetting
|
||||
}
|
||||
|
||||
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 {
|
||||
// Setup Kermit log writers immediately, they will handle delayed SDK initialization gracefully.
|
||||
val writers = buildList {
|
||||
add(DatadogLogWriter())
|
||||
add(CrashlyticsLogWriter())
|
||||
if (BuildConfig.DEBUG) {
|
||||
add(co.touchlab.kermit.LogcatWriter())
|
||||
}
|
||||
}
|
||||
KermitLogger.setLogWriters(writers)
|
||||
KermitLogger.setMinSeverity(if (BuildConfig.DEBUG) Severity.Debug else Severity.Info)
|
||||
|
||||
// Initial consent state
|
||||
updateAnalyticsConsent(analyticsPrefs.analyticsAllowed.value)
|
||||
|
||||
// Subscribe to analytics preference changes
|
||||
analyticsPrefs.analyticsAllowed
|
||||
.onEach { allowed -> updateAnalyticsConsent(allowed) }
|
||||
.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.value || 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(
|
||||
clientToken = BuildConfig.datadogClientToken,
|
||||
env = if (BuildConfig.DEBUG) "debug" else "release",
|
||||
variant = BuildConfig.FLAVOR,
|
||||
)
|
||||
.useSite(DatadogSite.US5)
|
||||
.setCrashReportsEnabled(true)
|
||||
.setUseDeveloperModeWhenDebuggable(true)
|
||||
.build()
|
||||
// Initialize with PENDING, consent will be updated via updateAnalyticsConsent
|
||||
Datadog.initialize(application, configuration, TrackingConsent.PENDING)
|
||||
Datadog.setVerbosity(if (BuildConfig.DEBUG) android.util.Log.DEBUG else android.util.Log.WARN)
|
||||
|
||||
val rumConfiguration =
|
||||
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
|
||||
.trackAnonymousUser(true)
|
||||
.trackBackgroundEvents(false) // Disable background noise
|
||||
.trackFrustrations(false) // Disable click-tracking based frustration detection
|
||||
.trackLongTasks()
|
||||
.trackNonFatalAnrs(true)
|
||||
.setSessionSampleRate(sampleRate)
|
||||
.build()
|
||||
Rum.enable(rumConfiguration)
|
||||
|
||||
val logsConfig = LogsConfiguration.Builder().build()
|
||||
Logs.enable(logsConfig)
|
||||
|
||||
val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(false).build()
|
||||
Trace.enable(traceConfig)
|
||||
|
||||
GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
|
||||
}
|
||||
|
||||
private fun initCrashlytics(application: Application) {
|
||||
Firebase.initialize(application)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the consent status for analytics, performance, and crash reporting services.
|
||||
*
|
||||
* @param allowed True if analytics are allowed, false otherwise.
|
||||
*/
|
||||
fun updateAnalyticsConsent(allowed: Boolean) {
|
||||
if (isInTestLab) return
|
||||
|
||||
if (allowed) {
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDeviceAttributes(firmwareVersion: String, model: String) {
|
||||
if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
|
||||
GlobalRumMonitor.get().addAttribute("firmware_version", firmwareVersion.extractSemanticVersion())
|
||||
GlobalRumMonitor.get().addAttribute("device_hardware", model)
|
||||
}
|
||||
|
||||
private val isGooglePlayAvailable: Boolean
|
||||
get() =
|
||||
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let {
|
||||
it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID
|
||||
}
|
||||
|
||||
override val isPlatformServicesAvailable: Boolean
|
||||
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
|
||||
Firebase.crashlytics.log("$severity/$tag: $message")
|
||||
|
||||
// Filter out normal coroutine cancellations
|
||||
if (throwable is CancellationException) return
|
||||
|
||||
// Only record non-fatal exceptions for actual Errors (Severity.Error or Severity.Assert)
|
||||
if (severity >= Severity.Error) {
|
||||
if (throwable != null) {
|
||||
Firebase.crashlytics.recordException(throwable)
|
||||
} else {
|
||||
Firebase.crashlytics.setCustomKeys {
|
||||
key(KEY_PRIORITY, severity.ordinal)
|
||||
key(KEY_TAG, tag)
|
||||
key(KEY_MESSAGE, message)
|
||||
}
|
||||
Firebase.crashlytics.recordException(Exception(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
Severity.Debug -> android.util.Log.DEBUG
|
||||
Severity.Info -> android.util.Log.INFO
|
||||
Severity.Warn -> android.util.Log.WARN
|
||||
Severity.Error -> android.util.Log.ERROR
|
||||
Severity.Assert -> android.util.Log.ASSERT
|
||||
}
|
||||
logger.log(datadogPriority, message, throwable, mapOf("tag" to tag))
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.extractSemanticVersion(): String {
|
||||
val regex = "^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?$".toRegex()
|
||||
val matchResult = regex.find(this)
|
||||
return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".") ?: this
|
||||
}
|
||||
|
||||
override fun track(event: String, vararg properties: DataPair) {
|
||||
if (!isFirebaseInitialized) return
|
||||
val bundle = Bundle()
|
||||
properties.forEach {
|
||||
val value = it.value
|
||||
when (value) {
|
||||
is Double -> bundle.putDouble(it.name, value)
|
||||
is Int -> bundle.putLong(it.name, value.toLong()) // Firebase expects Long for integer values in bundles
|
||||
is Long -> bundle.putLong(it.name, value)
|
||||
is Float -> bundle.putDouble(it.name, value.toDouble())
|
||||
is String -> bundle.putString(it.name, value) // Explicitly handle String
|
||||
else -> bundle.putString(it.name, value.toString()) // Fallback for other types
|
||||
}
|
||||
KermitLogger.withTag(TAG).d { "Analytics: track $event (${it.name} : $value)" }
|
||||
}
|
||||
Firebase.analytics.logEvent(event, bundle)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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 org.meshtastic.app.di
|
||||
|
||||
import android.content.Context
|
||||
import com.datadog.android.okhttp.DatadogEventListener
|
||||
import com.datadog.android.okhttp.DatadogInterceptor
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.network.service.ApiService
|
||||
import org.meshtastic.core.network.service.ApiServiceImpl
|
||||
import java.io.File
|
||||
import javax.inject.Singleton
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
interface GoogleNetworkModule {
|
||||
|
||||
@Binds @Singleton
|
||||
fun bindApiService(apiServiceImpl: ApiServiceImpl): ApiService
|
||||
|
||||
companion object {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
@ApplicationContext context: Context,
|
||||
buildConfigProvider: BuildConfigProvider,
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.cache(
|
||||
cache =
|
||||
Cache(
|
||||
directory = File(context.applicationContext.cacheDir, "http_cache"),
|
||||
maxSize = 50L * 1024L * 1024L, // 50 MiB
|
||||
),
|
||||
)
|
||||
.addInterceptor(
|
||||
interceptor =
|
||||
HttpLoggingInterceptor().apply {
|
||||
if (buildConfigProvider.isDebug) {
|
||||
setLevel(HttpLoggingInterceptor.Level.BODY)
|
||||
}
|
||||
},
|
||||
)
|
||||
.addInterceptor(
|
||||
interceptor = DatadogInterceptor.Builder(tracedHosts = listOf("meshtastic.org")).build(),
|
||||
)
|
||||
.eventListenerFactory(eventListenerFactory = DatadogEventListener.Factory())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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 org.meshtastic.app.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.meshtastic.app.analytics.GooglePlatformAnalytics
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Hilt module to provide the [GooglePlatformAnalytics] for the google flavor. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class GooglePlatformAnalyticsModule {
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindPlatformHelper(googlePlatformHelper: GooglePlatformAnalytics): PlatformAnalytics
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue