diff --git a/.gitignore b/.gitignore index f210185..173d0d3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ dist/ # webclient/dist is served by the backend and deployed from git — keep it tracked !webclient/dist/ +# ...except the copy of the download directory vite makes: the APK is served +# straight out of public/ (see the /download mount in navi/main.py), so this +# copy is dead weight that would double the binary in every release commit. +webclient/dist/download/ build/ *.db navi.db diff --git a/android-client/.gitignore b/android-client/.gitignore index 413f622..3565218 100644 --- a/android-client/.gitignore +++ b/android-client/.gitignore @@ -2,6 +2,10 @@ build/ app/build/ local.properties +# Signing material for the release APK. The keystore itself lives outside the +# repo (~/.navi-android/navi-release.jks); this file points at it and carries +# the passwords. Losing it means the installed app can never be updated again. +keystore.properties *.iml .idea/ captures/ diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts index 7218742..8c767da 100644 --- a/android-client/app/build.gradle.kts +++ b/android-client/app/build.gradle.kts @@ -1,8 +1,19 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) } +// Release signing material, kept out of the repo: the keystore lives in +// ~/.navi-android/, and android-client/keystore.properties (gitignored, 600) +// points at it. Without that file the build still works — assembleRelease then +// produces an unsigned APK, so a fresh clone is not blocked on a secret. +val keystoreProperties = Properties().apply { + val file = rootProject.file("keystore.properties") + if (file.exists()) file.inputStream().use { load(it) } +} + android { namespace = "com.navi.client" compileSdk = 35 @@ -15,8 +26,21 @@ versionName = "1.0" } + signingConfigs { + if (keystoreProperties.getProperty("storeFile") != null) { + create("release") { + storeFile = file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + buildTypes { release { + // null when keystore.properties is absent — see the note above. + signingConfig = signingConfigs.findByName("release") isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), diff --git a/android-client/tools/publish-apk.sh b/android-client/tools/publish-apk.sh new file mode 100755 index 0000000..c8590f7 --- /dev/null +++ b/android-client/tools/publish-apk.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Build the signed release APK and publish it for download from the web client. +# +# ./tools/publish-apk.sh +# +# The APK lands in the webclient's public directory, which the backend serves +# at /download/ (see navi/main.py) — so it ships with the next git pull on the +# server, exactly like the icons do. The JSON next to it is what the App +# settings panel reads, so the version and checksum on that page can never +# drift from the file the user actually downloads. +set -euo pipefail + +cd "$(dirname "$0")/.." +DEST="../webclient/public/download" +APK="app/build/outputs/apk/release/app-release.apk" + +if [ ! -f keystore.properties ]; then + echo "keystore.properties отсутствует — APK будет неподписанным." >&2 + echo "См. комментарий в app/build.gradle.kts." >&2 + exit 1 +fi + +./gradlew --quiet assembleRelease + +mkdir -p "$DEST" +cp "$APK" "$DEST/navi-android.apk" + +version_code=$(grep -m1 'versionCode' app/build.gradle.kts | tr -dc '0-9') +version_name=$(grep -m1 'versionName' app/build.gradle.kts | sed -E 's/.*"([^"]+)".*/\1/') +size=$(stat -c%s "$DEST/navi-android.apk") +sha=$(sha256sum "$DEST/navi-android.apk" | cut -d' ' -f1) + +printf '{\n "versionCode": %s,\n "versionName": "%s",\n "size": %s,\n "sha256": "%s",\n "builtAt": "%s"\n}\n' \ + "$version_code" "$version_name" "$size" "$sha" "$(date -Iseconds)" \ + > "$DEST/navi-android.json" + +echo "Готово: $DEST/navi-android.apk ($((size / 1024)) КиБ, версия $version_name)" +echo "Не забудьте пересобрать webclient и закоммитить оба файла." diff --git a/navi/main.py b/navi/main.py index 61a99ba..246bd7b 100644 --- a/navi/main.py +++ b/navi/main.py @@ -326,6 +326,15 @@ StaticFiles(directory=str(_base / "webclient" / "dist" / "content-viewers"), check_dir=False), name="content_viewers", ) + # The Android APK is served from public/, not dist/: it is a source + # artifact rather than a build output, so keeping it there means one copy + # in git instead of two (vite copies public/ into dist/ on every build, and + # that second copy is gitignored). + app.mount( + "/download", + StaticFiles(directory=str(_base / "webclient" / "public" / "download"), check_dir=False), + name="download", + ) app.mount( "/content", StaticFiles(directory=str(_base / "navi" / "content"), check_dir=False), diff --git a/webclient/public/download/navi-android.apk b/webclient/public/download/navi-android.apk new file mode 100644 index 0000000..fe78558 --- /dev/null +++ b/webclient/public/download/navi-android.apk Binary files differ diff --git a/webclient/public/download/navi-android.json b/webclient/public/download/navi-android.json new file mode 100644 index 0000000..7f57396 --- /dev/null +++ b/webclient/public/download/navi-android.json @@ -0,0 +1,7 @@ +{ + "versionCode": 1, + "versionName": "1.0", + "size": 2006296, + "sha256": "6c53720ed7c909ded03584cc948b2b56d086f43b09391a904893102285594f50", + "builtAt": "2026-10-09T19:56:24+03:00" +}