diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml index 63e1b9f5b5..7588cf886c 100644 --- a/.github/workflows/scheduled-updates.yml +++ b/.github/workflows/scheduled-updates.yml @@ -2,7 +2,7 @@ name: Scheduled Updates (Firmware, Hardware, Translations) on: schedule: - - cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost) + - cron: '0 */6 * * *' # Run every 6 hours (raised from 4h to absorb the added baseline-profile step) workflow_dispatch: # Allow manual triggering jobs: @@ -111,6 +111,45 @@ jobs: run: ./gradlew graphUpdate continue-on-error: true + # ── Baseline Profile regeneration ─────────────────────────────────── + # Runs on every scheduled tick (and manual dispatch). Generation needs a booted emulator + # (~10 min); continue-on-error keeps flakiness from blocking the firmware/translation PR. + - name: Enable KVM (for the emulator) + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Generate Baseline Profile + id: generate_baseline + continue-on-error: true # Emulator flakiness must not block the firmware/translation PR. + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis # google flavor needs GMS (Maps) on the device image + arch: x86_64 + profile: pixel_6 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + # Writes androidApp/src/google/generated/baselineProfiles/ via the androidx.baselineprofile plugin. + script: ./gradlew :androidApp:generateGoogleReleaseBaselineProfile -Pci=true + + - name: Detect baseline profile changes + id: baseline + run: | + profile_dir="androidApp/src/google/generated/baselineProfiles" + outcome="${{ steps.generate_baseline.outcome }}" + if [ "$outcome" = "skipped" ]; then + echo "status=skipped" >> "$GITHUB_OUTPUT" + elif [ "$outcome" != "success" ]; then + echo "::warning::Baseline profile generation failed (outcome: $outcome). Skipping." + echo "status=error" >> "$GITHUB_OUTPUT" + elif [ -n "$(git status --porcelain "$profile_dir" 2>/dev/null)" ]; then + echo "status=updated" >> "$GITHUB_OUTPUT" + else + echo "status=unchanged" >> "$GITHUB_OUTPUT" + fi - name: Build PR body id: pr_body @@ -119,6 +158,7 @@ jobs: firmware_detail="${{ steps.firmware.outputs.detail }}" hardware_status="${{ steps.hardware.outputs.status }}" hardware_detail="${{ steps.hardware.outputs.detail }}" + baseline_status="${{ steps.baseline.outputs.status }}" body="This PR includes automated updates from the scheduled workflow:" body+=$'\n' @@ -139,6 +179,15 @@ jobs: *) body+=$'\n'"- ❓ \`device_hardware.json\` — unknown status." ;; esac + # Baseline profile (daily / manual only) + case "$baseline_status" in + updated) body+=$'\n'"- ✅ \`androidApp\` baseline profile regenerated on an emulator." ;; + unchanged) body+=$'\n'"- ✔️ \`androidApp\` baseline profile regenerated — no changes detected." ;; + error) body+=$'\n'"- ⚠️ \`androidApp\` baseline profile generation failed — skipped (see workflow logs)." ;; + skipped) ;; # Not a daily/manual run — omit the line entirely. + *) ;; + esac + # Crowdin & graphs (always attempted) body+=$'\n'"- Source strings were uploaded to Crowdin." body+=$'\n'"- Latest translations were downloaded from Crowdin (if available)." @@ -158,7 +207,7 @@ jobs: with: token: ${{ secrets.CROWDIN_GITHUB_TOKEN }} commit-message: | - chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) + chore: Scheduled updates (Firmware, Hardware, Translations, Graphs, Baseline) Automated updates for: - Firmware releases list @@ -166,7 +215,8 @@ jobs: - Crowdin source string uploads - Crowdin translation downloads - Module dependency graphs - title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)' + - androidApp baseline profile + title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs, Baseline)' body: ${{ steps.pr_body.outputs.content }} branch: 'scheduled-updates' base: 'main' @@ -174,6 +224,7 @@ jobs: add-paths: | androidApp/src/main/assets/firmware_releases.json androidApp/src/main/assets/device_hardware.json + androidApp/src/google/generated/baselineProfiles/** fastlane/metadata/android/** **/strings.xml **/README.md diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index f934cacab9..d15e0c4c34 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -30,6 +30,7 @@ plugins { id("meshtastic.koin") alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.secrets) + alias(libs.plugins.androidx.baselineprofile) id("meshtastic.aboutlibraries") id("dev.mokkery") alias(libs.plugins.devtools.ksp) @@ -254,6 +255,9 @@ dependencies { implementation(libs.coil.network.ktor3) implementation(libs.coil.svg) implementation(libs.androidx.core.splashscreen) + // Installs the baseline profile produced by :baselineprofile at app startup (API < 31) + // and lets ART honor it on first launch. On API 31+ the platform installs it automatically. + implementation(libs.androidx.profileinstaller) implementation(libs.kotlinx.serialization.json) implementation(libs.usb.serial.android) implementation(libs.androidx.work.runtime.ktx) @@ -308,4 +312,8 @@ dependencies { testImplementation(libs.androidx.glance.appwidget) // JVM variant provides the host-platform native library for BundledSQLiteDriver under Robolectric testRuntimeOnly("androidx.sqlite:sqlite-bundled-jvm:2.6.2") + + // Producer of the baseline profile consumed by the release build. The androidx.baselineprofile + // plugin merges the generated rules into src//generated/baselineProfiles at build time. + baselineProfile(projects.baselineprofile) } diff --git a/baselineprofile/README.md b/baselineprofile/README.md new file mode 100644 index 0000000000..ca3ec581eb --- /dev/null +++ b/baselineprofile/README.md @@ -0,0 +1,32 @@ +# `:baselineprofile` + +Generates a [Baseline Profile](https://developer.android.com/topic/performance/baselineprofiles/overview) +for `:androidApp` — AOT-compiling the cold-start and first-frame code paths so ART doesn't pay the +JIT cost on first launch. Targets the **google** flavor (the variant most users run). + +## Generate the profile (run on a device/emulator) + +```bash +./gradlew :androidApp:generateGoogleReleaseBaselineProfile +``` + +Output is merged into `androidApp/src/google/generated/baselineProfiles/baseline-prof.txt`. +**Commit that file** — release builds package it via `androidx.profileinstaller`. + +## Quantify the win + +```bash +./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile +``` + +Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the output. + +## Scope / TODO + +- The journey (`BaselineProfileGenerator`) is cold-start only, since CI has no paired radio. + Extend it with post-connection screens (node list, map, message thread) once a fake transport or + connected device is wired into the harness — a more representative journey yields a better profile. +- For hermetic CI generation, swap `useConnectedDevices = true` in `build.gradle.kts` for a + [Gradle Managed Device](https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile#gradle-managed). +- f-droid currently inherits no profile (only `google` is produced). Add a second flavor here if + the f-droid startup path ever diverges enough to matter. diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts new file mode 100644 index 0000000000..c2cb86d866 --- /dev/null +++ b/baselineprofile/build.gradle.kts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.test) + alias(libs.plugins.androidx.baselineprofile) +} + +android { + namespace = "org.meshtastic.baselineprofile" + compileSdk = 37 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + defaultConfig { + // Macrobenchmark / BaselineProfileRule require API 28+ on the test (device) side. + // The generated profile is still installed on the app's real minSdk (26) via profileinstaller. + minSdk = 28 + targetSdk = 37 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + // App module whose startup we profile/benchmark. + targetProjectPath = ":androidApp" + + // The app declares a `marketplace` flavor dimension (google / fdroid). A test module must + // match it. We pin to `google` — the variant the vast majority of users run (and the one with + // Maps). f-droid can reuse the same profile; wire a second flavor here if it ever diverges. + flavorDimensions += "marketplace" + productFlavors { create("google") { dimension = "marketplace" } } +} + +kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } + +baselineProfile { + // Generate on an attached device/emulator. For hermetic CI, replace with a Gradle Managed + // Device (see README.md) and set managedDevices + useConnectedDevices = false. + useConnectedDevices = true +} + +dependencies { + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.espresso.core) + implementation(libs.androidx.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} diff --git a/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/BaselineProfileGenerator.kt b/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/BaselineProfileGenerator.kt new file mode 100644 index 0000000000..7928374083 --- /dev/null +++ b/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/BaselineProfileGenerator.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.baselineprofile + +import androidx.benchmark.macro.junit4.BaselineProfileRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Generates a Baseline Profile for the app's critical user journey. + * + * Run it with: + * ``` + * ./gradlew :androidApp:generateGoogleReleaseBaselineProfile + * ``` + * + * The [androidx.baselineprofile] plugin on `:androidApp` drives this against the auto-created + * `nonMinifiedRelease` variant and merges the result into + * `androidApp/src/google/generated/baselineProfiles/`. Commit that output so release builds ship it. + * + * The journey is intentionally minimal (cold start → first frame) because CI has no paired radio. + * Extend it with post-connection screens (node list, map, message thread) once a fake transport or + * connected device is available in the harness — the more representative the journey, the better the + * profile. + */ +@LargeTest +@RunWith(AndroidJUnit4::class) +class BaselineProfileGenerator { + + @get:Rule val baselineProfileRule = BaselineProfileRule() + + @Test + fun generate() = + baselineProfileRule.collect( + // The plugin injects the target applicationId (handles the google debug/release suffix). + packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: DEFAULT_APP_ID, + // Also produce a startup profile (dexlayout hints) for faster cold start, not just AOT rules. + includeInStartupProfile = true, + ) { + pressHome() + startActivityAndWait() + device.waitForIdle() + } + + private companion object { + const val DEFAULT_APP_ID = "com.geeksville.mesh" + } +} diff --git a/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/StartupBenchmark.kt b/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/StartupBenchmark.kt new file mode 100644 index 0000000000..b2f3bf43bb --- /dev/null +++ b/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/StartupBenchmark.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.baselineprofile + +import androidx.benchmark.macro.BaselineProfileMode +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Measures cold-start time with and without the Baseline Profile so the win is quantifiable. + * + * Run it with: + * ``` + * ./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile + * ``` + * + * Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the output: the delta + * is the startup improvement the shipped profile buys. `Partial(Require)` fails loudly if the + * profile is missing, so this also guards against a release that silently dropped it. + */ +@LargeTest +@RunWith(AndroidJUnit4::class) +class StartupBenchmark { + + @get:Rule val benchmarkRule = MacrobenchmarkRule() + + @Test fun startupCompilationNone() = startup(CompilationMode.None()) + + @Test + fun startupCompilationBaselineProfiles() = + startup(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require)) + + private fun startup(compilationMode: CompilationMode) = + benchmarkRule.measureRepeated( + packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: DEFAULT_APP_ID, + metrics = listOf(StartupTimingMetric()), + compilationMode = compilationMode, + startupMode = StartupMode.COLD, + iterations = 10, + ) { + pressHome() + startActivityAndWait() + } + + private companion object { + const val DEFAULT_APP_ID = "com.geeksville.mesh" + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c675fcdf5..769b1a6b7d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,14 @@ turbine = "1.2.1" # Compose Screenshot Testing compose-screenshot = "0.0.1-alpha15" +# Baseline Profiles / Macrobenchmark +# `benchmark` drives both the androidx.benchmark macro lib AND the androidx.baselineprofile +# Gradle plugin (same coordinates/version). Kept on the alpha track to stay compatible with +# AGP 9.x (the stable 1.4.x line predates AGP 9 support). +benchmark = "1.5.0-alpha06" +profileinstaller = "1.4.1" +androidx-uiautomator = "2.3.0" + # Compose Multiplatform compose-multiplatform = "1.11.1" compose-multiplatform-material3 = "1.11.0-alpha07" @@ -148,6 +156,11 @@ androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.2" } androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } +# Baseline Profiles / Macrobenchmark +androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" } +androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" } + # AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose-bom-aligned" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-bom-aligned" } # Required by Robolectric Compose tests (registers ComponentActivity) @@ -301,6 +314,8 @@ takpacket-sdk-kmp = { module = "org.meshtastic:takpacket-sdk", version.ref = "ta # Android android-application = { id = "com.android.application", version.ref = "agp" } android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +android-test = { id = "com.android.test" } +androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" } compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "compose-screenshot" } # Jetbrains diff --git a/settings.gradle.kts b/settings.gradle.kts index f2930cd6ee..391a9a015c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -134,4 +134,5 @@ include( ":core:barcode", ":feature:widget", ":screenshot-tests", + ":baselineprofile", )