38 Commits
Author SHA1 Message Date
austinried de4bd473f9 ci: upload the nightly APKs as separate raw artifacts
archive: false only supports a single file, so upload each of the two
APKs and SHA256SUMS as its own unzipped artifact instead of one zip.
2026-10-08 19:51:03 +00:00
austinried 7f421470b2 release: gzip the R8 mapping before attaching it 2026-10-08 19:23:56 +00:00
austinried 4bea164906 release: split the Play key write, guard the tag and attach the mapping
- write the Play service account in its own step so fastlane never has
  the secret in its environment (only the 0600 file path)
- fail the build when the tag does not match versionName
- serialize releases with a concurrency group
- attach the R8 mapping.txt to the GitHub release
2026-10-08 19:21:26 +00:00
austinried 9d43b6d104 address review: persist nightly checksums, guard the changelog, tidy CI
- write SHA256SUMS into the subtracks-nightly artifact
- assert at least one changelog exists in the metadata test
- drop the unused assembleDebug from the Forgejo CI
- only claim the AAB was uploaded to Play for stable releases
- clean up the export-certificate keystore with a trap
2026-10-08 19:12:56 +00:00
austinried 323bfde41b ci: fold the nightly artifact into the CI job
Drop the separate build-nightly workflow: on a push to main the CI job
uploads the debug and nightly APKs as the subtracks-nightly artifact, so
the nightly variant is built once instead of twice (and there is no
second Nix/cache setup).
2026-10-08 18:53:00 +00:00
austinried 18d06f750d ci: build the nightly variant in CI
Add :app:assembleNightly to the CI Gradle invocation so the R8 minify,
resource shrinking and packaging path is exercised on every PR (signed
with the committed debug key, so no secrets are needed).
2026-10-08 18:53:00 +00:00
austinried 44bc3f927e address review: guard the release pipeline and harden the metadata test
- fail the release before uploading to Play when the versionCode has no
  changelog
