Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e396671ac7 | ||
|
|
8e7431cc2e | ||
|
|
7bbc29a1d6 | ||
|
|
0a3e839da0 | ||
|
|
e0a09b724b | ||
|
|
e806eaef89 | ||
|
|
0cef3657bc | ||
|
|
20c6ba526f | ||
|
|
cfb663c250 | ||
|
|
ac1e62fc02 | ||
|
|
adabe21d63 | ||
|
|
0bdac8fa22 | ||
|
|
15d77f998f | ||
|
|
779131c766 | ||
|
|
9695d83b1a | ||
|
|
a8d6111aaa | ||
|
|
d4152ba3a1 | ||
|
|
79fe15da91 | ||
|
|
714440fb25 | ||
|
|
a2d89f28a6 | ||
|
|
b2b02e7c05 | ||
|
|
9d783b2301 | ||
|
|
713d9a976a | ||
|
|
c262e58a96 | ||
|
|
eb5019ea5e | ||
|
|
8fe26a36b1 | ||
|
|
7a890094fc | ||
|
|
4012cc0272 | ||
|
|
336fd9c49f | ||
|
|
5f138bde6a | ||
|
|
32360fac08 | ||
|
|
206ac8988f | ||
|
|
4fad6a78ec | ||
|
|
2439368e4e | ||
|
|
92c6f6945f | ||
|
|
bcd7bbf23a | ||
|
|
ce9e3e89a7 | ||
|
|
66ee837cbb | ||
|
|
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.
|
||||||
@@ -48,6 +48,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
|||||||
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
|
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
|
||||||
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
|
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
|
||||||
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
|
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
|
||||||
|
- Auto-sync is a foreground-only, in-process timer (`AutoSync`, driven by the Activity lifecycle), not a background worker: do not move it to WorkManager or an alarm. `SyncManager` coalesces a request that arrives while a sync is running into a single follow-up; the library and Home sync actions no-op while one is running.
|
||||||
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
|
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
|
||||||
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
|
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
|
||||||
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
|
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
|
||||||
@@ -59,8 +60,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.
|
||||||
|
|||||||
@@ -14,17 +14,31 @@ ktlint {
|
|||||||
version.set(libs.versions.ktlint.get())
|
version.set(libs.versions.ktlint.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val commitHash: String =
|
||||||
|
runCatching {
|
||||||
|
providers
|
||||||
|
.exec {
|
||||||
|
commandLine("git", "rev-parse", "HEAD")
|
||||||
|
workingDir = rootProject.layout.projectDirectory.asFile
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}.standardOutput.asText
|
||||||
|
.get()
|
||||||
|
.trim()
|
||||||
|
.take(7)
|
||||||
|
}.getOrDefault("")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.subtracks"
|
namespace = "com.subtracks"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
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
|
||||||
versionName = "3.0.0"
|
versionName = "3.0.0"
|
||||||
|
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
@@ -36,6 +50,12 @@ android {
|
|||||||
generateLocaleConfig = true
|
generateLocaleConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependenciesInfo {
|
||||||
|
// The APK signing-block copy is encrypted per build, breaking APK reproducibility, and
|
||||||
|
// F-Droid rejects it; the bundle keeps its copy for Play.
|
||||||
|
includeInApk = false
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
@@ -79,12 +99,43 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("debug").res.srcDir("src/nightly/res")
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,10 +178,12 @@ val integrationTestClasses =
|
|||||||
|
|
||||||
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
||||||
|
|
||||||
|
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
if (name.endsWith("UnitTest")) {
|
if (name.endsWith("UnitTest")) {
|
||||||
filter {
|
filter {
|
||||||
(integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) }
|
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
testLogging {
|
testLogging {
|
||||||
@@ -166,6 +219,20 @@ tasks.register<Test>("demoScreenshots") {
|
|||||||
outputs.upToDateWhen { false }
|
outputs.upToDateWhen { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register<Test>("queryTiming") {
|
||||||
|
description = "Measures SQL query timings for the logging threshold (not a CI gate)"
|
||||||
|
group = "verification"
|
||||||
|
val unitTest = tasks.named<Test>("testDebugUnitTest")
|
||||||
|
dependsOn("compileDebugUnitTestKotlin")
|
||||||
|
testClassesDirs = files(provider { unitTest.get().testClassesDirs })
|
||||||
|
classpath = files(provider { unitTest.get().classpath })
|
||||||
|
filter {
|
||||||
|
includeTestsMatching(queryTimingClass)
|
||||||
|
}
|
||||||
|
providers.gradleProperty("measureSongs").orNull?.let { systemProperty("measure.songs", it) }
|
||||||
|
outputs.upToDateWhen { false }
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
@@ -191,7 +258,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.session)
|
implementation(libs.androidx.media3.session)
|
||||||
|
implementation(libs.androidx.media3.datasource)
|
||||||
implementation(libs.androidx.media3.datasource.okhttp)
|
implementation(libs.androidx.media3.datasource.okhttp)
|
||||||
|
implementation(libs.androidx.media3.database)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -47,6 +47,16 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".data.download.DownloadCancelReceiver"
|
android:name=".data.download.DownloadCancelReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.subtracks.data.sync.AutoSync
|
||||||
import com.subtracks.ui.NowPlayingLauncher
|
import com.subtracks.ui.NowPlayingLauncher
|
||||||
import com.subtracks.ui.SubtracksRoot
|
import com.subtracks.ui.SubtracksRoot
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
@@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
||||||
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
||||||
|
private val autoSync: AutoSync by lazy { GlobalContext.get().get() }
|
||||||
|
|
||||||
private val requestNotifications =
|
private val requestNotifications =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
|
|||||||
@@ -5,20 +5,28 @@ import coil3.ImageLoader
|
|||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.annotation.DelicateCoilApi
|
import coil3.annotation.DelicateCoilApi
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.subtracks.data.LegacyDataCleanup
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.ArtworkSeedStore
|
import com.subtracks.data.repo.ArtworkSeedStore
|
||||||
import com.subtracks.di.appModule
|
import com.subtracks.di.appModule
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.core.context.stopKoin
|
import org.koin.core.context.stopKoin
|
||||||
|
|
||||||
class SubtracksApp : Application() {
|
class SubtracksApp : Application() {
|
||||||
private val http = OkHttpClient()
|
private val http =
|
||||||
|
UserAgent
|
||||||
|
.httpClient()
|
||||||
|
.newBuilder()
|
||||||
|
.addInterceptor(Log.interceptor())
|
||||||
|
.build()
|
||||||
|
|
||||||
@OptIn(DelicateCoilApi::class)
|
@OptIn(DelicateCoilApi::class)
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Log.init(this)
|
||||||
|
LegacyDataCleanup.run(this)
|
||||||
stopKoin()
|
stopKoin()
|
||||||
SingletonImageLoader.reset()
|
SingletonImageLoader.reset()
|
||||||
val koin =
|
val koin =
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.subtracks
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
object UserAgent {
|
||||||
|
val value: String = "subtracks (Android ${Build.VERSION.RELEASE}; ${BuildConfig.VERSION_NAME})"
|
||||||
|
|
||||||
|
fun interceptor(): Interceptor =
|
||||||
|
Interceptor { chain ->
|
||||||
|
chain.proceed(
|
||||||
|
chain
|
||||||
|
.request()
|
||||||
|
.newBuilder()
|
||||||
|
.header("User-Agent", value)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun httpClient(): OkHttpClient = OkHttpClient.Builder().addInterceptor(interceptor()).build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.subtracks.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
// One-shot cleanup of the 1.x React Native app, which shipped as com.subtracks before this rewrite.
|
||||||
|
// It left an AsyncStorage database (RKStorage), an older `servers` dump and a downloaded/cached
|
||||||
|
// artwork+audio tree (under the app's external files dir) that v3 never reads. Only a real 1.x upgrade
|
||||||
|
// has those paths, so a fresh install does nothing and an upgrade is cleaned exactly once.
|
||||||
|
//
|
||||||
|
// Delete this file and its call in SubtracksApp once installs can no longer be coming from 1.x.
|
||||||
|
internal object LegacyDataCleanup {
|
||||||
|
fun run(context: Context) {
|
||||||
|
// The cached trees can be large, so keep the walk off the main thread.
|
||||||
|
Thread { run(context.dataDir, context.getExternalFilesDir(null)) }.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun run(
|
||||||
|
dataDir: File,
|
||||||
|
externalFilesDir: File?,
|
||||||
|
) {
|
||||||
|
val database = File(dataDir, "databases/RKStorage")
|
||||||
|
val servers = File(dataDir, "files/servers")
|
||||||
|
val cache = externalFilesDir?.let { File(it, "s") }
|
||||||
|
if (!database.exists() && !servers.exists() && cache?.exists() != true) return
|
||||||
|
deleteDatabase(database)
|
||||||
|
deleteTree(servers)
|
||||||
|
cache?.let(::deleteTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteDatabase(database: File) {
|
||||||
|
listOf("", "-wal", "-shm", "-journal").forEach { deleteTree(File(database.path + it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteTree(target: File) {
|
||||||
|
if (!target.exists()) return
|
||||||
|
val removed = target.deleteRecursively()
|
||||||
|
Log.i("legacy", "removed ${target.path} ($removed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@ package com.subtracks.data.db
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||||
Room
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver(), Log::sql)
|
||||||
|
return Room
|
||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(BundledSQLiteDriver())
|
.setDriver(driver)
|
||||||
.addMigrations(*MIGRATIONS)
|
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||||
|
.addCallback(SqliteLifecycleLogging)
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.RoomDatabase
|
||||||
|
import androidx.room3.migration.Migration
|
||||||
|
import androidx.sqlite.SQLiteConnection
|
||||||
|
import androidx.sqlite.SQLiteDriver
|
||||||
|
import androidx.sqlite.SQLiteStatement
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
data class SqlExecution(
|
||||||
|
val sql: String,
|
||||||
|
val durationNanos: Long,
|
||||||
|
val rows: Long,
|
||||||
|
val error: Throwable?,
|
||||||
|
) {
|
||||||
|
val durationMillis: Double get() = durationNanos / 1_000_000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoggingSQLiteDriver(
|
||||||
|
private val delegate: SQLiteDriver,
|
||||||
|
private val onExecution: (SqlExecution) -> Unit,
|
||||||
|
) : SQLiteDriver {
|
||||||
|
override val hasConnectionPool: Boolean
|
||||||
|
get() = delegate.hasConnectionPool
|
||||||
|
|
||||||
|
override fun open(fileName: String): SQLiteConnection = LoggingConnection(delegate.open(fileName), onExecution)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoggingConnection(
|
||||||
|
private val delegate: SQLiteConnection,
|
||||||
|
private val onExecution: (SqlExecution) -> Unit,
|
||||||
|
) : SQLiteConnection by delegate {
|
||||||
|
override fun prepare(sql: String): SQLiteStatement {
|
||||||
|
val start = System.nanoTime()
|
||||||
|
return try {
|
||||||
|
LoggingStatement(delegate.prepare(sql), sql, onExecution)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
onExecution(SqlExecution(sql, System.nanoTime() - start, 0, failure))
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoggingStatement(
|
||||||
|
private val delegate: SQLiteStatement,
|
||||||
|
private val sql: String,
|
||||||
|
private val onExecution: (SqlExecution) -> Unit,
|
||||||
|
) : SQLiteStatement by delegate {
|
||||||
|
private var startNanos = 0L
|
||||||
|
private var rows = 0L
|
||||||
|
private var failure: Throwable? = null
|
||||||
|
private var active = false
|
||||||
|
|
||||||
|
override fun step(): Boolean {
|
||||||
|
begin()
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
delegate.step()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
failure = error
|
||||||
|
report()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (result) rows++
|
||||||
|
if (!result) report()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reset() {
|
||||||
|
report()
|
||||||
|
delegate.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
report()
|
||||||
|
delegate.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun report() {
|
||||||
|
if (!active) return
|
||||||
|
active = false
|
||||||
|
onExecution(SqlExecution(sql, System.nanoTime() - startNanos, rows, failure))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Started on the first step, not on reset: Room's statement cache resets a statement when it is
|
||||||
|
// returned to the cache (BasePreparedStatementCache.CachedStatement.close), so timing from reset
|
||||||
|
// would count however long the statement idled there before its next use.
|
||||||
|
private fun begin() {
|
||||||
|
if (active) return
|
||||||
|
startNanos = System.nanoTime()
|
||||||
|
rows = 0
|
||||||
|
failure = null
|
||||||
|
active = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoggedMigration(
|
||||||
|
private val delegate: Migration,
|
||||||
|
) : Migration(delegate.startVersion, delegate.endVersion) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
val label = "${delegate.startVersion} -> ${delegate.endVersion}"
|
||||||
|
Log.i("sql", "migrating $label")
|
||||||
|
val start = System.nanoTime()
|
||||||
|
try {
|
||||||
|
delegate.migrate(connection)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
Log.e("sql", "migration $label failed after ${(System.nanoTime() - start) / 1_000_000}ms", failure)
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
Log.i("sql", "migrated $label in ${(System.nanoTime() - start) / 1_000_000}ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
|
||||||
|
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||||
|
Log.i("sql", "created fresh database")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onOpen(connection: SQLiteConnection) {
|
||||||
|
Log.i("sql", "database opened")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onDestructiveMigration(connection: SQLiteConnection) {
|
||||||
|
Log.w("sql", "destructive migration: dropping all tables")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ package com.subtracks.data.db
|
|||||||
|
|
||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
import androidx.room3.Insert
|
||||||
|
import androidx.room3.OnConflictStrategy
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import androidx.room3.Upsert
|
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -55,7 +55,7 @@ interface QueueDao {
|
|||||||
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||||
suspend fun cursor(): PlaybackCursor?
|
suspend fun cursor(): PlaybackCursor?
|
||||||
|
|
||||||
@Upsert
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun setCursor(cursor: PlaybackCursor)
|
suspend fun setCursor(cursor: PlaybackCursor)
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.subtracks.UserAgent
|
||||||
import com.subtracks.data.model.DownloadError
|
import com.subtracks.data.model.DownloadError
|
||||||
|
|
||||||
class SystemDownloadEngine(
|
class SystemDownloadEngine(
|
||||||
@@ -16,6 +17,7 @@ class SystemDownloadEngine(
|
|||||||
.setTitle(request.title)
|
.setTitle(request.title)
|
||||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||||
|
.addRequestHeader("User-Agent", UserAgent.value)
|
||||||
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
||||||
// one of those; the platform otherwise permits it by default.
|
// one of those; the platform otherwise permits it by default.
|
||||||
.setAllowedOverMetered(request.allowMetered)
|
.setAllowedOverMetered(request.allowMetered)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
@@ -48,6 +49,10 @@ data class StreamQuality(
|
|||||||
|
|
||||||
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
||||||
|
|
||||||
|
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
|
||||||
|
|
||||||
|
enum class SyncMode { Off, WifiOnly, Always }
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
val cacheKey: String,
|
val cacheKey: String,
|
||||||
val primary: Int,
|
val primary: Int,
|
||||||
@@ -105,6 +110,34 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
||||||
|
|
||||||
|
fun syncMode(): Flow<SyncMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[syncModeKey]?.let { stored -> SyncMode.entries.firstOrNull { it.name == stored } } ?: SyncMode.WifiOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSyncMode(mode: SyncMode) {
|
||||||
|
store.edit { prefs -> prefs[syncModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val syncModeKey = stringPreferencesKey("sync_mode")
|
||||||
|
|
||||||
|
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
||||||
|
|
||||||
|
suspend fun setLastSyncAt(value: Long) {
|
||||||
|
store.edit { prefs -> prefs[lastSyncAtKey] = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val lastSyncAtKey = longPreferencesKey("last_sync_at")
|
||||||
|
|
||||||
|
fun syncIntervalMinutes(): Flow<Int> =
|
||||||
|
store.data.map { prefs -> (prefs[syncIntervalKey] ?: DEFAULT_SYNC_INTERVAL_MINUTES).coerceAtLeast(1) }
|
||||||
|
|
||||||
|
suspend fun setSyncIntervalMinutes(value: Int) {
|
||||||
|
store.edit { prefs -> prefs[syncIntervalKey] = value.coerceAtLeast(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val syncIntervalKey = intPreferencesKey("sync_interval_minutes")
|
||||||
|
|
||||||
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
||||||
|
|
||||||
suspend fun setScrobbling(enabled: Boolean) {
|
suspend fun setScrobbling(enabled: Boolean) {
|
||||||
@@ -121,6 +154,14 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
||||||
|
|
||||||
|
fun verboseLogging(): Flow<Boolean> = store.data.map { prefs -> prefs[verboseLoggingKey] ?: false }
|
||||||
|
|
||||||
|
suspend fun setVerboseLogging(enabled: Boolean) {
|
||||||
|
store.edit { prefs -> prefs[verboseLoggingKey] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val verboseLoggingKey = booleanPreferencesKey("verbose_logging")
|
||||||
|
|
||||||
suspend fun lastSeed(): ArtworkSeedValue? {
|
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||||
val prefs = store.data.first()
|
val prefs = store.data.first()
|
||||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
val cacheKey = prefs[lastSeedKey] ?: return null
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.subtracks.data.model.DownloadedSong
|
|||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -79,7 +80,10 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sourceRepository.offline.collect { offline ->
|
sourceRepository.offline.collect { offline ->
|
||||||
if (offline) cancelActive()
|
if (offline) {
|
||||||
|
Log.i("download", "offline: cancelling active downloads")
|
||||||
|
cancelActive()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +144,7 @@ class DownloadRepository(
|
|||||||
dir(sourceId).mkdirs()
|
dir(sourceId).mkdirs()
|
||||||
file(sourceId, songId).delete()
|
file(sourceId, songId).delete()
|
||||||
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
||||||
|
Log.i("download", "queued song=$songId (source $sourceId)")
|
||||||
ensurePolling()
|
ensurePolling()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -336,6 +341,7 @@ class DownloadRepository(
|
|||||||
|
|
||||||
private suspend fun removeRows(rows: List<SongDownload>) {
|
private suspend fun removeRows(rows: List<SongDownload>) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
|
Log.i("download", "removed ${rows.size} download(s)")
|
||||||
val engineIds = rows.mapNotNull { it.engineId }
|
val engineIds = rows.mapNotNull { it.engineId }
|
||||||
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
||||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||||
@@ -581,6 +587,14 @@ class DownloadRepository(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (updated != row) db.downloadDao().upsert(updated)
|
if (updated != row) db.downloadDao().upsert(updated)
|
||||||
|
if (updated.status != row.status) {
|
||||||
|
when (updated.status) {
|
||||||
|
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
||||||
|
DownloadStatus.Failed -> Log.w("download", "failed ${row.songId}: ${updated.error}")
|
||||||
|
DownloadStatus.Running -> Log.i("download", "started ${row.songId}")
|
||||||
|
DownloadStatus.Queued -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun markFailed(
|
private suspend fun markFailed(
|
||||||
@@ -590,6 +604,7 @@ class DownloadRepository(
|
|||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(row.sourceId, row.songId).delete()
|
file(row.sourceId, row.songId).delete()
|
||||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||||
|
Log.w("download", "failed ${row.songId}: $error")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensurePolling() {
|
private fun ensurePolling() {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -464,14 +465,17 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
val previous = starredValue(sourceId, type, id)
|
val previous = starredValue(sourceId, type, id)
|
||||||
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
||||||
|
val action = if (starred) "star" else "unstar"
|
||||||
return try {
|
return try {
|
||||||
serverActions.setStar(type, id, starred)
|
serverActions.setStar(type, id, starred)
|
||||||
|
Log.i("star", "$action ${type.name.lowercase()} $id")
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
||||||
showMessage(UiMessage(R.string.star_update_failed))
|
showMessage(UiMessage(R.string.star_update_failed))
|
||||||
|
Log.w("star", "failed to $action ${type.name.lowercase()} $id", failure)
|
||||||
Result.failure(failure)
|
Result.failure(failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.PendingActionKind
|
|||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -29,8 +30,13 @@ class PendingServerActionSink(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
var lastReady: Boolean? = null
|
||||||
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
||||||
.collect { ready ->
|
.collect { ready ->
|
||||||
|
if (ready && lastReady == false) {
|
||||||
|
Log.i("net", "connection available, replaying pending server actions")
|
||||||
|
}
|
||||||
|
lastReady = ready
|
||||||
// The persisted flag and connectivity are authoritative, unlike the process
|
// The persisted flag and connectivity are authoritative, unlike the process
|
||||||
// flag's default at cold start, so wait for that flag to agree before replaying.
|
// flag's default at cold start, so wait for that flag to agree before replaying.
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@@ -96,12 +102,14 @@ class PendingServerActionSink(
|
|||||||
for (action in dao.pending(sourceId)) {
|
for (action in dao.pending(sourceId)) {
|
||||||
try {
|
try {
|
||||||
deliver(action)
|
deliver(action)
|
||||||
|
Log.i("net", "replayed ${action.kind.name.lowercase()} ${action.targetId}")
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
// Still unreachable: keep this and the remaining actions for the next attempt.
|
// Still unreachable: keep this and the remaining actions for the next attempt.
|
||||||
if (isRetryable(failure)) return
|
if (isRetryable(failure)) return
|
||||||
// The server answered and refused; drop the action rather than block the queue.
|
// The server answered and refused; drop the action rather than block the queue.
|
||||||
|
Log.w("net", "dropped ${action.kind.name.lowercase()} ${action.targetId}", failure)
|
||||||
}
|
}
|
||||||
dao.delete(action.id)
|
dao.delete(action.id)
|
||||||
}
|
}
|
||||||
@@ -139,6 +147,7 @@ class PendingServerActionSink(
|
|||||||
db.pendingActionDao().replace(
|
db.pendingActionDao().replace(
|
||||||
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
||||||
)
|
)
|
||||||
|
Log.i("net", "queued ${kind.name.lowercase()} $targetId")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.subtracks.data.source.MusicSource
|
|||||||
import com.subtracks.data.source.declaredStreamLength
|
import com.subtracks.data.source.declaredStreamLength
|
||||||
import com.subtracks.data.source.streamLengthSuffix
|
import com.subtracks.data.source.streamLengthSuffix
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -46,6 +47,13 @@ class SourceRepository(
|
|||||||
val source: SubsonicMusicSource,
|
val source: SubsonicMusicSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ResolvedSource(
|
||||||
|
val config: SubsonicConfig?,
|
||||||
|
val quality: StreamQuality,
|
||||||
|
val mode: NetworkMode,
|
||||||
|
val concurrency: Int,
|
||||||
|
)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var active: ActiveSource? = null
|
private var active: ActiveSource? = null
|
||||||
|
|
||||||
@@ -64,6 +72,7 @@ class SourceRepository(
|
|||||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
|
Log.i("net", "offline mode -> $enabled")
|
||||||
scope.launch { prefs.setOfflineMode(enabled) }
|
scope.launch { prefs.setOfflineMode(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +90,7 @@ class SourceRepository(
|
|||||||
// Read the persisted flag before the source config can build a source or probe it, so a
|
// Read the persisted flag before the source config can build a source or probe it, so a
|
||||||
// cold start with offline on never touches the network.
|
// cold start with offline on never touches the network.
|
||||||
_offline.value = prefs.offlineMode().first()
|
_offline.value = prefs.offlineMode().first()
|
||||||
|
var lastMode: NetworkMode? = null
|
||||||
combine(
|
combine(
|
||||||
db.sourcesDao().activeSubsonicConfig(),
|
db.sourcesDao().activeSubsonicConfig(),
|
||||||
networkMode,
|
networkMode,
|
||||||
@@ -88,13 +98,23 @@ class SourceRepository(
|
|||||||
prefs.streamQuality(NetworkMode.Mobile),
|
prefs.streamQuality(NetworkMode.Mobile),
|
||||||
prefs.syncConcurrency(),
|
prefs.syncConcurrency(),
|
||||||
) { config, mode, wifi, mobile, concurrency ->
|
) { config, mode, wifi, mobile, concurrency ->
|
||||||
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
|
ResolvedSource(config, if (mode == NetworkMode.Wifi) wifi else mobile, mode, concurrency)
|
||||||
}.collect { (config, quality, concurrency) ->
|
}.collect { (config, quality, mode, concurrency) ->
|
||||||
|
if (mode != lastMode) {
|
||||||
|
Log.i("net", "network mode -> ${mode.key}")
|
||||||
|
lastMode = mode
|
||||||
|
}
|
||||||
val sourceChanged = config?.id != active?.id
|
val sourceChanged = config?.id != active?.id
|
||||||
|
if (sourceChanged) {
|
||||||
|
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
||||||
|
}
|
||||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||||
_quality.value = quality
|
_quality.value = quality
|
||||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||||
scope.launch { runCatching { config.toClient().check("ping") } }
|
scope.launch {
|
||||||
|
runCatching { config.toClient().check("ping") }
|
||||||
|
.onSuccess { Log.i("net", "connected (ping ok)") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.subtracks.data.source.subsonic
|
|||||||
|
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.log.Log
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -20,7 +21,6 @@ class SubsonicClient(
|
|||||||
private val password: String,
|
private val password: String,
|
||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
private val http: OkHttpClient,
|
private val http: OkHttpClient,
|
||||||
private val userAgent: String = "subtracks/android",
|
|
||||||
private val onTokenAuthUnsupported: () -> Unit = {},
|
private val onTokenAuthUnsupported: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -61,7 +61,11 @@ class SubsonicClient(
|
|||||||
return try {
|
return try {
|
||||||
execute(method, params, body)
|
execute(method, params, body)
|
||||||
} catch (failure: SubsonicException) {
|
} catch (failure: SubsonicException) {
|
||||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) throw failure
|
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
||||||
|
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
|
||||||
disableTokenAuth()
|
disableTokenAuth()
|
||||||
execute(method, params, body)
|
execute(method, params, body)
|
||||||
}
|
}
|
||||||
@@ -76,7 +80,6 @@ class SubsonicClient(
|
|||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
.url(uri(method, params))
|
.url(uri(method, params))
|
||||||
.header("User-Agent", userAgent)
|
|
||||||
.build()
|
.build()
|
||||||
http.newCall(request).execute().use { response ->
|
http.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
internal const val AUTO_SYNC_MAX_POLL_MS = 15L * 60 * 1000
|
||||||
|
|
||||||
|
internal fun shouldAutoSync(
|
||||||
|
mode: SyncMode,
|
||||||
|
network: NetworkMode,
|
||||||
|
offline: Boolean,
|
||||||
|
hasSource: Boolean,
|
||||||
|
lastSyncAt: Long,
|
||||||
|
now: Long,
|
||||||
|
intervalMs: Long,
|
||||||
|
): Boolean {
|
||||||
|
val allowed =
|
||||||
|
when (mode) {
|
||||||
|
SyncMode.Off -> false
|
||||||
|
SyncMode.WifiOnly -> network == NetworkMode.Wifi
|
||||||
|
SyncMode.Always -> true
|
||||||
|
}
|
||||||
|
return allowed && !offline && hasSource && (lastSyncAt > now || now - lastSyncAt >= intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
class AutoSync(
|
||||||
|
private val preferences: UserPreferences,
|
||||||
|
private val networkMode: Flow<NetworkMode>,
|
||||||
|
private val hasSource: suspend () -> Boolean,
|
||||||
|
private val requestSync: () -> Unit,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
|
private val maxPollMs: Long = AUTO_SYNC_MAX_POLL_MS,
|
||||||
|
) {
|
||||||
|
@Volatile
|
||||||
|
private var lastNetwork: NetworkMode? = null
|
||||||
|
private var loop: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { networkMode.collect { lastNetwork = it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForeground(active: Boolean) {
|
||||||
|
if (active) start() else stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start() {
|
||||||
|
if (loop?.isActive == true) return
|
||||||
|
loop =
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
val intervalMs =
|
||||||
|
try {
|
||||||
|
checkAndRequest()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Log.w("sync", "auto-sync check failed", failure)
|
||||||
|
maxPollMs
|
||||||
|
}
|
||||||
|
delay(intervalMs.coerceAtMost(maxPollMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stop() {
|
||||||
|
loop?.cancel()
|
||||||
|
loop = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkAndRequest(): Long {
|
||||||
|
val mode = preferences.syncMode().first()
|
||||||
|
val network = lastNetwork ?: networkMode.first()
|
||||||
|
val offline = preferences.offlineMode().first()
|
||||||
|
val hasSource = hasSource()
|
||||||
|
val lastSyncAt = preferences.lastSyncAt().first()
|
||||||
|
val intervalMs = preferences.syncIntervalMinutes().first() * 60_000L
|
||||||
|
val time = now()
|
||||||
|
val due =
|
||||||
|
shouldAutoSync(
|
||||||
|
mode = mode,
|
||||||
|
network = network,
|
||||||
|
offline = offline,
|
||||||
|
hasSource = hasSource,
|
||||||
|
lastSyncAt = lastSyncAt,
|
||||||
|
now = time,
|
||||||
|
intervalMs = intervalMs,
|
||||||
|
)
|
||||||
|
Log.d(
|
||||||
|
"sync",
|
||||||
|
"auto-sync due=$due (mode=$mode, network=$network, offline=$offline, hasSource=$hasSource, " +
|
||||||
|
"sinceLast=${time - lastSyncAt}ms, interval=${intervalMs}ms)",
|
||||||
|
)
|
||||||
|
if (due && currentCoroutineContext().isActive) requestSync()
|
||||||
|
return intervalMs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
sealed interface SyncStatus {
|
sealed interface SyncStatus {
|
||||||
data object Idle : SyncStatus
|
data object Idle : SyncStatus
|
||||||
|
|
||||||
data object Running : SyncStatus
|
data class Running(
|
||||||
|
val silent: Boolean,
|
||||||
|
) : SyncStatus
|
||||||
|
|
||||||
data object Success : SyncStatus
|
data object Success : SyncStatus
|
||||||
|
|
||||||
@@ -34,41 +39,65 @@ class SyncManager(
|
|||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val preferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
) {
|
) {
|
||||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
private val requests = Channel<Boolean>(Channel.CONFLATED)
|
||||||
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
||||||
val status: StateFlow<SyncStatus> = _status
|
val status: StateFlow<SyncStatus> = _status
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
for (ignored in requests) runSync()
|
for (silent in requests) {
|
||||||
|
try {
|
||||||
|
runSync(silent)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
if (!currentCoroutineContext().isActive) throw cancellation
|
||||||
|
_status.value = SyncStatus.Idle
|
||||||
|
Log.w("sync", "sync interrupted", cancellation)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
_status.value = SyncStatus.Idle
|
||||||
|
Log.w("sync", "unexpected failure", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestSync() {
|
fun requestSync(silent: Boolean = false) {
|
||||||
requests.trySend(Unit)
|
requests.trySend(silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runSync() {
|
private suspend fun runSync(silent: Boolean) {
|
||||||
if (sourceRepository.offline.value) {
|
if (sourceRepository.offline.value) {
|
||||||
val offline = UiMessage(R.string.sync_offline)
|
if (!silent) {
|
||||||
showMessage(offline)
|
val offline = UiMessage(R.string.sync_offline)
|
||||||
_status.value = SyncStatus.Failed(offline)
|
showMessage(offline)
|
||||||
|
_status.value = SyncStatus.Failed(offline)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_status.value = SyncStatus.Running
|
_status.value = SyncStatus.Running(silent)
|
||||||
|
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
|
||||||
|
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
|
||||||
|
val startedAt = System.nanoTime()
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||||
|
Log.i("sync", "started (source ${source.id})")
|
||||||
serverActions.flush()
|
serverActions.flush()
|
||||||
SyncService(db, source).sync()
|
val summary = SyncService(db, source).sync()
|
||||||
|
Log.i(
|
||||||
|
"sync",
|
||||||
|
"finished in ${elapsedMs(startedAt)}ms: " +
|
||||||
|
"${summary.artists} artists, ${summary.albums} albums, ${summary.songs} songs, " +
|
||||||
|
"${summary.playlists} playlists, ${summary.playlistSongs} playlist tracks, ${summary.pruned} pruned",
|
||||||
|
)
|
||||||
SyncStatus.Success
|
SyncStatus.Success
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
Log.w(TAG, "Sync failed", failure)
|
Log.w("sync", "failed after ${elapsedMs(startedAt)}ms", failure)
|
||||||
val message =
|
val message =
|
||||||
when (failure) {
|
when (failure) {
|
||||||
is NoServerException -> {
|
is NoServerException -> {
|
||||||
@@ -80,16 +109,14 @@ class SyncManager(
|
|||||||
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showMessage(message)
|
if (!silent) showMessage(message)
|
||||||
SyncStatus.Failed(message)
|
SyncStatus.Failed(message)
|
||||||
}
|
}
|
||||||
queueRepository.invalidateLibraryCache()
|
queueRepository.invalidateLibraryCache()
|
||||||
_status.value = result
|
_status.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000
|
||||||
const val TAG = "SubtracksSync"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NoServerException : IllegalStateException()
|
private class NoServerException : IllegalStateException()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.subtracks.R
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SyncNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
private val status: Flow<SyncStatus>,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
|
) {
|
||||||
|
private val manager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
createChannel()
|
||||||
|
scope.launch {
|
||||||
|
status.collect { syncStatus ->
|
||||||
|
if (syncStatus is SyncStatus.Running && manager.areNotificationsEnabled()) {
|
||||||
|
manager.notify(ID, build())
|
||||||
|
} else {
|
||||||
|
manager.cancel(ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun build(): Notification =
|
||||||
|
NotificationCompat
|
||||||
|
.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
|
.setContentTitle(context.getString(R.string.sync_notification_title))
|
||||||
|
.setContentText(context.getString(R.string.sync_notification_text))
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setContentIntent(launchIntent())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun launchIntent(): PendingIntent? =
|
||||||
|
context.packageManager
|
||||||
|
.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) }
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.sync_notification_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ID = 2
|
||||||
|
private const val CHANNEL_ID = "sync"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,28 @@ class SyncService(
|
|||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val source: MusicSource,
|
private val source: MusicSource,
|
||||||
) {
|
) {
|
||||||
suspend fun sync() {
|
private var artistsSynced = 0
|
||||||
|
private var albumsSynced = 0
|
||||||
|
private var songsSynced = 0
|
||||||
|
private var playlistsSynced = 0
|
||||||
|
private var playlistSongsSynced = 0
|
||||||
|
private var pruned = 0
|
||||||
|
|
||||||
|
suspend fun sync(): SyncSummary {
|
||||||
syncArtists()
|
syncArtists()
|
||||||
syncAlbums()
|
syncAlbums()
|
||||||
syncSongs()
|
syncSongs()
|
||||||
aggregatePlayData()
|
aggregatePlayData()
|
||||||
syncPlaylists()
|
syncPlaylists()
|
||||||
syncPlaylistSongs()
|
syncPlaylistSongs()
|
||||||
|
return SyncSummary(
|
||||||
|
artists = artistsSynced,
|
||||||
|
albums = albumsSynced,
|
||||||
|
songs = songsSynced,
|
||||||
|
playlists = playlistsSynced,
|
||||||
|
playlistSongs = playlistSongsSynced,
|
||||||
|
pruned = pruned,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncArtists() {
|
private suspend fun syncArtists() {
|
||||||
@@ -36,8 +51,9 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
artistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncAlbums() {
|
private suspend fun syncAlbums() {
|
||||||
@@ -53,11 +69,13 @@ class SyncService(
|
|||||||
writeAlbumsAndDiscs(batch, discs)
|
writeAlbumsAndDiscs(batch, discs)
|
||||||
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||||
|
albumsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
||||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
pruned +=
|
||||||
deleteStaleDiscs(it)
|
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||||
}
|
deleteStaleDiscs(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncSongs() {
|
private suspend fun syncSongs() {
|
||||||
@@ -67,8 +85,9 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
writeSongs(batch)
|
writeSongs(batch)
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
songsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
pruned += pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeSongs(batch: List<Song>) {
|
private suspend fun writeSongs(batch: List<Song>) {
|
||||||
@@ -106,8 +125,9 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
playlistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
pruned += pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPlaylistSongs() {
|
private suspend fun syncPlaylistSongs() {
|
||||||
@@ -118,6 +138,7 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
||||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||||
|
playlistSongsSynced += batch.size
|
||||||
}
|
}
|
||||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
||||||
library.deleteOrphanPlaylistSongs(source.id)
|
library.deleteOrphanPlaylistSongs(source.id)
|
||||||
@@ -173,15 +194,17 @@ class SyncService(
|
|||||||
seen: HashedIds,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterId: String) -> List<String>,
|
pageOf: suspend (afterId: String) -> List<String>,
|
||||||
delete: suspend (Collection<String>) -> Unit,
|
delete: suspend (Collection<String>) -> Unit,
|
||||||
) {
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterId = ""
|
var afterId = ""
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val ids = pageOf(afterId)
|
val ids = pageOf(afterId)
|
||||||
if (ids.isEmpty()) return
|
if (ids.isEmpty()) return pruned
|
||||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||||
if (ids.size < PRUNE_PAGE) return
|
pruned += stale.size
|
||||||
|
if (ids.size < PRUNE_PAGE) return pruned
|
||||||
afterId = ids.last()
|
afterId = ids.last()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,22 +213,33 @@ class SyncService(
|
|||||||
seen: HashedIds,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
||||||
delete: suspend (Collection<DiscKey>) -> Unit,
|
delete: suspend (Collection<DiscKey>) -> Unit,
|
||||||
) {
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterAlbumId = ""
|
var afterAlbumId = ""
|
||||||
var afterDisc = 0L
|
var afterDisc = 0L
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val keys = pageOf(afterAlbumId, afterDisc)
|
val keys = pageOf(afterAlbumId, afterDisc)
|
||||||
if (keys.isEmpty()) return
|
if (keys.isEmpty()) return pruned
|
||||||
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||||
if (keys.size < PRUNE_PAGE) return
|
pruned += stale.size
|
||||||
|
if (keys.size < PRUNE_PAGE) return pruned
|
||||||
afterAlbumId = keys.last().albumId
|
afterAlbumId = keys.last().albumId
|
||||||
afterDisc = keys.last().disc
|
afterDisc = keys.last().disc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SyncSummary(
|
||||||
|
val artists: Int,
|
||||||
|
val albums: Int,
|
||||||
|
val songs: Int,
|
||||||
|
val playlists: Int,
|
||||||
|
val playlistSongs: Int,
|
||||||
|
val pruned: Int,
|
||||||
|
)
|
||||||
|
|
||||||
private const val DELETE_CHUNK = 500
|
private const val DELETE_CHUNK = 500
|
||||||
private const val PRUNE_PAGE = 500
|
private const val PRUNE_PAGE = 500
|
||||||
private const val INITIAL_HASH_CAPACITY = 256
|
private const val INITIAL_HASH_CAPACITY = 256
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import com.subtracks.data.repo.PendingServerActionSink
|
|||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
|
import com.subtracks.data.sync.AutoSync
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
|
import com.subtracks.data.sync.SyncNotifier
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.playback.MediaSessionConnection
|
import com.subtracks.playback.MediaSessionConnection
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.playback.PlayerConnection
|
import com.subtracks.playback.PlayerConnection
|
||||||
@@ -48,6 +51,8 @@ import com.subtracks.ui.settings.SettingsViewModel
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
@@ -68,6 +73,7 @@ fun appModule(
|
|||||||
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
||||||
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
||||||
|
single(createdAtStart = true) { get<UserPreferences>().verboseLogging().onEach(Log::setVerbose).launchIn(get()) }
|
||||||
single {
|
single {
|
||||||
SourceRepository(
|
SourceRepository(
|
||||||
get(),
|
get(),
|
||||||
@@ -92,7 +98,16 @@ fun appModule(
|
|||||||
}
|
}
|
||||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) }
|
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||||
|
single {
|
||||||
|
AutoSync(
|
||||||
|
preferences = get(),
|
||||||
|
networkMode = networkMode(context.applicationContext),
|
||||||
|
hasSource = { get<SourceRepository>().activeSourceIdOnce() != null },
|
||||||
|
requestSync = { get<SyncManager>().requestSync(silent = true) },
|
||||||
|
scope = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
single { QueueRepository(get()) }
|
single { QueueRepository(get()) }
|
||||||
single { ArtworkStore(downloadsRoot(context)) }
|
single { ArtworkStore(downloadsRoot(context)) }
|
||||||
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
||||||
@@ -111,6 +126,7 @@ fun appModule(
|
|||||||
).also { it.start() }
|
).also { it.start() }
|
||||||
}
|
}
|
||||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
||||||
|
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||||
single(createdAtStart = true) {
|
single(createdAtStart = true) {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.subtracks.log
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.subtracks.BuildConfig
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.db.SqlExecution
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
import android.util.Log as AndroidLog
|
||||||
|
|
||||||
|
object Log {
|
||||||
|
private const val SLOW_QUERY_MS = 100.0
|
||||||
|
private const val MAX_SQL_CHARS = 500
|
||||||
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
|
||||||
|
private var fileStore: LogFileStore? = null
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var consoleLevel = LogLevel.DEBUG
|
||||||
|
private var defaultFileLevel = LogLevel.INFO
|
||||||
|
private var crashHandlerInstalled = false
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
val app = context.applicationContext
|
||||||
|
appContext = app
|
||||||
|
consoleLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
|
||||||
|
defaultFileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
||||||
|
fileStore?.shutdown()
|
||||||
|
fileStore = LogFileStore(File(app.filesDir, "logs"), defaultFileLevel)
|
||||||
|
installCrashHandler()
|
||||||
|
i("app", "started ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) on Android ${Build.VERSION.SDK_INT} ${Build.MODEL}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVerbose(enabled: Boolean) {
|
||||||
|
fileStore?.setMinLevel(if (enabled) LogLevel.VERBOSE else defaultFileLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun v(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.VERBOSE, tag, message, null)
|
||||||
|
|
||||||
|
fun d(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.DEBUG, tag, message, null)
|
||||||
|
|
||||||
|
fun i(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.INFO, tag, message, null)
|
||||||
|
|
||||||
|
fun w(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable? = null,
|
||||||
|
) = log(LogLevel.WARN, tag, message, throwable)
|
||||||
|
|
||||||
|
fun e(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable? = null,
|
||||||
|
) = log(LogLevel.ERROR, tag, message, throwable)
|
||||||
|
|
||||||
|
fun exportZip(): File? {
|
||||||
|
val store = fileStore ?: return null
|
||||||
|
val context = appContext ?: return null
|
||||||
|
store.flush()
|
||||||
|
return store.exportZip(File(context.cacheDir, "log_dumps"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun share(
|
||||||
|
context: Context,
|
||||||
|
file: File,
|
||||||
|
) {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
val send =
|
||||||
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/zip"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.settings_about_share_logs_subject))
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(send, context.getString(R.string.settings_about_actions_share_logs)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interceptor(): Interceptor =
|
||||||
|
Interceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
v("http", "${request.method} ${request.url}")
|
||||||
|
try {
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
if (response.code >= 400) {
|
||||||
|
w("http", "${request.method} ${request.url} -> ${response.code}")
|
||||||
|
}
|
||||||
|
response
|
||||||
|
} catch (failure: IOException) {
|
||||||
|
if (chain.call().isCanceled()) {
|
||||||
|
v("http", "${request.method} ${request.url} cancelled")
|
||||||
|
} else {
|
||||||
|
w("http", "${request.method} ${request.url} failed", failure)
|
||||||
|
}
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sql(execution: SqlExecution) {
|
||||||
|
val failure = execution.error
|
||||||
|
if (failure != null) {
|
||||||
|
if (isUpsertConflict(execution.sql, failure)) {
|
||||||
|
v("sql", "upsert fallback conflict: ${shortSql(execution.sql)}")
|
||||||
|
} else {
|
||||||
|
e("sql", "failed: ${shortSql(execution.sql)}", failure)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isSlowQuery(execution, SLOW_QUERY_MS)) return
|
||||||
|
val rows = if (isReadStatement(execution.sql)) " rows=${execution.rows}" else ""
|
||||||
|
w("sql", "slow ${execution.durationMillis.roundToLong()}ms$rows: ${shortSql(execution.sql)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
val toConsole = level.ordinal >= consoleLevel.ordinal
|
||||||
|
val store = fileStore
|
||||||
|
if (!toConsole && store?.willLog(level) != true) return
|
||||||
|
val redacted = redactLogs(message)
|
||||||
|
if (toConsole) {
|
||||||
|
// Logging is best effort: a logcat write must never disturb the caller.
|
||||||
|
runCatching {
|
||||||
|
val text =
|
||||||
|
if (throwable != null) {
|
||||||
|
"$redacted\n${redactLogs(AndroidLog.getStackTraceString(throwable))}"
|
||||||
|
} else {
|
||||||
|
redacted
|
||||||
|
}
|
||||||
|
AndroidLog.println(priority(level), tag.take(23), text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store?.write(level, tag, redacted, throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun priority(level: LogLevel): Int =
|
||||||
|
when (level) {
|
||||||
|
LogLevel.VERBOSE -> AndroidLog.VERBOSE
|
||||||
|
LogLevel.DEBUG -> AndroidLog.DEBUG
|
||||||
|
LogLevel.INFO -> AndroidLog.INFO
|
||||||
|
LogLevel.WARN -> AndroidLog.WARN
|
||||||
|
LogLevel.ERROR -> AndroidLog.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installCrashHandler() {
|
||||||
|
if (crashHandlerInstalled) return
|
||||||
|
crashHandlerInstalled = true
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
runCatching {
|
||||||
|
fileStore?.writeImmediately(LogLevel.ERROR, "crash", "uncaught exception on ${thread.name}", throwable)
|
||||||
|
}
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortSql(sql: String): String = sql.replace(WHITESPACE, " ").take(MAX_SQL_CHARS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DATA_KEYWORDS = setOf("SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "REPLACE")
|
||||||
|
private val READ_KEYWORDS = setOf("SELECT", "WITH")
|
||||||
|
private const val SQL_INTERNAL = "room_table_modification_log"
|
||||||
|
|
||||||
|
internal fun isDataStatement(sql: String): Boolean {
|
||||||
|
val normalized = sql.trimStart()
|
||||||
|
if (normalized.contains(SQL_INTERNAL)) return false
|
||||||
|
return normalized.takeWhile { !it.isWhitespace() }.uppercase() in DATA_KEYWORDS
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isReadStatement(sql: String): Boolean = sql.trimStart().takeWhile { !it.isWhitespace() }.uppercase() in READ_KEYWORDS
|
||||||
|
|
||||||
|
internal fun isSlowQuery(
|
||||||
|
execution: SqlExecution,
|
||||||
|
thresholdMs: Double,
|
||||||
|
): Boolean = execution.error == null && execution.durationMillis >= thresholdMs && isDataStatement(execution.sql)
|
||||||
|
|
||||||
|
// Room's EntityUpsertAdapter implements @Upsert as an INSERT and treats a uniqueness conflict as the
|
||||||
|
// signal to UPDATE instead (EntityUpsertAdapter.checkUniquenessException). Only downgrade a failed
|
||||||
|
// INSERT whose error says the conflict was a unique/primary key; anything else stays an ERROR so a
|
||||||
|
// genuine failure isn't hidden in release.
|
||||||
|
private const val CONSTRAINT_UNIQUE_MESSAGE = "unique constraint failed"
|
||||||
|
private const val CONSTRAINT_PRIMARY_KEY_MESSAGE = "primary key constraint failed"
|
||||||
|
|
||||||
|
internal fun isUpsertConflict(
|
||||||
|
sql: String,
|
||||||
|
error: Throwable,
|
||||||
|
): Boolean {
|
||||||
|
if (!sql.trimStart().uppercase().startsWith("INSERT")) return false
|
||||||
|
val message = error.message?.lowercase() ?: return false
|
||||||
|
return message.contains(CONSTRAINT_UNIQUE_MESSAGE) || message.contains(CONSTRAINT_PRIMARY_KEY_MESSAGE)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.subtracks.log
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR }
|
||||||
|
|
||||||
|
class LogFileStore(
|
||||||
|
private val rootDir: File,
|
||||||
|
@Volatile private var minLevel: LogLevel,
|
||||||
|
private val maxFileBytes: Long = DEFAULT_MAX_FILE_BYTES,
|
||||||
|
private val maxFiles: Int = DEFAULT_MAX_FILES,
|
||||||
|
) {
|
||||||
|
private val executor =
|
||||||
|
Executors.newSingleThreadExecutor { runnable ->
|
||||||
|
Thread(runnable, "log-writer").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
private val lock = Any()
|
||||||
|
private val pending = AtomicInteger()
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
private val lineFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||||
|
private var current = currentFile()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Log.init runs in Application.onCreate, so clean up leftovers on the writer thread.
|
||||||
|
runCatching { executor.execute { synchronized(lock) { prune() } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun willLog(level: LogLevel): Boolean = level.ordinal >= minLevel.ordinal
|
||||||
|
|
||||||
|
fun write(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
if (level.ordinal < minLevel.ordinal) return
|
||||||
|
// Drop under pressure rather than grow the queue without bound (verbose logs every request).
|
||||||
|
if (pending.get() >= MAX_PENDING) return
|
||||||
|
pending.incrementAndGet()
|
||||||
|
runCatching {
|
||||||
|
executor.execute {
|
||||||
|
try {
|
||||||
|
synchronized(lock) { append(level, tag, message, throwable) }
|
||||||
|
} finally {
|
||||||
|
pending.decrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { pending.decrementAndGet() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeImmediately(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
if (level.ordinal < minLevel.ordinal) return
|
||||||
|
runCatching { synchronized(lock) { append(level, tag, message, throwable) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flush() {
|
||||||
|
runCatching { executor.submit {}.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMinLevel(level: LogLevel) {
|
||||||
|
minLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun files(): List<File> =
|
||||||
|
rootDir
|
||||||
|
.listFiles { file -> file.isFile && file.name.endsWith(LOG_SUFFIX) }
|
||||||
|
?.sortedByDescending { it.lastModified() }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
fun exportZip(destDir: File): File? =
|
||||||
|
synchronized(lock) {
|
||||||
|
val logs = files()
|
||||||
|
if (logs.isEmpty()) return null
|
||||||
|
destDir.mkdirs()
|
||||||
|
destDir.listFiles()?.forEach { if (it.isFile) it.delete() }
|
||||||
|
val zip = File(destDir, "subtracks-logs-${System.currentTimeMillis()}.zip")
|
||||||
|
ZipOutputStream(zip.outputStream().buffered()).use { out ->
|
||||||
|
logs.forEach { log ->
|
||||||
|
if (!log.isFile) return@forEach
|
||||||
|
out.putNextEntry(ZipEntry(log.name))
|
||||||
|
log.inputStream().use { it.copyTo(out) }
|
||||||
|
out.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zip
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun append(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
val text =
|
||||||
|
buildString {
|
||||||
|
append(lineFormat.format(Date()))
|
||||||
|
append(' ')
|
||||||
|
append(LEVEL_CHARS[level.ordinal])
|
||||||
|
append(' ')
|
||||||
|
append(tag)
|
||||||
|
append(' ')
|
||||||
|
append(message)
|
||||||
|
throwable?.let {
|
||||||
|
append('\n')
|
||||||
|
append(it.stackTraceToString().trimEnd())
|
||||||
|
}
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
rootDir.mkdirs()
|
||||||
|
var rolled = false
|
||||||
|
val today = currentFile()
|
||||||
|
if (current.name != today.name) {
|
||||||
|
current = today
|
||||||
|
rolled = true
|
||||||
|
}
|
||||||
|
if (current.length() >= maxFileBytes) {
|
||||||
|
roll()
|
||||||
|
rolled = true
|
||||||
|
}
|
||||||
|
current.appendText(redactLogs(text))
|
||||||
|
if (rolled) prune()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rollSequence = 0
|
||||||
|
|
||||||
|
private fun roll() {
|
||||||
|
if (current.exists()) {
|
||||||
|
current.renameTo(uniqueRolledFile())
|
||||||
|
}
|
||||||
|
current = currentFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueRolledFile(): File {
|
||||||
|
val day = dateFormat.format(Date())
|
||||||
|
while (true) {
|
||||||
|
val candidate = File(rootDir, "$day-${rollSequence++}$LOG_SUFFIX")
|
||||||
|
if (!candidate.exists()) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prune() {
|
||||||
|
// Never prune the file we are writing to, and never keep fewer than one rolled file slot.
|
||||||
|
val rolled = files().filterNot { it == current }
|
||||||
|
if (rolled.size > maxFiles - 1) rolled.drop(maxFiles - 1).forEach { it.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentFile(): File = File(rootDir, dateFormat.format(Date()) + LOG_SUFFIX)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_MAX_FILE_BYTES = 1L * 1024 * 1024
|
||||||
|
const val DEFAULT_MAX_FILES = 7
|
||||||
|
private const val MAX_PENDING = 4096
|
||||||
|
private const val LOG_SUFFIX = ".log"
|
||||||
|
private val LEVEL_CHARS = charArrayOf('V', 'D', 'I', 'W', 'E')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val URL_USERINFO = Regex("([a-zA-Z][a-zA-Z0-9+.\\-]*://)[^/@\\s]+@")
|
||||||
|
private val URL_PARAM = Regex("([?&](?:u|p|s|t)=)[^&\\s]+")
|
||||||
|
|
||||||
|
internal fun redactLogs(input: String): String = input.replace(URL_USERINFO, "$1REDACTED@").replace(URL_PARAM, "$1REDACTED")
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
@@ -16,6 +15,7 @@ import com.subtracks.UiMessage
|
|||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
class Media3PlayerHandle(
|
class Media3PlayerHandle(
|
||||||
private val controller: MediaController,
|
private val controller: MediaController,
|
||||||
@@ -112,14 +112,19 @@ class Media3PlayerHandle(
|
|||||||
) = controller.replaceMediaItem(index, toMediaItem(item))
|
) = controller.replaceMediaItem(index, toMediaItem(item))
|
||||||
|
|
||||||
override fun seekToIndex(index: Int) {
|
override fun seekToIndex(index: Int) {
|
||||||
Log.d(TAG, "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
|
Log.d(
|
||||||
|
"playback",
|
||||||
|
"seekToIndex($index) position=${controller.currentPosition} duration=${duration(
|
||||||
|
controller.duration,
|
||||||
|
)} state=${controller.playbackState}",
|
||||||
|
)
|
||||||
controller.seekTo(index, 0)
|
controller.seekTo(index, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
"seekTo(${positionMs}ms) from=${controller.currentPosition} duration=${duration(controller.duration)} " +
|
||||||
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||||
)
|
)
|
||||||
controller.seekTo(positionMs)
|
controller.seekTo(positionMs)
|
||||||
@@ -136,7 +141,7 @@ class Media3PlayerHandle(
|
|||||||
mediaItem: MediaItem?,
|
mediaItem: MediaItem?,
|
||||||
reason: Int,
|
reason: Int,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
||||||
listener.onTransition()
|
listener.onTransition()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +152,9 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
"playback",
|
||||||
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
"error ${error.errorCodeName} ${describe(controller.currentMediaItem)} " +
|
||||||
|
"position=${controller.currentPosition} duration=${duration(controller.duration)}",
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
listener.onError(playbackErrorMessage(error))
|
listener.onError(playbackErrorMessage(error))
|
||||||
@@ -156,15 +162,54 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"state=$playbackState position=${controller.currentPosition} " +
|
"state=$playbackState position=${controller.currentPosition} " +
|
||||||
"playerDuration=${controller.duration} itemDuration=${currentItem?.durationMs}",
|
"duration=${duration(controller.duration)} itemDuration=${currentItem?.durationMs}",
|
||||||
)
|
)
|
||||||
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
|
Log.i("playback", "ended ${describe(controller.currentMediaItem)}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun describe(mediaItem: MediaItem?): String {
|
||||||
|
if (mediaItem == null) return "item=none"
|
||||||
|
val metadata = mediaItem.mediaMetadata
|
||||||
|
val artist =
|
||||||
|
metadata.artist
|
||||||
|
?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { " artist=\"$it\"" }
|
||||||
|
.orEmpty()
|
||||||
|
val album =
|
||||||
|
metadata.albumTitle
|
||||||
|
?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { " album=\"$it\"" }
|
||||||
|
.orEmpty()
|
||||||
|
val source =
|
||||||
|
when (mediaItem.localConfiguration?.uri?.scheme) {
|
||||||
|
"file", "content" -> "file"
|
||||||
|
"http", "https" -> "http"
|
||||||
|
null -> "none"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
return "item id=${mediaItem.mediaId} title=\"${metadata.title?.toString().orEmpty()}\"$artist$album source=$source"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun duration(value: Long): String = if (value == C.TIME_UNSET) "unset" else "${value}ms"
|
||||||
|
|
||||||
|
private fun transitionReason(reason: Int): String =
|
||||||
|
when (reason) {
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> "repeat"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "playlist"
|
||||||
|
else -> reason.toString()
|
||||||
|
}
|
||||||
|
|
||||||
private fun toMediaItem(item: QueueItem): MediaItem {
|
private fun toMediaItem(item: QueueItem): MediaItem {
|
||||||
val quality = sourceRepository.quality.value
|
val quality = sourceRepository.quality.value
|
||||||
return queueMediaItem(
|
return queueMediaItem(
|
||||||
@@ -190,10 +235,6 @@ class Media3PlayerHandle(
|
|||||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||||
?.getLong(EXTRA_DURATION_MS),
|
?.getLong(EXTRA_DURATION_MS),
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "SubtracksPlayback"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.subtracks.data.repo.QueueSnapshot
|
|||||||
import com.subtracks.data.repo.QueueWindowItem
|
import com.subtracks.data.repo.QueueWindowItem
|
||||||
import com.subtracks.data.repo.Shuffle
|
import com.subtracks.data.repo.Shuffle
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -127,7 +128,9 @@ class PlaybackController(
|
|||||||
private var positionJob: Job? = null
|
private var positionJob: Job? = null
|
||||||
private var windowJob: Job? = null
|
private var windowJob: Job? = null
|
||||||
private var bufferingJob: Job? = null
|
private var bufferingJob: Job? = null
|
||||||
|
private var stallJob: Job? = null
|
||||||
private var showBuffering = false
|
private var showBuffering = false
|
||||||
|
private var stalled = false
|
||||||
private val startLock = Mutex()
|
private val startLock = Mutex()
|
||||||
|
|
||||||
private var snapshot: QueueSnapshot? = null
|
private var snapshot: QueueSnapshot? = null
|
||||||
@@ -258,6 +261,15 @@ class PlaybackController(
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
||||||
|
|
||||||
|
fun shuffleGenre(
|
||||||
|
sourceId: Long,
|
||||||
|
genre: String,
|
||||||
|
) = shuffleContext(queueRepository.genreEntry(sourceId, genre))
|
||||||
|
|
||||||
|
fun shuffleStarred(sourceId: Long) = shuffleContext(queueRepository.starredEntry(sourceId))
|
||||||
|
|
||||||
|
fun shuffleDownloaded(sourceId: Long) = shuffleContext(queueRepository.downloadedEntry(sourceId))
|
||||||
|
|
||||||
private fun playContext(
|
private fun playContext(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
start: Long,
|
start: Long,
|
||||||
@@ -1047,6 +1059,9 @@ class PlaybackController(
|
|||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
bufferingJob?.cancel()
|
bufferingJob?.cancel()
|
||||||
bufferingJob = null
|
bufferingJob = null
|
||||||
|
stallJob?.cancel()
|
||||||
|
stallJob = null
|
||||||
|
stalled = false
|
||||||
showBuffering = false
|
showBuffering = false
|
||||||
snapshot = null
|
snapshot = null
|
||||||
queueSourceId = null
|
queueSourceId = null
|
||||||
@@ -1460,21 +1475,42 @@ class PlaybackController(
|
|||||||
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
||||||
|
|
||||||
private fun updateBuffering() {
|
private fun updateBuffering() {
|
||||||
|
val player = player
|
||||||
if (player?.isBuffering != true) {
|
if (player?.isBuffering != true) {
|
||||||
bufferingJob?.cancel()
|
bufferingJob?.cancel()
|
||||||
bufferingJob = null
|
bufferingJob = null
|
||||||
|
stallJob?.cancel()
|
||||||
|
stallJob = null
|
||||||
|
stalled = false
|
||||||
showBuffering = false
|
showBuffering = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (showBuffering || bufferingJob?.isActive == true) return
|
if (!showBuffering && bufferingJob?.isActive != true) {
|
||||||
bufferingJob =
|
bufferingJob =
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(BUFFERING_INDICATOR_DELAY_MS)
|
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||||
if (player?.isBuffering == true) {
|
if (player.isBuffering) {
|
||||||
showBuffering = true
|
showBuffering = true
|
||||||
refresh()
|
refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A stall is buffering that persists long enough to be a real problem, not a track change.
|
||||||
|
if (stallJob?.isActive != true && !stalled) {
|
||||||
|
stallJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(STALL_WATCHDOG_MS)
|
||||||
|
if (player === this@PlaybackController.player && player.isBuffering && player.playWhenReady) {
|
||||||
|
stalled = true
|
||||||
|
val item = player.currentItem
|
||||||
|
Log.w(
|
||||||
|
"playback",
|
||||||
|
"stalled: buffering for ${STALL_WATCHDOG_MS / 1000}s at ${player.currentPositionMs}ms " +
|
||||||
|
"item id=${item?.id} title=\"${item?.title}\"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refresh(position: Long? = currentPosition()) {
|
private fun refresh(position: Long? = currentPosition()) {
|
||||||
@@ -1541,6 +1577,7 @@ class PlaybackController(
|
|||||||
const val POSITION_TICK_MS = 500L
|
const val POSITION_TICK_MS = 500L
|
||||||
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
||||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||||
|
const val STALL_WATCHDOG_MS = 15_000L
|
||||||
const val QUEUE_WINDOW_RADIUS = 25L
|
const val QUEUE_WINDOW_RADIUS = 25L
|
||||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||||
const val RESTART_THRESHOLD_MS = 3_000L
|
const val RESTART_THRESHOLD_MS = 3_000L
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
@@ -13,8 +12,13 @@ import androidx.media3.common.MediaItem
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.ExperimentalApi
|
import androidx.media3.common.util.ExperimentalApi
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.datasource.cache.Cache
|
||||||
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
@@ -29,9 +33,11 @@ import com.google.common.util.concurrent.Futures
|
|||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.subtracks.MainActivity
|
import com.subtracks.MainActivity
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UserAgent
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -40,6 +46,7 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
@@ -90,6 +97,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
val sources = GlobalContext.get().get<SourceRepository>()
|
val sources = GlobalContext.get().get<SourceRepository>()
|
||||||
sourceRepository = sources
|
sourceRepository = sources
|
||||||
library = GlobalContext.get().get<LibraryRepository>()
|
library = GlobalContext.get().get<LibraryRepository>()
|
||||||
|
val cache = StreamCache.open(this)
|
||||||
val exoPlayer =
|
val exoPlayer =
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(this)
|
.Builder(this)
|
||||||
@@ -104,7 +112,13 @@ class PlaybackService : MediaSessionService() {
|
|||||||
.setMediaSourceFactory(
|
.setMediaSourceFactory(
|
||||||
DefaultMediaSourceFactory(this)
|
DefaultMediaSourceFactory(this)
|
||||||
.setEnableClippingInMediaPeriod(true)
|
.setEnableClippingInMediaPeriod(true)
|
||||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
.setDataSourceFactory(
|
||||||
|
mediaDataSourceFactory(
|
||||||
|
this,
|
||||||
|
cache,
|
||||||
|
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
|
||||||
|
),
|
||||||
|
),
|
||||||
).build()
|
).build()
|
||||||
exoPlayer.addListener(
|
exoPlayer.addListener(
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
@@ -183,12 +197,13 @@ class PlaybackService : MediaSessionService() {
|
|||||||
private fun streamingClient(): OkHttpClient =
|
private fun streamingClient(): OkHttpClient =
|
||||||
OkHttpClient
|
OkHttpClient
|
||||||
.Builder()
|
.Builder()
|
||||||
|
.addInterceptor(Log.interceptor())
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
val started = SystemClock.elapsedRealtime()
|
val started = SystemClock.elapsedRealtime()
|
||||||
val response = chain.proceed(chain.request())
|
val response = chain.proceed(chain.request())
|
||||||
Log.d(
|
Log.d(
|
||||||
STREAM_TAG,
|
"stream",
|
||||||
"stream range=${chain.request().header("Range")} code=${response.code} " +
|
"range=${chain.request().header("Range")} code=${response.code} " +
|
||||||
"contentRange=${response.header("Content-Range")} " +
|
"contentRange=${response.header("Content-Range")} " +
|
||||||
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
||||||
)
|
)
|
||||||
@@ -208,13 +223,50 @@ class PlaybackService : MediaSessionService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val STREAM_TAG = "SubtracksPlayback"
|
|
||||||
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
|
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process-scoped so it is never released while a loader thread is still committing to it, and so
|
||||||
|
// a recreated service does not try to lock the directory a second time.
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private object StreamCache {
|
||||||
|
@Volatile
|
||||||
|
private var instance: SimpleCache? = null
|
||||||
|
|
||||||
|
fun open(context: Context): SimpleCache? =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: runCatching {
|
||||||
|
SimpleCache(
|
||||||
|
File(context.cacheDir, STREAM_CACHE_DIR),
|
||||||
|
LeastRecentlyUsedCacheEvictor(STREAM_CACHE_BYTES),
|
||||||
|
StandaloneDatabaseProvider(context),
|
||||||
|
)
|
||||||
|
}.onFailure { Log.w("playback", "stream cache unavailable: ${it.message}") }
|
||||||
|
.getOrNull()
|
||||||
|
?.also { instance = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val STREAM_CACHE_DIR = "stream-cache"
|
||||||
|
private const val STREAM_CACHE_BYTES = 64L * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
internal fun mediaDataSourceFactory(
|
internal fun mediaDataSourceFactory(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
cache: Cache?,
|
||||||
upstream: DataSource.Factory,
|
upstream: DataSource.Factory,
|
||||||
): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream))
|
): DataSource.Factory {
|
||||||
|
val known = KnownLengthDataSourceFactory(upstream)
|
||||||
|
val source: DataSource.Factory =
|
||||||
|
if (cache == null) {
|
||||||
|
known
|
||||||
|
} else {
|
||||||
|
CacheDataSource
|
||||||
|
.Factory()
|
||||||
|
.setCache(cache)
|
||||||
|
.setUpstreamDataSourceFactory(known)
|
||||||
|
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||||
|
}
|
||||||
|
return DefaultDataSource.Factory(context, source)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -58,6 +59,7 @@ class ScrobblePolicy(
|
|||||||
item?.id != songId ||
|
item?.id != songId ||
|
||||||
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
||||||
if (restarted) {
|
if (restarted) {
|
||||||
|
val alreadyAnnounced = nowPlayingSent && item?.id == songId
|
||||||
songId = item?.id
|
songId = item?.id
|
||||||
lastItemId = null
|
lastItemId = null
|
||||||
playedMs = 0
|
playedMs = 0
|
||||||
@@ -67,7 +69,7 @@ class ScrobblePolicy(
|
|||||||
if (item == null || !isPlaying) return null
|
if (item == null || !isPlaying) return null
|
||||||
nowPlayingSent = true
|
nowPlayingSent = true
|
||||||
startedAt = now()
|
startedAt = now()
|
||||||
return Scrobble.NowPlaying(item.id)
|
return if (alreadyAnnounced) null else Scrobble.NowPlaying(item.id)
|
||||||
}
|
}
|
||||||
lastPositionMs = positionMs
|
lastPositionMs = positionMs
|
||||||
lastItemId = item?.id
|
lastItemId = item?.id
|
||||||
@@ -128,22 +130,28 @@ class Scrobbler(
|
|||||||
private suspend fun submit(event: Scrobble) {
|
private suspend fun submit(event: Scrobble) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is Scrobble.NowPlaying -> {
|
is Scrobble.NowPlaying -> {
|
||||||
attempt { sink.nowPlaying(event.songId) }
|
Log.i("scrobble", "now playing ${event.songId}")
|
||||||
|
attempt("now playing ${event.songId}") { sink.nowPlaying(event.songId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
is Scrobble.Submission -> {
|
is Scrobble.Submission -> {
|
||||||
attempt { recordPlay(event.songId, event.time / 1000) }
|
Log.i("scrobble", "submitting ${event.songId}")
|
||||||
attempt { sink.scrobble(event.songId, event.time) }
|
attempt("record play ${event.songId}") { recordPlay(event.songId, event.time / 1000) }
|
||||||
|
attempt("scrobble ${event.songId}") { sink.scrobble(event.songId, event.time) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun attempt(block: suspend () -> Unit) {
|
private suspend fun attempt(
|
||||||
|
label: String,
|
||||||
|
block: suspend () -> Unit,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
block()
|
block()
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (_: Exception) {
|
} catch (failure: Exception) {
|
||||||
|
Log.w("scrobble", "$label failed", failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,11 @@ fun CoverArt(
|
|||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val refKey = ref?.cacheKey
|
||||||
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val thumbnailKey = thumbnailRef?.cacheKey
|
||||||
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
|
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||||
val letterScheme =
|
val letterScheme =
|
||||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||||
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
||||||
@@ -102,7 +104,7 @@ fun CoverArt(
|
|||||||
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
|
||||||
if (square) {
|
if (square) {
|
||||||
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
val model = remember(refKey, thumbnailKey) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
||||||
Box(modifier.then(frameModifier)) {
|
Box(modifier.then(frameModifier)) {
|
||||||
CoverArtContent(
|
CoverArtContent(
|
||||||
ref = ref,
|
ref = ref,
|
||||||
@@ -147,7 +149,7 @@ fun CoverArt(
|
|||||||
val painter =
|
val painter =
|
||||||
rememberAsyncImagePainter(
|
rememberAsyncImagePainter(
|
||||||
model =
|
model =
|
||||||
remember(ref, thumbnailRef, target) {
|
remember(refKey, thumbnailKey, target) {
|
||||||
ref?.let {
|
ref?.let {
|
||||||
imageRequest(
|
imageRequest(
|
||||||
context,
|
context,
|
||||||
@@ -231,7 +233,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
}
|
}
|
||||||
if (thumbnailRef != null && thumbnailRef != ref) {
|
if (thumbnailRef != null && thumbnailRef != ref) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
|
model = remember(thumbnailRef.cacheKey) { imageRequest(context, thumbnailRef, crossfade = false) },
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import androidx.compose.runtime.derivedStateOf
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -77,6 +78,7 @@ import com.subtracks.ui.theme.ArtworkTheme
|
|||||||
import com.subtracks.ui.theme.HeroGradient
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
import com.subtracks.ui.theme.baseArtworkColors
|
import com.subtracks.ui.theme.baseArtworkColors
|
||||||
import com.subtracks.ui.theme.heroBarColor
|
import com.subtracks.ui.theme.heroBarColor
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val FADE_DISTANCE_DP = 64
|
private const val FADE_DISTANCE_DP = 64
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ fun HeroDetailScaffold(
|
|||||||
ArtworkTheme(effectiveArtwork) {
|
ArtworkTheme(effectiveArtwork) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
@@ -195,7 +198,8 @@ fun HeroDetailScaffold(
|
|||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
) {},
|
onClickLabel = stringResource(R.string.scroll_to_top),
|
||||||
|
) { scope.launch { listState.animateScrollToItem(0) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.subtracks.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShuffleFab(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
visible: Boolean = true,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn() + scaleIn(),
|
||||||
|
exit = fadeOut() + scaleOut(),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
FloatingActionButton(onClick = onClick) {
|
||||||
|
Icon(Icons.Rounded.Shuffle, contentDescription = stringResource(R.string.controls_shuffle_play))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,3 +88,34 @@ fun rememberViewportFill(state: LazyGridState): Dp {
|
|||||||
}
|
}
|
||||||
return with(density) { fillPx.toDp() }
|
return with(density) { fillPx.toDp() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls to the top when [request] changes to a new non-null value, but not when it merely becomes
|
||||||
|
* non-null. Selecting a page passes it the current request; only a fresh request (a tab re-tap)
|
||||||
|
* should move the list, so a stale scroll position survives tab switches.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScrollToTopOnRequest(
|
||||||
|
request: Any?,
|
||||||
|
state: LazyListState,
|
||||||
|
) {
|
||||||
|
var previous by remember { mutableStateOf(request) }
|
||||||
|
LaunchedEffect(request) {
|
||||||
|
val stale = previous
|
||||||
|
previous = request
|
||||||
|
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScrollToTopOnRequest(
|
||||||
|
request: Any?,
|
||||||
|
state: LazyGridState,
|
||||||
|
) {
|
||||||
|
var previous by remember { mutableStateOf(request) }
|
||||||
|
LaunchedEffect(request) {
|
||||||
|
val stale = previous
|
||||||
|
previous = request
|
||||||
|
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -59,8 +62,10 @@ import com.subtracks.ui.components.CoverArt
|
|||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.ShuffleFab
|
||||||
import com.subtracks.ui.library.PlaylistsContent
|
import com.subtracks.ui.library.PlaylistsContent
|
||||||
import com.subtracks.ui.library.SongRow
|
import com.subtracks.ui.library.SongRow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -110,6 +115,7 @@ fun HomeListRoute(
|
|||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||||
|
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
|
||||||
onGenreClick = onGenreClick,
|
onGenreClick = onGenreClick,
|
||||||
onDecadeClick = onDecadeClick,
|
onDecadeClick = onDecadeClick,
|
||||||
)
|
)
|
||||||
@@ -134,11 +140,14 @@ fun HomeListScreen(
|
|||||||
onPlaylistClick: (Playlist) -> Unit = {},
|
onPlaylistClick: (Playlist) -> Unit = {},
|
||||||
onSongClick: (Song) -> Unit = {},
|
onSongClick: (Song) -> Unit = {},
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
|
onShuffle: (() -> Unit)? = null,
|
||||||
onGenreClick: (String) -> Unit = {},
|
onGenreClick: (String) -> Unit = {},
|
||||||
onDecadeClick: (Long) -> Unit = {},
|
onDecadeClick: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val now = remember { System.currentTimeMillis() }
|
val now = remember { System.currentTimeMillis() }
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val songListState = rememberLazyListState()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -149,8 +158,21 @@ fun HomeListScreen(
|
|||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
modifier =
|
||||||
|
if (onShuffle != null) {
|
||||||
|
Modifier.clickable(onClickLabel = stringResource(R.string.scroll_to_top)) {
|
||||||
|
scope.launch { songListState.animateScrollToItem(0) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (onShuffle != null && songs.itemCount > 0) {
|
||||||
|
ShuffleFab(onClick = onShuffle)
|
||||||
|
}
|
||||||
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
val downloaded = request.downloaded
|
val downloaded = request.downloaded
|
||||||
if (downloaded != null) {
|
if (downloaded != null) {
|
||||||
@@ -184,6 +206,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -237,6 +260,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -254,6 +278,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -297,6 +322,7 @@ private fun SongsList(
|
|||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
onSongClick: (Song) -> Unit,
|
onSongClick: (Song) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
|
listState: LazyListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
||||||
@@ -304,8 +330,9 @@ private fun SongsList(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 88.dp),
|
||||||
) {
|
) {
|
||||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||||
val item = songs[index]
|
val item = songs[index]
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class HomeListRequest(
|
data class HomeListRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -51,6 +53,11 @@ class HomeListViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
|
val canShuffle: Boolean =
|
||||||
|
request.genre != null ||
|
||||||
|
request.section == HomeSection.RecentlyStarredSongs ||
|
||||||
|
request.downloaded == OfflineListKind.Songs
|
||||||
|
|
||||||
val albums: Flow<PagingData<Album>> =
|
val albums: Flow<PagingData<Album>> =
|
||||||
sourceId
|
sourceId
|
||||||
.flatMapLatest { id ->
|
.flatMapLatest { id ->
|
||||||
@@ -173,4 +180,18 @@ class HomeListViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shuffle() {
|
||||||
|
val genre = request.genre
|
||||||
|
val downloaded = request.downloaded
|
||||||
|
val starred = request.section == HomeSection.RecentlyStarredSongs
|
||||||
|
viewModelScope.launch {
|
||||||
|
val id = sourceId.first()
|
||||||
|
when {
|
||||||
|
genre != null -> playbackController.shuffleGenre(id, genre)
|
||||||
|
downloaded == OfflineListKind.Songs -> playbackController.shuffleDownloaded(id)
|
||||||
|
starred -> playbackController.shuffleStarred(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -53,11 +52,6 @@ class HomeViewModel(
|
|||||||
private val syncManager: SyncManager,
|
private val syncManager: SyncManager,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val syncing: StateFlow<Boolean> =
|
|
||||||
syncManager.status
|
|
||||||
.map { it == SyncStatus.Running }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> =
|
val offline: StateFlow<Boolean> =
|
||||||
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
@@ -141,7 +135,9 @@ class HomeViewModel(
|
|||||||
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun sync() {
|
||||||
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
|
}
|
||||||
|
|
||||||
private data class PlayRows(
|
private data class PlayRows(
|
||||||
val recentAlbums: List<Album>,
|
val recentAlbums: List<Album>,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ fun AlbumsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
|
scrollToTopKey: Any? = null,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -78,6 +80,7 @@ fun AlbumsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, gridState)
|
||||||
val fill = rememberViewportFill(gridState)
|
val fill = rememberViewportFill(gridState)
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ fun ArtistsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
|
scrollToTopKey: Any? = null,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -70,6 +72,7 @@ fun ArtistsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.rounded.Person
|
|||||||
import androidx.compose.material.icons.rounded.Remove
|
import androidx.compose.material.icons.rounded.Remove
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
@@ -243,6 +244,7 @@ fun LibraryRoute(
|
|||||||
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
||||||
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
||||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
|
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
|
||||||
val listTab = selectedTab.listTab()
|
val listTab = selectedTab.listTab()
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
if (previousTab != selectedTab) {
|
if (previousTab != selectedTab) {
|
||||||
@@ -296,6 +298,7 @@ fun LibraryRoute(
|
|||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
||||||
syncing = syncing,
|
syncing = syncing,
|
||||||
|
refreshing = refreshing,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
albums = viewModel.albums,
|
albums = viewModel.albums,
|
||||||
@@ -392,6 +395,7 @@ fun LibraryScreen(
|
|||||||
onExitOffline: () -> Unit = {},
|
onExitOffline: () -> Unit = {},
|
||||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||||
syncing: Boolean = false,
|
syncing: Boolean = false,
|
||||||
|
refreshing: Boolean = false,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
listQuery: ListQuery = ListQuery(""),
|
listQuery: ListQuery = ListQuery(""),
|
||||||
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
||||||
@@ -418,6 +422,7 @@ fun LibraryScreen(
|
|||||||
)
|
)
|
||||||
var showOptions by rememberSaveable { mutableStateOf(false) }
|
var showOptions by rememberSaveable { mutableStateOf(false) }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var topRequest by remember { mutableIntStateOf(0) }
|
||||||
val searchFocus = remember { FocusRequester() }
|
val searchFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||||
@@ -470,15 +475,16 @@ fun LibraryScreen(
|
|||||||
) { page ->
|
) { page ->
|
||||||
val pageTab = LibraryTab.entries[page]
|
val pageTab = LibraryTab.entries[page]
|
||||||
val resetKey = resetKeys[pageTab]
|
val resetKey = resetKeys[pageTab]
|
||||||
|
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
onRefresh = onSync,
|
onRefresh = onSync,
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.Indicator(
|
PullToRefreshDefaults.Indicator(
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
||||||
containerColor = headerColor,
|
containerColor = headerColor,
|
||||||
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
||||||
@@ -507,6 +513,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = albumDownloads,
|
downloadStatuses = albumDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +533,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = artistDownloads,
|
downloadStatuses = artistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +553,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = playlistDownloads,
|
downloadStatuses = playlistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,11 +579,14 @@ fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
LibraryTabs(
|
LibraryTabs(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = { tab ->
|
||||||
|
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||||
|
},
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
onExitOffline = onExitOffline,
|
onExitOffline = onExitOffline,
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
|
syncing = syncing,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -841,6 +853,7 @@ internal fun LibraryTabs(
|
|||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
onExitOffline: () -> Unit = {},
|
onExitOffline: () -> Unit = {},
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
|
syncing: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val indicatorStretch = 18.dp
|
val indicatorStretch = 18.dp
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -932,11 +945,20 @@ internal fun LibraryTabs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpenSettings) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
Icon(
|
Box(contentAlignment = Alignment.Center) {
|
||||||
imageVector = Icons.Rounded.Settings,
|
Icon(
|
||||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
imageVector = Icons.Rounded.Settings,
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||||
)
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
if (syncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,12 @@ class LibraryViewModel(
|
|||||||
|
|
||||||
val syncing: StateFlow<Boolean> =
|
val syncing: StateFlow<Boolean> =
|
||||||
syncManager.status
|
syncManager.status
|
||||||
.map { it == SyncStatus.Running }
|
.map { it is SyncStatus.Running }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
|
val refreshing: StateFlow<Boolean> =
|
||||||
|
syncManager.status
|
||||||
|
.map { it is SyncStatus.Running && !it.silent }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
@@ -206,7 +211,9 @@ class LibraryViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun sync() {
|
||||||
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -52,6 +53,7 @@ fun PlaylistsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
|
scrollToTopKey: Any? = null,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -74,6 +76,7 @@ fun PlaylistsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ class QueueViewModel(
|
|||||||
var generation by mutableIntStateOf(0)
|
var generation by mutableIntStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
private var reorderPending = false
|
||||||
|
|
||||||
private var nextId = 0L
|
private var nextId = 0L
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -249,12 +251,19 @@ class QueueViewModel(
|
|||||||
fun move(
|
fun move(
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
) {
|
): Boolean {
|
||||||
if (from == to) return
|
if (from == to) return false
|
||||||
|
if (reorderPending) return false
|
||||||
|
reorderPending = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackController.move(from, to)
|
try {
|
||||||
mutex.withLock { reconcileLocked() }
|
playbackController.move(from, to)
|
||||||
|
mutex.withLock { reconcileLocked() }
|
||||||
|
} finally {
|
||||||
|
reorderPending = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(position: Long) {
|
fun remove(position: Long) {
|
||||||
@@ -331,7 +340,7 @@ fun QueueScreen(
|
|||||||
onPlay: (Long) -> Unit,
|
onPlay: (Long) -> Unit,
|
||||||
onRemove: (Long) -> Unit,
|
onRemove: (Long) -> Unit,
|
||||||
onReorder: (Int, Int) -> Unit,
|
onReorder: (Int, Int) -> Unit,
|
||||||
onMove: (Long, Long) -> Unit,
|
onMove: (Long, Long) -> Boolean,
|
||||||
onLoadOlder: () -> Unit,
|
onLoadOlder: () -> Unit,
|
||||||
onLoadNewer: () -> Unit,
|
onLoadNewer: () -> Unit,
|
||||||
onUndo: () -> Unit,
|
onUndo: () -> Unit,
|
||||||
@@ -347,7 +356,6 @@ fun QueueScreen(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var dragId by remember { mutableStateOf<Long?>(null) }
|
var dragId by remember { mutableStateOf<Long?>(null) }
|
||||||
var dragFrom by remember { mutableStateOf<Long?>(null) }
|
|
||||||
val undoLabel = stringResource(R.string.undo)
|
val undoLabel = stringResource(R.string.undo)
|
||||||
val queueReordered = stringResource(R.string.queue_reordered)
|
val queueReordered = stringResource(R.string.queue_reordered)
|
||||||
val queueRemoved = stringResource(R.string.queue_removed)
|
val queueRemoved = stringResource(R.string.queue_removed)
|
||||||
@@ -470,28 +478,14 @@ fun QueueScreen(
|
|||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
Modifier.draggableHandle(
|
Modifier.draggableHandle(
|
||||||
onDragStarted = {
|
onDragStarted = { dragId = row.id },
|
||||||
dragId = row.id
|
|
||||||
dragFrom = row.position
|
|
||||||
},
|
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
val id = dragId
|
dragId?.let { id ->
|
||||||
val from = dragFrom
|
rows.dropMoveFor(id)?.let { move ->
|
||||||
if (id != null && from != null) {
|
if (onMove(move.from, move.to)) showUndo(queueReordered)
|
||||||
val draggedIndex = rows.indexOfFirst { it.id == id }
|
|
||||||
val to =
|
|
||||||
if (draggedIndex < 0) {
|
|
||||||
from
|
|
||||||
} else {
|
|
||||||
dropTarget(rows, draggedIndex, from)
|
|
||||||
}
|
|
||||||
if (to != from) {
|
|
||||||
onMove(from, to)
|
|
||||||
showUndo(queueReordered)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dragId = null
|
dragId = null
|
||||||
dragFrom = null
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -563,6 +557,19 @@ internal fun dropTarget(
|
|||||||
return (before - if (before > from) 1 else 0) + 1
|
return (before - if (before > from) 1 else 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class QueueMove(
|
||||||
|
val from: Long,
|
||||||
|
val to: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
||||||
|
val index = indexOfFirst { it.id == draggedId }
|
||||||
|
if (index < 0) return null
|
||||||
|
val from = this[index].position
|
||||||
|
val to = dropTarget(this, index, from)
|
||||||
|
return if (to == from) null else QueueMove(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("ModifierParameter")
|
@Suppress("ModifierParameter")
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRowItem(
|
private fun QueueRowItem(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.ui.settings
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -27,6 +28,7 @@ import androidx.compose.material.icons.rounded.Edit
|
|||||||
import androidx.compose.material.icons.rounded.Favorite
|
import androidx.compose.material.icons.rounded.Favorite
|
||||||
import androidx.compose.material.icons.rounded.Info
|
import androidx.compose.material.icons.rounded.Info
|
||||||
import androidx.compose.material.icons.rounded.Language
|
import androidx.compose.material.icons.rounded.Language
|
||||||
|
import androidx.compose.material.icons.rounded.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -47,11 +49,13 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalResources
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -61,11 +65,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.subtracks.BuildConfig
|
import com.subtracks.BuildConfig
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
||||||
@@ -74,7 +84,7 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
|
|||||||
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
||||||
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
||||||
|
|
||||||
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency }
|
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
@@ -91,32 +101,60 @@ fun SettingsRoute(
|
|||||||
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
||||||
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
||||||
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
||||||
|
val syncMode by viewModel.syncMode.collectAsStateWithLifecycle()
|
||||||
|
val syncIntervalMinutes by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle()
|
||||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
|
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
sources = sources,
|
sources = sources,
|
||||||
activeSourceId = activeSourceId,
|
activeSourceId = activeSourceId,
|
||||||
wifiQuality = wifiQuality,
|
wifiQuality = wifiQuality,
|
||||||
mobileQuality = mobileQuality,
|
mobileQuality = mobileQuality,
|
||||||
syncConcurrency = syncConcurrency,
|
syncConcurrency = syncConcurrency,
|
||||||
|
syncMode = syncMode,
|
||||||
|
syncInterval = syncIntervalMinutes,
|
||||||
downloadQuality = downloadQuality,
|
downloadQuality = downloadQuality,
|
||||||
downloadOverMetered = downloadOverMetered,
|
downloadOverMetered = downloadOverMetered,
|
||||||
scrobbling = scrobbling,
|
scrobbling = scrobbling,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
|
verboseLogging = verboseLogging,
|
||||||
onSelectSource = viewModel::selectSource,
|
onSelectSource = viewModel::selectSource,
|
||||||
onEditServer = onEditServer,
|
onEditServer = onEditServer,
|
||||||
onWifiQualityChange = viewModel::setWifiQuality,
|
onWifiQualityChange = viewModel::setWifiQuality,
|
||||||
onMobileQualityChange = viewModel::setMobileQuality,
|
onMobileQualityChange = viewModel::setMobileQuality,
|
||||||
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
||||||
|
onSyncModeChange = viewModel::setSyncMode,
|
||||||
|
onSyncIntervalChange = viewModel::setSyncIntervalMinutes,
|
||||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||||
onScrobblingChange = viewModel::setScrobbling,
|
onScrobblingChange = viewModel::setScrobbling,
|
||||||
onOfflineChange = viewModel::setOfflineMode,
|
onOfflineChange = viewModel::setOfflineMode,
|
||||||
|
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||||
onAddServer = onAddServer,
|
onAddServer = onAddServer,
|
||||||
onOpenDownloads = onOpenDownloads,
|
onOpenDownloads = onOpenDownloads,
|
||||||
onOpenLicenses = onOpenLicenses,
|
onOpenLicenses = onOpenLicenses,
|
||||||
|
onShareLogs = {
|
||||||
|
scope.launch {
|
||||||
|
val result =
|
||||||
|
runCatching {
|
||||||
|
val zip = withContext(Dispatchers.IO) { Log.exportZip() }
|
||||||
|
if (zip != null) Log.share(context, zip)
|
||||||
|
zip
|
||||||
|
}
|
||||||
|
val message =
|
||||||
|
when {
|
||||||
|
result.isFailure -> R.string.settings_about_share_logs_failed
|
||||||
|
result.getOrNull() == null -> R.string.settings_about_share_logs_empty
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
message?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
|
||||||
|
}
|
||||||
|
},
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
dismissals = dismissals,
|
dismissals = dismissals,
|
||||||
)
|
)
|
||||||
@@ -135,18 +173,25 @@ fun SettingsScreen(
|
|||||||
scrobbling: Boolean,
|
scrobbling: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
|
verboseLogging: Boolean = false,
|
||||||
|
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||||
|
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onEditServer: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
onMobileQualityChange: (StreamQuality) -> Unit,
|
onMobileQualityChange: (StreamQuality) -> Unit,
|
||||||
onSyncConcurrencyChange: (Int) -> Unit,
|
onSyncConcurrencyChange: (Int) -> Unit,
|
||||||
|
onSyncModeChange: (SyncMode) -> Unit = {},
|
||||||
|
onSyncIntervalChange: (Int) -> Unit = {},
|
||||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||||
onScrobblingChange: (Boolean) -> Unit,
|
onScrobblingChange: (Boolean) -> Unit,
|
||||||
onOfflineChange: (Boolean) -> Unit = {},
|
onOfflineChange: (Boolean) -> Unit = {},
|
||||||
|
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||||
onAddServer: () -> Unit,
|
onAddServer: () -> Unit,
|
||||||
onOpenDownloads: () -> Unit,
|
onOpenDownloads: () -> Unit,
|
||||||
onOpenLicenses: () -> Unit,
|
onOpenLicenses: () -> Unit,
|
||||||
|
onShareLogs: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
dismissals: Flow<Unit> = emptyFlow(),
|
dismissals: Flow<Unit> = emptyFlow(),
|
||||||
) {
|
) {
|
||||||
@@ -255,6 +300,22 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||||
|
supportingContent = { Text(syncModeLabel(LocalResources.current, syncMode)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SyncMode },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.sync_interval)) },
|
||||||
|
supportingContent = { Text(syncIntervalLabel(LocalResources.current, syncInterval)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SyncInterval },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -321,12 +382,50 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item { SectionHeader(stringResource(R.string.settings_troubleshooting_name)) }
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_about_actions_share_logs)) },
|
||||||
|
leadingContent = { Icon(Icons.Rounded.Share, contentDescription = null) },
|
||||||
|
modifier = Modifier.clickable(onClick = onShareLogs),
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_about_verbose_logging)) },
|
||||||
|
supportingContent = { Text(stringResource(R.string.settings_about_verbose_logging_description)) },
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = verboseLogging,
|
||||||
|
onCheckedChange = onVerboseLoggingChange,
|
||||||
|
colors =
|
||||||
|
SwitchDefaults.colors(
|
||||||
|
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { onVerboseLoggingChange(!verboseLogging) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.settings_about_name)) }
|
item { SectionHeader(stringResource(R.string.settings_about_name)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(stringResource(R.string.app_name)) },
|
headlineContent = { Text(stringResource(R.string.app_name)) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(stringResource(R.string.settings_about_version, BuildConfig.VERSION_NAME))
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(stringResource(R.string.settings_about_version, BuildConfig.VERSION_NAME))
|
||||||
|
if (BuildConfig.COMMIT_HASH.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
BuildConfig.COMMIT_HASH,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) },
|
leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) },
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
@@ -404,6 +503,22 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SyncInterval -> {
|
||||||
|
SyncIntervalDialog(
|
||||||
|
selected = syncInterval,
|
||||||
|
onSelect = onSyncIntervalChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SyncMode -> {
|
||||||
|
SyncModeDialog(
|
||||||
|
selected = syncMode,
|
||||||
|
onSelect = onSyncModeChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
@@ -429,11 +544,56 @@ private fun bitrateLabel(
|
|||||||
|
|
||||||
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
|
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
|
||||||
|
|
||||||
|
private val syncIntervalOptions = listOf(15, 60, 360, 720, DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||||
|
|
||||||
private fun concurrencyLabel(
|
private fun concurrencyLabel(
|
||||||
resources: Resources,
|
resources: Resources,
|
||||||
value: Int,
|
value: Int,
|
||||||
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
|
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
|
||||||
|
|
||||||
|
private fun syncIntervalLabel(
|
||||||
|
resources: Resources,
|
||||||
|
minutes: Int,
|
||||||
|
): String =
|
||||||
|
if (minutes >= 60 && minutes % 60 == 0) {
|
||||||
|
resources.getQuantityString(R.plurals.sync_interval_hours, minutes / 60, minutes / 60)
|
||||||
|
} else {
|
||||||
|
resources.getQuantityString(R.plurals.sync_interval_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SyncIntervalDialog(
|
||||||
|
selected: Int,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableIntStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.sync_interval)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.sync_interval_prompt),
|
||||||
|
options = syncIntervalOptions.map { it to syncIntervalLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConcurrencyDialog(
|
private fun ConcurrencyDialog(
|
||||||
selected: Int,
|
selected: Int,
|
||||||
@@ -467,6 +627,51 @@ private fun ConcurrencyDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val syncModeOptions = SyncMode.entries
|
||||||
|
|
||||||
|
private fun syncModeLabel(
|
||||||
|
resources: Resources,
|
||||||
|
mode: SyncMode,
|
||||||
|
): String =
|
||||||
|
when (mode) {
|
||||||
|
SyncMode.Off -> resources.getString(R.string.sync_mode_off)
|
||||||
|
SyncMode.WifiOnly -> resources.getString(R.string.sync_mode_wifi)
|
||||||
|
SyncMode.Always -> resources.getString(R.string.sync_mode_always)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SyncModeDialog(
|
||||||
|
selected: SyncMode,
|
||||||
|
onSelect: (SyncMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.sync_mode_prompt),
|
||||||
|
options = syncModeOptions.map { it to syncModeLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QualityDialog(
|
private fun QualityDialog(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||||
@@ -48,6 +50,16 @@ class SettingsViewModel(
|
|||||||
.syncConcurrency()
|
.syncConcurrency()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
||||||
|
|
||||||
|
val syncMode: StateFlow<SyncMode> =
|
||||||
|
userPreferences
|
||||||
|
.syncMode()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncMode.WifiOnly)
|
||||||
|
|
||||||
|
val syncIntervalMinutes: StateFlow<Int> =
|
||||||
|
userPreferences
|
||||||
|
.syncIntervalMinutes()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||||
|
|
||||||
val scrobbling: StateFlow<Boolean> =
|
val scrobbling: StateFlow<Boolean> =
|
||||||
userPreferences
|
userPreferences
|
||||||
.scrobbling()
|
.scrobbling()
|
||||||
@@ -55,10 +67,19 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
|
val verboseLogging: StateFlow<Boolean> =
|
||||||
|
userPreferences
|
||||||
|
.verboseLogging()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
sourceRepository.setOfflineMode(enabled)
|
sourceRepository.setOfflineMode(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setVerboseLogging(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { userPreferences.setVerboseLogging(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
fun selectSource(id: Long) {
|
fun selectSource(id: Long) {
|
||||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||||
}
|
}
|
||||||
@@ -83,6 +104,14 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSyncMode(mode: SyncMode) {
|
||||||
|
viewModelScope.launch { userPreferences.setSyncMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSyncIntervalMinutes(value: Int) {
|
||||||
|
viewModelScope.launch { userPreferences.setSyncIntervalMinutes(value) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setScrobbling(enabled: Boolean) {
|
fun setScrobbling(enabled: Boolean) {
|
||||||
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Pesquisar</string>
|
<string name="search_input_placeholder">Pesquisar</string>
|
||||||
<string name="settings_about_actions_licenses">Licenças</string>
|
<string name="settings_about_actions_licenses">Licenças</string>
|
||||||
<string name="settings_about_actions_project_homepage">Página do projeto</string>
|
<string name="settings_about_actions_project_homepage">Página do projeto</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Compartilhar logs</string>
|
||||||
<string name="settings_about_actions_support">Apoie o desenvolvedor</string>
|
<string name="settings_about_actions_support">Apoie o desenvolvedor</string>
|
||||||
<string name="settings_about_name">Sobre</string>
|
<string name="settings_about_name">Sobre</string>
|
||||||
<string name="settings_about_version">Versão %1$s</string>
|
<string name="settings_about_version">Versão %1$s</string>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<string name="search_input_placeholder">搜索</string>
|
<string name="search_input_placeholder">搜索</string>
|
||||||
<string name="settings_about_actions_licenses">许可</string>
|
<string name="settings_about_actions_licenses">许可</string>
|
||||||
<string name="settings_about_actions_project_homepage">项目地址</string>
|
<string name="settings_about_actions_project_homepage">项目地址</string>
|
||||||
|
<string name="settings_about_actions_share_logs">分享日志</string>
|
||||||
<string name="settings_about_actions_support">支持开发者</string>
|
<string name="settings_about_actions_support">支持开发者</string>
|
||||||
<string name="settings_about_name">关于</string>
|
<string name="settings_about_name">关于</string>
|
||||||
<string name="settings_about_version">版本 %1$s</string>
|
<string name="settings_about_version">版本 %1$s</string>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<string name="search_input_placeholder">搜尋</string>
|
<string name="search_input_placeholder">搜尋</string>
|
||||||
<string name="settings_about_actions_licenses">許可</string>
|
<string name="settings_about_actions_licenses">許可</string>
|
||||||
<string name="settings_about_actions_project_homepage">專案首頁</string>
|
<string name="settings_about_actions_project_homepage">專案首頁</string>
|
||||||
|
<string name="settings_about_actions_share_logs">分享日誌</string>
|
||||||
<string name="settings_about_actions_support">支援開發者</string>
|
<string name="settings_about_actions_support">支援開發者</string>
|
||||||
<string name="settings_about_name">關於</string>
|
<string name="settings_about_name">關於</string>
|
||||||
<string name="settings_about_version">版本 %1$s</string>
|
<string name="settings_about_version">版本 %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Suche</string>
|
<string name="search_input_placeholder">Suche</string>
|
||||||
<string name="settings_about_actions_licenses">Lizenzen</string>
|
<string name="settings_about_actions_licenses">Lizenzen</string>
|
||||||
<string name="settings_about_actions_project_homepage">Projektseite</string>
|
<string name="settings_about_actions_project_homepage">Projektseite</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Protokolle teilen</string>
|
||||||
<string name="settings_about_actions_support">Den Entwickler unterstützen</string>
|
<string name="settings_about_actions_support">Den Entwickler unterstützen</string>
|
||||||
<string name="settings_about_name">Über</string>
|
<string name="settings_about_name">Über</string>
|
||||||
<string name="settings_about_version">Version %1$s</string>
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Buscar</string>
|
<string name="search_input_placeholder">Buscar</string>
|
||||||
<string name="settings_about_actions_licenses">Licencias</string>
|
<string name="settings_about_actions_licenses">Licencias</string>
|
||||||
<string name="settings_about_actions_project_homepage">Página de inicio del proyecto</string>
|
<string name="settings_about_actions_project_homepage">Página de inicio del proyecto</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Compartir registros</string>
|
||||||
<string name="settings_about_actions_support">Apoya al desarrollador</string>
|
<string name="settings_about_actions_support">Apoya al desarrollador</string>
|
||||||
<string name="settings_about_name">Información</string>
|
<string name="settings_about_name">Información</string>
|
||||||
<string name="settings_about_version">versión %1$s</string>
|
<string name="settings_about_version">versión %1$s</string>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<string name="search_input_placeholder">Bilatu</string>
|
<string name="search_input_placeholder">Bilatu</string>
|
||||||
<string name="settings_about_actions_licenses">Lizentziak</string>
|
<string name="settings_about_actions_licenses">Lizentziak</string>
|
||||||
<string name="settings_about_actions_project_homepage">Proiektuaren webgunea</string>
|
<string name="settings_about_actions_project_homepage">Proiektuaren webgunea</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Partekatu erregistroak</string>
|
||||||
<string name="settings_about_actions_support">Lagundu garatzaileari</string>
|
<string name="settings_about_actions_support">Lagundu garatzaileari</string>
|
||||||
<string name="settings_about_name">Honi buruz</string>
|
<string name="settings_about_name">Honi buruz</string>
|
||||||
<string name="settings_about_version">%1$s bertsioa</string>
|
<string name="settings_about_version">%1$s bertsioa</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Recherche</string>
|
<string name="search_input_placeholder">Recherche</string>
|
||||||
<string name="settings_about_actions_licenses">Licences</string>
|
<string name="settings_about_actions_licenses">Licences</string>
|
||||||
<string name="settings_about_actions_project_homepage">Page d\'accueil du projet</string>
|
<string name="settings_about_actions_project_homepage">Page d\'accueil du projet</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Partager les logs</string>
|
||||||
<string name="settings_about_actions_support">Soutenir le développeur</string>
|
<string name="settings_about_actions_support">Soutenir le développeur</string>
|
||||||
<string name="settings_about_name">À propos</string>
|
<string name="settings_about_name">À propos</string>
|
||||||
<string name="settings_about_version">version %1$s</string>
|
<string name="settings_about_version">version %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Buscar</string>
|
<string name="search_input_placeholder">Buscar</string>
|
||||||
<string name="settings_about_actions_licenses">Licenzas</string>
|
<string name="settings_about_actions_licenses">Licenzas</string>
|
||||||
<string name="settings_about_actions_project_homepage">Web do Proxecto</string>
|
<string name="settings_about_actions_project_homepage">Web do Proxecto</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Compartir rexistros</string>
|
||||||
<string name="settings_about_actions_support">Axuda ao desenvolvemento</string>
|
<string name="settings_about_actions_support">Axuda ao desenvolvemento</string>
|
||||||
<string name="settings_about_name">Acerca de</string>
|
<string name="settings_about_name">Acerca de</string>
|
||||||
<string name="settings_about_version">versión %1$s</string>
|
<string name="settings_about_version">versión %1$s</string>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<string name="search_input_placeholder">Keresés</string>
|
<string name="search_input_placeholder">Keresés</string>
|
||||||
<string name="settings_about_actions_licenses">Licenszek</string>
|
<string name="settings_about_actions_licenses">Licenszek</string>
|
||||||
<string name="settings_about_actions_project_homepage">Projekt weboldal</string>
|
<string name="settings_about_actions_project_homepage">Projekt weboldal</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Logfájlok megosztása</string>
|
||||||
<string name="settings_about_actions_support">Támogasd a fejlesztőt</string>
|
<string name="settings_about_actions_support">Támogasd a fejlesztőt</string>
|
||||||
<string name="settings_about_name">Az alkalmazásról</string>
|
<string name="settings_about_name">Az alkalmazásról</string>
|
||||||
<string name="settings_about_version">%1$s verzió</string>
|
<string name="settings_about_version">%1$s verzió</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Ricerca</string>
|
<string name="search_input_placeholder">Ricerca</string>
|
||||||
<string name="settings_about_actions_licenses">Licenze</string>
|
<string name="settings_about_actions_licenses">Licenze</string>
|
||||||
<string name="settings_about_actions_project_homepage">Pagina principale del progetto</string>
|
<string name="settings_about_actions_project_homepage">Pagina principale del progetto</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Condividi i log</string>
|
||||||
<string name="settings_about_actions_support">Sostieni lo sviluppatore</string>
|
<string name="settings_about_actions_support">Sostieni lo sviluppatore</string>
|
||||||
<string name="settings_about_name">Informazioni</string>
|
<string name="settings_about_name">Informazioni</string>
|
||||||
<string name="settings_about_version">versione %1$s</string>
|
<string name="settings_about_version">versione %1$s</string>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<string name="search_input_placeholder">검색</string>
|
<string name="search_input_placeholder">검색</string>
|
||||||
<string name="settings_about_actions_licenses">저작권</string>
|
<string name="settings_about_actions_licenses">저작권</string>
|
||||||
<string name="settings_about_actions_project_homepage">프로젝트 홈페이지</string>
|
<string name="settings_about_actions_project_homepage">프로젝트 홈페이지</string>
|
||||||
|
<string name="settings_about_actions_share_logs">로그 공유</string>
|
||||||
<string name="settings_about_actions_support">개발자 지원하기</string>
|
<string name="settings_about_actions_support">개발자 지원하기</string>
|
||||||
<string name="settings_about_name">subtracks에 대하여</string>
|
<string name="settings_about_name">subtracks에 대하여</string>
|
||||||
<string name="settings_about_version">버전: %1$s</string>
|
<string name="settings_about_version">버전: %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Søk</string>
|
<string name="search_input_placeholder">Søk</string>
|
||||||
<string name="settings_about_actions_licenses">Lisenser</string>
|
<string name="settings_about_actions_licenses">Lisenser</string>
|
||||||
<string name="settings_about_actions_project_homepage">Prosjekthjemmeside</string>
|
<string name="settings_about_actions_project_homepage">Prosjekthjemmeside</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Dele logger</string>
|
||||||
<string name="settings_about_actions_support">Støtt utvikleren</string>
|
<string name="settings_about_actions_support">Støtt utvikleren</string>
|
||||||
<string name="settings_about_name">Om</string>
|
<string name="settings_about_name">Om</string>
|
||||||
<string name="settings_about_version">versjon %1$s</string>
|
<string name="settings_about_version">versjon %1$s</string>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<string name="search_input_placeholder">Поиск</string>
|
<string name="search_input_placeholder">Поиск</string>
|
||||||
<string name="settings_about_actions_licenses">Лицензии</string>
|
<string name="settings_about_actions_licenses">Лицензии</string>
|
||||||
<string name="settings_about_actions_project_homepage">Сайт проекта</string>
|
<string name="settings_about_actions_project_homepage">Сайт проекта</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Поделиться журналами</string>
|
||||||
<string name="settings_about_actions_support">Поддержать разработчика</string>
|
<string name="settings_about_actions_support">Поддержать разработчика</string>
|
||||||
<string name="settings_about_name">О Subtracks</string>
|
<string name="settings_about_name">О Subtracks</string>
|
||||||
<string name="settings_about_version">версия %1$s</string>
|
<string name="settings_about_version">версия %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Sök</string>
|
<string name="search_input_placeholder">Sök</string>
|
||||||
<string name="settings_about_actions_licenses">Licenser</string>
|
<string name="settings_about_actions_licenses">Licenser</string>
|
||||||
<string name="settings_about_actions_project_homepage">Projekthemsida</string>
|
<string name="settings_about_actions_project_homepage">Projekthemsida</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Dela loggarna</string>
|
||||||
<string name="settings_about_actions_support">Stöd utvecklaren</string>
|
<string name="settings_about_actions_support">Stöd utvecklaren</string>
|
||||||
<string name="settings_about_name">Om</string>
|
<string name="settings_about_name">Om</string>
|
||||||
<string name="settings_about_version">version %1$s</string>
|
<string name="settings_about_version">version %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">தேடல்</string>
|
<string name="search_input_placeholder">தேடல்</string>
|
||||||
<string name="settings_about_actions_licenses">உரிமங்கள்</string>
|
<string name="settings_about_actions_licenses">உரிமங்கள்</string>
|
||||||
<string name="settings_about_actions_project_homepage">திட்ட முகப்புப்பக்கம்</string>
|
<string name="settings_about_actions_project_homepage">திட்ட முகப்புப்பக்கம்</string>
|
||||||
|
<string name="settings_about_actions_share_logs">பதிவுகளைப் பகிரவும்</string>
|
||||||
<string name="settings_about_actions_support">டெவலப்பரை ஆதரிக்கவும்</string>
|
<string name="settings_about_actions_support">டெவலப்பரை ஆதரிக்கவும்</string>
|
||||||
<string name="settings_about_name">பற்றி</string>
|
<string name="settings_about_name">பற்றி</string>
|
||||||
<string name="settings_about_version">பதிப்பு %1$s</string>
|
<string name="settings_about_version">பதிப்பு %1$s</string>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<string name="search_input_placeholder">ค้นหา</string>
|
<string name="search_input_placeholder">ค้นหา</string>
|
||||||
<string name="settings_about_actions_licenses">ใบอนุญาต</string>
|
<string name="settings_about_actions_licenses">ใบอนุญาต</string>
|
||||||
<string name="settings_about_actions_project_homepage">โฮมเพจของโครงการ</string>
|
<string name="settings_about_actions_project_homepage">โฮมเพจของโครงการ</string>
|
||||||
|
<string name="settings_about_actions_share_logs">แบ่งปันล็อกไฟล์</string>
|
||||||
<string name="settings_about_actions_support">ให้การสนับสนุนผู้พัฒนา</string>
|
<string name="settings_about_actions_support">ให้การสนับสนุนผู้พัฒนา</string>
|
||||||
<string name="settings_about_name">เกี่ยวกับ</string>
|
<string name="settings_about_name">เกี่ยวกับ</string>
|
||||||
<string name="settings_about_version">เวอร์ชั่น %1$s</string>
|
<string name="settings_about_version">เวอร์ชั่น %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Ara</string>
|
<string name="search_input_placeholder">Ara</string>
|
||||||
<string name="settings_about_actions_licenses">Lisanslar</string>
|
<string name="settings_about_actions_licenses">Lisanslar</string>
|
||||||
<string name="settings_about_actions_project_homepage">Proje Ana Sayfası</string>
|
<string name="settings_about_actions_project_homepage">Proje Ana Sayfası</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Günlükleri paylaş</string>
|
||||||
<string name="settings_about_actions_support">Geliştiriciyi destekleyin</string>
|
<string name="settings_about_actions_support">Geliştiriciyi destekleyin</string>
|
||||||
<string name="settings_about_name">Hakkında</string>
|
<string name="settings_about_name">Hakkında</string>
|
||||||
<string name="settings_about_version">sürüm %1$s</string>
|
<string name="settings_about_version">sürüm %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Введіть заголовок/опис</string>
|
<string name="search_input_placeholder">Введіть заголовок/опис</string>
|
||||||
<string name="settings_about_actions_licenses">Ліцензії</string>
|
<string name="settings_about_actions_licenses">Ліцензії</string>
|
||||||
<string name="settings_about_actions_project_homepage">Головна сторінка проєкту</string>
|
<string name="settings_about_actions_project_homepage">Головна сторінка проєкту</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Поділитися журналами</string>
|
||||||
<string name="settings_about_actions_support">Підтримайте розробника</string>
|
<string name="settings_about_actions_support">Підтримайте розробника</string>
|
||||||
<string name="settings_about_name">Про застосунок</string>
|
<string name="settings_about_name">Про застосунок</string>
|
||||||
<string name="settings_about_version">версія %1$s</string>
|
<string name="settings_about_version">версія %1$s</string>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<string name="search_input_placeholder">Tìm kiếm</string>
|
<string name="search_input_placeholder">Tìm kiếm</string>
|
||||||
<string name="settings_about_actions_licenses">Bản quyền</string>
|
<string name="settings_about_actions_licenses">Bản quyền</string>
|
||||||
<string name="settings_about_actions_project_homepage">Trang chủ dự án</string>
|
<string name="settings_about_actions_project_homepage">Trang chủ dự án</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Chia sẻ log</string>
|
||||||
<string name="settings_about_actions_support">Hỗ trợ nhà phát triển</string>
|
<string name="settings_about_actions_support">Hỗ trợ nhà phát triển</string>
|
||||||
<string name="settings_about_name">Về ứng dụng</string>
|
<string name="settings_about_name">Về ứng dụng</string>
|
||||||
<string name="settings_about_version">phiên bản %1$s</string>
|
<string name="settings_about_version">phiên bản %1$s</string>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<string name="app_name">subtracks</string>
|
<string name="app_name">subtracks</string>
|
||||||
<string name="download_channel_description">Progress while songs download</string>
|
<string name="download_channel_description">Progress while songs download</string>
|
||||||
|
<string name="sync_notification_channel">Sync</string>
|
||||||
|
<string name="sync_notification_title">Syncing library</string>
|
||||||
|
<string name="sync_notification_text">Keeping your library up to date</string>
|
||||||
|
|
||||||
<string name="actions_cancel">Cancel</string>
|
<string name="actions_cancel">Cancel</string>
|
||||||
<string name="actions_delete">Delete</string>
|
<string name="actions_delete">Delete</string>
|
||||||
@@ -20,6 +23,7 @@
|
|||||||
<string name="controls_more">More</string>
|
<string name="controls_more">More</string>
|
||||||
|
|
||||||
<string name="navigation_back">Back</string>
|
<string name="navigation_back">Back</string>
|
||||||
|
<string name="scroll_to_top">Scroll to top</string>
|
||||||
<string name="navigation_tabs_home">Home</string>
|
<string name="navigation_tabs_home">Home</string>
|
||||||
<string name="navigation_tabs_library">Library</string>
|
<string name="navigation_tabs_library">Library</string>
|
||||||
<string name="navigation_tabs_settings">Settings</string>
|
<string name="navigation_tabs_settings">Settings</string>
|
||||||
@@ -57,8 +61,15 @@
|
|||||||
|
|
||||||
<string name="settings_about_actions_licenses">Licenses</string>
|
<string name="settings_about_actions_licenses">Licenses</string>
|
||||||
<string name="settings_about_actions_project_homepage">Project homepage</string>
|
<string name="settings_about_actions_project_homepage">Project homepage</string>
|
||||||
|
<string name="settings_about_actions_share_logs">Share logs</string>
|
||||||
<string name="settings_about_actions_support">Support the developer</string>
|
<string name="settings_about_actions_support">Support the developer</string>
|
||||||
<string name="settings_about_name">About</string>
|
<string name="settings_about_name">About</string>
|
||||||
|
<string name="settings_troubleshooting_name">Troubleshooting</string>
|
||||||
|
<string name="settings_about_share_logs_empty">No logs to send yet</string>
|
||||||
|
<string name="settings_about_share_logs_failed">Couldn\'t prepare the logs</string>
|
||||||
|
<string name="settings_about_share_logs_subject">Subtracks logs</string>
|
||||||
|
<string name="settings_about_verbose_logging">Verbose logging</string>
|
||||||
|
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</string>
|
||||||
<string name="settings_about_version">Version %1$s</string>
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
<string name="settings_music_name">Playback</string>
|
<string name="settings_music_name">Playback</string>
|
||||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||||
@@ -193,6 +204,21 @@
|
|||||||
<string name="sync_concurrency">Sync concurrency</string>
|
<string name="sync_concurrency">Sync concurrency</string>
|
||||||
<string name="sync_concurrency_parallel">Parallel server requests while syncing</string>
|
<string name="sync_concurrency_parallel">Parallel server requests while syncing</string>
|
||||||
<string name="sync_concurrency_sequential">1 (sequential)</string>
|
<string name="sync_concurrency_sequential">1 (sequential)</string>
|
||||||
|
<string name="settings_sync_auto_title">Sync automatically</string>
|
||||||
|
<string name="sync_mode_off">Off</string>
|
||||||
|
<string name="sync_mode_wifi">Wi-Fi only</string>
|
||||||
|
<string name="sync_mode_always">Always</string>
|
||||||
|
<string name="sync_mode_prompt">When to sync automatically</string>
|
||||||
|
<string name="sync_interval">Automatic sync interval</string>
|
||||||
|
<string name="sync_interval_prompt">How often to sync automatically</string>
|
||||||
|
<plurals name="sync_interval_minutes">
|
||||||
|
<item quantity="one">%1$d minute</item>
|
||||||
|
<item quantity="other">%1$d minutes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="sync_interval_hours">
|
||||||
|
<item quantity="one">%1$d hour</item>
|
||||||
|
<item quantity="other">%1$d hours</item>
|
||||||
|
</plurals>
|
||||||
<string name="download_quality">Download quality</string>
|
<string name="download_quality">Download quality</string>
|
||||||
<string name="download_over_mobile">Download over mobile data</string>
|
<string name="download_over_mobile">Download over mobile data</string>
|
||||||
<string name="download_network_wifi_and_mobile">Wi-Fi and mobile</string>
|
<string name="download_network_wifi_and_mobile">Wi-Fi and mobile</string>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="log_dumps" path="log_dumps/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="299.84"
|
||||||
|
android:viewportHeight="219.51">
|
||||||
|
<group android:scaleX="0.43"
|
||||||
|
android:scaleY="0.3147989"
|
||||||
|
android:translateX="85.4544"
|
||||||
|
android:translateY="75.20425">
|
||||||
|
<path
|
||||||
|
android:pathData="m23.84,-0c-12.76,0 -23.44,13 -23.65,25.12 -0.42,79.03 0,160.73 0,170.4 0,12.59 9.8,23.99 23.41,23.99l253.58,0c11.4,0 22.66,-12.38 22.66,-22.79L299.84,24.12c0,-10.8 -10.24,-23.81 -21.84,-23.81 -11.6,0 -254.16,-0.31 -254.16,-0.31zM39.05,25.28c0,0 217.71,0.71 223.67,0.71 6.07,0 12.17,6.83 12.17,12.06l0,142.25c0,8.97 -6.97,14.63 -12.48,14.64l-33.42,0.07c-5.32,0.01 -9.93,-7.19 -11.35,-10.61 -1.89,-4.55 -11.08,-25.75 -12.1,-28.1 -0.6,-1.38 -2.9,-2.83 -5.89,-2.83L98.33,153.45c-2.68,0 -4.89,2.87 -5.67,4.71 -1.12,2.66 -9.02,21.39 -11.63,27.57 -2.73,6.46 -4.37,9.06 -11.47,9.06l-30.97,0c-7.44,0 -13.69,-7.17 -13.67,-15.18l0.28,-139.51c0,-6.88 5.54,-14.83 13.85,-14.83z"
|
||||||
|
android:strokeWidth="0.9"
|
||||||
|
android:fillColor="#FFB300"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:strokeColor="#00000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M90.09,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
|
||||||
|
android:strokeWidth="0.86"
|
||||||
|
android:fillColor="#FFB300"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:strokeColor="#00000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M209.94,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
|
||||||
|
android:strokeWidth="0.86"
|
||||||
|
android:fillColor="#FFB300"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:strokeColor="#00000000"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 827 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1A1200</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.subtracks
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class UserAgentTest {
|
||||||
|
@Test
|
||||||
|
fun theSharedClientSendsTheSubtracksUserAgent() {
|
||||||
|
val server = MockWebServer()
|
||||||
|
server.enqueue(MockResponse())
|
||||||
|
server.start()
|
||||||
|
|
||||||
|
UserAgent
|
||||||
|
.httpClient()
|
||||||
|
.newCall(Request.Builder().url(server.url("/")).build())
|
||||||
|
.execute()
|
||||||
|
.close()
|
||||||
|
val header = server.takeRequest().getHeader("User-Agent")
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
assertEquals("subtracks (Android ${Build.VERSION.RELEASE}; ${BuildConfig.VERSION_NAME})", header)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.subtracks.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LegacyDataCleanupTest {
|
||||||
|
@get:Rule
|
||||||
|
val tmp = TemporaryFolder()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removesThe1xLeftoversAndLeavesTheNativeData() {
|
||||||
|
val dataDir = tmp.newFolder("data")
|
||||||
|
val external = tmp.newFolder("external")
|
||||||
|
|
||||||
|
val legacyDatabase = file(dataDir, "databases/RKStorage")
|
||||||
|
val legacyDatabaseWal = file(dataDir, "databases/RKStorage-wal")
|
||||||
|
val legacyServers = file(dataDir, "files/servers/0.json")
|
||||||
|
val legacyCache = file(external, "s/0/coverArt/cover")
|
||||||
|
val nativeDatabase = file(dataDir, "databases/subtracks.db")
|
||||||
|
val nativeDownload = file(external, "Music/downloads/song.mp3")
|
||||||
|
|
||||||
|
LegacyDataCleanup.run(dataDir, external)
|
||||||
|
|
||||||
|
assertFalse(legacyDatabase.exists())
|
||||||
|
assertFalse(legacyDatabaseWal.exists())
|
||||||
|
assertFalse(legacyServers.exists())
|
||||||
|
assertFalse(legacyCache.exists())
|
||||||
|
assertTrue(nativeDatabase.exists())
|
||||||
|
assertTrue(nativeDownload.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesTheNativeDataAloneOnAFreshInstall() {
|
||||||
|
val dataDir = tmp.newFolder("data2")
|
||||||
|
val external = tmp.newFolder("external2")
|
||||||
|
val nativeDatabase = file(dataDir, "databases/subtracks.db")
|
||||||
|
val nativeDownload = file(external, "Music/downloads/song.mp3")
|
||||||
|
|
||||||
|
LegacyDataCleanup.run(dataDir, external)
|
||||||
|
|
||||||
|
assertTrue(nativeDatabase.exists())
|
||||||
|
assertTrue(nativeDownload.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cleansUpEvenWhenExternalStorageIsUnavailable() {
|
||||||
|
val dataDir = tmp.newFolder("data3")
|
||||||
|
val legacyDatabase = file(dataDir, "databases/RKStorage")
|
||||||
|
|
||||||
|
LegacyDataCleanup.run(dataDir, null)
|
||||||
|
|
||||||
|
assertFalse(legacyDatabase.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun file(
|
||||||
|
dir: File,
|
||||||
|
path: String,
|
||||||
|
): File =
|
||||||
|
File(dir, path).apply {
|
||||||
|
parentFile?.mkdirs()
|
||||||
|
createNewFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.migration.Migration
|
||||||
|
import androidx.sqlite.SQLiteConnection
|
||||||
|
import androidx.sqlite.SQLiteDriver
|
||||||
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import androidx.sqlite.execSQL
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class DbLoggingTest {
|
||||||
|
private val executions = mutableListOf<SqlExecution>()
|
||||||
|
|
||||||
|
private fun driver() = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forwardsConnectionPoolFlag() {
|
||||||
|
assertTrue(LoggingSQLiteDriver(FakeDriver(hasConnectionPool = true)) {}.hasConnectionPool)
|
||||||
|
assertFalse(LoggingSQLiteDriver(FakeDriver(hasConnectionPool = false)) {}.hasConnectionPool)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recordsOneExecutionPerBatchedWriteRow() {
|
||||||
|
driver().open(":memory:").use { connection ->
|
||||||
|
connection.prepare("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)").use { it.step() }
|
||||||
|
connection.prepare("INSERT INTO t (name) VALUES (?)").use { statement ->
|
||||||
|
repeat(3) { index ->
|
||||||
|
statement.bindText(1, "row$index")
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
statement.clearBindings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(3, executions.count { it.sql.startsWith("INSERT") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recordsSuccessfulSelect() {
|
||||||
|
driver().open(":memory:").use { connection ->
|
||||||
|
connection.prepare("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)").use { it.step() }
|
||||||
|
connection.prepare("INSERT INTO t (name) VALUES (?)").use {
|
||||||
|
it.bindText(1, "a")
|
||||||
|
it.step()
|
||||||
|
}
|
||||||
|
connection.prepare("SELECT * FROM t").use { statement ->
|
||||||
|
while (statement.step()) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val select = executions.last { it.sql.startsWith("SELECT") }
|
||||||
|
assertNull(select.error)
|
||||||
|
assertEquals(1L, select.rows)
|
||||||
|
assertTrue(select.durationNanos > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotCountIdleTimeBetweenReuses() {
|
||||||
|
driver().open(":memory:").use { connection ->
|
||||||
|
connection.prepare("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)").use { it.step() }
|
||||||
|
connection.prepare("INSERT INTO t (name) VALUES (?)").use { statement ->
|
||||||
|
statement.bindText(1, "a")
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
Thread.sleep(250)
|
||||||
|
statement.bindText(1, "b")
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val inserts = executions.filter { it.sql.startsWith("INSERT") }
|
||||||
|
assertEquals(2, inserts.size)
|
||||||
|
inserts.forEach { assertTrue("${it.durationMillis}ms", it.durationNanos < 100_000_000) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recordsPrepareError() {
|
||||||
|
driver().open(":memory:").use { connection ->
|
||||||
|
runCatching { connection.prepare("THIS IS NOT SQL") }
|
||||||
|
}
|
||||||
|
val failed = executions.single()
|
||||||
|
assertNotNull(failed.error)
|
||||||
|
assertEquals("THIS IS NOT SQL", failed.sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recordsStepError() {
|
||||||
|
driver().open(":memory:").use { connection ->
|
||||||
|
connection.prepare("CREATE TABLE t (id INTEGER PRIMARY KEY)").use { it.step() }
|
||||||
|
connection.prepare("INSERT INTO t (id) VALUES (1)").use { it.step() }
|
||||||
|
runCatching {
|
||||||
|
connection.prepare("INSERT INTO t (id) VALUES (1)").use { it.step() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val failed = executions.last { it.error != null }
|
||||||
|
assertTrue(failed.sql.startsWith("INSERT"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loggedMigrationRunsDelegate() =
|
||||||
|
runTest {
|
||||||
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) {}
|
||||||
|
val migration =
|
||||||
|
LoggedMigration(
|
||||||
|
object : Migration(1, 2) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("CREATE TABLE m (id INTEGER)")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
driver.open(":memory:").use { connection ->
|
||||||
|
migration.migrate(connection)
|
||||||
|
connection.prepare("SELECT count(*) FROM m").use { statement ->
|
||||||
|
statement.step()
|
||||||
|
assertEquals(0L, statement.getLong(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loggedMigrationRethrows() =
|
||||||
|
runTest {
|
||||||
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) {}
|
||||||
|
val migration =
|
||||||
|
LoggedMigration(
|
||||||
|
object : Migration(1, 2) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
error("nope")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
driver.open(":memory:").use { connection ->
|
||||||
|
assertTrue(runCatching { migration.migrate(connection) }.isFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeDriver(
|
||||||
|
override val hasConnectionPool: Boolean,
|
||||||
|
) : SQLiteDriver {
|
||||||
|
override fun open(fileName: String): SQLiteConnection = error("open should not be called")
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.room3.Room
|
||||||
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.PlaylistSong
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.source.MusicSource
|
||||||
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.data.sync.SyncService
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Collections
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class QueryTimingMeasurementTest {
|
||||||
|
private val executions: MutableList<SqlExecution> = Collections.synchronizedList(mutableListOf())
|
||||||
|
private val timings = linkedMapOf<String, MutableList<Double>>()
|
||||||
|
private lateinit var db: SubtracksDatabase
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = db.close()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun measure() =
|
||||||
|
runTest {
|
||||||
|
val songCount = System.getProperty("measure.songs")?.toIntOrNull() ?: 20_000
|
||||||
|
val items = items(songCount)
|
||||||
|
db.sourcesDao().upsertSource(
|
||||||
|
Source(id = 1, name = "bench", address = "http://localhost", isActive = true, createdAt = 0),
|
||||||
|
)
|
||||||
|
val source = FakeSource(items)
|
||||||
|
|
||||||
|
executions.clear()
|
||||||
|
val firstSyncMs = wall { SyncService(db, source).sync() }
|
||||||
|
val firstSyncExecutions = executions.toList()
|
||||||
|
|
||||||
|
executions.clear()
|
||||||
|
val secondSyncMs = wall { SyncService(db, source).sync() }
|
||||||
|
val secondSyncExecutions = executions.toList()
|
||||||
|
|
||||||
|
executions.clear()
|
||||||
|
measureReads()
|
||||||
|
val readExecutions = executions.toList()
|
||||||
|
|
||||||
|
val report =
|
||||||
|
buildReport(
|
||||||
|
songCount = songCount,
|
||||||
|
firstSyncMs = firstSyncMs,
|
||||||
|
secondSyncMs = secondSyncMs,
|
||||||
|
firstSyncExecutions = firstSyncExecutions,
|
||||||
|
secondSyncExecutions = secondSyncExecutions,
|
||||||
|
readExecutions = readExecutions,
|
||||||
|
)
|
||||||
|
val out = File("build/query-timing.txt")
|
||||||
|
out.parentFile?.mkdirs()
|
||||||
|
out.writeText(report)
|
||||||
|
print(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun measureReads() {
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
timed("albums first page (name)") { dao.albumsByName(1, 0, "").firstPage() }
|
||||||
|
timed("albums first page (artist)") { dao.albumsByArtist(1, 0, "").firstPage() }
|
||||||
|
timed("albums first page (year)") { dao.albumsByYear(1, 0, "").firstPage() }
|
||||||
|
timed("albums first page (added)") { dao.albumsByRecentlyAdded(1, 0, "").firstPage() }
|
||||||
|
timed("albums first page (starred)") { dao.albumsByStarred(1, 0, "").firstPage() }
|
||||||
|
timed("albums first page (frequent)") { dao.albumsByFrequent(1, 0, "").firstPage() }
|
||||||
|
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
|
||||||
|
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
|
||||||
|
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
|
||||||
|
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
|
||||||
|
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
|
||||||
|
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
|
||||||
|
timed("albums search short") { dao.albumsByName(1, 0, "al").firstPage() }
|
||||||
|
timed("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
|
||||||
|
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
|
||||||
|
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
|
||||||
|
timed("home recently added") { dao.recentlyAddedAlbums(1, 20).first() }
|
||||||
|
timed("home recently starred") { dao.recentlyStarredSongs(1, 20).first() }
|
||||||
|
timed("home genres") { dao.genresByMostPlayed(1).first() }
|
||||||
|
timed("home decades") { dao.decades(1).first() }
|
||||||
|
timed("recompute play data") { dao.recomputePlayData(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T : Any> PagingSource<Int, T>.firstPage(): List<T> =
|
||||||
|
(
|
||||||
|
load(
|
||||||
|
PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false),
|
||||||
|
) as PagingSource.LoadResult.Page
|
||||||
|
).data
|
||||||
|
|
||||||
|
private suspend fun <T : Any> PagingSource<Int, T>.deepPage(offset: Int): List<T> =
|
||||||
|
(
|
||||||
|
load(
|
||||||
|
PagingSource.LoadParams.Append(key = offset, loadSize = 100, placeholdersEnabled = false),
|
||||||
|
) as PagingSource.LoadResult.Page
|
||||||
|
).data
|
||||||
|
|
||||||
|
private suspend fun <T> timed(
|
||||||
|
name: String,
|
||||||
|
iterations: Int = 10,
|
||||||
|
block: suspend () -> T,
|
||||||
|
) {
|
||||||
|
runCatching { block() }
|
||||||
|
val samples = timings.getOrPut(name) { mutableListOf() }
|
||||||
|
repeat(iterations) {
|
||||||
|
val start = System.nanoTime()
|
||||||
|
val result = runCatching { block() }
|
||||||
|
if (result.isSuccess) samples += (System.nanoTime() - start) / 1e6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun wall(block: suspend () -> Unit): Double {
|
||||||
|
val start = System.nanoTime()
|
||||||
|
block()
|
||||||
|
return (System.nanoTime() - start) / 1e6
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildReport(
|
||||||
|
songCount: Int,
|
||||||
|
firstSyncMs: Double,
|
||||||
|
secondSyncMs: Double,
|
||||||
|
firstSyncExecutions: List<SqlExecution>,
|
||||||
|
secondSyncExecutions: List<SqlExecution>,
|
||||||
|
readExecutions: List<SqlExecution>,
|
||||||
|
): String =
|
||||||
|
buildString {
|
||||||
|
appendLine("SQL query timing measurement")
|
||||||
|
appendLine("songs=$songCount albums=${songCount / 10} artists=${max(1, songCount / 200)}")
|
||||||
|
appendLine()
|
||||||
|
appendLine("sync: first=%.1fms second(no-op)=%.1fms".format(firstSyncMs, secondSyncMs))
|
||||||
|
appendLine()
|
||||||
|
appendLine("=== named measurements (ms) ===")
|
||||||
|
appendLine("%-32s %6s %8s %8s %8s %8s".format("measurement", "n", "p50", "p95", "p99", "max"))
|
||||||
|
timings.forEach { (name, samples) ->
|
||||||
|
if (samples.isEmpty()) return@forEach
|
||||||
|
val sorted = samples.sorted()
|
||||||
|
appendLine(
|
||||||
|
"%-32s %6d %8.2f %8.2f %8.2f %8.2f".format(
|
||||||
|
name,
|
||||||
|
sorted.size,
|
||||||
|
pct(sorted, 50),
|
||||||
|
pct(sorted, 95),
|
||||||
|
pct(sorted, 99),
|
||||||
|
sorted.last(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
appendLine()
|
||||||
|
appendLine("=== driver executions ===")
|
||||||
|
appendLine(overThresholds("first sync", firstSyncExecutions))
|
||||||
|
appendLine(overThresholds("second sync", secondSyncExecutions))
|
||||||
|
appendLine(overThresholds("reads", readExecutions))
|
||||||
|
appendLine()
|
||||||
|
appendLine("=== slowest first-sync executions ===")
|
||||||
|
firstSyncExecutions.sortedByDescending { it.durationMillis }.take(15).forEach {
|
||||||
|
appendLine(" %8.2fms rows=%-6d %s".format(it.durationMillis, it.rows, it.sql.replace(Regex("\\s+"), " ").take(140)))
|
||||||
|
}
|
||||||
|
appendLine("=== slowest read executions ===")
|
||||||
|
readExecutions.sortedByDescending { it.durationMillis }.take(15).forEach {
|
||||||
|
appendLine(" %8.2fms rows=%-6d %s".format(it.durationMillis, it.rows, it.sql.replace(Regex("\\s+"), " ").take(140)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun overThresholds(
|
||||||
|
label: String,
|
||||||
|
records: List<SqlExecution>,
|
||||||
|
): String {
|
||||||
|
val thresholds = listOf(10, 25, 50, 100, 250, 500)
|
||||||
|
val counts = thresholds.joinToString { t -> ">${t}ms=${records.count { it.durationMillis > t }}" }
|
||||||
|
return "$label: n=${records.size} $counts"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pct(
|
||||||
|
sorted: List<Double>,
|
||||||
|
percentile: Int,
|
||||||
|
): Double = sorted[((percentile / 100.0) * (sorted.size - 1)).toInt()]
|
||||||
|
|
||||||
|
internal data class Items(
|
||||||
|
val artists: List<Artist>,
|
||||||
|
val albums: List<Album>,
|
||||||
|
val songs: List<Song>,
|
||||||
|
val playlists: List<Playlist>,
|
||||||
|
val playlistSongs: List<PlaylistSong>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun items(songCount: Int): Items {
|
||||||
|
val words = listOf("Midnight", "Abbey", "Blue", "Golden", "Silent", "Rolling", "Purple", "Electric")
|
||||||
|
val artistCount = max(1, songCount / 200)
|
||||||
|
val albumCount = max(1, songCount / 10)
|
||||||
|
val artists =
|
||||||
|
(0 until artistCount).map { i ->
|
||||||
|
Artist(sourceId = 1, id = "ar$i", name = "Artist ${words[i % words.size]} $i", albumCount = 10, starred = null)
|
||||||
|
}
|
||||||
|
val albums =
|
||||||
|
(0 until albumCount).map { i ->
|
||||||
|
Album(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "al$i",
|
||||||
|
artistId = "ar${i / 20}",
|
||||||
|
name = "${words[i % words.size]} Album $i",
|
||||||
|
albumArtist = "Artist ${i / 20}",
|
||||||
|
created = 1_000_000L + i,
|
||||||
|
coverArt = null,
|
||||||
|
genre = null,
|
||||||
|
year = (1950 + i % 60).toLong(),
|
||||||
|
starred = if (i % 11 == 0) 100L + i else null,
|
||||||
|
songCount = 10,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val songs =
|
||||||
|
(0 until songCount).map { i ->
|
||||||
|
Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "s$i",
|
||||||
|
albumId = "al${i / 10}",
|
||||||
|
artistId = "ar${i / 200}",
|
||||||
|
title = "${words[i % words.size]} Song $i",
|
||||||
|
album = "Album ${i / 10}",
|
||||||
|
artist = "Artist ${i / 200}",
|
||||||
|
duration = 200,
|
||||||
|
track = (i % 10 + 1).toLong(),
|
||||||
|
disc = 1,
|
||||||
|
starred = if (i % 7 == 0) 1000L + i else null,
|
||||||
|
genre = null,
|
||||||
|
created = 1_000_000L + i,
|
||||||
|
playCount = (i % 13).toLong(),
|
||||||
|
played = if (i % 5 == 0) 1_700_000_000_000L + i else null,
|
||||||
|
genres = listOf("genre${i % 50}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val playlistCount = max(1, songCount / 100)
|
||||||
|
val playlists =
|
||||||
|
(0 until playlistCount).map { i ->
|
||||||
|
Playlist(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "pl$i",
|
||||||
|
name = "Playlist $i",
|
||||||
|
comment = null,
|
||||||
|
coverArt = null,
|
||||||
|
songCount = 20,
|
||||||
|
created = 1_000_000L + i,
|
||||||
|
changed = 2_000_000L + i,
|
||||||
|
duration = 3600,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val playlistSongs =
|
||||||
|
(0 until playlistCount).flatMap { p ->
|
||||||
|
(0 until 20).map { s -> PlaylistSong(1, "pl$p", "s${(p * 20 + s) % songCount}", s.toLong()) }
|
||||||
|
}
|
||||||
|
return Items(artists, albums, songs, playlists, playlistSongs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSource(
|
||||||
|
private val items: QueryTimingMeasurementTest.Items,
|
||||||
|
private val batchSize: Int = 500,
|
||||||
|
) : MusicSource {
|
||||||
|
override val id: Long = 1
|
||||||
|
|
||||||
|
override suspend fun ping() = Unit
|
||||||
|
|
||||||
|
override suspend fun setStar(
|
||||||
|
type: StarType,
|
||||||
|
id: String,
|
||||||
|
starred: Boolean,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override suspend fun scrobble(
|
||||||
|
songId: String,
|
||||||
|
submission: Boolean,
|
||||||
|
time: Long?,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun artists(): Flow<List<Artist>> = batches(items.artists)
|
||||||
|
|
||||||
|
override fun albums(): Flow<List<Album>> = batches(items.albums)
|
||||||
|
|
||||||
|
override fun songs(): Flow<List<Song>> = batches(items.songs)
|
||||||
|
|
||||||
|
override fun playlists(): Flow<List<Playlist>> = batches(items.playlists)
|
||||||
|
|
||||||
|
override fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>> = batches(items.playlistSongs)
|
||||||
|
|
||||||
|
private fun <T> batches(list: List<T>): Flow<List<T>> =
|
||||||
|
flow {
|
||||||
|
list.chunked(batchSize).forEach { emit(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,6 +173,21 @@ class UserPreferencesTest {
|
|||||||
prefs.setLastSeed(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null))
|
prefs.setLastSeed(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null))
|
||||||
assertEquals(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null), prefs.lastSeed())
|
assertEquals(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null), prefs.lastSeed())
|
||||||
|
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verboseLoggingDefaultsOffAndRoundTrips() =
|
||||||
|
runTest {
|
||||||
|
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||||
|
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||||
|
val prefs = UserPreferences(store)
|
||||||
|
|
||||||
|
assertEquals(false, prefs.verboseLogging().first())
|
||||||
|
|
||||||
|
prefs.setVerboseLogging(true)
|
||||||
|
assertEquals(true, prefs.verboseLogging().first())
|
||||||
|
|
||||||
file.delete()
|
file.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import com.subtracks.awaitUntil
|
||||||
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class AutoSyncTest {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
cancelAndJoinBlocking(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decidesWhetherASyncIsDue() {
|
||||||
|
assertTrue(shouldSync())
|
||||||
|
assertTrue(shouldSync(mode = SyncMode.WifiOnly))
|
||||||
|
assertTrue(shouldSync(mode = SyncMode.Always, network = NetworkMode.Mobile))
|
||||||
|
assertTrue(shouldSync(lastSyncAt = INTERVAL_MS * 2, now = INTERVAL_MS))
|
||||||
|
|
||||||
|
assertFalse(shouldSync(mode = SyncMode.Off))
|
||||||
|
assertFalse(shouldSync(mode = SyncMode.WifiOnly, network = NetworkMode.Mobile))
|
||||||
|
assertFalse(shouldSync(lastSyncAt = INTERVAL_MS - 1, now = INTERVAL_MS))
|
||||||
|
assertFalse(shouldSync(offline = true))
|
||||||
|
assertFalse(shouldSync(hasSource = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncsWhenTheAppIsActiveAndDue() {
|
||||||
|
val preferences = fakeUserPreferences()
|
||||||
|
runBlocking {
|
||||||
|
preferences.setSyncMode(SyncMode.WifiOnly)
|
||||||
|
preferences.setLastSyncAt(0L)
|
||||||
|
}
|
||||||
|
val requested = AtomicBoolean(false)
|
||||||
|
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
|
||||||
|
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
|
||||||
|
awaitUntil("auto sync requested") { requested.get() }
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotSyncWhenOff() {
|
||||||
|
val preferences = fakeUserPreferences()
|
||||||
|
runBlocking {
|
||||||
|
preferences.setSyncMode(SyncMode.Off)
|
||||||
|
preferences.setLastSyncAt(0L)
|
||||||
|
}
|
||||||
|
val requested = AtomicBoolean(false)
|
||||||
|
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
|
||||||
|
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
runBlocking { delay(300) }
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
|
||||||
|
assertFalse(requested.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stopsSyncingWhenBackgrounded() {
|
||||||
|
val preferences = fakeUserPreferences()
|
||||||
|
runBlocking {
|
||||||
|
preferences.setSyncMode(SyncMode.Always)
|
||||||
|
preferences.setLastSyncAt(0L)
|
||||||
|
}
|
||||||
|
val count = AtomicInteger(0)
|
||||||
|
val autoSync =
|
||||||
|
AutoSync(
|
||||||
|
preferences = preferences,
|
||||||
|
networkMode = flowOf(NetworkMode.Wifi),
|
||||||
|
hasSource = { true },
|
||||||
|
requestSync = { count.incrementAndGet() },
|
||||||
|
scope = scope,
|
||||||
|
now = { INTERVAL_MS + 1 },
|
||||||
|
maxPollMs = 50L,
|
||||||
|
)
|
||||||
|
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
awaitUntil("first sync") { count.get() >= 1 }
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
|
||||||
|
runBlocking { delay(200) }
|
||||||
|
val afterStop = count.get()
|
||||||
|
runBlocking { delay(300) }
|
||||||
|
|
||||||
|
assertEquals(afterStop, count.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun autoSync(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
requestSync: () -> Unit,
|
||||||
|
now: () -> Long = { INTERVAL_MS + 1 },
|
||||||
|
): AutoSync =
|
||||||
|
AutoSync(
|
||||||
|
preferences = preferences,
|
||||||
|
networkMode = flowOf(NetworkMode.Wifi),
|
||||||
|
hasSource = { true },
|
||||||
|
requestSync = requestSync,
|
||||||
|
scope = scope,
|
||||||
|
now = now,
|
||||||
|
maxPollMs = 10_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun shouldSync(
|
||||||
|
mode: SyncMode = SyncMode.WifiOnly,
|
||||||
|
network: NetworkMode = NetworkMode.Wifi,
|
||||||
|
offline: Boolean = false,
|
||||||
|
hasSource: Boolean = true,
|
||||||
|
lastSyncAt: Long = 0L,
|
||||||
|
now: Long = INTERVAL_MS,
|
||||||
|
intervalMs: Long = INTERVAL_MS,
|
||||||
|
): Boolean =
|
||||||
|
shouldAutoSync(
|
||||||
|
mode = mode,
|
||||||
|
network = network,
|
||||||
|
offline = offline,
|
||||||
|
hasSource = hasSource,
|
||||||
|
lastSyncAt = lastSyncAt,
|
||||||
|
now = now,
|
||||||
|
intervalMs = intervalMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val INTERVAL_MS = DEFAULT_SYNC_INTERVAL_MINUTES * 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import com.subtracks.TEST_TIMEOUT_MS
|
|||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
import com.subtracks.data.repo.NetworkServerActionSink
|
import com.subtracks.data.repo.NetworkServerActionSink
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
@@ -44,6 +45,7 @@ class SyncManagerTest {
|
|||||||
private lateinit var sourceRepository: SourceRepository
|
private lateinit var sourceRepository: SourceRepository
|
||||||
private lateinit var queueRepository: QueueRepository
|
private lateinit var queueRepository: QueueRepository
|
||||||
private lateinit var manager: SyncManager
|
private lateinit var manager: SyncManager
|
||||||
|
private lateinit var preferences: UserPreferences
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
private val messages = CopyOnWriteArrayList<String>()
|
private val messages = CopyOnWriteArrayList<String>()
|
||||||
private lateinit var server: MockWebServer
|
private lateinit var server: MockWebServer
|
||||||
@@ -62,12 +64,14 @@ class SyncManagerTest {
|
|||||||
sourceRepository =
|
sourceRepository =
|
||||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||||
queueRepository = QueueRepository(db)
|
queueRepository = QueueRepository(db)
|
||||||
|
preferences = fakeUserPreferences()
|
||||||
manager =
|
manager =
|
||||||
SyncManager(
|
SyncManager(
|
||||||
db,
|
db,
|
||||||
sourceRepository,
|
sourceRepository,
|
||||||
queueRepository,
|
queueRepository,
|
||||||
NetworkServerActionSink(sourceRepository),
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
preferences = preferences,
|
||||||
showMessage = { messages += it.resolve(resources) },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
@@ -89,6 +93,16 @@ class SyncManagerTest {
|
|||||||
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
assertEquals(listOf("No server configured"), messages)
|
assertEquals(listOf("No server configured"), messages)
|
||||||
|
assertTrue(preferences.lastSyncAt().first() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSilentSyncDoesNotTellTheUser() =
|
||||||
|
runBlocking {
|
||||||
|
manager.requestSync(silent = true)
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
|
assertTrue(messages.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -114,6 +128,7 @@ class SyncManagerTest {
|
|||||||
|
|
||||||
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
|
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
|
||||||
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
|
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
|
||||||
|
assertTrue(preferences.lastSyncAt().first() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.subtracks.log
|
||||||
|
|
||||||
|
import com.subtracks.data.db.SqlExecution
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import java.io.File
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
class LogFileStoreTest {
|
||||||
|
@get:Rule
|
||||||
|
val folder = TemporaryFolder()
|
||||||
|
|
||||||
|
private val root: File by lazy { folder.newFolder("logs") }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writesFormattedLine() {
|
||||||
|
val store = LogFileStore(root, LogLevel.VERBOSE)
|
||||||
|
store.write(LogLevel.INFO, "sync", "hello world", null)
|
||||||
|
store.flush()
|
||||||
|
val text = store.files().single().readText()
|
||||||
|
assertTrue(text, Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} I sync hello world""").containsMatchIn(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun redactsSubsonicParams() {
|
||||||
|
val store = LogFileStore(root, LogLevel.VERBOSE)
|
||||||
|
store.write(LogLevel.INFO, "http", "http://host/rest/stream?u=alice&p=hunter2&s=salt&t=tok&id=7", null)
|
||||||
|
store.flush()
|
||||||
|
val text = store.files().single().readText()
|
||||||
|
assertTrue(text, text.contains("u=REDACTED"))
|
||||||
|
assertTrue(text, text.contains("p=REDACTED"))
|
||||||
|
assertTrue(text, text.contains("&id=7"))
|
||||||
|
assertFalse(text, text.contains("hunter2"))
|
||||||
|
assertFalse(text, text.contains("salt"))
|
||||||
|
assertFalse(text, text.contains("tok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun redactsBasicAuth() {
|
||||||
|
val store = LogFileStore(root, LogLevel.VERBOSE)
|
||||||
|
store.write(LogLevel.INFO, "http", "http://alice:hunter2@music.example.org/rest/ping", null)
|
||||||
|
store.flush()
|
||||||
|
val text = store.files().single().readText()
|
||||||
|
assertTrue(text, text.contains("http://REDACTED@music.example.org/rest/ping"))
|
||||||
|
assertFalse(text, text.contains("hunter2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun includesStackTrace() {
|
||||||
|
val store = LogFileStore(root, LogLevel.VERBOSE)
|
||||||
|
store.write(LogLevel.ERROR, "sql", "boom", IllegalStateException("kaboom"))
|
||||||
|
store.flush()
|
||||||
|
val text = store.files().single().readText()
|
||||||
|
assertTrue(text, text.contains("IllegalStateException: kaboom"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun redactsCredentialsInThrowable() {
|
||||||
|
val store = LogFileStore(root, LogLevel.VERBOSE)
|
||||||
|
store.write(
|
||||||
|
LogLevel.ERROR,
|
||||||
|
"http",
|
||||||
|
"failed",
|
||||||
|
IllegalStateException("GET http://alice:secret@host/x?u=bob&p=hunter2 failed"),
|
||||||
|
)
|
||||||
|
store.flush()
|
||||||
|
val text = store.files().single().readText()
|
||||||
|
assertTrue(text, text.contains("REDACTED@host"))
|
||||||
|
assertFalse(text, text.contains("secret"))
|
||||||
|
assertFalse(text, text.contains("hunter2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dropsBelowMinimumLevel() {
|
||||||
|
val store = LogFileStore(root, LogLevel.WARN)
|
||||||
|
store.write(LogLevel.INFO, "x", "quiet", null)
|
||||||
|
store.write(LogLevel.WARN, "x", "loud", null)
|
||||||
|
store.flush()
|
||||||
|
val text = store.files().single().readText()
|
||||||
|
assertFalse(text, text.contains("quiet"))
|
||||||
|
assertTrue(text, text.contains("loud"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setMinLevelChangesWhatIsWritten() {
|
||||||
|
val store = LogFileStore(root, LogLevel.WARN)
|
||||||
|
store.setMinLevel(LogLevel.VERBOSE)
|
||||||
|
store.write(LogLevel.VERBOSE, "x", "verbose-line", null)
|
||||||
|
store.setMinLevel(LogLevel.ERROR)
|
||||||
|
store.write(LogLevel.INFO, "x", "info-line", null)
|
||||||
|
store.flush()
|
||||||
|
val text = store.files().single().readText()
|
||||||
|
assertTrue(text, text.contains("verbose-line"))
|
||||||
|
assertFalse(text, text.contains("info-line"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rotatesWhenFileExceedsCap() {
|
||||||
|
val store = LogFileStore(root, LogLevel.VERBOSE, maxFileBytes = 1)
|
||||||
|
repeat(4) { store.write(LogLevel.INFO, "x", "line $it", null) }
|
||||||
|
store.flush()
|
||||||
|
assertEquals(4, store.files().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun prunesToMaxFiles() {
|
||||||
|
val store = LogFileStore(root, LogLevel.VERBOSE, maxFileBytes = 1, maxFiles = 3)
|
||||||
|
repeat(10) { store.write(LogLevel.INFO, "x", "line $it", null) }
|
||||||
|
store.flush()
|
||||||
|
assertEquals(3, store.files().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exportZipContainsLogs() {
|
||||||
|
val store = LogFileStore(root, LogLevel.VERBOSE)
|
||||||
|
store.write(LogLevel.INFO, "x", "one", null)
|
||||||
|
store.flush()
|
||||||
|
val zip = store.exportZip(folder.newFolder("out"))
|
||||||
|
assertTrue(zip != null)
|
||||||
|
ZipFile(zip!!).use { archive ->
|
||||||
|
assertEquals(1, archive.size())
|
||||||
|
assertTrue(
|
||||||
|
archive
|
||||||
|
.entries()
|
||||||
|
.nextElement()
|
||||||
|
.name
|
||||||
|
.endsWith(".log"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exportZipIsNullWhenEmpty() {
|
||||||
|
val store = LogFileStore(root, LogLevel.VERBOSE)
|
||||||
|
assertNull(store.exportZip(folder.newFolder("out")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun slowQueryPredicateExcludesNonQueriesAndInternalSql() {
|
||||||
|
assertTrue(isSlowQuery(execution(150.0, "SELECT * FROM songs"), 100.0))
|
||||||
|
assertFalse(isSlowQuery(execution(50.0, "SELECT * FROM songs"), 100.0))
|
||||||
|
assertFalse(isSlowQuery(execution(150.0, "BEGIN IMMEDIATE TRANSACTION"), 100.0))
|
||||||
|
assertFalse(isSlowQuery(execution(150.0, "SELECT * FROM room_table_modification_log WHERE invalidated = 1"), 100.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun classifiesDataStatements() {
|
||||||
|
assertTrue(isDataStatement("insert into t values (1)"))
|
||||||
|
assertTrue(isDataStatement(" SELECT 1"))
|
||||||
|
assertFalse(isDataStatement("PRAGMA user_version"))
|
||||||
|
assertFalse(isDataStatement("END TRANSACTION"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun classifiesReadStatements() {
|
||||||
|
assertTrue(isReadStatement("select * from songs"))
|
||||||
|
assertTrue(isReadStatement("WITH x AS (SELECT 1) SELECT * FROM x"))
|
||||||
|
assertFalse(isReadStatement("INSERT INTO t VALUES (1)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun detectsUpsertConflictErrors() {
|
||||||
|
assertTrue(
|
||||||
|
isUpsertConflict(
|
||||||
|
"INSERT INTO playback_cursor (id) VALUES (?)",
|
||||||
|
IllegalStateException("UNIQUE constraint failed: playback_cursor.id"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
isUpsertConflict(
|
||||||
|
"INSERT OR ABORT INTO t VALUES (?)",
|
||||||
|
IllegalStateException("PRIMARY KEY constraint failed"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertFalse(isUpsertConflict("UPDATE t SET x = 1", IllegalStateException("UNIQUE constraint failed: t.x")))
|
||||||
|
assertFalse(isUpsertConflict("SELECT * FROM t", IllegalStateException("no such table: t")))
|
||||||
|
assertFalse(isUpsertConflict("INSERT INTO t VALUES (?)", IllegalStateException("Error code: 1555")))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun execution(
|
||||||
|
milliseconds: Double,
|
||||||
|
sql: String,
|
||||||
|
) = SqlExecution(sql, (milliseconds * 1_000_000).toLong(), 0, null)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ package com.subtracks.playback
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DataSpec
|
import androidx.media3.datasource.DataSpec
|
||||||
|
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
@@ -13,13 +18,16 @@ import okhttp3.mockwebserver.MockWebServer
|
|||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class MediaDataSourceTest {
|
class MediaDataSourceTest {
|
||||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
|
||||||
private fun dataSource() = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
|
private fun dataSource(cache: SimpleCache? = null) =
|
||||||
|
mediaDataSourceFactory(context, cache, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aDownloadedFileIsReadThroughTheStreamingChain() {
|
fun aDownloadedFileIsReadThroughTheStreamingChain() {
|
||||||
@@ -50,4 +58,44 @@ class MediaDataSourceTest {
|
|||||||
|
|
||||||
assertEquals(1_000_000L, length)
|
assertEquals(1_000_000L, length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningAStreamIsServedFromTheCache() {
|
||||||
|
val server = MockWebServer()
|
||||||
|
server.enqueue(MockResponse().setBody("0123456789"))
|
||||||
|
server.start()
|
||||||
|
val url = "http://${server.hostName}:${server.port}/rest/stream"
|
||||||
|
val cache =
|
||||||
|
SimpleCache(
|
||||||
|
File(context.cacheDir, "stream-cache-${System.nanoTime()}"),
|
||||||
|
NoOpCacheEvictor(),
|
||||||
|
StandaloneDatabaseProvider(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
val first = readAll(dataSource(cache), url)
|
||||||
|
val second = readAll(dataSource(cache), url)
|
||||||
|
val requests = server.requestCount
|
||||||
|
server.shutdown()
|
||||||
|
cache.release()
|
||||||
|
|
||||||
|
assertEquals("0123456789", String(first, StandardCharsets.UTF_8))
|
||||||
|
assertEquals(first.toList(), second.toList())
|
||||||
|
assertEquals(1, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAll(
|
||||||
|
dataSource: DataSource,
|
||||||
|
url: String,
|
||||||
|
): ByteArray {
|
||||||
|
dataSource.open(DataSpec(Uri.parse(url)))
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(1024)
|
||||||
|
while (true) {
|
||||||
|
val read = dataSource.read(buffer, 0, buffer.size)
|
||||||
|
if (read == C.RESULT_END_OF_INPUT) break
|
||||||
|
out.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
dataSource.close()
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
@@ -2391,6 +2392,61 @@ class PlaybackControllerTest {
|
|||||||
assertEquals(setOf("a1", "a2", "a3", "b1", "b2"), handle.items.map { it.id }.toSet())
|
assertEquals(setOf("a1", "a2", "a3", "b1", "b2"), handle.items.map { it.id }.toSet())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffleGenrePlaysOnlyThatGenresSongs() {
|
||||||
|
seedGenre()
|
||||||
|
|
||||||
|
controller.shuffleGenre(1, "Rock")
|
||||||
|
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||||
|
|
||||||
|
assertTrue(controller.state.value.shuffle)
|
||||||
|
assertEquals(
|
||||||
|
QueueKind.Genre,
|
||||||
|
controller.state.value.context
|
||||||
|
?.kind,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Rock",
|
||||||
|
controller.state.value.context
|
||||||
|
?.refId,
|
||||||
|
)
|
||||||
|
assertEquals(setOf("r1", "r2"), handle.items.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffleStarredPlaysOnlyStarredSongs() {
|
||||||
|
seedStarred()
|
||||||
|
|
||||||
|
controller.shuffleStarred(1)
|
||||||
|
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||||
|
|
||||||
|
assertTrue(controller.state.value.shuffle)
|
||||||
|
assertEquals(
|
||||||
|
QueueKind.Starred,
|
||||||
|
controller.state.value.context
|
||||||
|
?.kind,
|
||||||
|
)
|
||||||
|
assertEquals(setOf("t1", "t2"), handle.items.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffleDownloadedPlaysOnlyDownloadedSongs() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
markDownloaded(1, "s1")
|
||||||
|
markDownloaded(1, "s3")
|
||||||
|
|
||||||
|
controller.shuffleDownloaded(1)
|
||||||
|
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||||
|
|
||||||
|
assertTrue(controller.state.value.shuffle)
|
||||||
|
assertEquals(
|
||||||
|
QueueKind.Downloaded,
|
||||||
|
controller.state.value.context
|
||||||
|
?.kind,
|
||||||
|
)
|
||||||
|
assertEquals(setOf("s1", "s3"), handle.items.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun artistQueueRowsFollowTheFlatShuffleIdOrder() {
|
fun artistQueueRowsFollowTheFlatShuffleIdOrder() {
|
||||||
seedArtist()
|
seedArtist()
|
||||||
@@ -3100,6 +3156,39 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun seedGenre() {
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
Song(1, "r1", "al1", "ar1", "Rock 1", "Album", "Artist", 100, 1, 1, null, null),
|
||||||
|
Song(1, "r2", "al1", "ar1", "Rock 2", "Album", "Artist", 100, 2, 1, null, null),
|
||||||
|
Song(1, "j1", "al1", "ar1", "Jazz 1", "Album", "Artist", 100, 3, 1, null, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
listOf("r1" to "Rock", "r2" to "Rock", "j1" to "Jazz").forEach { (songId, genre) ->
|
||||||
|
connection.usePrepared(
|
||||||
|
"INSERT INTO song_genres (sourceId, songId, position, genre) VALUES (1, '$songId', 0, '$genre')",
|
||||||
|
) { it.step() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedStarred() {
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
Song(1, "t1", "al1", "ar1", "Star 1", "Album", "Artist", 100, 1, 1, 1_700_000_000L, null),
|
||||||
|
Song(1, "t2", "al1", "ar1", "Star 2", "Album", "Artist", 100, 2, 1, 1_700_000_000L, null),
|
||||||
|
Song(1, "n1", "al1", "ar1", "Plain 1", "Album", "Artist", 100, 3, 1, null, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun seedAlbumTracks(
|
private fun seedAlbumTracks(
|
||||||
albumId: String,
|
albumId: String,
|
||||||
count: Int,
|
count: Int,
|
||||||
|
|||||||
@@ -130,15 +130,54 @@ class ScrobblePolicyTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun restartingTheSameTrackScrobblesAgain() {
|
fun restartingTheSameTrackResubmitsWithoutReannouncing() {
|
||||||
val item = track(durationMs = 200_000)
|
val item = track(durationMs = 200_000)
|
||||||
play(item, toPositionMs = 100_000)
|
play(item, toPositionMs = 100_000)
|
||||||
clock = 120_000L
|
clock = 120_000L
|
||||||
val repeat = play(item, toPositionMs = 100_000)
|
val repeat = play(item, toPositionMs = 100_000)
|
||||||
assertEquals(Scrobble.NowPlaying("s1"), repeat.first())
|
assertEquals(emptyList<Scrobble>(), repeat.filterIsInstance<Scrobble.NowPlaying>())
|
||||||
assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last())
|
assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotReannounceTheTrackItJustLeftWhenItsPositionResets() {
|
||||||
|
val events = mutableListOf<Scrobble>()
|
||||||
|
events += play(track(id = "s1"), toPositionMs = 100_000)
|
||||||
|
events +=
|
||||||
|
listOfNotNull(
|
||||||
|
policy.advance(track(id = "s1"), 200_000, isPlaying = true, positionMs = 0),
|
||||||
|
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(Scrobble.NowPlaying("s1"), Scrobble.Submission("s1", 1_000L), Scrobble.NowPlaying("s2")),
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotReannounceWhenTheNewTracksPositionJittersBackToZero() {
|
||||||
|
val events = mutableListOf<Scrobble>()
|
||||||
|
events += play(track(id = "s1"), toPositionMs = 100_000)
|
||||||
|
events +=
|
||||||
|
listOfNotNull(
|
||||||
|
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0),
|
||||||
|
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 500),
|
||||||
|
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(Scrobble.NowPlaying("s1"), Scrobble.Submission("s1", 1_000L), Scrobble.NowPlaying("s2")),
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun announcesWhenATrackLoadedWhilePausedStartsFromTheBeginning() {
|
||||||
|
val item = track(id = "s1")
|
||||||
|
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 0))
|
||||||
|
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 2_500))
|
||||||
|
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, 200_000, isPlaying = true, positionMs = 0))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() {
|
fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() {
|
||||||
val first = track(id = "s1")
|
val first = track(id = "s1")
|
||||||
|
|||||||
@@ -190,4 +190,34 @@ class ScrobblerTest {
|
|||||||
enabled.value = true
|
enabled.value = true
|
||||||
assertEquals(listOf(Call.NowPlaying("s1")), sink.calls)
|
assertEquals(listOf(Call.NowPlaying("s1")), sink.calls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTransitionSendsNowPlayingOnceForTheNewTrack() =
|
||||||
|
runTest {
|
||||||
|
val sink = RecordingSink()
|
||||||
|
val scrobbler = Scrobbler(sink = sink, enabled = flowOf(true), scope = testScope(), policy = ScrobblePolicy(now = { 1_000L }))
|
||||||
|
val state = MutableStateFlow(PlaybackState())
|
||||||
|
val position = MutableStateFlow(0L)
|
||||||
|
scrobbler.attach(state, position)
|
||||||
|
val first = QueueItem("s1", "One", "Artist", "Album", null, durationMs = 30_000)
|
||||||
|
val second = QueueItem("s2", "Two", "Artist", "Album", null, durationMs = 30_000)
|
||||||
|
|
||||||
|
state.value = PlaybackState(item = first, isPlaying = true)
|
||||||
|
var tick = 500L
|
||||||
|
while (tick <= 15_000L) {
|
||||||
|
position.value = tick
|
||||||
|
tick += 500
|
||||||
|
}
|
||||||
|
position.value = 0L
|
||||||
|
state.value = PlaybackState(item = second, isPlaying = true)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
Call.NowPlaying("s1"),
|
||||||
|
Call.Scrobble("s1", 1_000L),
|
||||||
|
Call.NowPlaying("s2"),
|
||||||
|
),
|
||||||
|
sink.calls,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ class AppScreenshotTest {
|
|||||||
onAddServer = {},
|
onAddServer = {},
|
||||||
onOpenDownloads = {},
|
onOpenDownloads = {},
|
||||||
onOpenLicenses = {},
|
onOpenLicenses = {},
|
||||||
|
onShareLogs = {},
|
||||||
onBack = {},
|
onBack = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -505,7 +506,7 @@ class AppScreenshotTest {
|
|||||||
onPlay = {},
|
onPlay = {},
|
||||||
onRemove = {},
|
onRemove = {},
|
||||||
onReorder = { _, _ -> },
|
onReorder = { _, _ -> },
|
||||||
onMove = { _, _ -> },
|
onMove = { _, _ -> true },
|
||||||
onLoadOlder = {},
|
onLoadOlder = {},
|
||||||
onLoadNewer = {},
|
onLoadNewer = {},
|
||||||
onUndo = {},
|
onUndo = {},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||