Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions .github/workflows/scheduled-updates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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)."
Expand All @@ -158,22 +207,24 @@ 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
- Device hardware list
- 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'
delete-branch: true
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
Expand Down
8 changes: 8 additions & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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/<variant>/generated/baselineProfiles at build time.
baselineProfile(projects.baselineprofile)
}
32 changes: 32 additions & 0 deletions baselineprofile/README.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions baselineprofile/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}
}
Loading