This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
# The build context of tools/ci/release.Dockerfile: the sources, not what builds, local runs and personal files leave.
|
||||
.git/
|
||||
**/build/
|
||||
.gradle/
|
||||
.kotlin/
|
||||
.idea/
|
||||
local/
|
||||
local.properties
|
||||
**/__pycache__/
|
||||
*.apk
|
||||
181
.gitea/workflows/release.yaml
Normal file
181
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,181 @@
|
||||
# CI — on push to master: the TV's APK and the desktop's .deb, built in Docker, go onto the repo's Releases page as
|
||||
# tag v<versionName> (docs/development.md, «CI»).
|
||||
#
|
||||
# A push that changed the apps since the last release (docs, tools, tests and the baseline profile generator aside —
|
||||
# they never reach the APK or the .deb) builds both one patch up (v0.1.1 → 0.1.2). A sleepyowl.versionName raised by
|
||||
# hand in gradle.properties past the last release (0.2.0) is taken as is; with no release yet, that one goes out
|
||||
# first. Any other push stops in a few seconds.
|
||||
#
|
||||
# Secrets (Settings → Actions → Secrets):
|
||||
# ANDROID_KEYSTORE_BASE64 base64 of the keystore that signs the APK
|
||||
# ANDROID_KEYSTORE_PASSWORD its password (also the key's: PKCS12 has one)
|
||||
# ANDROID_KEY_ALIAS the key's alias; «sleepyowl» if not set
|
||||
# RELEASE_TOKEN optional: a token with write:repository, if the automatic one may not create releases
|
||||
# Without the keystore the job fails: an APK on a key made up in the container installs over nothing.
|
||||
#
|
||||
# Actions variables, all with defaults: RELEASES_KEEP (10) — how many releases stay on the page (older ones go with
|
||||
# their files, their tags stay); BUILD_CACHE_KEEP (10GB) — the cap of the runner's build cache.
|
||||
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }}
|
||||
# RUN --mount and --output need BuildKit; newer CLIs use it anyway.
|
||||
DOCKER_BUILDKIT: '1'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# The whole history with its tags: the last release and what changed since.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Version, and whether it is out already
|
||||
id: version
|
||||
run: |
|
||||
base=$(sed -n 's/^sleepyowl\.versionName=//p' gradle.properties)
|
||||
# The last release: Gitea tags its commit v<versionName> when it goes out.
|
||||
last=$(git tag -l 'v*' | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)
|
||||
if [ -z "$last" ]; then
|
||||
name=$base
|
||||
elif [ "$base" != "$last" ] && [ "$(printf '%s\n%s\n' "$last" "$base" | sort -V | tail -n 1)" = "$base" ]; then
|
||||
# Raised by hand past the last release.
|
||||
name=$base
|
||||
# What never reaches the APK or the .deb makes no release on its own.
|
||||
elif git diff --quiet "v$last" HEAD -- . ':(exclude)docs' ':(exclude)tools' ':(exclude).gitea' \
|
||||
':(exclude)baselineprofile' ':(exclude,glob)**/src/test/**' ':(exclude,glob)**/*.md' \
|
||||
':(exclude).gitignore' ':(exclude).dockerignore'; then
|
||||
echo "Nothing in the apps changed since v$last (docs, tools and tests aside): no new release."
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
else
|
||||
name=$(printf '%s' "$last" | awk -F . '{ printf "%d.%d.%d", $1, $2, $3 + 1 }')
|
||||
fi
|
||||
tag="v$name"
|
||||
echo "Next release: $tag (last: ${last:-none}, gradle.properties: $base)"
|
||||
{ echo "name=$name"; echo "tag=$tag"; } >> "$GITHUB_OUTPUT"
|
||||
# Without a token the private repo answers 404 to everything, which would read as "not released" and build
|
||||
# for nothing on every push.
|
||||
[ -n "$TOKEN" ] || { echo "No token for the releases API (RELEASE_TOKEN or the automatic one)." >&2; exit 1; }
|
||||
status=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$tag")
|
||||
if [ "$status" = 200 ]; then
|
||||
echo "$tag is released already: nothing to build."
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$status" != 404 ]; then
|
||||
echo "The releases API answered $status." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build the APK and the .deb
|
||||
if: steps.version.outputs.build == 'true'
|
||||
env:
|
||||
KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
NAME: ${{ steps.version.outputs.name }}
|
||||
run: |
|
||||
if [ -z "$KEYSTORE" ]; then
|
||||
echo "::error::ANDROID_KEYSTORE_BASE64 is not set: no release without the signing key (docs/development.md, «CI»)."
|
||||
exit 1
|
||||
fi
|
||||
keys=$(mktemp -d)
|
||||
trap 'rm -rf "$keys"' EXIT
|
||||
printf '%s' "$KEYSTORE" | base64 -d > "$keys/release.jks"
|
||||
printf '%s' "$KEYSTORE_PASSWORD" > "$keys/password"
|
||||
printf '%s' "$KEY_ALIAS" > "$keys/alias"
|
||||
docker build --label ru.stenfredd.ci=sleepyowl-ci -t sleepyowl-ci tools/ci
|
||||
# The previous toolchain image, when its Dockerfile changed.
|
||||
docker image prune -f --filter label=ru.stenfredd.ci=sleepyowl-ci
|
||||
# --no-cache: secrets are not part of the layer cache key, so a cached layer could hand back an APK signed
|
||||
# with whatever key built it first. Gradle's cache mount stays.
|
||||
docker build --no-cache -f tools/ci/release.Dockerfile \
|
||||
--secret id=keystore,src="$keys/release.jks" \
|
||||
--secret id=keystore_password,src="$keys/password" \
|
||||
--secret id=key_alias,src="$keys/alias" \
|
||||
--build-arg VERSION_NAME="$NAME" \
|
||||
-o out .
|
||||
ls -l out
|
||||
cat out/version.txt
|
||||
|
||||
- name: Publish on the Releases page
|
||||
if: steps.version.outputs.build == 'true'
|
||||
env:
|
||||
NAME: ${{ steps.version.outputs.name }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -eu
|
||||
auth="Authorization: token $TOKEN"
|
||||
# What the APK came out as; versionCode follows versionName (app/build.gradle.kts).
|
||||
read -r built CODE < out/version.txt
|
||||
[ "$built" = "$NAME" ] || { echo "The APK is $built, not $NAME." >&2; exit 1; }
|
||||
deb=$(ls out/sleepyowl_*_amd64.deb)
|
||||
apk_sha=$(sha256sum out/sleepyowl.apk | cut -d ' ' -f 1)
|
||||
deb_sha=$(sha256sum "$deb" | cut -d ' ' -f 1)
|
||||
cert=$(cat out/cert-sha256.txt)
|
||||
body=$(printf 'Коммит %s.\\n\\n**Android TV:** `sleepyowl-%s.apk`, versionCode %s.\\nSHA-256: `%s`\\nСертификат подписи, SHA-256: `%s`\\n\\n**Linux:** `%s` — `sudo apt install ./%s` (нужен libmpv2).\\nSHA-256: `%s`' \
|
||||
"${{ github.sha }}" "$NAME" "$CODE" "$apk_sha" "$cert" "${deb#out/}" "${deb#out/}" "$deb_sha")
|
||||
release=$(curl -sS --fail-with-body -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$TAG\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"SleepyOwl $NAME\",\"body\":\"$body\",\"draft\":true}" \
|
||||
"$API/releases")
|
||||
id=$(printf '%s' "$release" | grep -o '"id":[0-9]*' | head -n 1 | cut -d : -f 2)
|
||||
# A draft until both files are in: a failed upload leaves no half release behind, and the next push tries again.
|
||||
trap 'curl -sS -X DELETE -H "$auth" "$API/releases/$id" > /dev/null' EXIT
|
||||
curl -sS --fail-with-body -H "$auth" -F "attachment=@out/sleepyowl.apk" \
|
||||
"$API/releases/$id/assets?name=sleepyowl-$NAME.apk" > /dev/null
|
||||
curl -sS --fail-with-body -H "$auth" -F "attachment=@$deb" \
|
||||
"$API/releases/$id/assets?name=${deb#out/}" > /dev/null
|
||||
curl -sS --fail-with-body -X PATCH -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d '{"draft":false}' "$API/releases/$id" > /dev/null
|
||||
trap - EXIT
|
||||
echo "Released $TAG: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
|
||||
|
||||
- name: Keep the newest releases only
|
||||
if: steps.version.outputs.build == 'true'
|
||||
# Housekeeping: the release above is out whatever happens here.
|
||||
continue-on-error: true
|
||||
env:
|
||||
KEEP: ${{ vars.RELEASES_KEEP || '10' }}
|
||||
run: |
|
||||
set -eu
|
||||
auth="Authorization: token $TOKEN"
|
||||
list=$(mktemp)
|
||||
page=1
|
||||
while [ "$page" -le 100 ]; do
|
||||
resp=$(curl -sS --fail-with-body -H "$auth" "$API/releases?limit=50&page=$page")
|
||||
[ "$resp" != "[]" ] || break
|
||||
# "<version> <id>" of each release (Gitea writes id, then tag_name).
|
||||
printf '%s' "$resp" | grep -o '"id":[0-9]*,"tag_name":"v[0-9.]*"' \
|
||||
| sed 's/"id":\([0-9]*\),"tag_name":"v\(.*\)"/\2 \1/' >> "$list" || true
|
||||
page=$((page + 1))
|
||||
done
|
||||
echo "Releases: $(wc -l < "$list"), keeping the newest $KEEP."
|
||||
sort -rV "$list" | tail -n +"$((KEEP + 1))" | while read -r version id; do
|
||||
curl -sS --fail-with-body -X DELETE -H "$auth" "$API/releases/$id" > /dev/null
|
||||
echo "Deleted the release v$version (its tag stays)."
|
||||
done
|
||||
|
||||
# Docker's build cache stays for the next build — the toolchain image's layers, Gradle's cache mount — but no
|
||||
# bigger than BUILD_CACHE_KEEP: the least recently used goes first. It is the runner daemon's cache, shared by
|
||||
# everything that builds there.
|
||||
- name: Cap the build cache
|
||||
if: always() && steps.version.outputs.build == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
KEEP: ${{ vars.BUILD_CACHE_KEEP || '10GB' }}
|
||||
run: |
|
||||
# --max-used-space is the cap (Docker 28+, API 1.48); older daemons know --keep-storage.
|
||||
if printf '1.48\n%s\n' "$(docker version --format '{{.Server.APIVersion}}')" | sort -V -C; then
|
||||
docker builder prune -af --max-used-space "$KEEP"
|
||||
else
|
||||
docker builder prune -af --keep-storage "$KEEP"
|
||||
fi
|
||||
docker system df
|
||||
@@ -4,6 +4,20 @@ plugins {
|
||||
alias(libs.plugins.androidx.baselineprofile)
|
||||
}
|
||||
|
||||
// Версия из gradle.properties или `-Psleepyowl.versionName=…` (CI поднимает patch). versionCode следует за ней:
|
||||
// 1.2.3 → 1_002_003, так что более новая версия всегда ставится поверх старой.
|
||||
val appVersion = providers.gradleProperty("sleepyowl.versionName").get()
|
||||
val appVersionCode = appVersion.split('.').mapNotNull(String::toIntOrNull).let { parts ->
|
||||
require(parts.size == 3 && parts[1] < 1000 && parts[2] < 1000) { "sleepyowl.versionName must be major.minor.patch (minor, patch < 1000): $appVersion" }
|
||||
parts[0] * 1_000_000 + parts[1] * 1_000 + parts[2]
|
||||
}
|
||||
|
||||
// Ключ подписи release вне репозитория: ANDROID_KEYSTORE (+ пароль, alias). Без него — debug-ключ этой машины.
|
||||
val releaseKeystore = providers.environmentVariable("ANDROID_KEYSTORE").orNull?.let(::file)?.also {
|
||||
require(it.isFile) { "ANDROID_KEYSTORE is not a file: $it" }
|
||||
}
|
||||
val releaseStorePassword = providers.environmentVariable("ANDROID_KEYSTORE_PASSWORD").orNull.orEmpty()
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl"
|
||||
compileSdk = 37
|
||||
@@ -12,19 +26,31 @@ android {
|
||||
applicationId = "ru.stenfredd.sleepyowl"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = appVersionCode
|
||||
versionName = appVersion
|
||||
ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a") }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (releaseKeystore != null) {
|
||||
create("release") {
|
||||
storeFile = releaseKeystore
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = providers.environmentVariable("ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotEmpty() } ?: "sleepyowl"
|
||||
// У PKCS12 (по умолчанию в keytool) один пароль на хранилище и ключ.
|
||||
keyPassword = providers.environmentVariable("ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotEmpty() } ?: releaseStorePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Скорость интерфейса проверяем только на этой сборке: R8 + baseline profile.
|
||||
// Пока своего ключа нет, подписываем debug-ключом, чтобы ставилась на ТВ.
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Без ANDROID_KEYSTORE — debug-ключом, чтобы локальная сборка ставилась на ТВ.
|
||||
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ plugins {
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
}
|
||||
|
||||
/** The desktop app's version: the About screen, the log, the .deb. */
|
||||
val appVersion = "0.1.0"
|
||||
/** The desktop app's version (gradle.properties, or `-Psleepyowl.versionName=…` from CI): the About screen, the log, the .deb. */
|
||||
val appVersion = providers.gradleProperty("sleepyowl.versionName").get()
|
||||
|
||||
kotlin { jvmToolchain(21) }
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ sudo apt remove sleepyowl # ~/.config/sleepyowl остаётс
|
||||
|
||||
- **.deb собирается своей задачей** (`PackageDeb` в `desktop/build.gradle.kts`, через `dpkg-deb`), а не `packageDeb` jpackage: его пакет не умеет зависеть от `libmpv2` — libmpv загружается через JNA, и зависимость сама не находится. Свой пакет даёт ещё короткое имя в `/usr/bin` и меню без лишних скриптов.
|
||||
- **libmpv — системная** `libmpv2 (>= 0.37)`: на проверенных файлах 0.37 не хуже 0.41 (§6), своя libmpv не нужна. Остальные зависимости — то, что `dpkg-shlibdeps` нашёл у библиотек JRE, Skiko и JNA (имена Mint 22 / Ubuntu 24.04: `libc6 (>= 2.34)`, `libstdc++6`, `libfontconfig1`, `libx11-6`, `libxext6`, `libxi6`, `libxrender1`, `libxtst6`, `libasound2t64 | libasound2`), плюс то, что грузится через JNA: `libegl1`, `libopengl0`, `libgl1`.
|
||||
- **Версия пакета** — `0.1.0-ГГГГММДДЧЧММ` (время сборки): `apt install` любой новой сборки идёт как обновление. Номер версии приложения — одно место, `appVersion` в `desktop/build.gradle.kts`; `BuildInfo.VERSION` генерируется из него.
|
||||
- **Версия пакета** — `0.1.0-ГГГГММДДЧЧММ` (время сборки): `apt install` любой новой сборки идёт как обновление. Номер версии — `sleepyowl.versionName` в `gradle.properties`, общий с ТВ (CI поднимает его на релизе); `BuildInfo.VERSION` генерируется из него.
|
||||
- **Модули JRE:** `suggestRuntimeModules` дал `java.instrument`, `jdk.security.auth` (dbus-java), `jdk.unsupported`. Добавлены два, которых jdeps не видит: `jdk.crypto.ec` (TLS к публичному адресу сервера по https) и `jdk.localedata` — без него `Locale.getDisplayLanguage(ru)` даёт названия языков дорожек по-английски.
|
||||
- **Права файлов в пакете** выставляются явно (755/644): при `umask 002` иначе получается групповая запись.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Что | Команда |
|
||||
|---|---|
|
||||
| ТВ, debug | `./gradlew :app:assembleDebug` → `app/build/outputs/apk/debug/app-debug.apk` |
|
||||
| ТВ, release | `./gradlew :app:assembleRelease` — R8 без обфускации, пока подписан debug-ключом; ABI `armeabi-v7a` + `arm64-v8a` |
|
||||
| ТВ, release | `./gradlew :app:assembleRelease` — R8 без обфускации, ключ — ниже; ABI `armeabi-v7a` + `arm64-v8a` |
|
||||
| ПК, запуск | `./gradlew :desktop:run` или `tools/linux/desktop.sh` (без Gradle вокруг процесса) |
|
||||
| ПК, пакет | `./gradlew :desktop:packageDeb` → `desktop/build/deb/sleepyowl_<версия>_amd64.deb` (зависит от `libmpv2`) |
|
||||
| Тесты | `./gradlew test` (в т.ч. рендер-тесты экранов ПК и `ui:shared` без дисплея) |
|
||||
@@ -15,6 +15,47 @@
|
||||
Производительность интерфейса на ТВ мерить только на release-сборке (см. [ui-performance.md](ui-performance.md)).
|
||||
Baseline profile лежит в `app/src/release/generated/baselineProfiles/` и снимается заново на ТВ (`gen_profile`, ниже).
|
||||
|
||||
Версия обоих приложений — `sleepyowl.versionName` в `gradle.properties` (или `-Psleepyowl.versionName=…`);
|
||||
`versionCode` APK следует за ней (`1.2.3` → `1002003`). Release-APK подписывается ключом из `ANDROID_KEYSTORE`
|
||||
(+ `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, по умолчанию `sleepyowl`), без него — debug-ключом машины.
|
||||
|
||||
## CI: релизы в Gitea
|
||||
|
||||
`.gitea/workflows/release.yaml`: на пуш в `master` APK и .deb собираются в Docker и выкладываются на страницу
|
||||
Releases репозитория как тег `v<версия>` (APK `sleepyowl-<версия>.apk`, `.deb` как его назвал `packageDeb`).
|
||||
|
||||
- **Версия.** Каждый релиз — patch на единицу выше прошлого тега (`v0.1.1` → `0.1.2`). Чтобы начать новую minor или
|
||||
major, поднять `sleepyowl.versionName` в `gradle.properties` выше последнего релиза — следующий релиз возьмёт её.
|
||||
Первый релиз — версия из `gradle.properties`. Пуш, который не менял приложения (только `docs/`, `tools/`,
|
||||
`.gitea/`, `baselineprofile/`, тесты, `*.md`), релиза не делает.
|
||||
- **Сборка.** `tools/ci/Dockerfile` — образ с JDK 21 и Android SDK; `tools/ci/release.Dockerfile` собирает в нём
|
||||
`:app:assembleRelease :desktop:packageDeb` (`tools/ci/release.sh`) целиком внутри BuildKit: исходники — контекст
|
||||
сборки, ключ — BuildKit-секреты (в слои не попадает), кэш Gradle — cache mount, который живёт между сборками,
|
||||
результат — `--output`. Так раннеру не нужны bind mount'ы.
|
||||
- **Раннер.** Нужен Gitea Actions runner с меткой `ubuntu-latest`, видящий этот репозиторий (на уровне репозитория,
|
||||
организации `StenFreddSoft` или всего сервера) и с доступом к Docker.
|
||||
- **Секреты** (*Settings → Actions → Secrets*):
|
||||
- `ANDROID_KEYSTORE_BASE64` — `base64 -w0 <keystore>`;
|
||||
- `ANDROID_KEYSTORE_PASSWORD` — его пароль (у PKCS12 он же пароль ключа);
|
||||
- `ANDROID_KEY_ALIAS` — alias ключа, если не `sleepyowl`;
|
||||
- `RELEASE_TOKEN` — только если автоматический токен Actions не может создавать релизы: токен с `write:repository`.
|
||||
|
||||
Без ключа джоба падает: APK на ключе, придуманном в контейнере, не встанет поверх ни одной установленной версии.
|
||||
Ключ должен быть один и тот же навсегда — APK на другом ключе ставится только после удаления приложения (с его
|
||||
настройками и входом). Свой ключ: `keytool -genkeypair -keystore sleepyowl.jks -alias sleepyowl -keyalg RSA
|
||||
-keysize 4096 -validity 36500`, хранить вне репозитория и с резервной копией.
|
||||
- **Переменные** (*Settings → Actions → Variables*, необязательные): `RELEASES_KEEP` (10) — сколько релизов остаётся
|
||||
на странице (старые удаляются вместе с файлами, теги остаются); `BUILD_CACHE_KEEP` (`10GB`) — потолок кэша
|
||||
сборки Docker на раннере.
|
||||
|
||||
Локально то же самое (команды — в шапке `tools/ci/release.Dockerfile`):
|
||||
|
||||
```sh
|
||||
docker build -t sleepyowl-ci tools/ci
|
||||
docker build --no-cache -f tools/ci/release.Dockerfile --secret id=keystore,src=<keystore> \
|
||||
--secret id=keystore_password,src=<файл с паролем> --secret id=key_alias,src=<файл с alias> -o out .
|
||||
```
|
||||
|
||||
## Личные настройки инструментов: `local/`
|
||||
|
||||
Папка `local/` целиком в `.gitignore`: там всё, что относится к конкретной машине, серверу и аккаунтам —
|
||||
|
||||
@@ -150,7 +150,7 @@ SleepyOwl — open-source клиент Jellyfin на Kotlin (GPL-3.0-or-later).
|
||||
- `ui/theme` (`Tokens`): цвета, типографика, отступы, радиусы, свечение фокуса из макета. `ui/components`: `FocusCard`/`focusCard` (фокус-узел со свечением), `GlowCache`, `Cards` (постер, широкая, большая), `Buttons` (основная с градиентом, круглые, чип; `Chip(swatch = …)` — цветная точка), `Icons`, `RemoteImage` + `CardImageTransformation`, `BlurHash`, `Avatar`, `StatusDot`, `Brand`, `Grid`, `Section`, `Scrolling`, `TextInput`.
|
||||
- **Оболочка** (`ui/shell/Shell.kt`): «Назад» на вкладке → фокус в рейл, «Назад» в рейле → выход из приложения.
|
||||
- **Упрощения макета ради скорости:** нет сетки точек на фоне; у постеров нет затемнения снизу — значок «4K» на маленькой тёмной плашке; полоска прогресса на постере — со скруглёнными концами и отступами, а не во всю ширину по краю; тень раскрытого рейла — узкая полоса 40 dp вместо размытия 80 px. Длинное название в большой карточке обрезается многоточием.
|
||||
- **Release-сборка:** R8 с keep-правилами (ass-media лезет рефлексией в поля `MatroskaExtractor` — `extractorOutput`, `subtitleSample`; libVLC; ffmpeg; SDK), `profileinstaller`, подпись пока debug-ключом. Воспроизведение Exo и VLC в release проверено.
|
||||
- **Release-сборка:** R8 с keep-правилами (ass-media лезет рефлексией в поля `MatroskaExtractor` — `extractorOutput`, `subtitleSample`; libVLC; ffmpeg; SDK), `profileinstaller`, подпись ключом из `ANDROID_KEYSTORE` (в CI — секрет, см. development.md), локально без него — debug-ключом. Воспроизведение Exo и VLC в release проверено.
|
||||
- **Открытие из adb** (`am start -n ru.stenfredd.sleepyowl/.MainActivity …`), работает и в release: `--es item <id> [--ez play true] [--el start <мс>]`; `--es series <id> [--es episode <id>]` — страница сериала. Только в debug: `--es engine exo|vlc`, `--ez tour true [--ei tour_file N]` (режим обхода). Без `--el start` фильм с `--ez play true` начинается с нуля и выпадает из «Продолжить просмотр».
|
||||
|
||||
### 5.2 Данные
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
### 1.2 Правила замеров
|
||||
|
||||
- **Только release**: R8 (минификация и оптимизация, без обфускации — проект open source, а стеки и теги логов остаются читаемыми), подпись debug-ключом, `profileinstaller`.
|
||||
- **Только release**: R8 (минификация и оптимизация, без обфускации — проект open source, а стеки и теги логов остаются читаемыми), `profileinstaller`.
|
||||
- После установки — сразу компиляция по baseline profile:
|
||||
```
|
||||
adb shell am broadcast -a androidx.profileinstaller.action.INSTALL_PROFILE <pkg>/androidx.profileinstaller.ProfileInstallReceiver
|
||||
|
||||
@@ -5,3 +5,5 @@ org.gradle.configuration-cache=true
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
# Версия приложений (ТВ и ПК). CI поднимает patch на каждый релиз (docs/development.md, «CI»).
|
||||
sleepyowl.versionName=0.1.0
|
||||
|
||||
26
tools/ci/Dockerfile
Normal file
26
tools/ci/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Build toolchain of CI (.gitea/workflows/release.yaml, docs/development.md «CI»): JDK 21 (Gradle, the desktop app's
|
||||
# toolchain and its jpackage) and the Android SDK the TV app compiles against. dpkg-deb for the .deb comes with the base
|
||||
# image. Gradle itself comes through the wrapper and, like every dependency, lands in /gradle — a BuildKit cache mount of
|
||||
# release.Dockerfile. Bump the platform together with compileSdk in app/build.gradle.kts.
|
||||
FROM eclipse-temurin:21-jdk-noble
|
||||
|
||||
ARG CMDLINE_TOOLS=16111833
|
||||
ARG SDK_PACKAGES="platforms;android-37.0 build-tools;36.0.0"
|
||||
|
||||
ENV ANDROID_HOME=/opt/android-sdk
|
||||
ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$PATH
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl unzip \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL -o /tmp/tools.zip https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip \
|
||||
&& mkdir -p $ANDROID_HOME/cmdline-tools \
|
||||
&& unzip -q /tmp/tools.zip -d $ANDROID_HOME/cmdline-tools \
|
||||
&& mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \
|
||||
&& rm /tmp/tools.zip \
|
||||
&& (yes | sdkmanager --licenses > /dev/null) \
|
||||
&& sdkmanager $SDK_PACKAGES
|
||||
|
||||
ENV GRADLE_USER_HOME=/gradle \
|
||||
ANDROID_USER_HOME=/gradle/android \
|
||||
GRADLE_OPTS=-Dorg.gradle.daemon=false
|
||||
26
tools/ci/release.Dockerfile
Normal file
26
tools/ci/release.Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# The release build of CI (.gitea/workflows/release.yaml) on the toolchain of ./Dockerfile. BuildKit does it all, so
|
||||
# the job needs no bind mounts (act_runner's docker resolves those on the host, not in the job): the sources go in as
|
||||
# the build context, the signing key as secrets that never land in a layer, Gradle's caches in a cache mount that
|
||||
# outlives the build, and the result comes out with --output.
|
||||
#
|
||||
# docker build -t sleepyowl-ci tools/ci
|
||||
# docker build --no-cache -f tools/ci/release.Dockerfile \
|
||||
# --secret id=keystore,src=release.jks --secret id=keystore_password,src=password.txt \
|
||||
# --secret id=key_alias,src=alias.txt --build-arg VERSION_NAME=0.1.1 -o out .
|
||||
#
|
||||
# out/ then holds what tools/ci/release.sh lists. --no-cache because secrets are not in the layer cache key: a cached
|
||||
# layer would return an APK signed with whatever key built it; the Gradle cache mount survives it.
|
||||
ARG CI_IMAGE=sleepyowl-ci
|
||||
|
||||
FROM ${CI_IMAGE} AS build
|
||||
ARG VERSION_NAME=
|
||||
WORKDIR /work
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=sleepyowl-gradle,target=/gradle,sharing=locked \
|
||||
--mount=type=secret,id=keystore \
|
||||
--mount=type=secret,id=keystore_password \
|
||||
--mount=type=secret,id=key_alias \
|
||||
sh tools/ci/release.sh /out
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /out/ /
|
||||
41
tools/ci/release.sh
Normal file
41
tools/ci/release.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
# The release build of CI (release.Dockerfile): the TV's APK on the release key, which BuildKit hands in as secrets,
|
||||
# and the desktop's .deb.
|
||||
# $1/sleepyowl.apk the APK (R8 without obfuscation: no mapping needed for stack traces)
|
||||
# $1/sleepyowl_*_amd64.deb the .deb
|
||||
# $1/cert-sha256.txt the APK's signing certificate, SHA-256 (as apksigner prints it)
|
||||
# $1/version.txt "<versionName> <versionCode>", as the APK has them
|
||||
# VERSION_NAME overrides gradle.properties' sleepyowl.versionName.
|
||||
set -eu
|
||||
|
||||
OUT=$1
|
||||
APK=app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
# Without the key Gradle signs with a debug key made up in the container: such an APK installs over nothing.
|
||||
[ -s /run/secrets/keystore ] || { echo "No keystore secret: the release key is required." >&2; exit 1; }
|
||||
ANDROID_KEYSTORE=/run/secrets/keystore
|
||||
ANDROID_KEYSTORE_PASSWORD=$(cat /run/secrets/keystore_password)
|
||||
ANDROID_KEY_ALIAS=$(cat /run/secrets/key_alias 2>/dev/null || true)
|
||||
export ANDROID_KEYSTORE ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS
|
||||
|
||||
./gradlew :app:assembleRelease :desktop:packageDeb \
|
||||
${VERSION_NAME:+"-Psleepyowl.versionName=$VERSION_NAME"}
|
||||
|
||||
buildtools=$(ls -d "$ANDROID_HOME"/build-tools/* | tail -n 1)
|
||||
digest=$("$buildtools/apksigner" verify --print-certs "$APK" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')
|
||||
[ -n "$digest" ] || { echo "apksigner printed no certificate digest." >&2; exit 1; }
|
||||
line=$("$buildtools/aapt2" dump badging "$APK" | sed -n '1p')
|
||||
name=$(printf '%s' "$line" | sed -n "s/.* versionName='\([^']*\)'.*/\1/p")
|
||||
code=$(printf '%s' "$line" | sed -n "s/.* versionCode='\([0-9]*\)'.*/\1/p")
|
||||
[ -n "$name" ] && [ -n "$code" ] || { echo "No version in the APK's manifest: $line" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$OUT"
|
||||
cp "$APK" "$OUT/sleepyowl.apk"
|
||||
cp desktop/build/deb/sleepyowl_*_amd64.deb "$OUT/"
|
||||
printf '%s\n' "$digest" > "$OUT/cert-sha256.txt"
|
||||
printf '%s %s\n' "$name" "$code" > "$OUT/version.txt"
|
||||
|
||||
# What the build left in /work would land in this RUN's layer, and BuildKit keeps such a layer from every run
|
||||
# (--no-cache): only the sources stay. (.dockerignore keeps every build/ out of the context, so these are all Gradle's.)
|
||||
find . -type d -name build -prune -exec rm -rf {} +
|
||||
rm -rf .gradle .kotlin
|
||||
Reference in New Issue
Block a user