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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Local release output from scripts/local-ci.sh
/dist/
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.1.4
7 changes: 5 additions & 2 deletions android-app/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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\"")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,13 +37,19 @@ 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())
}

@After
fun tearDown() {
viewModelStore.clear()
Dispatchers.resetMain()
server.shutdown()
}
Expand All @@ -50,6 +60,15 @@ class ConnectViewModelTest {
return PreferenceDataStoreFactory.create(produceFile = { file })
}

private fun connectViewModel(
dataStore: DataStore<Preferences>,
apiClient: HhoApiClient,
): ConnectViewModel =
ViewModelProvider.create(
viewModelStore,
viewModelFactory { initializer { ConnectViewModel(dataStore, apiClient) } },
)[ConnectViewModel::class]

private fun apiClient(): HhoApiClient =
HhoApiClient(OkHttpClient(), OkHttpClient.Builder().addInterceptor(ClientVersionInterceptor()).build())

Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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()

Expand All @@ -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())

Expand All @@ -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())

Expand All @@ -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())

Expand All @@ -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())

Expand All @@ -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")

Expand 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()

Expand All @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
}
}
122 changes: 122 additions & 0 deletions scripts/local-ci.sh
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions scripts/usage.md
Original file line number Diff line number Diff line change
@@ -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/
Loading