- rely on supply's defaults for release_status and metadata_path
- enforce 8-bit PNGs and reject tRNS chunks in MetadataConstraintsTest
- name the nightly artifact's APKs distinctly
- fix doc drift and the stale libraries uniqueId
2026-10-08 18:53:00 +00:00
austinried b0a0bc1867 build: label the nightly/debug variants distinctly 2026-10-08 18:53:00 +00:00
austinried e700c99796 docs: label the fingerprints as keytool/apksigner format 2026-10-08 18:52:44 +00:00
austinried 6af43c9c16 docs: drop the private Play Console references from signing/README 2026-10-08 18:52:44 +00:00
austinried 4f04129b0e docs: show both fingerprint formats with tool labels 2026-10-08 18:52:44 +00:00
austinried 772ab5f563 docs: note the fingerprint formats and normalize the release-notes one 2026-10-08 18:52:44 +00:00
austinried 1eb7c136bf docs: link F-Droid's signing keys and the nightly keystore 2026-10-08 18:52:44 +00:00
austinried 17ba581f88 docs: list F-Droid before nightly in the signing channels 2026-10-08 18:52:44 +00:00
austinried 76f2e4014c docs: put the release certificate before the nightly section 2026-10-08 18:52:44 +00:00
austinried 3d2ec5e931 docs: unwrap the lines in signing/README.md 2026-10-08 18:52:44 +00:00
austinried 368f53d589 release: attach the signed AAB to the GitHub release
Publish the exact bundle sent to Play alongside the APK, cover both in
SHA256SUMS and the release notes, and document verifying the AAB.
2026-10-08 18:52:44 +00:00
austinried 3f3db71f14 docs: commit the release signing certificate and fingerprint
Add the release key's public certificate (signing/release-certificate.pem)
and its SHA-256 to signing/README.md.
2026-10-08 18:52:44 +00:00
austinried e60b2d9910 rename the next build back to nightly
Rename the build type, workflow, artifact and app id (com.subtracks.nightly)
from next to nightly, and update the signing docs to match.
2026-10-08 18:52:44 +00:00
austinried f6d1d548e4 docs: correct the signing notes for Play App Signing
Google re-signs Play-delivered apps, so the release key is only the
upload key there and signs the AAB we send; the installed app verifies
against Google's app signing certificate. The release key also signs the
GitHub-release APK.
2026-10-08 18:52:44 +00:00
austinried e74360fe07 docs: link the verification steps under the download badges 2026-10-08 18:52:44 +00:00
austinried d3373261d2 release: publish checksums and the signing certificate
Print the artifact SHA-256 and the APK signer certificate in the release
log, attach SHA256SUMS and the public release certificate to the GitHub
release, list the APK and certificate SHA-256 in the release notes, print
the next build checksums, and commit the next build's public certificate
with verification steps in signing/README.md.
2026-10-08 18:52:44 +00:00
austinried 7f4e9703d4 test: cite the store docs in the metadata constraint tests
Quote and link the Play preview-asset requirements and recommendations
and the F-Droid metadata limits alongside the checks they justify.
2026-10-08 18:52:44 +00:00
austinried ff8011eaf4 release: only publish to Play for a stable tag
Gate the Play upload on a full vX.Y.Z tag and mark other tags as
prereleases. Fold the changelog length limit into MetadataConstraintsTest
and make its image checks reject anything but PNG or JPEG. Keep the
decoded keystore at 0600 and note in .forgejo/README.md that the GitHub
CI now factors the build into composite actions.
2026-10-08 18:52:44 +00:00
austinried b4b5f66d81 ci: track the build composite action
The .gitignore build/ pattern also matched .github/actions/build, so the
action was never committed even though the workflows referenced it.
Negate the rule for that path.
2026-10-08 18:52:44 +00:00
austinried 8fb930c2ce ci: share the build as a composite action
Replace the reusable workflow with .github/actions/build plus a nested
nix setup action, invoked as a step. CI and the next build stay a single
job (no extra runner or Nix restore); the release keeps its build/publish
split for credential isolation.
2026-10-08 18:52:44 +00:00
austinried af3741a549 ci: share the build and isolate the release credentials
Extract checkout, nix, signing and the Gradle run into a reusable build
workflow used by CI, the next build and the release build job. The build
job holds only the signing secrets; the publish job is the only one with
a write token and the Play credential. The Play service account is now
written to a 0600 file and passed via --json_key instead of on argv.
2026-10-08 18:52:44 +00:00
austinried fded9949af ci: stop exposing the checkout token and the keystore to later steps
Set persist-credentials: false so the contents:write token is not left
in .git/config where every later step (including the Gradle build) could
read it, and delete the decoded keystore once signing is done.
2026-10-08 18:52:44 +00:00
austinried 5c219c9f06 test: guard the store metadata against the store limits
Add metadata checks (title, short/full description, icon, feature
graphic, phone screenshots) at the stricter of the Play and F-Droid
limits, and drop the trailing period from the short description.
2026-10-08 18:52:44 +00:00
austinried 7145b9c3c7 test: write the demo screenshots as 24-bit no-alpha PNGs
Re-encode each capture to opaque RGB so the store screenshots copied
into metadata/ already satisfy Play's rule, instead of depending on a
manual palette-free re-encode.
2026-10-08 18:52:44 +00:00
austinried 4db8645691 docs: note the Play 24-bit screenshot requirement 2026-10-08 18:52:43 +00:00
austinried eaab5368d0 play: push the full store listing and make the screenshots compliant
Drop the fastlane --skip_upload_* flags so the title, descriptions,
icon, feature graphic and screenshots upload alongside the AAB, and
re-encode the phone screenshots as 24-bit no-alpha PNGs, which is what
Play requires.
2026-10-08 18:52:43 +00:00
austinried 6b6a99e51c release: create the GitHub release with the gh CLI
Drop the third-party action in favour of GitHub's own gh CLI, which the
hosted runners already provide.
2026-10-08 18:52:43 +00:00
austinried 21d6c57bc2 ci: match the repo GitHub runner and action versions
Use ubuntu-26.04, actions/checkout v7 and actions/upload-artifact v7,
and cap the cached Nix store at 6G, matching .github/workflows/ci.yml.
2026-10-08 18:52:43 +00:00
austinried 650d8c49e6 release: upload to Google Play with fastlane
Replace the unpinned third-party upload-google-play action with fastlane
supply from a small nix release devshell, and pin the remaining
third-party GitHub release action to a commit SHA.
2026-10-08 18:52:43 +00:00
austinried c76a79c3ed ci: run the GitHub builds in the nix devshell
Drop setup-java, setup-android and the Gradle wrapper in favour of the
flake's pinned JDK, Gradle and Android SDK, restoring the Nix store with
cache-nix-action, so the release and next builds share the dev toolchain.
2026-10-08 18:52:43 +00:00
austinried 23911fceb5 ci: build signed debug and release next builds on every main push
Commit a public debug keystore and point the debug signing config at it,
and add a next build type (release-like, same key). Both carry the .next
application id, so next builds install alongside the release and update
each other without exposing the release key. A GitHub workflow builds
both APKs on every push to main and uploads them as artifacts.
2026-10-08 18:52:43 +00:00
austinried f506ac0410 release: publish signed builds on v* tags
Set applicationId to com.subtracks and add a GitHub Actions workflow
that, on a v* tag, builds and signs the release APK and AAB, creates
the GitHub release with the APK, and uploads the AAB to Google Play.
Release notes come from the matching metadata changelog.

Signing reads the keystore from the environment
(RELEASE_KEYSTORE, RELEASE_KEYSTORE_PASSWORD, RELEASE_KEY_ALIAS,
RELEASE_KEY_PASSWORD) and falls back to the debug key locally.
2026-10-08 18:52:43 +00:00
25 changed files with 671 additions and 59 deletions
+74
View File
@@ -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"
+14
View File
@@ -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
+28 -19
View File
@@ -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
+148
View File
@@ -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
View File
@@ -1,5 +1,6 @@
.gradle/ .gradle/
build/ build/
!.github/actions/build/
local.properties local.properties
*.apk *.apk
+3 -3
View File
@@ -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.
+2
View File
@@ -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.
+32 -2
View File
@@ -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 -1
View File
@@ -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",
Binary file not shown.
@@ -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
+4
View File
@@ -83,6 +83,10 @@
inherit shellHook; inherit shellHook;
}; };
release = pkgs.mkShell {
packages = [ pkgs.fastlane ];
};
} }
); );
}; };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 861 KiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 410 KiB

