diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7934f6f..5b00512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,46 @@ jobs: name: helm-debug path: app/build/outputs/apk/debug/app-debug.apk retention-days: 14 + + smoke: + name: UI smoke test + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + + - name: Download debug APK + uses: actions/download-artifact@v4 + with: + name: helm-debug + path: app/build/outputs/apk/debug + + # The emulator needs KVM, and the runner does not grant it by default. + - name: Enable KVM + 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 + + # API 29 + 768x1024 portrait mirrors the real head unit, so the smoke run + # exercises the same layout the car does. + - name: Run smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + target: google_apis + arch: x86_64 + emulator-options: >- + -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + -camera-back emulated -skin 768x1024 + script: bash tools/smoke.sh + + - name: Upload smoke screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-screenshots + path: build/smoke/ + retention-days: 14 diff --git a/ota/build.gradle.kts b/ota/build.gradle.kts index 4bd3787..3f45fe9 100644 --- a/ota/build.gradle.kts +++ b/ota/build.gradle.kts @@ -12,10 +12,17 @@ android { targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } + testOptions { + unitTests.all { it.useJUnitPlatform() } + } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.lifecycle.viewmodel.ktx) + + testImplementation(libs.junit5.api) + testImplementation(libs.junit5.params) + testRuntimeOnly(libs.junit5.engine) } diff --git a/ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt b/ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt index 4931e85..c525904 100644 --- a/ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt +++ b/ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt @@ -5,3 +5,23 @@ data class OtaInfo( val apkUrl: String, val changelog: String, ) + +// Semantic-ish version compare. Tolerates a leading "v", ignores pre-release ("-rc1") +// and build metadata ("+42") suffixes, and pads missing components with 0 so +// "1.2" and "1.2.0" compare equal. +internal fun isNewer(server: String, installed: String): Boolean { + fun String.components(): List { + val core = substringBefore('+').substringBefore('-').trimStart('v') + val parts = core.split('.').mapNotNull { it.toIntOrNull() } + return parts.ifEmpty { listOf(0) } + } + val s = server.components() + val c = installed.components() + for (i in 0 until maxOf(s.size, c.size)) { + val sv = s.getOrElse(i) { 0 } + val cv = c.getOrElse(i) { 0 } + if (sv > cv) return true + if (sv < cv) return false + } + return false +} diff --git a/ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt b/ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt index 24077dd..7cf2d5c 100644 --- a/ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt +++ b/ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt @@ -85,22 +85,4 @@ class OtaViewModel(application: Application) : AndroidViewModel(application) { fun reset() { _state.value = OtaState.Idle } - - private fun isNewer(server: String, installed: String): Boolean { - fun String.components(): List { - val core = substringBefore('+').substringBefore('-').trimStart('v') - val parts = core.split('.').mapNotNull { it.toIntOrNull() } - return parts.ifEmpty { listOf(0) } - } - val s = server.components() - val c = installed.components() - val len = maxOf(s.size, c.size) - for (i in 0 until len) { - val sv = s.getOrElse(i) { 0 } - val cv = c.getOrElse(i) { 0 } - if (sv > cv) return true - if (sv < cv) return false - } - return false - } } diff --git a/ota/src/test/kotlin/dev/helm/ota/OtaVersionTest.kt b/ota/src/test/kotlin/dev/helm/ota/OtaVersionTest.kt new file mode 100644 index 0000000..e5009b8 --- /dev/null +++ b/ota/src/test/kotlin/dev/helm/ota/OtaVersionTest.kt @@ -0,0 +1,61 @@ +package dev.helm.ota + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +class OtaVersionTest { + + @ParameterizedTest(name = "server {0} is newer than installed {1}") + @CsvSource( + "0.2.0, 0.1.0", + "1.0.0, 0.9.9", + "0.1.1, 0.1.0", + "0.10.0, 0.9.0", // numeric compare, not lexicographic + "1.1, 1.0.9", // missing component pads to 0, compared left to right + ) + fun `newer server version triggers update`(server: String, installed: String) { + assertTrue(isNewer(server, installed)) + } + + @ParameterizedTest(name = "server {0} is NOT newer than installed {1}") + @CsvSource( + "0.1.0, 0.1.0", + "0.1.0, 0.2.0", + "0.9.0, 0.10.0", + "1.0, 1.0.0", // equal once padded + "0.1.0, 1.0.0", // downgrade must never be offered + ) + fun `same or older server version does not trigger update`(server: String, installed: String) { + assertFalse(isNewer(server, installed)) + } + + @Test + fun `leading v is stripped from the release tag`() { + assertTrue(isNewer("v0.2.0", "0.1.0")) + assertFalse(isNewer("v0.1.0", "0.1.0")) + } + + @Test + fun `pre-release and build metadata suffixes are ignored`() { + assertFalse(isNewer("0.1.0-rc1", "0.1.0")) + assertFalse(isNewer("0.1.0+42", "0.1.0")) + assertTrue(isNewer("0.2.0-rc1", "0.1.0")) + } + + @Test + fun `unparseable versions never trigger an update`() { + // A garbage tag_name must not push an APK onto the head unit. + assertFalse(isNewer("", "0.1.0")) + assertFalse(isNewer("latest", "0.1.0")) + assertFalse(isNewer("nightly", "0.1.0")) + } + + @Test + fun `unparseable installed version accepts any real release`() { + // versionName came back empty from PackageManager — treated as 0. + assertTrue(isNewer("0.1.0", "")) + } +} diff --git a/sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt b/sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt index a04d8e5..25ab3a7 100644 --- a/sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt +++ b/sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt @@ -110,16 +110,4 @@ class OpenMeteoWeatherDataSource( } } } - - private fun wmoToCondition(code: Int): WeatherCondition = when (code) { - 0 -> WeatherCondition.CLEAR - in 1..3 -> WeatherCondition.CLOUDY - 45, 48 -> WeatherCondition.HAZE - in 51..67 -> WeatherCondition.RAIN - in 71..77 -> WeatherCondition.SNOW - in 80..82 -> WeatherCondition.RAIN - 85, 86 -> WeatherCondition.SNOW - in 95..99 -> WeatherCondition.THUNDERSTORM - else -> WeatherCondition.CLOUDY - } } diff --git a/sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt b/sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt index 3e47dce..22d3411 100644 --- a/sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt +++ b/sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt @@ -12,10 +12,17 @@ internal class TwUtilMcuDataSource : McuDataSource { override fun events(): Flow = adapter.events() .retryWhen { _, attempt -> - delay(minOf(500L shl attempt.toInt().coerceAtMost(6), 30_000L)) + delay(reconnectDelayMs(attempt)) true } override suspend fun send(code: Int, arg1: Int, arg2: Int, data: ByteArray): Result = adapter.send(code, arg1, arg2, data) } + +// Exponential back-off for MCU reconnection: 500 ms, 1 s, 2 s, 4 s, 8 s, 16 s, then 30 s +// forever. Clamp the attempt as a Long *before* narrowing to Int: Long.MAX_VALUE.toInt() +// is -1, and `shl` only reads the low 6 bits of the count, so narrowing first turns a very +// long outage into a negative delay and busy-loops the UART. +internal fun reconnectDelayMs(attempt: Long): Long = + minOf(500L shl attempt.coerceIn(0L, 6L).toInt(), 30_000L) diff --git a/sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt b/sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt index 90adc0b..237a2ae 100644 --- a/sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt +++ b/sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt @@ -8,3 +8,18 @@ enum class WeatherCondition(val label: String) { SNOW("Snow"), HAZE("Haze"), } + +// WMO weather interpretation codes → Helm's 6 icon buckets. +// https://open-meteo.com/en/docs — unknown codes fall back to CLOUDY so the +// widget always renders something rather than blanking out. +internal fun wmoToCondition(code: Int): WeatherCondition = when (code) { + 0 -> WeatherCondition.CLEAR + in 1..3 -> WeatherCondition.CLOUDY + 45, 48 -> WeatherCondition.HAZE + in 51..67 -> WeatherCondition.RAIN + in 71..77 -> WeatherCondition.SNOW + in 80..82 -> WeatherCondition.RAIN + 85, 86 -> WeatherCondition.SNOW + in 95..99 -> WeatherCondition.THUNDERSTORM + else -> WeatherCondition.CLOUDY +} diff --git a/sdk/src/test/kotlin/dev/helm/sdk/ReconnectBackoffTest.kt b/sdk/src/test/kotlin/dev/helm/sdk/ReconnectBackoffTest.kt new file mode 100644 index 0000000..596017f --- /dev/null +++ b/sdk/src/test/kotlin/dev/helm/sdk/ReconnectBackoffTest.kt @@ -0,0 +1,46 @@ +package dev.helm.sdk + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +class ReconnectBackoffTest { + + @ParameterizedTest(name = "attempt {0} waits {1} ms") + @CsvSource( + "0, 500", + "1, 1000", + "2, 2000", + "3, 4000", + "4, 8000", + "5, 16000", + "6, 30000", // 32000 clamped to the 30 s ceiling + ) + fun `back-off follows the documented schedule`(attempt: Long, expectedMs: Long) { + assertEquals(expectedMs, reconnectDelayMs(attempt)) + } + + @ParameterizedTest(name = "attempt {0} stays at the 30 s ceiling") + @ValueSource(longs = [7, 8, 20, 63, 64, 1_000, Int.MAX_VALUE.toLong(), Long.MAX_VALUE]) + fun `long outages stay at the ceiling`(attempt: Long) { + assertEquals(30_000L, reconnectDelayMs(attempt)) + } + + @Test + fun `delay is never zero or negative`() { + // A non-positive delay would turn reconnection into a busy loop on the UART. + listOf(0L, 6L, 7L, 64L, Long.MAX_VALUE).forEach { + assertTrue(reconnectDelayMs(it) > 0, "attempt $it produced a non-positive delay") + } + } + + @Test + fun `back-off is monotonic up to the ceiling`() { + val delays = (0L..10L).map { reconnectDelayMs(it) } + delays.zipWithNext { a, b -> assertTrue(b >= a, "delay decreased: $a then $b") } + assertEquals(30_000L, delays.last()) + } +} diff --git a/sdk/src/test/kotlin/dev/helm/sdk/WmoConditionTest.kt b/sdk/src/test/kotlin/dev/helm/sdk/WmoConditionTest.kt new file mode 100644 index 0000000..cd562af --- /dev/null +++ b/sdk/src/test/kotlin/dev/helm/sdk/WmoConditionTest.kt @@ -0,0 +1,45 @@ +package dev.helm.sdk + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +class WmoConditionTest { + + @ParameterizedTest(name = "WMO {0} maps to {1}") + @CsvSource( + "0, CLEAR", + "1, CLOUDY", "2, CLOUDY", "3, CLOUDY", + "45, HAZE", "48, HAZE", + "51, RAIN", "61, RAIN", "67, RAIN", + "71, SNOW", "77, SNOW", + "80, RAIN", "82, RAIN", + "85, SNOW", "86, SNOW", + "95, THUNDERSTORM", "99, THUNDERSTORM", + ) + fun `documented WMO codes map to the right icon`(code: Int, expected: WeatherCondition) { + assertEquals(expected, wmoToCondition(code)) + } + + @ParameterizedTest(name = "gap code {0} falls back to CLOUDY") + @ValueSource(ints = [4, 44, 49, 50, 68, 70, 78, 79, 83, 84, 87, 94, 100]) + fun `codes in the gaps fall back to CLOUDY`(code: Int) { + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(code)) + } + + @Test + fun `nonsense codes never crash the widget`() { + // A malformed API response must degrade to an icon, not blank the home screen. + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(-1)) + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(9999)) + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(Int.MAX_VALUE)) + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(Int.MIN_VALUE)) + } + + @Test + fun `every condition has a non-empty label for the widget`() { + WeatherCondition.entries.forEach { assertEquals(it.label, it.label.trim().ifEmpty { null }) } + } +} diff --git a/tools/smoke.sh b/tools/smoke.sh new file mode 100755 index 0000000..718e2a7 --- /dev/null +++ b/tools/smoke.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Helm UI smoke test — drives the app over adb and asserts on the accessibility +# tree that Compose exposes (uiautomator sees every Text node, with bounds). +# +# tools/smoke.sh # install the debug APK, then run +# tools/smoke.sh --no-install # run against whatever is already installed +# tools/smoke.sh -s emulator-5554 +# +# Exits non-zero on the first failed assertion. Screenshots land in +# build/smoke/ so you can see what the screen looked like when it broke. +set -uo pipefail + +SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/AppData/Local/Android/Sdk}}" +ADB="$SDK/platform-tools/adb" +[ -x "$ADB" ] || ADB="$ADB.exe" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk" +PKG="dev.helm.launcher" +OUT="$ROOT/build/smoke" +SERIAL="" +INSTALL=1 +TIMEOUT=30 # seconds an assertion waits before giving up, à la Playwright. + # Each poll costs a full ui_dump (~2 s on a CI emulator), so this is + # only a handful of attempts, not 30. + +while [ $# -gt 0 ]; do + case "$1" in + --no-install) INSTALL=0; shift ;; + -s) SERIAL="-s $2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +adb_() { "$ADB" $SERIAL "$@"; } + +step=0 +pass() { echo " ok $1"; } +fail() { + echo " FAIL $1" >&2 + adb_ exec-out screencap -p > "$OUT/FAIL-$step.png" 2>/dev/null + echo >&2 + echo "screenshot: $OUT/FAIL-$step.png" >&2 + echo "visible text was:" >&2 + ui_text | sed 's/^/ /' >&2 + exit 1 +} + +# Whole accessibility tree, one node per line. +ui_dump() { + adb_ shell 'uiautomator dump /sdcard/helm-ui.xml >/dev/null 2>&1; cat /sdcard/helm-ui.xml' \ + | tr '<' '\n' +} +ui_text() { ui_dump | grep -o 'text="[^"]*"' | sed 's/text="//; s/"$//' | grep -v '^$' | sort -u; } + +bounds_of() { + ui_dump | grep -F "text=\"$1\"" | grep -o 'bounds="\[[0-9]*,[0-9]*\]\[[0-9]*,[0-9]*\]"' | head -1 +} +center_of() { + local b; b="$(bounds_of "$1")" + [ -n "$b" ] || return 1 + echo "$b" | sed 's/bounds="\[//; s/\]\[/ /; s/\]"//; s/,/ /g' \ + | awk '{ printf "%d %d\n", ($1+$3)/2, ($2+$4)/2 }' +} + +# Poll until the text shows up, like Playwright's auto-waiting locators. +wait_for_text() { + local want="$1" deadline=$(( SECONDS + TIMEOUT )) + while [ $SECONDS -lt $deadline ]; do + sees_now "$want" && return 0 + sleep 1 + done + return 1 +} + +assert_text() { + step=$((step+1)) + wait_for_text "$1" && pass "sees \"$1\"" || fail "never saw \"$1\"" +} + +tap_text() { + step=$((step+1)) + wait_for_text "$1" || fail "cannot tap \"$1\" — not on screen" + local xy; xy="$(center_of "$1")" || fail "cannot tap \"$1\" — no bounds" + adb_ shell input tap $xy + pass "tapped \"$1\"" + sleep 1 +} + +back() { adb_ shell input keyevent KEYCODE_BACK; sleep 2; } + +# Sees $1 right now, without the polling grace period. +# Match with `case` on a captured dump rather than `ui_dump | grep -q`: under +# `pipefail`, grep -q exits on the first hit and SIGPIPEs adb, which makes the +# whole pipeline report 141 exactly when the text WAS found. +sees_now() { case "$(ui_dump)" in *"text=\"$1\""*) return 0 ;; *) return 1 ;; esac; } + +launch() { + adb_ shell am start -n "$PKG/.MainActivity" >/dev/null + # Wait for the activity to actually be resumed rather than sleeping a fixed + # amount: a cold start on a CI emulator takes far longer than on a warm local + # one, and a fixed sleep turns that into a flaky failure. + local deadline=$(( SECONDS + 60 )) top + while [ $SECONDS -lt $deadline ]; do + top="$(adb_ shell dumpsys activity activities 2>/dev/null | grep -F mResumedActivity)" + case "$top" in *"$PKG"*) break ;; esac + sleep 1 + done + sleep 3 # splash auto-dismisses at 1.6 s +} + +assert_no_crash() { + step=$((step+1)) + local crashes + crashes="$(adb_ logcat -d -b crash 2>/dev/null | grep -F "$PKG" | head -20)" + [ -z "$crashes" ] || { echo "$crashes" >&2; fail "crash in logcat"; } + pass "no crash in logcat" +} + +# ── run ────────────────────────────────────────────────────────────────────── +mkdir -p "$OUT" +adb_ get-state >/dev/null 2>&1 || { echo "no device — start the AVD: emulator -avd helm" >&2; exit 1; } +echo "device: $(adb_ shell getprop ro.product.model | tr -d '\r') / Android $(adb_ shell getprop ro.build.version.release | tr -d '\r')" + +if [ "$INSTALL" = 1 ]; then + [ -f "$APK" ] || { echo "no APK at $APK — run ./gradlew assembleDebug" >&2; exit 1; } + echo "installing $(basename "$APK")" + adb_ install -r "$APK" >/dev/null || { echo "install failed" >&2; exit 1; } +fi + +for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION CAMERA READ_EXTERNAL_STORAGE READ_PHONE_STATE; do + adb_ shell pm grant "$PKG" "android.permission.$p" >/dev/null 2>&1 +done + +adb_ logcat -b crash -c >/dev/null 2>&1 +adb_ shell am force-stop "$PKG" +launch + +echo +echo "home" +assert_text "km/h" # speed badge — the home screen's unique marker +assert_text "Navigation" +assert_text "Settings" +assert_text "Camera" +adb_ exec-out screencap -p > "$OUT/1-home.png" + +echo +echo "settings" +tap_text "Settings" +assert_text "Ajustes" +assert_text "Apariencia" +assert_text "Acerca de" +adb_ exec-out screencap -p > "$OUT/2-settings.png" + +echo +echo "appearance" +tap_text "Apariencia" +assert_text "Android Auto" # unique to the theme picker +assert_text "Tesla" +adb_ exec-out screencap -p > "$OUT/3-themes.png" + +echo +echo "system back" +# Known bug: there is no BackHandler anywhere in the app, so the system BACK +# finishes the Activity from every screen instead of popping to the previous +# one. Asserted inverted on purpose — this stays green while the bug is there +# and shouts the day someone fixes it, instead of rotting as a red test. +step=$((step+1)) +back +if sees_now "Apariencia" || sees_now "Ajustes"; then + echo " NOTE system BACK now pops to the previous screen — the BackHandler" + echo " bug is fixed. Turn this into a real assertion." +else + pass "system BACK still exits the app (known bug, no BackHandler)" +fi + +echo +echo "relaunch" +launch +assert_text "km/h" +assert_text "Settings" +adb_ exec-out screencap -p > "$OUT/4-home-again.png" + +echo +assert_no_crash + +echo +echo "PASS — $step checks, screenshots in build/smoke/"