From ab22a655c487ddebae4d4a8c6692f674deddeab0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:54:46 -0500 Subject: [PATCH] feat(analytics): Integrate Datadog for RUM and Logging (#2578) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .github/workflows/merge-queue.yml | 2 + .github/workflows/pull-request.yml | 13 +- .github/workflows/release.yml | 7 + .github/workflows/reusable-android-build.yml | 26 +++- .gitignore | 1 + app/build.gradle.kts | 31 ++++- .../geeksville/mesh/MeshUtilApplication.kt | 6 +- .../mesh/android/GeeksvilleApplication.kt | 29 +++-- .../geeksville/mesh/MeshUtilApplication.kt | 49 ++++--- .../mesh/android/GeeksvilleApplication.kt | 121 +++++++++++++----- .../com/geeksville/mesh/android/Logging.kt | 75 +++-------- .../java/com/geeksville/mesh/model/UIState.kt | 11 ++ .../api/DeviceHardwareRepository.kt | 20 ++- .../api/FirmwareReleaseRepository.kt | 92 ++++++------- .../main/java/com/geeksville/mesh/ui/Main.kt | 15 +++ build.gradle.kts | 2 + gradle/libs.versions.toml | 17 +++ secrets.defaults.properties | 25 ++++ 18 files changed, 348 insertions(+), 194 deletions(-) create mode 100644 secrets.defaults.properties diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml index 2617addba..5f584b3e7 100644 --- a/.github/workflows/merge-queue.yml +++ b/.github/workflows/merge-queue.yml @@ -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' diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 76a78d0f5..d75d6577b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daa41e132..bf7071d6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/reusable-android-build.yml b/.github/workflows/reusable-android-build.yml index 83d4a5f2f..b9482718b 100644 --- a/.github/workflows/reusable-android-build.yml +++ b/.github/workflows/reusable-android-build.yml @@ -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: diff --git a/.gitignore b/.gitignore index 60be31b37..7842527ab 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ keystore.properties # VS code .vscode/settings.json +/secrets.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bba0c8370..8ca48e692 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 ( diff --git a/app/src/fdroid/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/fdroid/java/com/geeksville/mesh/MeshUtilApplication.kt index 1674ad754..5361646ce 100644 --- a/app/src/fdroid/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/fdroid/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -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 - } -} \ No newline at end of file +} 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 f58046e30..058ba4c85 100644 --- a/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt +++ b/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt @@ -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 \ No newline at end of file +fun Context.isGooglePlayAvailable(): Boolean = false + +fun setAttributes(deviceVersion: String, deviceHardware: DeviceHardware) { + // No-op for F-Droid version +} diff --git a/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt index 30e20331e..4e7a962ca 100644 --- a/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -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) } } } 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 8f4ac2d2d..b1ecb6fc4 100644 --- a/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt +++ b/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt @@ -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 - } -} \ No newline at end of file +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 + } diff --git a/app/src/main/java/com/geeksville/mesh/android/Logging.kt b/app/src/main/java/com/geeksville/mesh/android/Logging.kt index 9411c89b0..3f3c00ab9 100644 --- a/app/src/main/java/com/geeksville/mesh/android/Logging.kt +++ b/app/src/main/java/com/geeksville/mesh/android/Logging.kt @@ -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) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index b5a04a42a..4770bb512 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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 = + ourNodeInfo + .mapNotNull { nodeInfo -> + nodeInfo?.user?.hwModel?.let { deviceHardwareRepository.getDeviceHardwareByModel(it.number) } + } + .stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = null) + val clientNotification: StateFlow = radioConfigRepository.clientNotification fun clearClientNotification(notification: MeshProtos.ClientNotification) { diff --git a/app/src/main/java/com/geeksville/mesh/repository/api/DeviceHardwareRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/api/DeviceHardwareRepository.kt index 196229e63..db9693f2a 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/api/DeviceHardwareRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/api/DeviceHardwareRepository.kt @@ -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 } diff --git a/app/src/main/java/com/geeksville/mesh/repository/api/FirmwareReleaseRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/api/FirmwareReleaseRepository.kt index 3ab2af789..447bb1118 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/api/FirmwareReleaseRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/api/FirmwareReleaseRepository.kt @@ -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 = getLatestFirmware(FirmwareReleaseType.ALPHA) - private fun getLatestFirmware( - releaseType: FirmwareReleaseType, - refresh: Boolean = false - ): Flow = 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 = + 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 } diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index b5a758443..c96eb7ebe 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -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) { diff --git a/build.gradle.kts b/build.gradle.kts index 464807150..89544f76b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bfeda9a9e..0905e2393 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/secrets.defaults.properties b/secrets.defaults.properties new file mode 100644 index 000000000..419b53183 --- /dev/null +++ b/secrets.defaults.properties @@ -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 . +# + +# 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 + +