Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de4bd473f9 | ||
|
|
7f421470b2 | ||
|
|
4bea164906 | ||
|
|
9d43b6d104 | ||
|
|
323bfde41b | ||
|
|
18d06f750d | ||
|
|
44bc3f927e | ||
|
|
b0a0bc1867 | ||
|
|
e700c99796 | ||
|
|
6af43c9c16 | ||
|
|
4f04129b0e | ||
|
|
772ab5f563 | ||
|
|
1eb7c136bf | ||
|
|
17ba581f88 | ||
|
|
76f2e4014c | ||
|
|
3d2ec5e931 | ||
|
|
368f53d589 | ||
|
|
3f3db71f14 | ||
|
|
e60b2d9910 | ||
|
|
f6d1d548e4 | ||
|
|
e74360fe07 | ||
|
|
d3373261d2 | ||
|
|
7f4e9703d4 | ||
|
|
ff8011eaf4 | ||
|
|
b4b5f66d81 | ||
|
|
8fb930c2ce | ||
|
|
af3741a549 | ||
|
|
fded9949af | ||
|
|
5c219c9f06 | ||
|
|
7145b9c3c7 | ||
|
|
4db8645691 | ||
|
|
eaab5368d0 | ||
|
|
6b6a99e51c | ||
|
|
21d6c57bc2 | ||
|
|
650d8c49e6 | ||
|
|
c76a79c3ed | ||
|
|
23911fceb5 | ||
|
|
f506ac0410 |
@@ -0,0 +1,74 @@
|
|||||||
|
name: Build
|
||||||
|
description: Set up Nix, optionally sign, run Gradle and report the version code
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
gradle-args:
|
||||||
|
description: Gradle tasks and flags to run
|
||||||
|
required: true
|
||||||
|
sign:
|
||||||
|
description: Decode and use the store keystore when "true"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
keystore-base64:
|
||||||
|
description: Base64 of the release keystore
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
keyproperties-base64:
|
||||||
|
description: Base64 of the key.properties holding the key alias
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
keystore-password:
|
||||||
|
description: Keystore (store) password
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
key-password:
|
||||||
|
description: Signing key password
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
version-code:
|
||||||
|
description: versionCode read from app/build.gradle.kts
|
||||||
|
value: ${{ steps.version.outputs.code }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: ./.github/actions/nix
|
||||||
|
- uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.gradle/caches
|
||||||
|
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: gradle-
|
||||||
|
- id: signing
|
||||||
|
if: inputs.sign == 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ inputs.keystore-base64 }}
|
||||||
|
KEYPROPERTIES_BASE64: ${{ inputs.keyproperties-base64 }}
|
||||||
|
run: |
|
||||||
|
umask 077
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||||
|
alias=$(printf '%s' "$KEYPROPERTIES_BASE64" | base64 -d | sed -n 's/^keyAlias=//p' | tr -d '\r')
|
||||||
|
test -n "$alias" || { echo "no keyAlias in keyproperties" >&2; exit 1; }
|
||||||
|
echo "alias=$alias" >> "$GITHUB_OUTPUT"
|
||||||
|
- shell: bash
|
||||||
|
env:
|
||||||
|
SIGN: ${{ inputs.sign }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ inputs.keystore-password }}
|
||||||
|
KEY_ALIAS: ${{ steps.signing.outputs.alias }}
|
||||||
|
KEY_PASSWORD: ${{ inputs.key-password }}
|
||||||
|
run: |
|
||||||
|
if [ "$SIGN" = "true" ]; then
|
||||||
|
export RELEASE_KEYSTORE="$RUNNER_TEMP/release.keystore"
|
||||||
|
export RELEASE_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD"
|
||||||
|
export RELEASE_KEY_ALIAS="$KEY_ALIAS"
|
||||||
|
export RELEASE_KEY_PASSWORD="$KEY_PASSWORD"
|
||||||
|
fi
|
||||||
|
nix develop --command gradle ${{ inputs.gradle-args }}
|
||||||
|
- if: always()
|
||||||
|
shell: bash
|
||||||
|
run: rm -f "$RUNNER_TEMP/release.keystore"
|
||||||
|
- id: version
|
||||||
|
shell: bash
|
||||||
|
run: echo "code=$(grep -oP 'versionCode = \K\d+' app/build.gradle.kts)" >> "$GITHUB_OUTPUT"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name: Nix
|
||||||
|
description: Install Nix and restore the store cache
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||||
|
with:
|
||||||
|
extra_nix_config: experimental-features = nix-command flakes
|
||||||
|
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
||||||
|
with:
|
||||||
|
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
|
||||||
|
restore-prefixes-first-match: nix-v2-
|
||||||
|
gc-max-store-size-linux: 6G
|
||||||
@@ -9,37 +9,46 @@ concurrency:
|
|||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
|
||||||
GRADLE_OPTS: -Dorg.gradle.daemon=false
|
|
||||||
NIX_CONFIG: experimental-features = nix-command flakes
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: sh
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
env:
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.daemon=false
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
|
||||||
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
|
||||||
with:
|
with:
|
||||||
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
|
persist-credentials: false
|
||||||
restore-prefixes-first-match: nix-v2-
|
- uses: ./.github/actions/build
|
||||||
gc-max-store-size-linux: 6G
|
|
||||||
- uses: actions/cache@v6
|
|
||||||
with:
|
with:
|
||||||
path: |
|
gradle-args: ":app:ktlintCheck :app:lintDebug :app:testDebugUnitTest :app:assembleDebug :app:assembleNightly --no-configuration-cache --console=plain --no-daemon"
|
||||||
~/.gradle/caches
|
- name: Collect nightly builds
|
||||||
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
if: github.ref == 'refs/heads/main'
|
||||||
restore-keys: gradle-
|
run: |
|
||||||
|
cp app/build/outputs/apk/debug/app-debug.apk subtracks-nightly-debug.apk
|
||||||
|
cp app/build/outputs/apk/nightly/app-nightly.apk subtracks-nightly.apk
|
||||||
|
- name: Checksums
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: sha256sum subtracks-nightly.apk subtracks-nightly-debug.apk | tee SHA256SUMS
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
with:
|
||||||
|
path: subtracks-nightly.apk
|
||||||
|
archive: false
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
with:
|
||||||
|
path: subtracks-nightly-debug.apk
|
||||||
|
archive: false
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
with:
|
||||||
|
path: SHA256SUMS
|
||||||
|
archive: false
|
||||||
- uses: actions/cache@v6
|
- uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: .integration/music
|
path: .integration/music
|
||||||
key: integration-music-v1
|
key: integration-music-v1
|
||||||
- run: nix develop --command gradle :app:ktlintCheck :app:lintDebug :app:testDebugUnitTest --no-configuration-cache --console=plain
|
|
||||||
- run: nix develop .#integration --command nu tools/integration-test.nu
|
- run: nix develop .#integration --command nu tools/integration-test.nu
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
version-code: ${{ steps.build.outputs.version-code }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Check tag matches versionName
|
||||||
|
run: |
|
||||||
|
version=$(grep -oP 'versionName = "\K[^"]+' app/build.gradle.kts)
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
"v$version" | "v$version"-*) ;;
|
||||||
|
*)
|
||||||
|
echo "tag $GITHUB_REF_NAME does not match versionName $version" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
- id: build
|
||||||
|
uses: ./.github/actions/build
|
||||||
|
with:
|
||||||
|
gradle-args: ":app:assembleRelease :app:bundleRelease --no-daemon"
|
||||||
|
sign: "true"
|
||||||
|
keystore-base64: ${{ secrets.SUBTRACKS_KEYSTORE_BASE64 }}
|
||||||
|
keyproperties-base64: ${{ secrets.SUBTRACKS_KEYPROPERTIES_BASE64 }}
|
||||||
|
keystore-password: ${{ secrets.SUBTRACKS_UPLOAD_STORE_PASSWORD }}
|
||||||
|
key-password: ${{ secrets.SUBTRACKS_UPLOAD_KEY_PASSWORD }}
|
||||||
|
- name: Export release certificate
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.SUBTRACKS_KEYSTORE_BASE64 }}
|
||||||
|
KEYPROPERTIES_BASE64: ${{ secrets.SUBTRACKS_KEYPROPERTIES_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.SUBTRACKS_UPLOAD_STORE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
umask 077
|
||||||
|
trap 'rm -f "$RUNNER_TEMP/keystore"' EXIT
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/keystore"
|
||||||
|
alias=$(printf '%s' "$KEYPROPERTIES_BASE64" | base64 -d | sed -n 's/^keyAlias=//p' | tr -d '\r')
|
||||||
|
nix develop --command keytool -exportcert -rfc -keystore "$RUNNER_TEMP/keystore" -alias "$alias" -storepass "$KEYSTORE_PASSWORD" -file app/build/outputs/release-certificate.pem
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: release
|
||||||
|
path: app/build/outputs
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: release
|
||||||
|
path: dist
|
||||||
|
- uses: ./.github/actions/nix
|
||||||
|
- name: Detect stable tag
|
||||||
|
id: stable
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "value=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "value=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Check release notes exist
|
||||||
|
run: test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||||
|
- name: Write Play service account
|
||||||
|
if: steps.stable.outputs.value == 'true'
|
||||||
|
env:
|
||||||
|
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
run: |
|
||||||
|
umask 077
|
||||||
|
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/play.json"
|
||||||
|
- name: Upload to Google Play
|
||||||
|
if: steps.stable.outputs.value == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop .#release --command fastlane supply \
|
||||||
|
--aab dist/bundle/release/app-release.aab \
|
||||||
|
--package_name com.subtracks \
|
||||||
|
--track "${{ vars.PLAY_TRACK || 'internal' }}" \
|
||||||
|
--json_key "$RUNNER_TEMP/play.json" \
|
||||||
|
--mapping dist/mapping/release/mapping.txt
|
||||||
|
- name: Remove Play service account
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/play.json"
|
||||||
|
- name: Checksums and signature
|
||||||
|
id: checksums
|
||||||
|
run: |
|
||||||
|
cp dist/apk/release/app-release.apk subtracks.apk
|
||||||
|
cp dist/bundle/release/app-release.aab subtracks.aab
|
||||||
|
gzip -n -9 -c dist/mapping/release/mapping.txt > mapping.txt.gz
|
||||||
|
sha256sum subtracks.apk subtracks.aab > SHA256SUMS
|
||||||
|
echo "Artifact SHA-256:"
|
||||||
|
sha256sum subtracks.apk subtracks.aab
|
||||||
|
echo "APK signature:"
|
||||||
|
certs=$(nix develop --command bash -c '"$ANDROID_HOME/build-tools/37.0.0/apksigner" verify --print-certs subtracks.apk')
|
||||||
|
printf '%s\n' "$certs"
|
||||||
|
fingerprint=$(printf '%s\n' "$certs" | sed -n 's/.*certificate SHA-256 digest: //p' | head -1 | tr '[:lower:]' '[:upper:]' | sed 's/\(..\)/\1:/g; s/:$//')
|
||||||
|
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Compose release notes
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
cat "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||||
|
echo
|
||||||
|
echo '---'
|
||||||
|
echo
|
||||||
|
echo "APK SHA-256: \`$(grep 'subtracks\.apk$' SHA256SUMS | cut -d ' ' -f1)\`"
|
||||||
|
echo
|
||||||
|
echo "AAB SHA-256: \`$(grep 'subtracks\.aab$' SHA256SUMS | cut -d ' ' -f1)\`"
|
||||||
|
echo
|
||||||
|
echo "Signing certificate SHA-256: \`${{ steps.checksums.outputs.fingerprint }}\`"
|
||||||
|
echo
|
||||||
|
echo 'The signing certificate is attached as `release-certificate.pem`.'
|
||||||
|
if [ "${{ steps.stable.outputs.value }}" = "true" ]; then
|
||||||
|
echo 'The `.aab` is the exact bundle uploaded to Google Play; the `.apk` is the universal APK for sideloading.'
|
||||||
|
else
|
||||||
|
echo 'The `.aab` is the built app bundle (this pre-release was not uploaded to Google Play); the `.apk` is the universal APK for sideloading.'
|
||||||
|
fi
|
||||||
|
} > release-notes.md
|
||||||
|
- name: Create GitHub release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file release-notes.md)
|
||||||
|
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
||||||
|
args+=(--prerelease)
|
||||||
|
fi
|
||||||
|
gh release create "$GITHUB_REF_NAME" subtracks.apk subtracks.aab SHA256SUMS dist/release-certificate.pem mapping.txt.gz "${args[@]}"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
|
!.github/actions/build/
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
*.apk
|
*.apk
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Guidance for agents (and humans) working in this repository.
|
|||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
A native Android rewrite of Subtracks, a client for Subsonic-compatible servers (Navidrome, gonic, Airsonic, ...), in Kotlin and Jetpack Compose. The `main` history still contains the earlier Flutter app; the native app is being built fresh under `app/` and ships as `com.subtracks.next` for side-by-side beta. The aim is a clean, F-Droid-friendly client: free/libre dependencies only, no telemetry.
|
A native Android rewrite of Subtracks, a client for Subsonic-compatible servers (Navidrome, gonic, Airsonic, ...), in Kotlin and Jetpack Compose. The `main` history still contains the earlier Flutter app; the native app is being built fresh under `app/` and ships as `com.subtracks`. The aim is a clean, F-Droid-friendly client: free/libre dependencies only, no telemetry.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
- Forgejo Actions, `.forgejo/workflows/ci.yml`, a single `ci` job that runs the lint, unit test and integration test steps (one cache restore/save and Gradle warm-up instead of three).
|
- Forgejo Actions, `.forgejo/workflows/ci.yml`, a single `ci` job that runs lint, the unit tests, the debug and nightly assembles and the integration suite (one cache restore/save and Gradle warm-up instead of several); the GitHub copy uploads the debug and nightly APKs and the checksums as separate artifacts on a push to `main`.
|
||||||
- Runs on the `nix-docker` runner (the `localhost/nix-ci` image built by the separate `nix-home` repo) inside the devshell, restoring the Nix store via `cache-nix-action` and caching Gradle and the integration test music.
|
- Runs on the `nix-docker` runner (the `localhost/nix-ci` image built by the separate `nix-home` repo) inside the devshell, restoring the Nix store via `cache-nix-action` and caching Gradle and the integration test music.
|
||||||
- The `nix-docker` and `bookworm` runner labels, their images and the runner's networking are defined in the separate `nix-home` repo (`hosts-incus/git-runner.nix`).
|
- The `nix-docker` and `bookworm` runner labels, their images and the runner's networking are defined in the separate `nix-home` repo (`hosts-incus/git-runner.nix`).
|
||||||
- Pull requests from a fork need approval before the workflow runs.
|
- Pull requests from a fork need approval before the workflow runs.
|
||||||
@@ -59,8 +59,8 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
|||||||
- Room 3 does not map `PagingSource` automatically: a DAO that returns it needs `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)`.
|
- Room 3 does not map `PagingSource` automatically: a DAO that returns it needs `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)`.
|
||||||
- Material icons are not pulled in by `material3`; use the BOM-managed `material-icons-core`/`material-icons-extended` (frozen at 1.7.8). Extended is large in debug builds but R8 strips unused icons from release.
|
- Material icons are not pulled in by `material3`; use the BOM-managed `material-icons-core`/`material-icons-extended` (frozen at 1.7.8). Extended is large in debug builds but R8 strips unused icons from release.
|
||||||
- Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`.
|
- Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`.
|
||||||
- `applicationId` is `com.subtracks.next` for the beta; change it to `com.subtracks` before any store release.
|
|
||||||
- The CI image provides `nix-ld`, `jq`, `sqlite`, `node`, `zstd` and a `runner` user that `cache-nix-action` expects. None of that is needed locally beyond the devshell.
|
- The CI image provides `nix-ld`, `jq`, `sqlite`, `node`, `zstd` and a `runner` user that `cache-nix-action` expects. None of that is needed locally beyond the devshell.
|
||||||
- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`), lms on 5082 (`admin`/`subtracks-lms`), Nextcloud Music on 8090 (`admin`/`subtracks-nextcloud`, base URL `http://localhost:8090/index.php/apps/music/subsonic/`). The test music is cached in `.integration/music` (gitignored).
|
- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`), lms on 5082 (`admin`/`subtracks-lms`), Nextcloud Music on 8090 (`admin`/`subtracks-nextcloud`, base URL `http://localhost:8090/index.php/apps/music/subsonic/`). The test music is cached in `.integration/music` (gitignored).
|
||||||
- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys** instead of passwords. The harness seeds a key and most tests use `useTokenAuth = false`; `SubsonicAuthIntegrationTest` drives `useTokenAuth = true` and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
|
- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys** instead of passwords. The harness seeds a key and most tests use `useTokenAuth = false`; `SubsonicAuthIntegrationTest` drives `useTokenAuth = true` and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
|
||||||
- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
|
- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
|
||||||
|
- The store screenshots in `metadata/en-US/images/phoneScreenshots/` must be 24-bit no-alpha PNGs (Play rejects palette and alpha PNGs for screenshots). `:app:demoScreenshots` writes its captures in that form, so copy them across as-is and don't run a palette optimizer over them.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ subtracks is an open source Android client for [Subsonic-compatible](http://www.
|
|||||||
<a href="https://github.com/austinried/subtracks/releases/"><img src=".assets/github-badge.png" width="250"/></a>
|
<a href="https://github.com/austinried/subtracks/releases/"><img src=".assets/github-badge.png" width="250"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
See [signing/README.md](signing/README.md) for the fingerprints and the commands to verify a download.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Large Libraries:** Built for very large libraries, from syncing the whole collection to playing playlists with thousands of tracks.
|
- **Large Libraries:** Built for very large libraries, from syncing the whole collection to playing playlists with thousands of tracks.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ android {
|
|||||||
buildToolsVersion = "37.0.0"
|
buildToolsVersion = "37.0.0"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.subtracks.next"
|
applicationId = "com.subtracks"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 13
|
versionCode = 13
|
||||||
@@ -30,6 +30,7 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
|
resValues = true
|
||||||
}
|
}
|
||||||
|
|
||||||
androidResources {
|
androidResources {
|
||||||
@@ -79,13 +80,42 @@ android {
|
|||||||
.kotlin.directories
|
.kotlin.directories
|
||||||
.add("src/integrationTest/kotlin")
|
.add("src/integrationTest/kotlin")
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
// Committed on purpose: a public key for debug and nightly builds, never for distribution.
|
||||||
|
getByName("debug") {
|
||||||
|
storeFile = file("debug.keystore")
|
||||||
|
storePassword = "android"
|
||||||
|
keyAlias = "androiddebugkey"
|
||||||
|
keyPassword = "android"
|
||||||
|
}
|
||||||
|
val keystorePath = providers.environmentVariable("RELEASE_KEYSTORE").orNull
|
||||||
|
if (keystorePath != null) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystorePath)
|
||||||
|
storePassword = providers.environmentVariable("RELEASE_KEYSTORE_PASSWORD").orNull
|
||||||
|
keyAlias = providers.environmentVariable("RELEASE_KEY_ALIAS").orNull
|
||||||
|
keyPassword = providers.environmentVariable("RELEASE_KEY_PASSWORD").orNull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".nightly"
|
||||||
|
resValue("string", "app_name", "subtracks nightly")
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
// Debug key so `installRelease` works locally; store and F-Droid builds re-sign.
|
// Release signing when the keystore is supplied (CI); debug elsewhere so local build/install works.
|
||||||
|
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
create("nightly") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
applicationIdSuffix = ".nightly"
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
resValue("string", "app_name", "subtracks nightly")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"uniqueId": "com.subtracks:next",
|
"uniqueId": "com.subtracks",
|
||||||
"name": "Subtracks",
|
"name": "Subtracks",
|
||||||
"description": "Subtracks itself, a client for Subsonic-compatible servers.",
|
"description": "Subtracks itself, a client for Subsonic-compatible servers.",
|
||||||
"website": "https://github.com/austinried/subtracks",
|
"website": "https://github.com/austinried/subtracks",
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package com.subtracks.metadata
|
|
||||||
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class ChangelogTest {
|
|
||||||
@Test
|
|
||||||
fun changelogsFitStoreLimit() {
|
|
||||||
val root =
|
|
||||||
generateSequence(File(".").canonicalFile) { it.parentFile }
|
|
||||||
.first { File(it, "settings.gradle.kts").isFile }
|
|
||||||
val files =
|
|
||||||
File(root, "metadata")
|
|
||||||
.listFiles()
|
|
||||||
.orEmpty()
|
|
||||||
.flatMap { locale -> File(locale, "changelogs").listFiles().orEmpty().toList() }
|
|
||||||
.filter { it.isFile && it.extension == "txt" }
|
|
||||||
assertTrue("no changelog files found under metadata/", files.isNotEmpty())
|
|
||||||
files.forEach { file ->
|
|
||||||
val text = file.readText().replace("\r\n", "\n")
|
|
||||||
val length = text.codePointCount(0, text.length)
|
|
||||||
assertTrue(
|
|
||||||
"metadata changelogs must be at most $LIMIT characters; ${file.name} is $length",
|
|
||||||
length <= LIMIT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val LIMIT = 500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package com.subtracks.metadata
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the committed store metadata against the stricter of the two stores' rules.
|
||||||
|
*
|
||||||
|
* Google Play, "Add preview assets to showcase your app":
|
||||||
|
* https://support.google.com/googleplay/android-developer/answer/9866151
|
||||||
|
* - Screenshots (Requirements): "JPEG or 24-bit PNG (no alpha)", "Minimum dimension: 320px",
|
||||||
|
* "Maximum dimension: 3840px", "The maximum dimension of your screenshot can't be more than
|
||||||
|
* twice as long as the minimum dimension"; "a minimum of two screenshots" and "up to 8".
|
||||||
|
* - Screenshots (Highly recommended, not enforced here): "16:9 for landscape ... and 9:16 for
|
||||||
|
* portrait screenshots (minimum 1080x1920px)" for recommendation eligibility.
|
||||||
|
* - App icon (Requirements): "32-bit PNG (with alpha)", "512px by 512px", "Maximum file size: 1024KB".
|
||||||
|
* - Feature graphic (Requirements): "JPEG or 24-bit PNG (no alpha)", "1024px by 500px".
|
||||||
|
* - Short description (Requirements): "80 character limit".
|
||||||
|
*
|
||||||
|
* F-Droid, "All About Descriptions, Graphics, and Screenshots":
|
||||||
|
* https://f-droid.org/docs/All_About_Descriptions_Graphics_and_Screenshots/
|
||||||
|
* - Fastlane structure: "title.txt (app name, max 50 chars)",
|
||||||
|
* "short_description.txt (short description, max 80 chars)",
|
||||||
|
* "full_description.txt (full app description, max 4000 chars)", "changelogs/... (max 500 chars)".
|
||||||
|
*/
|
||||||
|
class MetadataConstraintsTest {
|
||||||
|
@Test
|
||||||
|
fun titlesFitStoreLimit() {
|
||||||
|
localeFiles("title.txt").forEach { assertAtMost(it, TITLE_LIMIT) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shortDescriptionsFitStoreLimit() {
|
||||||
|
localeFiles("short_description.txt").forEach { assertAtMost(it, SHORT_DESCRIPTION_LIMIT) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullDescriptionsFitStoreLimit() {
|
||||||
|
localeFiles("full_description.txt").forEach { assertAtMost(it, FULL_DESCRIPTION_LIMIT) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun changelogsFitStoreLimit() {
|
||||||
|
localeFiles("changelogs").forEach { dir ->
|
||||||
|
val changelogs = dir.listFiles().orEmpty().filter { it.isFile && it.extension == "txt" }
|
||||||
|
assertTrue("no changelogs found in ${dir.path}", changelogs.isNotEmpty())
|
||||||
|
changelogs.forEach { assertAtMost(it, CHANGELOG_LIMIT) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play (Requirements): "32-bit PNG (with alpha)", "512px by 512px", "Maximum file size: 1024KB".
|
||||||
|
@Test
|
||||||
|
fun iconsAre512AlphaPngsWithinOneMegabyte() {
|
||||||
|
localeFiles("images/icon.png").forEach { file ->
|
||||||
|
val image = readImage(file)
|
||||||
|
assertEquals("${file.path} must be a PNG", ImageFormat.PNG, image.format)
|
||||||
|
assertEquals("${file.path} must be 512x512", 512, image.width)
|
||||||
|
assertEquals("${file.path} must be 512x512", 512, image.height)
|
||||||
|
assertEquals(
|
||||||
|
"${file.path} must be a 32-bit PNG with alpha (Play); colorType ${image.colorType}",
|
||||||
|
RGBA_COLOR_TYPE,
|
||||||
|
image.colorType,
|
||||||
|
)
|
||||||
|
assertTrue("${file.path} must be an 8-bit PNG; bitDepth ${image.bitDepth}", image.bitDepth == 8)
|
||||||
|
assertTrue("${file.path} must be at most 1 MiB", file.length() <= 1024 * 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play (Requirements): "JPEG or 24-bit PNG (no alpha)", "1024px by 500px".
|
||||||
|
@Test
|
||||||
|
fun featureGraphicsAre1024x500WithoutAlpha() {
|
||||||
|
localeFiles("images/featureGraphic.png").forEach { file ->
|
||||||
|
val image = readImage(file)
|
||||||
|
assertEquals("${file.path} must be 1024x500", 1024, image.width)
|
||||||
|
assertEquals("${file.path} must be 1024x500", 500, image.height)
|
||||||
|
assertOpaquePngOrJpeg(file, image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play (Requirements): 2-8 screenshots, sides 320-3840px, and "The maximum dimension of your
|
||||||
|
// screenshot can't be more than twice as long as the minimum dimension" (the 2:1 ratio).
|
||||||
|
@Test
|
||||||
|
fun phoneScreenshotsMeetStoreRules() {
|
||||||
|
localeFiles("images/phoneScreenshots").forEach { dir ->
|
||||||
|
val screenshots =
|
||||||
|
dir
|
||||||
|
.listFiles()
|
||||||
|
.orEmpty()
|
||||||
|
.filter { it.isFile && it.extension.lowercase() in IMAGE_EXTENSIONS }
|
||||||
|
.sortedBy { it.name }
|
||||||
|
assertTrue(
|
||||||
|
"${dir.path} needs 2-8 phone screenshots, found ${screenshots.size}",
|
||||||
|
screenshots.size in 2..8,
|
||||||
|
)
|
||||||
|
screenshots.forEach { file ->
|
||||||
|
val image = readImage(file)
|
||||||
|
val short = minOf(image.width, image.height)
|
||||||
|
val long = maxOf(image.width, image.height)
|
||||||
|
assertTrue("${file.path} shortest side $short must be at least 320", short >= 320)
|
||||||
|
assertTrue("${file.path} longest side $long must be at most 3840", long <= 3840)
|
||||||
|
assertTrue("${file.path} aspect ratio exceeds 2:1", long <= 2 * short)
|
||||||
|
assertOpaquePngOrJpeg(file, image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertOpaquePngOrJpeg(
|
||||||
|
file: File,
|
||||||
|
image: ImageInfo,
|
||||||
|
) {
|
||||||
|
when (image.format) {
|
||||||
|
ImageFormat.PNG -> {
|
||||||
|
assertEquals(
|
||||||
|
"${file.path} must be a 24-bit PNG without alpha (colorType $RGB_COLOR_TYPE); colorType ${image.colorType}",
|
||||||
|
RGB_COLOR_TYPE,
|
||||||
|
image.colorType,
|
||||||
|
)
|
||||||
|
assertTrue("${file.path} must be an 8-bit PNG; bitDepth ${image.bitDepth}", image.bitDepth == 8)
|
||||||
|
assertTrue("${file.path} must not carry a tRNS (transparency) chunk", !image.hasTrns)
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageFormat.JPEG -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageFormat.OTHER -> {
|
||||||
|
assertTrue("${file.path} must be a PNG or JPEG", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertAtMost(
|
||||||
|
file: File,
|
||||||
|
limit: Int,
|
||||||
|
) {
|
||||||
|
val text = file.readText().replace("\r\n", "\n")
|
||||||
|
val length = text.codePointCount(0, text.length)
|
||||||
|
assertTrue("${file.path} is $length characters, over the $limit limit", length <= limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun localeFiles(relativePath: String): List<File> {
|
||||||
|
val locales = File(repoRoot(), "metadata").listFiles().orEmpty().filter { it.isDirectory }
|
||||||
|
assertTrue("no locales found under metadata/", locales.isNotEmpty())
|
||||||
|
return locales.map { File(it, relativePath) }.onEach { assertTrue("missing ${it.path}", it.exists()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun repoRoot(): File =
|
||||||
|
generateSequence(File(".").canonicalFile) { it.parentFile }
|
||||||
|
.first { File(it, "settings.gradle.kts").isFile }
|
||||||
|
|
||||||
|
private enum class ImageFormat { PNG, JPEG, OTHER }
|
||||||
|
|
||||||
|
private data class ImageInfo(
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val colorType: Int?,
|
||||||
|
val bitDepth: Int?,
|
||||||
|
val hasTrns: Boolean,
|
||||||
|
val format: ImageFormat,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun readImage(file: File): ImageInfo {
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
if (bytes.isPng()) {
|
||||||
|
return ImageInfo(
|
||||||
|
width = bytes.beInt(PNG_WIDTH_OFFSET),
|
||||||
|
height = bytes.beInt(PNG_HEIGHT_OFFSET),
|
||||||
|
colorType = bytes[PNG_COLOR_TYPE_OFFSET].toInt() and 0xFF,
|
||||||
|
bitDepth = bytes[PNG_BIT_DEPTH_OFFSET].toInt() and 0xFF,
|
||||||
|
hasTrns = bytes.hasTrnsChunk(),
|
||||||
|
format = ImageFormat.PNG,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val image = ImageIO.read(file)
|
||||||
|
assertTrue("unreadable image ${file.path}", image != null)
|
||||||
|
return ImageInfo(
|
||||||
|
image.width,
|
||||||
|
image.height,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
hasTrns = false,
|
||||||
|
format = if (bytes.isJpeg()) ImageFormat.JPEG else ImageFormat.OTHER,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.hasTrnsChunk(): Boolean {
|
||||||
|
var offset = PNG_SIGNATURE_SIZE
|
||||||
|
while (offset + 8 <= size) {
|
||||||
|
val length = beInt(offset)
|
||||||
|
val type = String(this, offset + 4, 4, Charsets.US_ASCII)
|
||||||
|
if (type == "tRNS") return true
|
||||||
|
if (type == "IEND" || length < 0) return false
|
||||||
|
offset += PNG_CHUNK_OVERHEAD + length
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.isPng(): Boolean =
|
||||||
|
size > PNG_COLOR_TYPE_OFFSET &&
|
||||||
|
this[0] == 0x89.toByte() &&
|
||||||
|
this[1] == 'P'.code.toByte() &&
|
||||||
|
this[2] == 'N'.code.toByte() &&
|
||||||
|
this[3] == 'G'.code.toByte()
|
||||||
|
|
||||||
|
private fun ByteArray.isJpeg(): Boolean = size > 2 && this[0] == 0xFF.toByte() && this[1] == 0xD8.toByte()
|
||||||
|
|
||||||
|
private fun ByteArray.beInt(offset: Int): Int =
|
||||||
|
((this[offset].toInt() and 0xFF) shl 24) or
|
||||||
|
((this[offset + 1].toInt() and 0xFF) shl 16) or
|
||||||
|
((this[offset + 2].toInt() and 0xFF) shl 8) or
|
||||||
|
(this[offset + 3].toInt() and 0xFF)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Play (Requirements): 30; F-Droid title.txt: 50. Stricter wins.
|
||||||
|
const val TITLE_LIMIT = 30
|
||||||
|
|
||||||
|
// Play short description and F-Droid short_description.txt: 80.
|
||||||
|
const val SHORT_DESCRIPTION_LIMIT = 80
|
||||||
|
|
||||||
|
// F-Droid full_description.txt: 4000.
|
||||||
|
const val FULL_DESCRIPTION_LIMIT = 4000
|
||||||
|
|
||||||
|
// Play "What's new" and F-Droid changelogs: 500.
|
||||||
|
const val CHANGELOG_LIMIT = 500
|
||||||
|
|
||||||
|
const val RGB_COLOR_TYPE = 2
|
||||||
|
const val RGBA_COLOR_TYPE = 6
|
||||||
|
val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg")
|
||||||
|
|
||||||
|
const val PNG_WIDTH_OFFSET = 16
|
||||||
|
const val PNG_HEIGHT_OFFSET = 20
|
||||||
|
const val PNG_BIT_DEPTH_OFFSET = 24
|
||||||
|
const val PNG_COLOR_TYPE_OFFSET = 25
|
||||||
|
const val PNG_SIGNATURE_SIZE = 8
|
||||||
|
const val PNG_CHUNK_OVERHEAD = 12
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,9 @@ import org.junit.Test
|
|||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records the store screenshots from a real sync of the public Navidrome demo library. It is not run
|
* Records the store screenshots from a real sync of the public Navidrome demo library. It is not run
|
||||||
@@ -269,6 +271,17 @@ class DemoScreenshotTest {
|
|||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
}
|
}
|
||||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/demo/$file")
|
composeRule.onRoot().captureRoboImage("src/test/screenshots/demo/$file")
|
||||||
|
reencodeOpaqueRgb(File("src/test/screenshots/demo/$file"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reencodeOpaqueRgb(file: File) {
|
||||||
|
val decoded = ImageIO.read(file)
|
||||||
|
val rgb = BufferedImage(decoded.width, decoded.height, BufferedImage.TYPE_INT_RGB)
|
||||||
|
rgb.createGraphics().run {
|
||||||
|
drawImage(decoded, 0, 0, null)
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
ImageIO.write(rgb, "png", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -83,6 +83,10 @@
|
|||||||
|
|
||||||
inherit shellHook;
|
inherit shellHook;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
release = pkgs.mkShell {
|
||||||
|
packages = [ pkgs.fastlane ];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 514 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 513 KiB |
|
Before Width: | Height: | Size: 861 KiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 496 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 410 KiB |
@@ -1 +1 @@
|
|||||||
A clean, open source Subsonic music player.
|
A clean, open source Subsonic music player
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Verifying a subtracks download
|
||||||
|
|
||||||
|
subtracks ships through four channels:
|
||||||
|
|
||||||
|
- **GitHub releases** — the APK and the AAB are signed with the release key. The public certificate is committed here as [`release-certificate.pem`](release-certificate.pem) and attached to each release, along with a `SHA256SUMS`.
|
||||||
|
- **Google Play** — uses Play App Signing, so Google re-signs the app it delivers. The release key is only the *upload* key there: it signs the AAB we send to Play, not the APK users install. The installed app is signed by Google's app signing key, and the upload key only proves the AAB we uploaded.
|
||||||
|
- **F-Droid** — re-signed by F-Droid with its own key; see [F-Droid's signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/).
|
||||||
|
- **`nightly` builds** (the `subtracks-nightly` artifact from CI) — signed with the public debug key committed at [`app/debug.keystore`](../app/debug.keystore), whose certificate is [`nightly-certificate.pem`](nightly-certificate.pem).
|
||||||
|
|
||||||
|
## GitHub releases
|
||||||
|
|
||||||
|
The APK and the AAB are signed with the release key. Certificate SHA-256:
|
||||||
|
|
||||||
|
```
|
||||||
|
# keytool format
|
||||||
|
02:DA:2C:B7:B8:21:4E:53:BD:BF:B4:55:69:8D:CB:96:52:22:F9:1C:95:4B:52:10:AE:AC:96:FE:32:9C:8F:01
|
||||||
|
# apksigner format
|
||||||
|
02da2cb7b8214e53bdbfb455698dcb965222f91c954b5210aeac96fe329c8f01
|
||||||
|
```
|
||||||
|
|
||||||
|
Each release attaches the APK, the AAB, the checksums, the certificate (committed here as [`release-certificate.pem`](release-certificate.pem)) and the gzipped R8 `mapping.txt.gz`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
apksigner verify --print-certs subtracks.apk # compare with the .pem
|
||||||
|
keytool -printcert -jarfile subtracks.aab # the AAB
|
||||||
|
keytool -printcert -file release-certificate.pem # prints its SHA-256
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.aab` is the app bundle (the one uploaded to Google Play for stable releases; install it by generating device APKs with [bundletool](https://github.com/google/bundletool)); the `.apk` is the universal APK for sideloading. The signing certificate SHA-256 is also listed in the release notes.
|
||||||
|
|
||||||
|
## Google Play
|
||||||
|
|
||||||
|
The AAB we upload is signed with the release (upload) key, and that is what Play checks; Google then re-signs the delivered APKs with its own app signing key. So a Play-installed app is signed by Google's key, not `release-certificate.pem`.
|
||||||
|
|
||||||
|
To check the AAB that was sent:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
keytool -printcert -jarfile app-release.aab
|
||||||
|
```
|
||||||
|
|
||||||
|
## `nightly` builds
|
||||||
|
|
||||||
|
The signing key is the committed [`app/debug.keystore`](../app/debug.keystore) (a public key: alias `androiddebugkey`, password `android`). The nightly build uploads `subtracks-nightly.apk` (minified) and `subtracks-nightly-debug.apk` (debuggable), both `com.subtracks.nightly`, as separate artifacts, along with `SHA256SUMS`. Certificate SHA-256:
|
||||||
|
|
||||||
|
```
|
||||||
|
# keytool format
|
||||||
|
DF:31:3F:18:58:E9:AD:F4:7F:44:65:B2:80:8F:DC:E5:B9:F8:8A:48:35:DC:D0:79:95:19:61:C3:05:05:08:37
|
||||||
|
# apksigner format
|
||||||
|
df313f1858e9adf47f4465b2808fdce5b9f88a4835dcd079951961c305050837
|
||||||
|
```
|
||||||
|
|
||||||
|
To check a `nightly` APK:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
apksigner verify --print-certs subtracks-nightly.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
The `SHA-256 digest` line must equal the fingerprint above (the certificate is committed at [`nightly-certificate.pem`](nightly-certificate.pem)).
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDEzCCAfugAwIBAgIIWpLReZ2f+20wDQYJKoZIhvcNAQEMBQAwNzELMAkGA1UE
|
||||||
|
BhMCVVMxEDAOBgNVBAoTB0FuZHJvaWQxFjAUBgNVBAMTDUFuZHJvaWQgRGVidWcw
|
||||||
|
IBcNMjYxMDA0MDMyNTU4WhgPMjA1NDAyMTkwMzI1NThaMDcxCzAJBgNVBAYTAlVT
|
||||||
|
MRAwDgYDVQQKEwdBbmRyb2lkMRYwFAYDVQQDEw1BbmRyb2lkIERlYnVnMIIBIjAN
|
||||||
|
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn4/a05V9p2CHVx2YWwPFHtecUocy
|
||||||
|
WmGQGPhN0VepKU2DC6LDCM7X8YBqS+RyIiDDVqmlUrnYu1CgbaglAySz8vMQBjUo
|
||||||
|
4XUiC7iyNX9JP5exfnGmFsWMM6zHIVyOViX1ZryZlMUAA+CXXj72Mkv1YAijtEu0
|
||||||
|
JFHvGgUDDhPsJmToHQtEDHhvN+nUWR7zl7mVcrE3Al6YT+rfSgGOl5Sg6umjyx3c
|
||||||
|
VwtiQCR5eDW9YiGCDufjBJYox2oNFdvMViPcn9bJu1maAS+SMHL9gpcysriSDLp/
|
||||||
|
rDs6an1zIP76zRPOVMJ/Aqi1JvMtcCBREY5qk3qwtNWQ6YnBcHKJ+7oHzwIDAQAB
|
||||||
|
oyEwHzAdBgNVHQ4EFgQUM7dLj1ZLZvtvLOlI9cap5RldteAwDQYJKoZIhvcNAQEM
|
||||||
|
BQADggEBACDujVeOnL95ybrTm9mwlVsG0DZ5ttRxt6JHEnVjHWOxv/W0F5FanEz1
|
||||||
|
DonQIpRa4eOJ06eBvPcBVpNuLKzczpO1Ue3AaX6kwjWD4n6XYMVWZP6euOj2wvwK
|
||||||
|
93Kf4iG6ObMJ6WMA91VIvtTMvDGhzoL/5Kk5+esBbzLrBWLU/VJrySABFU5cwnqF
|
||||||
|
cP7kGR5JvHuJKiuJ/99yXZeg0piI3n/rdEYsjWkpIIIfaFY2j/n1r+evQ9J/aln/
|
||||||
|
i0dif06Fl11KdFF4456nGH59S0SeckG9jKo04nj4fKeMqwE/7MDRpDa+a0e9Xu7Z
|
||||||
|
0jGgQKg1SFbI8xMrJn2ybdWquVZtKHo=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
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
|
||||||
|
CbcA2PhHua2HFcHcoAwUgFI=
|
||||||
|
-----END CERTIFICATE-----
|
||||||