From 33d7478e7a60fc13cfb1090c59f35dae4c38401f Mon Sep 17 00:00:00 2001 From: ankek Date: Tue, 6 Oct 2026 09:44:10 +0300 Subject: [PATCH 1/2] version kick --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 10 +- .gitignore | 2 + VERSION | 1 + android-app/app/build.gradle.kts | 7 +- .../ui/connect/ConnectViewModelTest.kt | 39 ++++-- .../dev/hho/android/buildsrc/ClientVersion.kt | 12 ++ .../hho/android/buildsrc/ClientVersionTest.kt | 18 +++ scripts/local-ci.sh | 122 ++++++++++++++++++ scripts/usage.md | 5 + 10 files changed, 200 insertions(+), 18 deletions(-) create mode 100644 .gitignore create mode 100644 VERSION create mode 100755 scripts/local-ci.sh create mode 100644 scripts/usage.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b590c0..931f1ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ permissions: jobs: build: name: Build and test - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 45 defaults: run: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a970f0..63aea53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ permissions: jobs: check: name: Check version - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} @@ -31,9 +31,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - version=$(sed -n 's/.*ClientVersion\.validate("\([^"]*\)").*/\1/p' android-app/app/build.gradle.kts) - if [ -z "$version" ]; then - echo "::error::Could not read the app version from android-app/app/build.gradle.kts" + version=$(tr -d '[:space:]' < VERSION) + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error file=VERSION::VERSION must contain MAJOR.MINOR.PATCH (e.g. 1.4.2), got '$version'" exit 1 fi tag="v$version" @@ -61,7 +61,7 @@ jobs: name: Build and publish release needs: check if: needs.check.outputs.publish == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 45 defaults: run: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91a83e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Local release output from scripts/local-ci.sh +/dist/ diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..b1e80bb --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.3 diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index a500a33..05469d7 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -11,7 +11,10 @@ plugins { alias(libs.plugins.openapi.generator) } -val hhoClientVersion = ClientVersion.validate("0.1.3") +// The app version lives in VERSION at the repository root; edit only that file to release. +val hhoClientVersion = ClientVersion.validate( + providers.fileContents(rootProject.layout.projectDirectory.file("../VERSION")).asText.get().trim(), +) val releaseKeystore = providers.environmentVariable("HHO_KEYSTORE_FILE") .orElse(providers.gradleProperty("hho.keystore.file")) @@ -27,7 +30,7 @@ android { applicationId = "dev.hho.android" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 1 + versionCode = ClientVersion.versionCode(hhoClientVersion) versionName = hhoClientVersion buildConfigField("String", "HHO_CLIENT_VERSION", "\"$hhoClientVersion\"") diff --git a/android-app/app/src/test/kotlin/dev/hho/android/ui/connect/ConnectViewModelTest.kt b/android-app/app/src/test/kotlin/dev/hho/android/ui/connect/ConnectViewModelTest.kt index 0630e01..cdbd657 100644 --- a/android-app/app/src/test/kotlin/dev/hho/android/ui/connect/ConnectViewModelTest.kt +++ b/android-app/app/src/test/kotlin/dev/hho/android/ui/connect/ConnectViewModelTest.kt @@ -4,6 +4,10 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import dev.hho.android.data.apiclient.HhoApiClient import dev.hho.android.data.network.ClientVersionInterceptor import dev.hho.android.data.network.InstanceUrlNormalization @@ -33,6 +37,11 @@ class ConnectViewModelTest { private val server = MockWebServer() + // ConnectViewModel's init launches a DataStore read that resumes on Dispatchers.Main. + // Clearing the store cancels viewModelScope before resetMain(), so that read can never + // resume after the test has ended and crash whichever test runs next. + private val viewModelStore = ViewModelStore() + @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) @@ -40,6 +49,7 @@ class ConnectViewModelTest { @After fun tearDown() { + viewModelStore.clear() Dispatchers.resetMain() server.shutdown() } @@ -50,6 +60,15 @@ class ConnectViewModelTest { return PreferenceDataStoreFactory.create(produceFile = { file }) } + private fun connectViewModel( + dataStore: DataStore, + apiClient: HhoApiClient, + ): ConnectViewModel = + ViewModelProvider.create( + viewModelStore, + viewModelFactory { initializer { ConnectViewModel(dataStore, apiClient) } }, + )[ConnectViewModel::class] + private fun apiClient(): HhoApiClient = HhoApiClient(OkHttpClient(), OkHttpClient.Builder().addInterceptor(ClientVersionInterceptor()).build()) @@ -63,7 +82,7 @@ class ConnectViewModelTest { runTest { server.enqueue(MockResponse().setResponseCode(200).setBody(statusOkBody)) val dataStore = tempDataStore() - val viewModel = ConnectViewModel(dataStore, apiClient()) + val viewModel = connectViewModel(dataStore, apiClient()) val candidate = server.url("/").toString() val expected = (InstanceUrlNormalizer.normalize(candidate) as InstanceUrlNormalization.Valid).url.toString() @@ -81,7 +100,7 @@ class ConnectViewModelTest { runTest { server.enqueue(MockResponse().setResponseCode(200).setBody(statusOkBody)) val dataStore = tempDataStore() - val viewModel = ConnectViewModel(dataStore, apiClient()) + val viewModel = connectViewModel(dataStore, apiClient()) val candidate = server.url("/hho").toString() val expected = (InstanceUrlNormalizer.normalize(candidate) as InstanceUrlNormalization.Valid).url.toString() @@ -98,7 +117,7 @@ class ConnectViewModelTest { val dataStore = tempDataStore() dataStore.edit { it[SettingsKeys.INSTANCE_BASE_URL] = "https://already-configured.example.com" } val before = storedUrl(dataStore) - val viewModel = ConnectViewModel(dataStore, apiClient()) + val viewModel = connectViewModel(dataStore, apiClient()) val candidate = server.url("/").toString() server.shutdown() @@ -119,7 +138,7 @@ class ConnectViewModelTest { val dataStore = tempDataStore() dataStore.edit { it[SettingsKeys.INSTANCE_BASE_URL] = "https://already-configured.example.com" } val before = storedUrl(dataStore) - val viewModel = ConnectViewModel(dataStore, apiClient()) + val viewModel = connectViewModel(dataStore, apiClient()) viewModel.connect(server.url("/").toString()) @@ -134,7 +153,7 @@ class ConnectViewModelTest { val dataStore = tempDataStore() dataStore.edit { it[SettingsKeys.INSTANCE_BASE_URL] = "https://already-configured.example.com" } val before = storedUrl(dataStore) - val viewModel = ConnectViewModel(dataStore, apiClient()) + val viewModel = connectViewModel(dataStore, apiClient()) viewModel.connect(server.url("/").toString()) @@ -154,7 +173,7 @@ class ConnectViewModelTest { val dataStore = tempDataStore() dataStore.edit { it[SettingsKeys.INSTANCE_BASE_URL] = "https://already-configured.example.com" } val before = storedUrl(dataStore) - val viewModel = ConnectViewModel(dataStore, apiClient()) + val viewModel = connectViewModel(dataStore, apiClient()) viewModel.connect(server.url("/").toString()) @@ -169,7 +188,7 @@ class ConnectViewModelTest { val dataStore = tempDataStore() dataStore.edit { it[SettingsKeys.INSTANCE_BASE_URL] = "https://already-configured.example.com" } val before = storedUrl(dataStore) - val viewModel = ConnectViewModel(dataStore, apiClient()) + val viewModel = connectViewModel(dataStore, apiClient()) viewModel.connect(server.url("/").toString()) @@ -183,7 +202,7 @@ class ConnectViewModelTest { val dataStore = tempDataStore() dataStore.edit { it[SettingsKeys.INSTANCE_BASE_URL] = "https://already-configured.example.com" } val before = storedUrl(dataStore) - val viewModel = ConnectViewModel(dataStore, apiClient()) + val viewModel = connectViewModel(dataStore, apiClient()) viewModel.connect("not-a-url-at-all") @@ -197,7 +216,7 @@ class ConnectViewModelTest { runTest { val dataStore = tempDataStore() dataStore.edit { it[SettingsKeys.INSTANCE_BASE_URL] = "https://stored.example.com" } - val viewModel = ConnectViewModel(dataStore, apiClient()) + val viewModel = connectViewModel(dataStore, apiClient()) viewModel.prefillStoredUrl() @@ -207,7 +226,7 @@ class ConnectViewModelTest { @Test fun `the url field stays blank when nothing is stored yet`() = runTest { - val viewModel = ConnectViewModel(tempDataStore(), apiClient()) + val viewModel = connectViewModel(tempDataStore(), apiClient()) viewModel.prefillStoredUrl() diff --git a/android-app/buildSrc/src/main/kotlin/dev/hho/android/buildsrc/ClientVersion.kt b/android-app/buildSrc/src/main/kotlin/dev/hho/android/buildsrc/ClientVersion.kt index f377e29..ed1db9c 100644 --- a/android-app/buildSrc/src/main/kotlin/dev/hho/android/buildsrc/ClientVersion.kt +++ b/android-app/buildSrc/src/main/kotlin/dev/hho/android/buildsrc/ClientVersion.kt @@ -12,4 +12,16 @@ object ClientVersion { } return raw } + + /** + * Derives the Android versionCode from MAJOR.MINOR.PATCH as MAJOR * 10000 + MINOR * 100 + PATCH, + * so every version bump also produces a higher versionCode (1.4.2 -> 10402). + */ + fun versionCode(version: String): Int { + val (major, minor, patch) = validate(version).split('.').map(String::toInt) + require(minor < 100 && patch < 100) { + "hhoClientVersion \"$version\" cannot be mapped to a versionCode: MINOR and PATCH must be below 100." + } + return major * 10_000 + minor * 100 + patch + } } diff --git a/android-app/buildSrc/src/test/kotlin/dev/hho/android/buildsrc/ClientVersionTest.kt b/android-app/buildSrc/src/test/kotlin/dev/hho/android/buildsrc/ClientVersionTest.kt index 0d66d83..a9b3997 100644 --- a/android-app/buildSrc/src/test/kotlin/dev/hho/android/buildsrc/ClientVersionTest.kt +++ b/android-app/buildSrc/src/test/kotlin/dev/hho/android/buildsrc/ClientVersionTest.kt @@ -77,4 +77,22 @@ class ClientVersionTest { assertEquals(true, error.message?.contains("0.1.0-debug")) assertEquals(true, error.message?.contains("MAJOR.MINOR.PATCH")) } + + @Test + fun `versionCode packs MAJOR MINOR PATCH into an increasing integer`() { + assertEquals(103, ClientVersion.versionCode("0.1.3")) + assertEquals(10402, ClientVersion.versionCode("1.4.2")) + assertEquals(true, ClientVersion.versionCode("0.2.0") > ClientVersion.versionCode("0.1.99")) + } + + @Test + fun `versionCode rejects MINOR or PATCH of 100 or more`() { + assertThrows(IllegalArgumentException::class.java) { ClientVersion.versionCode("1.100.0") } + assertThrows(IllegalArgumentException::class.java) { ClientVersion.versionCode("1.0.100") } + } + + @Test + fun `versionCode rejects an invalid version`() { + assertThrows(IllegalArgumentException::class.java) { ClientVersion.versionCode("1.2.3-rc1") } + } } diff --git a/scripts/local-ci.sh b/scripts/local-ci.sh new file mode 100755 index 0000000..fcb3f47 --- /dev/null +++ b/scripts/local-ci.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Run the CI and release pipeline steps locally, so problems surface before a paid GitHub Actions run. +# +# Usage: +# scripts/local-ci.sh same as CI: unit tests + debug APKs (foss, gms) +# scripts/local-ci.sh test unit tests only (fastest feedback) +# scripts/local-ci.sh release same as the release workflow: tests + signed release APKs in dist/ +# +# Release signing uses the same variables as the workflow: +# HHO_KEYSTORE_FILE, HHO_KEYSTORE_PASSWORD, HHO_KEY_ALIAS, HHO_KEY_PASSWORD +# (or hho.keystore.* properties in ~/.gradle/gradle.properties). + +set -euo pipefail + +mode="${1:-ci}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +app_dir="$repo_root/android-app" + +step() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; } +fail() { printf '\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } + +case "$mode" in + ci | test | release) ;; + -h | --help) sed -n '2,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) fail "unknown mode '$mode' (expected: ci, test, release)" ;; +esac + +java_major() { "$1/bin/java" -version 2>&1 | sed -n 's/.*version "\([0-9]*\).*/\1/p' | head -n1; } + +# CI uses Temurin 21. Keep JAVA_HOME if it points at a JDK 21, otherwise look for one. +find_jdk21() { + if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ] && [ "$(java_major "$JAVA_HOME")" = 21 ]; then + echo "$JAVA_HOME"; return + fi + if [ -x /usr/libexec/java_home ] && /usr/libexec/java_home -v 21 >/dev/null 2>&1; then + /usr/libexec/java_home -v 21; return + fi + local candidate + for candidate in /usr/lib/jvm/*21* "$HOME"/.sdkman/candidates/java/21* "$HOME"/.jdks/*21*; do + if [ -x "$candidate/bin/java" ] && [ "$(java_major "$candidate")" = 21 ]; then + echo "$candidate"; return + fi + done +} + +find_android_sdk() { + local sdk_dir + sdk_dir=$(sed -n 's/^sdk\.dir=//p' "$app_dir/local.properties" 2>/dev/null || true) + local candidate + for candidate in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$sdk_dir" "$HOME/Android/Sdk" "$HOME/Library/Android/sdk"; do + if [ -n "$candidate" ] && [ -d "$candidate/platforms" ]; then + echo "$candidate"; return + fi + done +} + +step "Checking environment" +JAVA_HOME="$(find_jdk21)" +[ -n "$JAVA_HOME" ] || fail "JDK 21 not found. Install one (e.g. sudo apt install openjdk-21-jdk) or set JAVA_HOME." +ANDROID_HOME="$(find_android_sdk)" +[ -n "$ANDROID_HOME" ] || fail "Android SDK not found. Set ANDROID_HOME or sdk.dir in android-app/local.properties." +export JAVA_HOME ANDROID_HOME +echo "JDK: $JAVA_HOME" +echo "Android SDK: $ANDROID_HOME" + +version="$(tr -d '[:space:]' < "$repo_root/VERSION")" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "VERSION must contain MAJOR.MINOR.PATCH (e.g. 1.4.2), got '$version'" +echo "App version: $version" + +cd "$app_dir" + +if [ "$mode" = release ]; then + step "Verifying release signing" + keystore="${HHO_KEYSTORE_FILE:-}" + if [ -z "$keystore" ]; then + keystore=$(sed -n 's/^hho\.keystore\.file=//p' "$HOME/.gradle/gradle.properties" 2>/dev/null || true) + fi + [ -n "$keystore" ] && [ -f "$keystore" ] \ + || fail "No keystore: set HHO_KEYSTORE_FILE (plus HHO_KEYSTORE_PASSWORD, HHO_KEY_ALIAS, HHO_KEY_PASSWORD)." + if [ -n "${HHO_KEYSTORE_PASSWORD:-}" ] && [ -n "${HHO_KEY_ALIAS:-}" ]; then + "$JAVA_HOME/bin/keytool" -list -keystore "$keystore" -storepass "$HHO_KEYSTORE_PASSWORD" -alias "$HHO_KEY_ALIAS" >/dev/null 2>&1 \ + || fail "Keystore check failed: wrong HHO_KEYSTORE_PASSWORD, or alias '$HHO_KEY_ALIAS' is not in $keystore." + echo "Keystore and alias OK: $keystore" + else + echo "Keystore: $keystore (passwords come from gradle.properties)" + fi +fi + +step "Build logic tests (buildSrc)" +./gradlew -p buildSrc test + +step "Unit tests (foss + gms)" +if ! ./gradlew testFossDebugUnitTest testGmsDebugUnitTest; then + echo "Test reports: $app_dir/app/build/reports/tests/" >&2 + fail "unit tests failed" +fi + +case "$mode" in + ci) + step "Assembling debug APKs" + ./gradlew assembleFossDebug assembleGmsDebug + step "Done" + ls -1 app/build/outputs/apk/*/debug/*.apk + ;; + release) + step "Assembling release APKs" + ./gradlew assembleFossRelease assembleGmsRelease + dist="$repo_root/dist" + rm -rf "$dist" && mkdir -p "$dist" + for flavor in foss gms; do + apk="app/build/outputs/apk/$flavor/release/app-$flavor-release.apk" + [ -f "$apk" ] || fail "$apk missing (an unsigned build is named *-unsigned.apk; check signing settings)." + cp "$apk" "$dist/hho-$version-$flavor.apk" + done + (cd "$dist" && sha256sum -- *.apk > SHA256SUMS) + step "Done" + ls -1 "$dist" + ;; + test) + step "Done" + ;; +esac diff --git a/scripts/usage.md b/scripts/usage.md new file mode 100644 index 0000000..38eab5b --- /dev/null +++ b/scripts/usage.md @@ -0,0 +1,5 @@ +Usage + +scripts/local-ci.sh # same as the CI workflow: unit tests + foss/gms debug APKs +scripts/local-ci.sh test # unit tests only, for the fastest feedback +scripts/local-ci.sh release # same as the release workflow: tests + signed APKs + SHA256SUMS in dist/ \ No newline at end of file From 91e8e2d9739b7b2e125dafd9b285b7b146873c51 Mon Sep 17 00:00:00 2001 From: ankek Date: Tue, 6 Oct 2026 09:44:44 +0300 Subject: [PATCH 2/2] 0.1.4 test --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b1e80bb..845639e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.1.4