feat(analytics): Integrate Datadog for RUM and Logging (#2578)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-01 16:54:46 -05:00 committed by GitHub
parent f5478b42c3
commit ab22a655c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 348 additions and 194 deletions

View file

@ -16,6 +16,8 @@ jobs:
upload_artifacts: false
secrets:
GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
androidTest:
if: github.repository == 'meshtastic/Meshtastic-Android'

View file

@ -12,12 +12,19 @@ concurrency:
cancel-in-progress: true
jobs:
test_secrets:
runs-on: ubuntu-latest
env:
TEST_SECRET: ${{ secrets.TEST_SECRET }}
steps:
- name: Test Secrets
run: |
echo "$TEST_SECRET"
build_and_detekt:
if: github.repository == 'meshtastic/Meshtastic-Android' && github.head_ref != 'scheduled-updates'
uses: ./.github/workflows/reusable-android-build.yml
secrets:
GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
# inputs.upload_artifacts defaults to true, so no need to specify for PRs
secrets: inherit
androidTest:
# Assuming androidTest should also only run for the main repository

View file

@ -99,6 +99,9 @@ jobs:
needs: prepare-release-info # Depends on version info
runs-on: ubuntu-latest
if: github.repository == 'meshtastic/Meshtastic-Android'
env:
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
outputs:
aab_path: app/build/outputs/bundle/googleRelease/app-google-release.aab
aab_name: googleRelease-${{ needs.prepare-release-info.outputs.versionNameBase }}-${{ needs.prepare-release-info.outputs.versionCode }}.aab
@ -118,11 +121,15 @@ jobs:
echo $GSERVICES > ./app/google-services.json
echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties
env:
GSERVICES: ${{ secrets.GSERVICES }}
KEYSTORE: ${{ secrets.KEYSTORE }}
KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }}
KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }}
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
- name: Set up JDK 21
uses: actions/setup-java@v4

View file

@ -2,21 +2,36 @@ name: Reusable Android Build and Detekt
on:
workflow_call:
secrets:
GRADLE_ENCRYPTION_KEY:
required: false
DATADOG_APPLICATION_ID:
required: false
DATADOG_CLIENT_TOKEN:
required: false
TEST_SECRET:
required: false
inputs:
upload_artifacts:
description: 'Whether to upload build and Detekt artifacts'
required: false
type: boolean
default: true
secrets:
GRADLE_ENCRYPTION_KEY:
required: false
jobs:
build_and_detekt:
runs-on: ubuntu-latest
timeout-minutes: 35
env:
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
steps:
- name: Test Secrets
env:
TEST_SECRET: ${{ secrets.TEST_SECRET }}
run: echo "$TEST_SECRET"
- name: Checkout code
uses: actions/checkout@v4
with:
@ -47,6 +62,11 @@ jobs:
- name: Expose Version Code as Environment Variable
run: echo "VERSION_CODE=${{ steps.calculate_version_code.outputs.versionCode }}" >> $GITHUB_ENV
- name: Load secrets
if: env.DATADOG_APPLICATION_ID != '' && env.DATADOG_CLIENT_TOKEN != ''
run: |
echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties
- name: Run Spotless, Detekt, Build, Lint, and Local Tests
run: ./gradlew :app:spotlessCheck :app:detekt :app:lintFdroidDebug :app:lintGoogleDebug :app:assembleDebug :app:testFdroidDebug :app:testGoogleDebug --configuration-cache --scan
env:

1
.gitignore vendored
View file

@ -29,3 +29,4 @@ keystore.properties
# VS code
.vscode/settings.json
/secrets.properties

View file