+1 -1
View File
@@ -1 +1 @@
A clean, open source Subsonic music player. A clean, open source Subsonic music player
+59
View File
@@ -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)).
+19
View File
@@ -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-----
+32
View File
@@ -0,0 +1,32 @@
-----BEGIN CERTIFICATE-----
MIIFfTCCA2WgAwIBAgIEFf1YeTANBgkqhkiG9w0BAQwFADBvMRAwDgYDVQQGEwdV
bmtub3duMRAwDgYDVQQIEwdVbmtub3duMRAwDgYDVQQHEwdVbmtub3duMRAwDgYD
VQQKEwdVbmtub3duMRAwDgYDVQQLEwdVbmtub3duMRMwEQYDVQQDEwphdXN0aW5y
aWVkMB4XDTIxMDgyMzA2MzMyMVoXDTQ5MDEwODA2MzMyMVowbzEQMA4GA1UEBhMH
VW5rbm93bjEQMA4GA1UECBMHVW5rbm93bjEQMA4GA1UEBxMHVW5rbm93bjEQMA4G
A1UEChMHVW5rbm93bjEQMA4GA1UECxMHVW5rbm93bjETMBEGA1UEAxMKYXVzdGlu
cmllZDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPDxh5StdFjO/79y
87/RbFoZ2H8SLqY8WPpgrAKg/DU/T+jz/S6EPIB6houZDMPs0xo0fbXU0w5iGxGw
FBunzHucugs/iU0HfBk+XbCJDaAzxe13b/KY1y2EuaCjRanGQRqpEXspoQwEz0GZ
VbBci/UsPlVZzoC+hxUtwfCogdvzEAPtdHogvYgC4VXhr85FBs03H91o6hRgeJBK
kbxo2kWrl/Xd6Gz2Lu59AKTRjXD3cu7b51u6Hx5+MSpnx8V3UWdGalTrDsDzocP1
JRnmlHG21ivlpaphZy0xMMz3EQDuEXZaZFkPDIP6KUPP7+92d/eWnWwsrB+YGBjg
Olu1cLekzf7zjy150GtoCVe664bwZ91RP3Ih9mjqql8tJSwZGHqoYdN+isXd7ffR
mT/elwA5ZZMLvE/JwLCd+M8Av2QA7Vusz8bJid/Ppc/5BkdgpC7uhUq233/T6j6U
qKFrFcNLRzrRE9Av/p+ZgQRghqDH8JcZp3FYqXDq0ldeyUM0PTzmfWgYnyg+PALx
w9b8xGP6qVxD0fL/QGj8SoRd535UjEpjNcGdmbC6QmSWPEpnq2OLfnShucKZAeUk
qu0u+ba2WN5TX68Tp7h7kERxwggahGSTC4pPxn0c3fo7fFOVFqMhieOlRdMZV6K3
r4RwwZs6lxThNPljxLsAOlUI36LZAgMBAAGjITAfMB0GA1UdDgQWBBTOqOVaPyDQ
NNd1tT+8yiSq4GWH6zANBgkqhkiG9w0BAQwFAAOCAgEAHgEtVhsFCumGoRBpjyTB
WJ4cka0BHWgIptFzBcS5DaLY+pyOT5J1LFmDYzmF5HTQdJNv+k+i+x4MUcXgg09e
haZGJq2t8BZVrF/ynmwbaPxa81WdKCTEQYz0B+hlaAYG+c2XwTfx9TMtJv/Tg8U0
iSUXPlOkWzv4vL8axbBizYsegjgv/L/znQHdvmoVb9GUguP+0qNqfTSkA7SF01cj
eWfP9JhtzxV6BlEfhialiKq6hoZzwDgye1k12zNqjvAj4IiFzDyxZafBgPMZrAC8
XkorfRoJtCAJck/EInXDre2Aosi3uQ9GoUHylHaJlpWDje3G4e9FdBBk9voVnY0u
EaE9orgteRukzYd6jQiVIJZl+Ol9hBJQgaL2ChrVSC2DRQaTtmsBg1D+DOU8P3q0
/P//iAcPyAYOPj9d06Vm8JATAJg4jRiomA4CddSJ8/IHNkdSgcwkU+3mpzzbnolt
/y0z6sPxcfLb1SGNrqevOKdfpKhxb9lhgBiro7F3uu7schkrcB8MczS/pTLzjyKd
7o4QVEehbFEG5czMX9eb1cobiTE3cnTAMuHEfSrs+WKmHot19R54zb19d3X7eyiT
rLzCFrqjict5aA0ckZlsmh2zRO1tNoB4YkiPvgmV/kaxg1UoK7pbgN3foMTFgjph
CbcA2PhHua2HFcHcoAwUgFI=
-----END CERTIFICATE-----