@ -28,6 +28,8 @@ plugins {
alias(libs.plugins.protobuf)
alias(libs.plugins.devtools.ksp)
alias(libs.plugins.detekt)
alias(libs.plugins.datadog)
alias(libs.plugins.secrets.gradle.plugin)
alias(libs.plugins.spotless)
}
@ -163,6 +165,17 @@ kotlin {
}
}
secrets {
defaultPropertiesFileName = "secrets.defaults.properties"
propertiesFileName = "secrets.properties"
}
datadog {
// compose instrumentation is broken for kotlin 2.2.x - see:
// https://github.com/DataDog/dd-sdk-android-gradle-plugin/issues/407
// composeInstrumentation = InstrumentationMode.AUTO
}
// per protobuf-gradle-plugin docs, this is recommended for android
protobuf {
protoc { artifact = libs.protobuf.protoc.get().toString() }
@ -180,8 +193,18 @@ protobuf {
androidComponents {
onVariants(selector().all()) { variant ->
project.afterEvaluate {
val capName = variant.name.replaceFirstChar { it.uppercase() }
tasks.named("ksp${capName}Kotlin") { dependsOn("generate${capName}Proto") }
val variantNameCapped = variant.name.replaceFirstChar { it.uppercase() }
tasks.named("ksp${variantNameCapped}Kotlin") { dependsOn("generate${variantNameCapped}Proto") }
}
}
onVariants(selector().withBuildType("release")) { variant ->
if (variant.flavorName == "google") {
val variantNameCapped = variant.name.replaceFirstChar { it.uppercase() }
val minifyTaskName = "minify${variantNameCapped}WithR8"
val uploadTaskName = "uploadMapping$variantNameCapped"
if (project.tasks.findByName(uploadTaskName) != null && project.tasks.findByName(minifyTaskName) != null) {
tasks.named(minifyTaskName).configure { finalizedBy(uploadTaskName) }
}
}
}
}
@ -225,6 +248,7 @@ dependencies {
implementation(libs.work.runtime.ktx)
implementation(libs.core.location.altitude)
implementation(libs.accompanist.permissions)
implementation(libs.timber)
// Compose BOM
implementation(platform(libs.compose.bom))
@ -233,6 +257,7 @@ dependencies {
// Firebase BOM
"googleImplementation"(platform(libs.firebase.bom))
"googleImplementation"(libs.bundles.firebase)
"googleImplementation"(libs.bundles.datadog)
// ksp
ksp(libs.room.compiler)
@ -262,7 +287,7 @@ detekt {
baseline = file("../config/detekt/detekt-baseline.xml")
}
val googleServiceKeywords = listOf("crashlytics", "google")
val googleServiceKeywords = listOf("crashlytics", "google", "datadog")
tasks.configureEach {
if (

View file

@ -18,7 +18,6 @@
package com.geeksville.mesh
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
@ -26,8 +25,5 @@ class MeshUtilApplication : GeeksvilleApplication() {
override fun onCreate() {
super.onCreate()
Logging.showLogs = BuildConfig.DEBUG
}
}
}

View file

@ -23,21 +23,24 @@ import android.content.SharedPreferences
import android.provider.Settings
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.analytics.AnalyticsProvider
import com.geeksville.mesh.model.DeviceHardware
import timber.log.Timber
open class GeeksvilleApplication : Application(), Logging {
open class GeeksvilleApplication :
Application(),
Logging {
companion object {
lateinit var analytics: AnalyticsProvider
}
/// Are we running inside the testlab?
// / Are we running inside the testlab?
val isInTestLab: Boolean
get() {
val testLabSetting =
Settings.System.getString(contentResolver, "firebase.test.lab") ?: null
if(testLabSetting != null)
info("Testlab is $testLabSetting")
val testLabSetting = Settings.System.getString(contentResolver, "firebase.test.lab") ?: null
if (testLabSetting != null) info("Testlab is $testLabSetting")
return "true" == testLabSetting
}
@ -48,9 +51,7 @@ open class GeeksvilleApplication : Application(), Logging {
var isAnalyticsAllowed: Boolean
get() = analyticsPrefs.getBoolean("allowed", true)
set(value) {
analyticsPrefs.edit {
putBoolean("allowed", value)
}
analyticsPrefs.edit { putBoolean("allowed", value) }
// Change the flag with the providers
analytics.setEnabled(value && !isInTestLab) // Never do analytics in the test lab
@ -64,10 +65,18 @@ open class GeeksvilleApplication : Application(), Logging {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
val nopAnalytics = com.geeksville.mesh.analytics.NopAnalytics(this)
analytics = nopAnalytics
isAnalyticsAllowed = false
}
}
fun Context.isGooglePlayAvailable(): Boolean = false
fun Context.isGooglePlayAvailable(): Boolean = false
fun setAttributes(deviceVersion: String, deviceHardware: DeviceHardware) {
// No-op for F-Droid version
}

View file

@ -21,11 +21,11 @@ import android.os.Debug
import com.geeksville.mesh.android.AppPrefs
import com.geeksville.mesh.android.BuildUtils.isEmulator
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.util.Exceptions
import com.google.firebase.crashlytics.crashlytics
import com.google.firebase.Firebase
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.crashlytics.setCustomKeys
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@HiltAndroidApp
class MeshUtilApplication : GeeksvilleApplication() {
@ -33,30 +33,15 @@ class MeshUtilApplication : GeeksvilleApplication() {
override fun onCreate() {
super.onCreate()
Logging.showLogs = BuildConfig.DEBUG
// 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 = Firebase.crashlytics
crashlytics.setCrashlyticsCollectionEnabled(isAnalyticsAllowed)
crashlytics.setCustomKey("debug_build", BuildConfig.DEBUG)
val crashlytics = FirebaseCrashlytics.getInstance()
val pref = AppPrefs(this)
crashlytics.setUserId(pref.getInstallId()) // be able to group all bugs per anonymous user
// We always send our log messages to the crashlytics lib, but they only get sent to the server if we report an exception
// This makes log messages work properly if someone turns on analytics just before they click report bug.
// send all log messages through crashyltics, so if we do crash we'll have those in the report
val standardLogger = Logging.printlog
Logging.printlog = { level, tag, message ->
crashlytics.log("$tag: $message")
standardLogger(level, tag, message)
}
fun sendCrashReports() {
if (isAnalyticsAllowed)
crashlytics.sendUnsentReports()
if (isAnalyticsAllowed) crashlytics.sendUnsentReports()
}
// Send any old reports if user approves
@ -67,6 +52,30 @@ class MeshUtilApplication : GeeksvilleApplication() {
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)
}
}
}

View file

@ -21,44 +21,54 @@ import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.provider.Settings
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
import com.datadog.android.Datadog
import com.datadog.android.DatadogSite
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
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.timber.DatadogTree
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.analytics.AnalyticsProvider
import com.geeksville.mesh.analytics.FirebaseAnalytics
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.suddenh4x.ratingdialog.AppRating
import timber.log.Timber
/**
* Created by kevinh on 1/4/15.
*/
open class GeeksvilleApplication : Application(), Logging {
/** Created by kevinh on 1/4/15. */
open class GeeksvilleApplication :
Application(),
Logging {
companion object {
lateinit var analytics: AnalyticsProvider
}
/// Are we running inside the testlab?
// / Are we running inside the testlab?
val isInTestLab: Boolean
get() {
val testLabSetting =
Settings.System.getString(contentResolver, "firebase.test.lab") ?: null
if(testLabSetting != null)
info("Testlab is $testLabSetting")
val testLabSetting = Settings.System.getString(contentResolver, "firebase.test.lab") ?: null
if (testLabSetting != null) info("Testlab is $testLabSetting")
return "true" == testLabSetting
}
private val analyticsPrefs: SharedPreferences by lazy {
getSharedPreferences("analytics-prefs", Context.MODE_PRIVATE)
}
private val analyticsPrefs: SharedPreferences by lazy { getSharedPreferences("analytics-prefs", MODE_PRIVATE) }
var isAnalyticsAllowed: Boolean
get() = analyticsPrefs.getBoolean("allowed", true)
set(value) {
analyticsPrefs.edit {
putBoolean("allowed", value)
}
analyticsPrefs.edit { putBoolean("allowed", value) }
// Change the flag with the providers
analytics.setEnabled(value && !isInTestLab) // Never do analytics in the test lab
@ -68,12 +78,20 @@ open class GeeksvilleApplication : Application(), Logging {
fun askToRate(activity: AppCompatActivity) {
if (!isGooglePlayAvailable()) return
exceptionReporter { // we don't want to crash our app because of bugs in this optional feature
exceptionReporter {
// we don't want to crash our app because of bugs in this optional feature
AppRating.Builder(activity)
.setMinimumLaunchTimes(10) // default is 5, 3 means app is launched 3 or more times
.setMinimumDays(10) // default is 5, 0 means install day, 10 means app is launched 10 or more days later than installation
.setMinimumLaunchTimesToShowAgain(5) // default is 5, 1 means app is launched 1 or more times after neutral button clicked
.setMinimumDaysToShowAgain(14) // default is 14, 1 means app is launched 1 or more days after neutral button clicked
.setMinimumDays(10) // default is 5, 0 means install day, 10 means app is launched 10 or more days
// later than installation
.setMinimumLaunchTimesToShowAgain(
5,
) // default is 5, 1 means app is launched 1 or more times after neutral button
// clicked
.setMinimumDaysToShowAgain(
14,
) // default is 14, 1 means app is launched 1 or more days after neutral button
// clicked
.showIfMeetsConditions()
}
}
@ -81,19 +99,64 @@ open class GeeksvilleApplication : Application(), Logging {
override fun onCreate() {
super.onCreate()
val firebaseAnalytics = com.geeksville.mesh.analytics.FirebaseAnalytics(this)
val logger =
Logger.Builder()
.setNetworkInfoEnabled(true)
.setLogcatLogsEnabled(true)
.setRemoteSampleRate(100f)
.setBundleWithTraceEnabled(true)
.setName("TimberLogger")
.build()
val firebaseAnalytics = FirebaseAnalytics(this)
analytics = firebaseAnalytics
// Set analytics per prefs
isAnalyticsAllowed = isAnalyticsAllowed
if (isAnalyticsAllowed || BuildConfig.DEBUG) {
// datadog analytics
val configuration =
Configuration.Builder(
clientToken = BuildConfig.datadogClientToken,
env = if (BuildConfig.DEBUG || true) "debug" else "release",
variant = BuildConfig.FLAVOR,
)
.useSite(DatadogSite.US5)
.setCrashReportsEnabled(true)
.setUseDeveloperModeWhenDebuggable(true)
.build()
val consent =
if (isAnalyticsAllowed) {
TrackingConsent.GRANTED
} else {
TrackingConsent.NOT_GRANTED
}
Datadog.initialize(this, configuration, consent)
Datadog.setVerbosity(Log.VERBOSE)
val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackUserInteractions()
.trackLongTasks()
.trackBackgroundEvents(true)
.enableComposeActionTracking()
.build()
Rum.enable(rumConfiguration)
val logsConfig = LogsConfiguration.Builder().build()
Logs.enable(logsConfig)
Timber.plant(Timber.DebugTree(), DatadogTree(logger))
}
}
}
fun Context.isGooglePlayAvailable(): Boolean {
return GoogleApiAvailabilityLight.getInstance()
.isGooglePlayServicesAvailable(this)
.let {
it != ConnectionResult.SERVICE_MISSING &&
it != ConnectionResult.SERVICE_INVALID
}
}
fun setAttributes(firmwareVersion: String, deviceHardware: DeviceHardware) {
GlobalRumMonitor.get().addAttribute("firmware_version", firmwareVersion)
GlobalRumMonitor.get().addAttribute("device_hardware", deviceHardware.hwModelSlug)
}
fun Context.isGooglePlayAvailable(): Boolean =
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(this).let {
it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID
}

View file

@ -17,73 +17,32 @@
package com.geeksville.mesh.android
import android.os.Build
import android.util.Log
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.util.Exceptions
/**
* Created by kevinh on 12/24/14.
*/
typealias LogPrinter = (Int, String, String) -> Unit
import timber.log.Timber
interface Logging {
companion object {
/** Some vendors strip log messages unless the severity is super high.
*
* alps == Soyes
* HMD Global == mfg of the Nokia 7.2
*/
private val badVendors = setOf("OnePlus", "alps", "HMD Global", "Sony")
private fun tag(): String = this.javaClass.name
/// if false NO logs will be shown, set this in the application based on BuildConfig.DEBUG
var showLogs = true
fun info(msg: String) = Timber.tag(tag()).i(msg)
/** if true, all logs will be printed at error level. Sometimes necessary for buggy ROMs
* that filter logcat output below this level.
*
* Since there are so many bad vendors, we just always lie if we are a release build
*/
var forceErrorLevel = !BuildConfig.DEBUG || badVendors.contains(Build.MANUFACTURER)
fun debug(msg: String) = Timber.tag(tag()).d(msg)
/// If false debug logs will not be shown (but others might)
var showDebug = true
fun warn(msg: String) = Timber.tag(tag()).w(msg)
/**
* By default all logs are printed using the standard android Log class. But clients
* can change printlog to a different implementation (for logging to files or via
* google crashlytics)
*/
var printlog: LogPrinter = { level, tag, message ->
if (showLogs) {
if (showDebug || level > Log.DEBUG) {
Log.println(if (forceErrorLevel) Log.ERROR else level, tag, message)
}
}
/**
* Log an error message, note - we call this errormsg rather than error because error() is a stdlib function in
* kotlin in the global namespace and we don't want users to accidentally call that.
*/
fun errormsg(msg: String, ex: Throwable? = null) {
if (ex?.message != null) {
Timber.tag(tag()).e(ex, msg)
} else {
Timber.tag(tag()).e(msg)
}
}
private fun tag(): String = this.javaClass.getName()
fun info(msg: String) = printlog(Log.INFO, tag(), msg)
fun verbose(msg: String) = printlog(Log.VERBOSE, tag(), msg)
fun debug(msg: String) = printlog(Log.DEBUG, tag(), msg)
fun warn(msg: String) = printlog(Log.WARN, tag(), msg)
/**
* Log an error message, note - we call this errormsg rather than error because error() is
* a stdlib function in kotlin in the global namespace and we don't want users to accidentally call that.
*/
fun errormsg(msg: String, ex: Throwable? = null) {
if (ex?.message != null)
printlog(Log.ERROR, tag(), "$msg (exception ${ex.message})")
else
printlog(Log.ERROR, tag(), "$msg")
}
/// Kotlin assertions are disabled on android, so instead we use this assert helper
// / Kotlin assertions are disabled on android, so instead we use this assert helper
fun logAssert(f: Boolean) {
if (!f) {
val ex = AssertionError("Assertion failed")
@ -93,8 +52,8 @@ interface Logging {
}
}
/// Report an error (including messaging our crash reporter service if allowed
// / Report an error (including messaging our crash reporter service if allowed
fun reportError(s: String) {
Exceptions.report(Exception("logging reportError: $s"), s)
}
}
}

View file

@ -56,6 +56,7 @@ import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.database.entity.asDeviceVersion
import com.geeksville.mesh.repository.api.DeviceHardwareRepository
import com.geeksville.mesh.repository.api.FirmwareReleaseRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.location.LocationRepository
@ -192,6 +193,7 @@ constructor(
private val radioConfigRepository: RadioConfigRepository,
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: MeshLogRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val packetRepository: PacketRepository,
private val quickChatActionRepository: QuickChatActionRepository,
private val locationRepository: LocationRepository,
@ -219,8 +221,17 @@ constructor(
viewModelScope.launch { _excludedModulesUnlocked.value = true }
}
val firmwareVersion = myNodeInfo.mapNotNull { nodeInfo -> nodeInfo?.firmwareVersion }
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmwareEdition }
val deviceHardware: StateFlow<DeviceHardware?> =
ourNodeInfo
.mapNotNull { nodeInfo ->
nodeInfo?.user?.hwModel?.let { deviceHardwareRepository.getDeviceHardwareByModel(it.number) }
}
.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = null)
val clientNotification: StateFlow<MeshProtos.ClientNotification?> = radioConfigRepository.clientNotification
fun clearClientNotification(notification: MeshProtos.ClientNotification) {

View file

@ -27,7 +27,9 @@ import kotlinx.coroutines.withContext
import java.io.IOException
import javax.inject.Inject
class DeviceHardwareRepository @Inject constructor(
class DeviceHardwareRepository
@Inject
constructor(
private val apiDataSource: DeviceHardwareRemoteDataSource,
private val localDataSource: DeviceHardwareLocalDataSource,
private val jsonDataSource: DeviceHardwareJsonDataSource,
@ -45,15 +47,13 @@ class DeviceHardwareRepository @Inject constructor(
} else {
val cachedHardware = localDataSource.getByHwModel(hwModel)
if (cachedHardware != null && !isCacheExpired(cachedHardware.lastUpdated)) {
debug("Using recent cached device hardware")
val externalModel = cachedHardware.asExternalModel()
return@withContext externalModel
}
}
try {
debug("Fetching device hardware from server")
val deviceHardware = apiDataSource.getAllDeviceHardware()
?: throw IOException("empty response from server")
val deviceHardware =
apiDataSource.getAllDeviceHardware() ?: throw IOException("empty response from server")
localDataSource.insertAllDeviceHardware(deviceHardware)
val cachedHardware = localDataSource.getByHwModel(hwModel)
val externalModel = cachedHardware?.asExternalModel()
@ -65,7 +65,6 @@ class DeviceHardwareRepository @Inject constructor(
debug("Using stale cached device hardware")
return@withContext cachedHardware.asExternalModel()
}
debug("Loading and caching device hardware from local JSON asset")
localDataSource.insertAllDeviceHardware(jsonDataSource.loadDeviceHardwareFromJsonAsset())
cachedHardware = localDataSource.getByHwModel(hwModel)
val externalModel = cachedHardware?.asExternalModel()
@ -78,10 +77,7 @@ class DeviceHardwareRepository @Inject constructor(
localDataSource.deleteAllDeviceHardware()
}
/**
* Check if the cache is expired
*/
private fun isCacheExpired(lastUpdated: Long): Boolean {
return System.currentTimeMillis() - lastUpdated > CACHE_EXPIRATION_TIME_MS
}
/** Check if the cache is expired */
private fun isCacheExpired(lastUpdated: Long): Boolean =
System.currentTimeMillis() - lastUpdated > CACHE_EXPIRATION_TIME_MS
}

View file

@ -17,7 +17,6 @@
package com.geeksville.mesh.repository.api
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.database.entity.FirmwareRelease
import com.geeksville.mesh.database.entity.FirmwareReleaseType
@ -28,7 +27,9 @@ import kotlinx.coroutines.flow.flow
import java.io.IOException
import javax.inject.Inject
class FirmwareReleaseRepository @Inject constructor(
class FirmwareReleaseRepository
@Inject
constructor(
private val apiDataSource: FirmwareReleaseRemoteDataSource,
private val localDataSource: FirmwareReleaseLocalDataSource,
private val jsonDataSource: FirmwareReleaseJsonDataSource,
@ -43,61 +44,50 @@ class FirmwareReleaseRepository @Inject constructor(
val alphaRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.ALPHA)
private fun getLatestFirmware(
releaseType: FirmwareReleaseType,
refresh: Boolean = false
): Flow<FirmwareRelease?> = flow {
if (refresh) {
invalidateCache()
} else {
val cachedRelease = localDataSource.getLatestRelease(releaseType)
if (cachedRelease != null && !isCacheExpired(cachedRelease.lastUpdated)) {
debug("Using recent cached firmware release")
val externalModel = cachedRelease.asExternalModel()
private fun getLatestFirmware(releaseType: FirmwareReleaseType, refresh: Boolean = false): Flow<FirmwareRelease?> =
flow {
if (refresh) {
invalidateCache()
} else {
val cachedRelease = localDataSource.getLatestRelease(releaseType)
if (cachedRelease != null && !isCacheExpired(cachedRelease.lastUpdated)) {
val externalModel = cachedRelease.asExternalModel()
emit(externalModel)
return@flow
}
}
try {
val networkFirmwareReleases =
apiDataSource.getFirmwareReleases() ?: throw IOException("empty response from server")
val releases =
when (releaseType) {
FirmwareReleaseType.STABLE -> networkFirmwareReleases.releases.stable
FirmwareReleaseType.ALPHA -> networkFirmwareReleases.releases.alpha
}
localDataSource.insertFirmwareReleases(releases, releaseType)
val cachedRelease = localDataSource.getLatestRelease(releaseType)
val externalModel = cachedRelease?.asExternalModel()
emit(externalModel)
} catch (e: IOException) {
warn("Failed to fetch firmware releases from server: ${e.message}")
val jsonFirmwareReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
val releases =
when (releaseType) {
FirmwareReleaseType.STABLE -> jsonFirmwareReleases.releases.stable
FirmwareReleaseType.ALPHA -> jsonFirmwareReleases.releases.alpha
}
localDataSource.insertFirmwareReleases(releases, releaseType)
val cachedRelease = localDataSource.getLatestRelease(releaseType)
val externalModel = cachedRelease?.asExternalModel()
emit(externalModel)
return@flow
}
}
try {
debug("Fetching firmware releases from server")
val networkFirmwareReleases = apiDataSource.getFirmwareReleases()
?: throw IOException("empty response from server")
val releases = when (releaseType) {
FirmwareReleaseType.STABLE -> networkFirmwareReleases.releases.stable
FirmwareReleaseType.ALPHA -> networkFirmwareReleases.releases.alpha
}
localDataSource.insertFirmwareReleases(
releases,
releaseType
)
val cachedRelease = localDataSource.getLatestRelease(releaseType)
val externalModel = cachedRelease?.asExternalModel()
emit(externalModel)
} catch (e: IOException) {
warn("Failed to fetch firmware releases from server: ${e.message}")
val jsonFirmwareReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
val releases = when (releaseType) {
FirmwareReleaseType.STABLE -> jsonFirmwareReleases.releases.stable
FirmwareReleaseType.ALPHA -> jsonFirmwareReleases.releases.alpha
}
localDataSource.insertFirmwareReleases(
releases,
releaseType
)
val cachedRelease = localDataSource.getLatestRelease(releaseType)
val externalModel = cachedRelease?.asExternalModel()
emit(externalModel)
}
}
suspend fun invalidateCache() {
localDataSource.deleteAllFirmwareReleases()
}
/**
* Check if the cache is expired
*/
private fun isCacheExpired(lastUpdated: Long): Boolean {
return System.currentTimeMillis() - lastUpdated > CACHE_EXPIRATION_TIME_MS
}
/** Check if the cache is expired */
private fun isCacheExpired(lastUpdated: Long): Boolean =
System.currentTimeMillis() - lastUpdated > CACHE_EXPIRATION_TIME_MS
}

View file

@ -83,6 +83,7 @@ import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.setAttributes
import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.Node
@ -306,6 +307,7 @@ fun MainScreen(
}
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun VersionChecks(viewModel: UIViewModel) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
@ -313,6 +315,10 @@ private fun VersionChecks(viewModel: UIViewModel) {
val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
val currentFirmwareVersion by viewModel.firmwareVersion.collectAsStateWithLifecycle(null)
val currentDeviceHardware by viewModel.deviceHardware.collectAsStateWithLifecycle(null)
val latestStableFirmwareRelease by viewModel.latestStableFirmwareRelease.collectAsState(DeviceVersion("2.6.4"))
LaunchedEffect(connectionState, firmwareEdition) {
if (connectionState == MeshService.ConnectionState.CONNECTED) {
@ -330,6 +336,15 @@ private fun VersionChecks(viewModel: UIViewModel) {
}
}
}
LaunchedEffect(connectionState, currentFirmwareVersion, currentDeviceHardware) {
if (connectionState == MeshService.ConnectionState.CONNECTED) {
if (currentDeviceHardware != null && currentFirmwareVersion != null) {
setAttributes(currentFirmwareVersion!!, currentDeviceHardware!!)
}
}
}
// Check if the device is running an old app version or firmware version
LaunchedEffect(connectionState, myNodeInfo) {
if (connectionState == MeshService.ConnectionState.CONNECTED) {

View file

@ -28,6 +28,8 @@ buildscript {
classpath(libs.firebase.crashlytics.gradle)
classpath(libs.protobuf.gradle.plugin)
classpath(libs.hilt.android.gradle.plugin)
classpath(libs.secrets.gradle.plugin)
classpath(libs.dd.sdk.android.gradle.plugin)
}
}

View file

@ -1,3 +1,4 @@
#[allow(unused)]
[versions]
accompanistPermissions = "0.37.3"
adaptive = "1.2.0-alpha10"
@ -13,6 +14,8 @@ core-location-altitude = "1.0.0-alpha03"
core-splashscreen = "1.0.1"
crashlytics = "3.0.5"
datastore = "1.1.7"
dd-sdk-android = "2.25.0"
dd-sdk-android-gradle-plugin = "1.18.0"
detekt = "1.23.8"
devtools-ksp = "2.2.0-2.0.2"
emoji2 = "1.5.0"
@ -43,7 +46,9 @@ protobuf-gradle-plugin = "0.9.5"
protobuf-kotlin = "4.31.1"
retrofit = "3.0.0"
room = "2.7.2"
secrets-gradle-plugin = "2.0.1"
streamsupport-minifuture = "1.7.4"
timber = "5.0.1"
usb-serial-android = "3.9.0"
work-runtime-ktx = "2.10.3"
zxing-android-embedded = "4.3.0"
@ -82,6 +87,11 @@ core-location-altitude = { group = "androidx.core", name = "core-location-altitu
core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "core-splashscreen" }
datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
dd-sdk-android-compose = { group = "com.datadoghq", name = "dd-sdk-android-compose", version.ref = "dd-sdk-android" }
dd-sdk-android-gradle-plugin = { group = "com.datadoghq", name = "dd-sdk-android-gradle-plugin", version.ref = "dd-sdk-android-gradle-plugin" }
dd-sdk-android-logs = { group = "com.datadoghq", name = "dd-sdk-android-logs", version.ref = "dd-sdk-android" }
dd-sdk-android-rum = { group = "com.datadoghq", name = "dd-sdk-android-rum", version.ref = "dd-sdk-android" }
dd-sdk-android-timber = { group = "com.datadoghq", name = "dd-sdk-android-timber", version.ref = "dd-sdk-android" }
detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" }
emoji2-emojipicker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emoji2" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
@ -134,7 +144,9 @@ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref =
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
secrets-gradle-plugin = { group = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", name = "secrets-gradle-plugin", version.ref = "secrets-gradle-plugin" }
streamsupport-minifuture = { group = "net.sourceforge.streamsupport", name = "streamsupport-minifuture", version.ref = "streamsupport-minifuture" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
usb-serial-android = { group = "com.github.mik3y", name = "usb-serial-for-android", version.ref = "usb-serial-android" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-runtime-ktx" }
zxing-android-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxing-android-embedded" }
@ -180,6 +192,9 @@ osm = ["osmdroid-android", "osmbonuspack", "mgrs"]
# Firebase
firebase = ["firebase-analytics", "firebase-crashlytics"]
# Datadog
datadog = ["dd-sdk-android-compose", "dd-sdk-android-logs", "dd-sdk-android-timber", "dd-sdk-android-rum"]
# Protobuf
protobuf = ["protobuf-kotlin"]
@ -192,6 +207,7 @@ coil = ["coil", "coil-network-core", "coil-network-okhttp", "coil-svg"]
[plugins]
android-application = { id = "com.android.application" }
compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
datadog = { id = "com.datadoghq.dd-sdk-android-gradle-plugin"}
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "devtools-ksp" }
hilt = { id = "com.google.dagger.hilt.android" }
@ -203,4 +219,5 @@ protobuf = { id = "com.google.protobuf" }
android-library = { id = "com.android.library" }
google-services = { id = "com.google.gms.google-services" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics" }
secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin"}
spotless = { id = "com.diffplug.spotless", version .ref= "spotless" }

View file

@ -0,0 +1,25 @@
#
# Copyright (c) 2025 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/>.
#
# These are placeholder values for the Meshtastic Android App secrets.
# Datadog API keys for crash reporting and analytics
# Replace these with actual keys when building the app to enable datadog reporting
datadogClientToken=faketoken1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
datadogApplicationId=fakeappid1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef