76 Commits
Author SHA1 Message Date
austinried e396671ac7 feat: auto-sync the library on a timer while the app is open
While the app is in the foreground a timer checks the auto-sync setting and
syncs when due (default every 24h; the interval is selectable from 15 minutes
to 24 hours). It stops when the app is backgrounded. "Sync automatically"
is one setting: Off, Wi-Fi only (default) or Always.

A running sync is shown by a silent, dismissible notification and a small
spinner on the library tab bar's settings button. A manual pull-to-refresh
still holds the pull spinner; only an automatic sync does not. The library
and Home sync actions no-op while a sync is running, and SyncManager
coalesces a request that arrives mid-run into a single follow-up, so repeated
pull-to-refresh cannot pile up runs while adding a server still syncs it.

The time of the last sync attempt is recorded, so a failing server is retried
once per interval rather than every poll. The sync consumer and the auto-sync
loop both survive an unexpected failure instead of silently disabling syncing
for the rest of the process.

The poll sleeps for the interval (capped at 15 minutes) instead of a fixed
minute, the network mode is collected once rather than re-registering a
connectivity callback each poll, and the decision is logged at DEBUG (visible
with Verbose logging) rather than writing to the log file every poll.
2026-10-10 03:36:16 +00:00
austinried 8e7431cc2e fix: stop a queue drag from moving the wrong rows
A drop captured the dragged row's queue position once, at drag start, and
committed on a fire-and-forget coroutine. If the queue renumbered while the
drag was in flight (up-next consumed, a reconcile) or before a prior move had
reconciled, the drop resolved its target against stale coordinates and moved
the wrong entry - the "two middle rows swap on release" symptom.

Resolve the move from the current rows at drop time, and have move() report
whether it was accepted so a refused reorder is not announced as done; a
further move is refused while one is still committing.
2026-10-09 19:01:22 +00:00
austinried 7bbc29a1d6 feat: show the build's commit hash under About
A short, faded commit hash next to the version in Settings > About, so a bug
report can name the exact build. Baked into BuildConfig at build time from
`git rev-parse HEAD` (fixed 7 chars, so the same commit always yields the
same value and the APK stays reproducible); empty, and hidden, on a build
with no git metadata such as a source tarball.
2026-10-09 18:06:38 +00:00
austinried 0a3e839da0 feat: clean up the 1.x app's leftover data on upgrade
The React Native 1.x app shipped as com.subtracks and left an AsyncStorage
database (RKStorage), an older `servers` dump and a downloaded/cached
artwork+audio tree in app-specific storage. v3 reads none of those paths, so
an update would otherwise leave the data behind, reachable only by wiping the
whole app.

Remove them once at startup, gated on one of those 1.x paths existing, so a
fresh install does nothing and an upgrade is cleaned exactly once. The cached
trees can be large, so the delete runs off the main thread. Kept in a single
file so it can be deleted once no installs can come from 1.x.
2026-10-09 17:05:19 +00:00
austinried e0a09b724b chore: drop the F-Droid sync and submit workflows
The recipe is maintained by hand now, so the two Forgejo workflows that
pulled fdroiddata into tools/fdroid, tracked a working-copy hash and
opened the GitLab merge request are more machinery than the channel
needs. Their support files and the GL_TOKEN note go with them; the
F-Droid references in the README, signing docs and architecture notes
describe the channel itself and stay.
2026-10-09 16:47:34 +00:00
austinried e806eaef89 playback: cache streamed audio to stop redundant refetches
A streamed MP3 with no Xing/Info/VBRI header gets an estimated duration
from Mp3Extractor. At end of stream the extractor rewrites it, which
refreshes the timeline; MediaPeriodQueue.updateQueuedPeriods then sees
the playing period's duration change, drops every period after it and
re-enqueues the next one, so the next track is opened from byte 0 again.
After a seek near the end that repeats a few times.

Route streamed bytes through a bounded SimpleCache (DefaultDataSource ->
CacheDataSource -> KnownLengthDataSource -> OkHttp). A re-opened next
period is served from the bytes the prefetch already committed, so the
redundant network fetches stop without changing playback length or
gapless padding. DefaultDataSource still handles file:/content:, so
downloads bypass the cache.

The cache is a process-scoped 64 MiB LRU in cacheDir/stream-cache; a
construction failure falls back to no cache so playback still starts.
2026-10-09 13:36:55 +00:00
austinried 0cef3657bc build: make the release APK reproducible
AGP's dependency-metadata signing block (0x504b4453) is encrypted per
build, so two otherwise byte-identical release APKs differed in that
block. Disable it in the APK with dependenciesInfo.includeInApk = false;
the bundle keeps its copy for Play.
2026-10-09 13:34:38 +00:00
austinried 20c6ba526f scrobble: only dedupe when the track was already announced
The now-playing suppression keyed off the item id alone, so a track loaded while paused (which sets the tracked id without announcing) never sent its first now-playing when playback then started from the beginning. Gate the suppression on nowPlayingSent as well, and cover that case plus the transition at the combine boundary.
2026-10-09 12:31:48 +00:00
austinried cfb663c250 scrobble: send now playing once per track change
A track transition resets the player position, and the scrobble policy's restart heuristic (same song, position drops to ~0) then re-announced the track we just left and, when the new track's position jittered back to zero, announced it twice. Suppress the now-playing when the restart is the same song; the submission logic still resets so a genuine replay is scrobbled again.
2026-10-09 12:31:48 +00:00
austinried ac1e62fc02 library: drop shuffle fab from album and playlist detail 2026-10-09 09:15:36 +00:00
austinried adabe21d63 library: cover starred/downloaded shuffle in tests, dedupe list predicate 2026-10-09 09:15:36 +00:00
austinried 0bdac8fa22 library: tap-to-top and shuffle fab for long song lists 2026-10-09 09:15:36 +00:00
austinried 15d77f998f build: drop the agent-specific gradle wrapper from the devshell
The flock/nice/worker-cap wrapper serialised local builds to protect the
shared box. That is now handled at the machine level (a GRADLE_OPTS cap
in nix-home), so the devshell goes back to the plain nix Gradle.
2026-10-09 09:11:54 +00:00
austinried 779131c766 logging: address review of the breadcrumbs
Drop the non-distinct song comparison (sum of album song counts double-counts compilations), skip redaction for dropped lines via a level check, defer the startup prune off the main thread and cap the writer queue, and only warn about a stall against the still-current player. Reward the scrobble info line and cover the new preferences/level toggles with tests.
2026-10-09 07:58:55 +00:00
austinried 9695d83b1a logging: make verbose capture everything, tidy the toggle text
Verbose now drops the file level to VERBOSE (including HTTP request lines) rather than DEBUG, and the switch no longer warns about larger files.
2026-10-09 07:58:55 +00:00
austinried a8d6111aaa logging: drop the noisy album count from the sync summary
sum(artist.albumCount) double-counts compilation albums, so it read higher than the albums actually synced; keep only the song total, which matches exactly.
2026-10-09 07:58:55 +00:00
austinried d4152ba3a1 logging: add a stall watchdog and server-vs-synced counts
Warn when playback stays buffering for 15s while play-wanted, and log what the server reports for album/song totals next to what was synced and pruned.
2026-10-09 07:58:55 +00:00
austinried 79fe15da91 settings: move the log actions into a Troubleshooting section
Share logs and Verbose logging move out of About into their own Troubleshooting section.
2026-10-09 07:58:55 +00:00
austinried 714440fb25 logging: add release-visible breadcrumbs and a verbose toggle
Playback now logs each track transition and end at info (title/artist/album/source/reason), sync logs per-entity counts and pruned rows, and the Subsonic client logs server error codes and the token-auth fallback. Add a Verbose logging setting that drops the file level to debug at runtime (persisted) so a support session can capture full detail without flooding normal release logs.
2026-10-09 07:58:55 +00:00
austinried a2d89f28a6 logging: add breadcrumbs for common actions
Log sync start/finish/failure, star/unstar, scrobble now-playing/submission (and failures), download queue/start/complete/fail/remove, offline-mode toggles, network-mode and active-source changes, and pending-action queue/replay/drop. Enrich playback logs with title/artist/album, file-vs-http source and the transition reason, and render C.TIME_UNSET durations as 'unset'. Make console logging non-fatal so a logcat write never breaks the caller, and never prune the file being written.
2026-10-09 07:58:55 +00:00
austinried b2b02e7c05 logging: log upsert conflicts at verbose
Room's @Upsert insert-then-update fires a uniqueness conflict on every existing-row write (e.g. each playback-cursor save), which is normal control flow and flooded the debug file at debug level. Move it below the file threshold so the shared log stays quiet.
2026-10-09 07:58:55 +00:00
austinried 9d783b2301 logging: address review of the SQL logging path
Only downgrade a uniqueness conflict on an INSERT, so genuine duplicate-key failures still surface. Clear old zips from the dump directory before exporting, roll the log file over at midnight, prune on roll/day-change instead of on every line, and drop the unused files-path from the FileProvider config.
2026-10-09 07:58:55 +00:00
austinried 713d9a976a logging: log cancelled HTTP requests at verbose
Coil cancels in-flight image requests routinely, so an IOException from a cancelled call is not a warning.
2026-10-09 07:58:55 +00:00
austinried c262e58a96 logging: fix SQL timing and upsert-conflict false alarms
Room's statement cache resets a statement when returning it to the cache, so starting the timer at reset counted idle time in the cache and reported normal queries as tens of seconds. Start timing on the first step instead, and finish on the completing step. Also downgrade Room @Upsert uniqueness-conflict errors (its insert-then-catch-then-update control flow) from ERROR to DEBUG, and log successful HTTP requests at VERBOSE so debug logs aren't flooded by cover-art requests.
2026-10-09 07:58:55 +00:00
austinried eb5019ea5e logging: use Share logs and restore its Flutter translations
Align the Settings label with the old Flutter wording and port the 19 existing settingsAboutShareLogs translations. The subtitle was new text with no prior translation, so drop it rather than ship an untranslated string.
2026-10-09 07:58:55 +00:00
austinried 8fe26a36b1 logging: make the writer total and harden export
Never let a log-write or export failure reach the uncaught handler: wrap the async writer and crash path, synchronize export with rotation, and tolerate vanished files. Redact the Logcat stack-trace copy, stop leaking rows=0 on writes, shut down the previous writer on re-init, and drop unused API. Strengthen tests for the batched write path, rotation count and throwable redaction.
2026-10-09 07:58:55 +00:00
austinried 7a890094fc logging: add file-based logging and a Send logs option
Capture logs to rotating app-private files with Subsonic credential and URL basic-auth redaction, mirror to Logcat, and capture uncaught crashes. Log slow SQL (>100ms), SQL errors, migrations and DB lifecycle via a decorating SQLiteDriver. Add a Send logs row in Settings that zips the logs and opens the share sheet through a FileProvider.
2026-10-09 07:58:54 +00:00
austinried 4012cc0272 build: harden the gradle guard wrapper
- cap workers via GRADLE_OPTS instead of a CLI flag, so a caller passing
  --max-workers no longer collides and fails the build
- keep the lock under $XDG_RUNTIME_DIR (falling back to ~/.cache) rather
  than a world-writable /tmp path
- let --stop/--status/--version bypass the lock, and announce when a build
  is waiting for it
- drop the redundant GITHUB_ACTIONS check; the CI runners all set CI
2026-10-09 07:48:00 +00:00
austinried 336fd9c49f build: guard local gradle runs against exhausting the box
The devshell `gradle` now serialises local builds with flock and runs
them under `nice` with capped workers, so several agents building at
once queue instead of piling up daemons and compile workers and taking
the machine down. With CI/GITHUB_ACTIONS set it execs Gradle directly,
leaving CI untouched. Also shorten the Gradle daemon idle timeout so
idle daemons are reaped sooner.
2026-10-09 07:42:05 +00:00
austinried 5f138bde6a artwork: cover the non-square and thumbnail reload paths
The fix keyed the request remembers in all three CoverArt branches; assert
each of them survives a re-salted URL for the same cacheKey, not just the
square grid path.
2026-10-09 07:03:49 +00:00
austinried 32360fac08 artwork: keep the cover request stable across recompositions
CoverArt keyed its remember blocks on the whole CoverArtRef, whose url is
freshly salted on every call. A recomposition therefore built a new
ImageRequest, Coil restarted the load and dropped the current drawable, so
the image flashed back to the letter placeholder before resolving from
cache. Key the request and the load-state remembers on the ref's stable
cacheKey instead.
2026-10-09 07:03:49 +00:00
austinried 206ac8988f net: include the Android release in the user agent 2026-10-09 06:33:22 +00:00
austinried 4fad6a78ec net: use the lowercase subtracks name in the user agent 2026-10-09 06:33:22 +00:00
austinried 2439368e4e net: pin the user agent value and test the shared client
Build the shared OkHttpClient through UserAgent.httpClient() so the unit test exercises the same client the app uses, assert the exact 'Subtracks (Android; <version>)' value rather than the property it writes, and run the test under the Android runner.
2026-10-09 06:33:22 +00:00
austinried 92c6f6945f net: use the AppName (platform; version) user agent form 2026-10-09 06:33:22 +00:00
austinried bcd7bbf23a net: send the subtracks user agent on every request
Only SubsonicClient tagged requests with 'subtracks/android'; playback (Media3 OkHttpDataSource), downloads (DownloadManager) and artwork (Coil/OkHttpArtworkFetcher) fell back to the OkHttp or platform default. Add a single UserAgent with the app version, apply it to the shared OkHttp client, the streaming data source factory and the DownloadManager request, and drop SubsonicClient's now-redundant header.
2026-10-09 06:33:22 +00:00
austinried ce9e3e89a7 build: pair the nightly icon background with its amber fill
Set the nightly launcher background to a dark tint of the amber mark
(#1A1200) instead of the magenta-tinted near-black, mirroring how the
standard icon's background is a dark tint of its magenta fill.
2026-10-09 05:27:05 +00:00
austinried 66ee837cbb build: give the nightly a distinct icon and drop the longer label
The 17-character 'subtracks nightly' label just truncates in the launcher,
so revert the app_name override (debug and nightly show 'subtracks' like
the release) and distinguish the nightly with a recoloured launcher icon
(amber mark) instead. The debug and nightly variants share src/nightly/res,
so installDebug and installNightly look the same.
2026-10-09 05:20:55 +00:00
austinried de4bd473f9 ci: upload the nightly APKs as separate raw artifacts
archive: false only supports a single file, so upload each of the two
APKs and SHA256SUMS as its own unzipped artifact instead of one zip.
2026-10-08 19:51:03 +00:00
austinried 7f421470b2 release: gzip the R8 mapping before attaching it 2026-10-08 19:23:56 +00:00
austinried 4bea164906 release: split the Play key write, guard the tag and attach the mapping
- write the Play service account in its own step so fastlane never has
  the secret in its environment (only the 0600 file path)
- fail the build when the tag does not match versionName
- serialize releases with a concurrency group
- attach the R8 mapping.txt to the GitHub release
2026-10-08 19:21:26 +00:00
austinried 9d43b6d104 address review: persist nightly checksums, guard the changelog, tidy CI
- write SHA256SUMS into the subtracks-nightly artifact
- assert at least one changelog exists in the metadata test
- drop the unused assembleDebug from the Forgejo CI
- only claim the AAB was uploaded to Play for stable releases
- clean up the export-certificate keystore with a trap
2026-10-08 19:12:56 +00:00
austinried 323bfde41b ci: fold the nightly artifact into the CI job
Drop the separate build-nightly workflow: on a push to main the CI job
uploads the debug and nightly APKs as the subtracks-nightly artifact, so
the nightly variant is built once instead of twice (and there is no
second Nix/cache setup).
2026-10-08 18:53:00 +00:00
austinried 18d06f750d ci: build the nightly variant in CI
Add :app:assembleNightly to the CI Gradle invocation so the R8 minify,
resource shrinking and packaging path is exercised on every PR (signed
with the committed debug key, so no secrets are needed).
2026-10-08 18:53:00 +00:00
austinried 44bc3f927e address review: guard the release pipeline and harden the metadata test
- fail the release before uploading to Play when the versionCode has no
  changelog
- rely on supply's defaults for release_status and metadata_path
- enforce 8-bit PNGs and reject tRNS chunks in MetadataConstraintsTest
- name the nightly artifact's APKs distinctly
- fix doc drift and the stale libraries uniqueId
2026-10-08 18:53:00 +00:00
austinried b0a0bc1867 build: label the nightly/debug variants distinctly 2026-10-08 18:53:00 +00:00
austinried e700c99796 docs: label the fingerprints as keytool/apksigner format 2026-10-08 18:52:44 +00:00
austinried 6af43c9c16 docs: drop the private Play Console references from signing/README 2026-10-08 18:52:44 +00:00
austinried 4f04129b0e docs: show both fingerprint formats with tool labels 2026-10-08 18:52:44 +00:00
austinried 772ab5f563 docs: note the fingerprint formats and normalize the release-notes one 2026-10-08 18:52:44 +00:00
austinried 1eb7c136bf docs: link F-Droid's signing keys and the nightly keystore 2026-10-08 18:52:44 +00:00
austinried 17ba581f88 docs: list F-Droid before nightly in the signing channels 2026-10-08 18:52:44 +00:00
austinried 76f2e4014c docs: put the release certificate before the nightly section 2026-10-08 18:52:44 +00:00
austinried 3d2ec5e931 docs: unwrap the lines in signing/README.md 2026-10-08 18:52:44 +00:00
austinried 368f53d589 release: attach the signed AAB to the GitHub release
Publish the exact bundle sent to Play alongside the APK, cover both in
SHA256SUMS and the release notes, and document verifying the AAB.
2026-10-08 18:52:44 +00:00
austinried 3f3db71f14 docs: commit the release signing certificate and fingerprint
Add the release key's public certificate (signing/release-certificate.pem)
and its SHA-256 to signing/README.md.
2026-10-08 18:52:44 +00:00
austinried e60b2d9910 rename the next build back to nightly
Rename the build type, workflow, artifact and app id (com.subtracks.nightly)
from next to nightly, and update the signing docs to match.
2026-10-08 18:52:44 +00:00
austinried f6d1d548e4 docs: correct the signing notes for Play App Signing
Google re-signs Play-delivered apps, so the release key is only the
upload key there and signs the AAB we send; the installed app verifies
against Google's app signing certificate. The release key also signs the
GitHub-release APK.
2026-10-08 18:52:44 +00:00
austinried e74360fe07 docs: link the verification steps under the download badges 2026-10-08 18:52:44 +00:00
austinried d3373261d2 release: publish checksums and the signing certificate
Print the artifact SHA-256 and the APK signer certificate in the release
log, attach SHA256SUMS and the public release certificate to the GitHub
release, list the APK and certificate SHA-256 in the release notes, print
the next build checksums, and commit the next build's public certificate
with verification steps in signing/README.md.
2026-10-08 18:52:44 +00:00
austinried 7f4e9703d4 test: cite the store docs in the metadata constraint tests
Quote and link the Play preview-asset requirements and recommendations
and the F-Droid metadata limits alongside the checks they justify.
2026-10-08 18:52:44 +00:00
austinried ff8011eaf4 release: only publish to Play for a stable tag
Gate the Play upload on a full vX.Y.Z tag and mark other tags as
prereleases. Fold the changelog length limit into MetadataConstraintsTest
and make its image checks reject anything but PNG or JPEG. Keep the
decoded keystore at 0600 and note in .forgejo/README.md that the GitHub
CI now factors the build into composite actions.
2026-10-08 18:52:44 +00:00
austinried b4b5f66d81 ci: track the build composite action
The .gitignore build/ pattern also matched .github/actions/build, so the
action was never committed even though the workflows referenced it.
Negate the rule for that path.
2026-10-08 18:52:44 +00:00
austinried 8fb930c2ce ci: share the build as a composite action
Replace the reusable workflow with .github/actions/build plus a nested
nix setup action, invoked as a step. CI and the next build stay a single
job (no extra runner or Nix restore); the release keeps its build/publish
split for credential isolation.
2026-10-08 18:52:44 +00:00
austinried af3741a549 ci: share the build and isolate the release credentials
Extract checkout, nix, signing and the Gradle run into a reusable build
workflow used by CI, the next build and the release build job. The build
job holds only the signing secrets; the publish job is the only one with
a write token and the Play credential. The Play service account is now
written to a 0600 file and passed via --json_key instead of on argv.
2026-10-08 18:52:44 +00:00
austinried fded9949af ci: stop exposing the checkout token and the keystore to later steps
Set persist-credentials: false so the contents:write token is not left
in .git/config where every later step (including the Gradle build) could
read it, and delete the decoded keystore once signing is done.
2026-10-08 18:52:44 +00:00
austinried 5c219c9f06 test: guard the store metadata against the store limits
Add metadata checks (title, short/full description, icon, feature
graphic, phone screenshots) at the stricter of the Play and F-Droid
limits, and drop the trailing period from the short description.
2026-10-08 18:52:44 +00:00
austinried 7145b9c3c7 test: write the demo screenshots as 24-bit no-alpha PNGs
Re-encode each capture to opaque RGB so the store screenshots copied
into metadata/ already satisfy Play's rule, instead of depending on a
manual palette-free re-encode.
2026-10-08 18:52:44 +00:00
austinried 4db8645691 docs: note the Play 24-bit screenshot requirement 2026-10-08 18:52:43 +00:00
austinried eaab5368d0 play: push the full store listing and make the screenshots compliant
Drop the fastlane --skip_upload_* flags so the title, descriptions,
icon, feature graphic and screenshots upload alongside the AAB, and
re-encode the phone screenshots as 24-bit no-alpha PNGs, which is what
Play requires.
2026-10-08 18:52:43 +00:00
austinried 6b6a99e51c release: create the GitHub release with the gh CLI
Drop the third-party action in favour of GitHub's own gh CLI, which the
hosted runners already provide.
2026-10-08 18:52:43 +00:00
austinried 21d6c57bc2 ci: match the repo GitHub runner and action versions
Use ubuntu-26.04, actions/checkout v7 and actions/upload-artifact v7,
and cap the cached Nix store at 6G, matching .github/workflows/ci.yml.
2026-10-08 18:52:43 +00:00
austinried 650d8c49e6 release: upload to Google Play with fastlane
Replace the unpinned third-party upload-google-play action with fastlane
supply from a small nix release devshell, and pin the remaining
third-party GitHub release action to a commit SHA.
2026-10-08 18:52:43 +00:00
austinried c76a79c3ed ci: run the GitHub builds in the nix devshell
Drop setup-java, setup-android and the Gradle wrapper in favour of the
flake's pinned JDK, Gradle and Android SDK, restoring the Nix store with
cache-nix-action, so the release and next builds share the dev toolchain.
2026-10-08 18:52:43 +00:00
austinried 23911fceb5 ci: build signed debug and release next builds on every main push
Commit a public debug keystore and point the debug signing config at it,
and add a next build type (release-like, same key). Both carry the .next
application id, so next builds install alongside the release and update
each other without exposing the release key. A GitHub workflow builds
both APKs on every push to main and uploads them as artifacts.
2026-10-08 18:52:43 +00:00
austinried f506ac0410 release: publish signed builds on v* tags
Set applicationId to com.subtracks and add a GitHub Actions workflow
that, on a v* tag, builds and signs the release APK and AAB, creates
the GitHub release with the APK, and uploads the AAB to Google Play.
Release notes come from the matching metadata changelog.

Signing reads the keystore from the environment
(RELEASE_KEYSTORE, RELEASE_KEYSTORE_PASSWORD, RELEASE_KEY_ALIAS,
RELEASE_KEY_PASSWORD) and falls back to the debug key locally.
2026-10-08 18:52:43 +00:00
122 changed files with 3744 additions and 575 deletions
+74
View File
@@ -0,0 +1,74 @@
name: Build
description: Set up Nix, optionally sign, run Gradle and report the version code
inputs:
gradle-args:
description: Gradle tasks and flags to run
required: true
sign:
description: Decode and use the store keystore when "true"
required: false
default: "false"
keystore-base64:
description: Base64 of the release keystore
required: false
default: ""
keyproperties-base64:
description: Base64 of the key.properties holding the key alias
required: false
default: ""
keystore-password:
description: Keystore (store) password
required: false
default: ""
key-password:
description: Signing key password
required: false
default: ""
outputs:
version-code:
description: versionCode read from app/build.gradle.kts
value: ${{ steps.version.outputs.code }}
runs:
using: composite
steps:
- uses: ./.github/actions/nix
- uses: actions/cache@v6
with:
path: ~/.gradle/caches
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
- id: signing
if: inputs.sign == 'true'
shell: bash
env:
KEYSTORE_BASE64: ${{ inputs.keystore-base64 }}
KEYPROPERTIES_BASE64: ${{ inputs.keyproperties-base64 }}
run: |
umask 077
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
alias=$(printf '%s' "$KEYPROPERTIES_BASE64" | base64 -d | sed -n 's/^keyAlias=//p' | tr -d '\r')
test -n "$alias" || { echo "no keyAlias in keyproperties" >&2; exit 1; }
echo "alias=$alias" >> "$GITHUB_OUTPUT"
- shell: bash
env:
SIGN: ${{ inputs.sign }}
KEYSTORE_PASSWORD: ${{ inputs.keystore-password }}
KEY_ALIAS: ${{ steps.signing.outputs.alias }}
KEY_PASSWORD: ${{ inputs.key-password }}
run: |
if [ "$SIGN" = "true" ]; then
export RELEASE_KEYSTORE="$RUNNER_TEMP/release.keystore"
export RELEASE_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD"
export RELEASE_KEY_ALIAS="$KEY_ALIAS"
export RELEASE_KEY_PASSWORD="$KEY_PASSWORD"
fi
nix develop --command gradle ${{ inputs.gradle-args }}
- if: always()
shell: bash
run: rm -f "$RUNNER_TEMP/release.keystore"
- id: version
shell: bash
run: echo "code=$(grep -oP 'versionCode = \K\d+' app/build.gradle.kts)" >> "$GITHUB_OUTPUT"
+14
View File
@@ -0,0 +1,14 @@
name: Nix
description: Install Nix and restore the store cache
runs:
using: composite
steps:
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
extra_nix_config: experimental-features = nix-command flakes
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
restore-prefixes-first-match: nix-v2-
gc-max-store-size-linux: 6G
+28 -19
View File
@@ -9,37 +9,46 @@ concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
GRADLE_OPTS: -Dorg.gradle.daemon=false
NIX_CONFIG: experimental-features = nix-command flakes
defaults:
run:
shell: sh
jobs:
ci:
runs-on: ubuntu-26.04
timeout-minutes: 90
permissions:
contents: read
env:
GRADLE_OPTS: -Dorg.gradle.daemon=false
steps:
- uses: actions/checkout@v7
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- 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
- uses: actions/cache@v6
persist-credentials: false
- uses: ./.github/actions/build
with:
path: |
~/.gradle/caches
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
gradle-args: ":app:ktlintCheck :app:lintDebug :app:testDebugUnitTest :app:assembleDebug :app:assembleNightly --no-configuration-cache --console=plain --no-daemon"
- name: Collect nightly builds
if: github.ref == 'refs/heads/main'
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
with:
path: .integration/music
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
+148
View File
@@ -0,0 +1,148 @@
name: Release
on:
push:
tags:
- "v*"
concurrency:
group: release
cancel-in-progress: false
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-26.04
timeout-minutes: 60
permissions:
contents: read
outputs:
version-code: ${{ steps.build.outputs.version-code }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Check tag matches versionName
run: |
version=$(grep -oP 'versionName = "\K[^"]+' app/build.gradle.kts)
case "$GITHUB_REF_NAME" in
"v$version" | "v$version"-*) ;;
*)
echo "tag $GITHUB_REF_NAME does not match versionName $version" >&2
exit 1
;;
esac
- id: build
uses: ./.github/actions/build
with:
gradle-args: ":app:assembleRelease :app:bundleRelease --no-daemon"
sign: "true"
keystore-base64: ${{ secrets.SUBTRACKS_KEYSTORE_BASE64 }}
keyproperties-base64: ${{ secrets.SUBTRACKS_KEYPROPERTIES_BASE64 }}
keystore-password: ${{ secrets.SUBTRACKS_UPLOAD_STORE_PASSWORD }}
key-password: ${{ secrets.SUBTRACKS_UPLOAD_KEY_PASSWORD }}
- name: Export release certificate
env:
KEYSTORE_BASE64: ${{ secrets.SUBTRACKS_KEYSTORE_BASE64 }}
KEYPROPERTIES_BASE64: ${{ secrets.SUBTRACKS_KEYPROPERTIES_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.SUBTRACKS_UPLOAD_STORE_PASSWORD }}
run: |
umask 077
trap 'rm -f "$RUNNER_TEMP/keystore"' EXIT
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/keystore"
alias=$(printf '%s' "$KEYPROPERTIES_BASE64" | base64 -d | sed -n 's/^keyAlias=//p' | tr -d '\r')
nix develop --command keytool -exportcert -rfc -keystore "$RUNNER_TEMP/keystore" -alias "$alias" -storepass "$KEYSTORE_PASSWORD" -file app/build/outputs/release-certificate.pem
- uses: actions/upload-artifact@v7
with:
name: release
path: app/build/outputs
publish:
needs: build
runs-on: ubuntu-26.04
timeout-minutes: 60
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/download-artifact@v8
with:
name: release
path: dist
- uses: ./.github/actions/nix
- name: Detect stable tag
id: stable
run: |
if [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "value=true" >> "$GITHUB_OUTPUT"
else
echo "value=false" >> "$GITHUB_OUTPUT"
fi
- name: Check release notes exist
run: test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
- name: Write Play service account
if: steps.stable.outputs.value == 'true'
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
umask 077
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/play.json"
- name: Upload to Google Play
if: steps.stable.outputs.value == 'true'
run: |
nix develop .#release --command fastlane supply \
--aab dist/bundle/release/app-release.aab \
--package_name com.subtracks \
--track "${{ vars.PLAY_TRACK || 'internal' }}" \
--json_key "$RUNNER_TEMP/play.json" \
--mapping dist/mapping/release/mapping.txt
- name: Remove Play service account
if: always()
run: rm -f "$RUNNER_TEMP/play.json"
- name: Checksums and signature
id: checksums
run: |
cp dist/apk/release/app-release.apk subtracks.apk
cp dist/bundle/release/app-release.aab subtracks.aab
gzip -n -9 -c dist/mapping/release/mapping.txt > mapping.txt.gz
sha256sum subtracks.apk subtracks.aab > SHA256SUMS
echo "Artifact SHA-256:"
sha256sum subtracks.apk subtracks.aab
echo "APK signature:"
certs=$(nix develop --command bash -c '"$ANDROID_HOME/build-tools/37.0.0/apksigner" verify --print-certs subtracks.apk')
printf '%s\n' "$certs"
fingerprint=$(printf '%s\n' "$certs" | sed -n 's/.*certificate SHA-256 digest: //p' | head -1 | tr '[:lower:]' '[:upper:]' | sed 's/\(..\)/\1:/g; s/:$//')
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
- name: Compose release notes
run: |
{
cat "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
echo
echo '---'
echo
echo "APK SHA-256: \`$(grep 'subtracks\.apk$' SHA256SUMS | cut -d ' ' -f1)\`"
echo
echo "AAB SHA-256: \`$(grep 'subtracks\.aab$' SHA256SUMS | cut -d ' ' -f1)\`"
echo
echo "Signing certificate SHA-256: \`${{ steps.checksums.outputs.fingerprint }}\`"
echo
echo 'The signing certificate is attached as `release-certificate.pem`.'
if [ "${{ steps.stable.outputs.value }}" = "true" ]; then
echo 'The `.aab` is the exact bundle uploaded to Google Play; the `.apk` is the universal APK for sideloading.'
else
echo 'The `.aab` is the built app bundle (this pre-release was not uploaded to Google Play); the `.apk` is the universal APK for sideloading.'
fi
} > release-notes.md
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file release-notes.md)
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
args+=(--prerelease)
fi
gh release create "$GITHUB_REF_NAME" subtracks.apk subtracks.aab SHA256SUMS dist/release-certificate.pem mapping.txt.gz "${args[@]}"
+1
View File
@@ -1,5 +1,6 @@
.gradle/
build/
!.github/actions/build/
local.properties
*.apk
+4 -3
View File
@@ -4,7 +4,7 @@ Guidance for agents (and humans) working in this repository.
## 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
@@ -34,7 +34,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
## 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.
- 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.
@@ -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.
- 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.
- 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.
- 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.
@@ -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)`.
- 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`.
- `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.
- 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).
- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
- The store screenshots in `metadata/en-US/images/phoneScreenshots/` must be 24-bit no-alpha PNGs (Play rejects palette and alpha PNGs for screenshots). `:app:demoScreenshots` writes its captures in that form, so copy them across as-is and don't run a palette optimizer over them.
+2
View File
@@ -26,6 +26,8 @@ subtracks is an open source Android client for [Subsonic-compatible](http://www.
<a href="https://github.com/austinried/subtracks/releases/"><img src=".assets/github-badge.png" width="250"/></a>
</div>
See [signing/README.md](signing/README.md) for the fingerprints and the commands to verify a download.
## Features
- **Large Libraries:** Built for very large libraries, from syncing the whole collection to playing playlists with thousands of tracks.
+72 -3
View File
@@ -14,17 +14,31 @@ ktlint {
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 {
namespace = "com.subtracks"
compileSdk = 37
buildToolsVersion = "37.0.0"
defaultConfig {
applicationId = "com.subtracks.next"
applicationId = "com.subtracks"
minSdk = 24
targetSdk = 37
versionCode = 13
versionName = "3.0.0"
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
}
buildFeatures {
@@ -36,6 +50,12 @@ android {
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 {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -79,12 +99,43 @@ android {
.kotlin.directories
.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 {
debug {
applicationIdSuffix = ".nightly"
}
release {
isMinifyEnabled = true
isShrinkResources = true
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")
}
}
@@ -127,10 +178,12 @@ val integrationTestClasses =
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
tasks.withType<Test>().configureEach {
if (name.endsWith("UnitTest")) {
filter {
(integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) }
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
}
}
testLogging {
@@ -166,6 +219,20 @@ tasks.register<Test>("demoScreenshots") {
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 {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.core.ktx)
@@ -191,7 +258,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.datasource)
implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.database)
implementation(libs.okhttp)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
+1 -1
View File
@@ -1,5 +1,5 @@
{
"uniqueId": "com.subtracks:next",
"uniqueId": "com.subtracks",
"name": "Subtracks",
"description": "Subtracks itself, a client for Subsonic-compatible servers.",
"website": "https://github.com/austinried/subtracks",
Binary file not shown.
+10
View File
@@ -47,6 +47,16 @@
<receiver
android:name=".data.download.DownloadCancelReceiver"
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>
</manifest>
@@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import com.subtracks.data.sync.AutoSync
import com.subtracks.ui.NowPlayingLauncher
import com.subtracks.ui.SubtracksRoot
import com.subtracks.ui.theme.SubtracksTheme
@@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext
class MainActivity : ComponentActivity() {
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
private val autoSync: AutoSync by lazy { GlobalContext.get().get() }
private val requestNotifications =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onStart() {
super.onStart()
autoSync.setForeground(true)
}
override fun onStop() {
super.onStop()
autoSync.setForeground(false)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermission()
@@ -5,20 +5,28 @@ import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.subtracks.data.LegacyDataCleanup
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.di.appModule
import com.subtracks.log.Log
import com.subtracks.ui.theme.ArtworkSeedCache
import okhttp3.OkHttpClient
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
class SubtracksApp : Application() {
private val http = OkHttpClient()
private val http =
UserAgent
.httpClient()
.newBuilder()
.addInterceptor(Log.interceptor())
.build()
@OptIn(DelicateCoilApi::class)
override fun onCreate() {
super.onCreate()
Log.init(this)
LegacyDataCleanup.run(this)
stopKoin()
SingletonImageLoader.reset()
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 androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.subtracks.log.Log
fun createAndroidDatabase(context: Context): SubtracksDatabase =
Room
fun createAndroidDatabase(context: Context): SubtracksDatabase {
val driver = LoggingSQLiteDriver(BundledSQLiteDriver(), Log::sql)
return Room
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
.setDriver(BundledSQLiteDriver())
.addMigrations(*MIGRATIONS)
.setDriver(driver)
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
.addCallback(SqliteLifecycleLogging)
.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.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Upsert
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.PlaylistSongItem
@@ -55,7 +55,7 @@ interface QueueDao {
@Query("SELECT * FROM playback_cursor WHERE id = 1")
suspend fun cursor(): PlaybackCursor?
@Upsert
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setCursor(cursor: PlaybackCursor)
@Query(
@@ -4,6 +4,7 @@ import android.app.DownloadManager
import android.content.Context
import android.os.Environment
import androidx.core.net.toUri
import com.subtracks.UserAgent
import com.subtracks.data.model.DownloadError
class SystemDownloadEngine(
@@ -16,6 +17,7 @@ class SystemDownloadEngine(
.setTitle(request.title)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
.addRequestHeader("User-Agent", UserAgent.value)
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
// one of those; the platform otherwise permits it by default.
.setAllowedOverMetered(request.allowMetered)
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.subtracks.data.net.NetworkMode
@@ -48,6 +49,10 @@ data class StreamQuality(
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(
val cacheKey: String,
val primary: Int,
@@ -105,6 +110,34 @@ class UserPreferences(
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 }
suspend fun setScrobbling(enabled: Boolean) {
@@ -121,6 +154,14 @@ class UserPreferences(
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? {
val prefs = store.data.first()
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.SongDownload
import com.subtracks.data.model.coverArtKey
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
@@ -79,7 +80,10 @@ class DownloadRepository(
}
scope.launch {
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()
file(sourceId, songId).delete()
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
Log.i("download", "queued song=$songId (source $sourceId)")
ensurePolling()
return true
}
@@ -336,6 +341,7 @@ class DownloadRepository(
private suspend fun removeRows(rows: List<SongDownload>) {
if (rows.isEmpty()) return
Log.i("download", "removed ${rows.size} download(s)")
val engineIds = rows.mapNotNull { it.engineId }
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
@@ -581,6 +587,14 @@ class DownloadRepository(
},
)
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(
@@ -590,6 +604,7 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) }
file(row.sourceId, row.songId).delete()
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
Log.w("download", "failed ${row.songId}: $error")
}
private fun ensurePolling() {
@@ -24,6 +24,7 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.StarType
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -464,14 +465,17 @@ class LibraryRepository(
}
val previous = starredValue(sourceId, type, id)
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
val action = if (starred) "star" else "unstar"
return try {
serverActions.setStar(type, id, starred)
Log.i("star", "$action ${type.name.lowercase()} $id")
Result.success(Unit)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
showMessage(UiMessage(R.string.star_update_failed))
Log.w("star", "failed to $action ${type.name.lowercase()} $id", 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.StarType
import com.subtracks.data.source.subsonic.SubsonicException
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -29,8 +30,13 @@ class PendingServerActionSink(
init {
scope.launch {
var lastReady: Boolean? = null
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
.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
// flag's default at cold start, so wait for that flag to agree before replaying.
if (ready) {
@@ -96,12 +102,14 @@ class PendingServerActionSink(
for (action in dao.pending(sourceId)) {
try {
deliver(action)
Log.i("net", "replayed ${action.kind.name.lowercase()} ${action.targetId}")
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
// Still unreachable: keep this and the remaining actions for the next attempt.
if (isRetryable(failure)) return
// 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)
}
@@ -139,6 +147,7 @@ class PendingServerActionSink(
db.pendingActionDao().replace(
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
)
Log.i("net", "queued ${kind.name.lowercase()} $targetId")
return true
}
@@ -17,6 +17,7 @@ import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.declaredStreamLength
import com.subtracks.data.source.streamLengthSuffix
import com.subtracks.data.source.subsonic.SubsonicClient
import com.subtracks.log.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -46,6 +47,13 @@ class SourceRepository(
val source: SubsonicMusicSource,
)
private data class ResolvedSource(
val config: SubsonicConfig?,
val quality: StreamQuality,
val mode: NetworkMode,
val concurrency: Int,
)
@Volatile
private var active: ActiveSource? = null
@@ -64,6 +72,7 @@ class SourceRepository(
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
fun setOfflineMode(enabled: Boolean) {
Log.i("net", "offline mode -> $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
// cold start with offline on never touches the network.
_offline.value = prefs.offlineMode().first()
var lastMode: NetworkMode? = null
combine(
db.sourcesDao().activeSubsonicConfig(),
networkMode,
@@ -88,13 +98,23 @@ class SourceRepository(
prefs.streamQuality(NetworkMode.Mobile),
prefs.syncConcurrency(),
) { config, mode, wifi, mobile, concurrency ->
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
}.collect { (config, quality, concurrency) ->
ResolvedSource(config, if (mode == NetworkMode.Wifi) wifi else mobile, mode, concurrency)
}.collect { (config, quality, mode, concurrency) ->
if (mode != lastMode) {
Log.i("net", "network mode -> ${mode.key}")
lastMode = mode
}
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)) }
_quality.value = quality
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.UiMessage
import com.subtracks.log.Log
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -20,7 +21,6 @@ class SubsonicClient(
private val password: String,
useTokenAuth: Boolean,
private val http: OkHttpClient,
private val userAgent: String = "subtracks/android",
private val onTokenAuthUnsupported: () -> Unit = {},
) {
@Volatile
@@ -61,7 +61,11 @@ class SubsonicClient(
return try {
execute(method, params, body)
} 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()
execute(method, params, body)
}
@@ -76,7 +80,6 @@ class SubsonicClient(
Request
.Builder()
.url(uri(method, params))
.header("User-Agent", userAgent)
.build()
http.newCall(request).execute().use { response ->
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
import android.util.Log
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.subsonic.SubsonicException
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
sealed interface SyncStatus {
data object Idle : SyncStatus
data object Running : SyncStatus
data class Running(
val silent: Boolean,
) : SyncStatus
data object Success : SyncStatus
@@ -34,41 +39,65 @@ class SyncManager(
private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository,
private val serverActions: ServerActionSink,
private val preferences: UserPreferences,
private val showMessage: (UiMessage) -> Unit = {},
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)
val status: StateFlow<SyncStatus> = _status
init {
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() {
requests.trySend(Unit)
fun requestSync(silent: Boolean = false) {
requests.trySend(silent)
}
private suspend fun runSync() {
private suspend fun runSync(silent: Boolean) {
if (sourceRepository.offline.value) {
if (!silent) {
val offline = UiMessage(R.string.sync_offline)
showMessage(offline)
_status.value = SyncStatus.Failed(offline)
}
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 =
try {
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
Log.i("sync", "started (source ${source.id})")
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
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
Log.w(TAG, "Sync failed", failure)
Log.w("sync", "failed after ${elapsedMs(startedAt)}ms", failure)
val message =
when (failure) {
is NoServerException -> {
@@ -80,16 +109,14 @@ class SyncManager(
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
}
}
showMessage(message)
if (!silent) showMessage(message)
SyncStatus.Failed(message)
}
queueRepository.invalidateLibraryCache()
_status.value = result
}
private companion object {
const val TAG = "SubtracksSync"
}
private fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000
}
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 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()
syncAlbums()
syncSongs()
aggregatePlayData()
syncPlaylists()
syncPlaylistSongs()
return SyncSummary(
artists = artistsSynced,
albums = albumsSynced,
songs = songsSynced,
playlists = playlistsSynced,
playlistSongs = playlistSongsSynced,
pruned = pruned,
)
}
private suspend fun syncArtists() {
@@ -36,8 +51,9 @@ class SyncService(
if (batch.isEmpty()) return@collect
write("artists", artistColumns, primaryKey, batch) { it.values() }
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() {
@@ -53,8 +69,10 @@ class SyncService(
writeAlbumsAndDiscs(batch, discs)
batch.forEach { seenAlbums.add(idHash(it.id)) }
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) })
pruned +=
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
deleteStaleDiscs(it)
}
@@ -67,8 +85,9 @@ class SyncService(
if (batch.isEmpty()) return@collect
writeSongs(batch)
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>) {
@@ -106,8 +125,9 @@ class SyncService(
if (batch.isEmpty()) return@collect
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
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() {
@@ -118,6 +138,7 @@ class SyncService(
if (batch.isEmpty()) return@collect
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
playlistSongsSynced += batch.size
}
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
library.deleteOrphanPlaylistSongs(source.id)
@@ -173,15 +194,17 @@ class SyncService(
seen: HashedIds,
pageOf: suspend (afterId: String) -> List<String>,
delete: suspend (Collection<String>) -> Unit,
) {
): Int {
seen.freeze()
var afterId = ""
var pruned = 0
while (true) {
val ids = pageOf(afterId)
if (ids.isEmpty()) return
if (ids.isEmpty()) return pruned
val stale = ids.filterNot { seen.contains(idHash(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()
}
}
@@ -190,22 +213,33 @@ class SyncService(
seen: HashedIds,
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
delete: suspend (Collection<DiscKey>) -> Unit,
) {
): Int {
seen.freeze()
var afterAlbumId = ""
var afterDisc = 0L
var pruned = 0
while (true) {
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)) }
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
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 PRUNE_PAGE = 500
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.SourceRepository
import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.sync.AutoSync
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.PlaybackController
import com.subtracks.playback.PlayerConnection
@@ -48,6 +51,8 @@ import com.subtracks.ui.settings.SettingsViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import okhttp3.OkHttpClient
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
@@ -68,6 +73,7 @@ fun appModule(
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
single(createdAtStart = true) { get<UserPreferences>().verboseLogging().onEach(Log::setVerbose).launchIn(get()) }
single {
SourceRepository(
get(),
@@ -92,7 +98,16 @@ fun appModule(
}
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
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 { ArtworkStore(downloadsRoot(context)) }
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
@@ -111,6 +126,7 @@ fun appModule(
).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 { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
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
import android.os.Bundle
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
@@ -16,6 +15,7 @@ import com.subtracks.UiMessage
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.log.Log
class Media3PlayerHandle(
private val controller: MediaController,
@@ -112,14 +112,19 @@ class Media3PlayerHandle(
) = controller.replaceMediaItem(index, toMediaItem(item))
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)
}
override fun seekTo(positionMs: Long) {
Log.d(
TAG,
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
"playback",
"seekTo(${positionMs}ms) from=${controller.currentPosition} duration=${duration(controller.duration)} " +
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
)
controller.seekTo(positionMs)
@@ -136,7 +141,7 @@ class Media3PlayerHandle(
mediaItem: MediaItem?,
reason: Int,
) {
Log.d(TAG, "item=${mediaItem?.mediaId}")
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
listener.onTransition()
}
@@ -147,8 +152,9 @@ class Media3PlayerHandle(
override fun onPlayerError(error: PlaybackException) {
Log.w(
TAG,
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
"playback",
"error ${error.errorCodeName} ${describe(controller.currentMediaItem)} " +
"position=${controller.currentPosition} duration=${duration(controller.duration)}",
error,
)
listener.onError(playbackErrorMessage(error))
@@ -156,15 +162,54 @@ class Media3PlayerHandle(
override fun onPlaybackStateChanged(playbackState: Int) {
Log.d(
TAG,
"playback",
"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 {
val quality = sourceRepository.quality.value
return queueMediaItem(
@@ -190,10 +235,6 @@ class Media3PlayerHandle(
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
?.getLong(EXTRA_DURATION_MS),
)
private companion object {
const val TAG = "SubtracksPlayback"
}
}
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.Shuffle
import com.subtracks.data.repo.SourceRepository
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -127,7 +128,9 @@ class PlaybackController(
private var positionJob: Job? = null
private var windowJob: Job? = null
private var bufferingJob: Job? = null
private var stallJob: Job? = null
private var showBuffering = false
private var stalled = false
private val startLock = Mutex()
private var snapshot: QueueSnapshot? = null
@@ -258,6 +261,15 @@ class PlaybackController(
artistId: String,
) = 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(
entry: QueueEntry,
start: Long,
@@ -1047,6 +1059,9 @@ class PlaybackController(
windowJob?.cancel()
bufferingJob?.cancel()
bufferingJob = null
stallJob?.cancel()
stallJob = null
stalled = false
showBuffering = false
snapshot = null
queueSourceId = null
@@ -1460,22 +1475,43 @@ class PlaybackController(
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
private fun updateBuffering() {
val player = player
if (player?.isBuffering != true) {
bufferingJob?.cancel()
bufferingJob = null
stallJob?.cancel()
stallJob = null
stalled = false
showBuffering = false
return
}
if (showBuffering || bufferingJob?.isActive == true) return
if (!showBuffering && bufferingJob?.isActive != true) {
bufferingJob =
scope.launch {
delay(BUFFERING_INDICATOR_DELAY_MS)
if (player?.isBuffering == true) {
if (player.isBuffering) {
showBuffering = true
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()) {
val player = player ?: return
@@ -1541,6 +1577,7 @@ class PlaybackController(
const val POSITION_TICK_MS = 500L
const val POSITION_SAVE_INTERVAL_MS = 15_000L
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
const val STALL_WATCHDOG_MS = 15_000L
const val QUEUE_WINDOW_RADIUS = 25L
const val WINDOW_SHIFT_DELAY_MS = 400L
const val RESTART_THRESHOLD_MS = 3_000L
@@ -5,7 +5,6 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
@@ -13,8 +12,13 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
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.exoplayer.ExoPlayer
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.subtracks.MainActivity
import com.subtracks.R
import com.subtracks.UserAgent
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.log.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -40,6 +46,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext
import java.io.File
@OptIn(UnstableApi::class)
class PlaybackService : MediaSessionService() {
@@ -90,6 +97,7 @@ class PlaybackService : MediaSessionService() {
val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>()
val cache = StreamCache.open(this)
val exoPlayer =
ExoPlayer
.Builder(this)
@@ -104,7 +112,13 @@ class PlaybackService : MediaSessionService() {
.setMediaSourceFactory(
DefaultMediaSourceFactory(this)
.setEnableClippingInMediaPeriod(true)
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
.setDataSourceFactory(
mediaDataSourceFactory(
this,
cache,
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
),
),
).build()
exoPlayer.addListener(
object : Player.Listener {
@@ -183,12 +197,13 @@ class PlaybackService : MediaSessionService() {
private fun streamingClient(): OkHttpClient =
OkHttpClient
.Builder()
.addInterceptor(Log.interceptor())
.addInterceptor { chain ->
val started = SystemClock.elapsedRealtime()
val response = chain.proceed(chain.request())
Log.d(
STREAM_TAG,
"stream range=${chain.request().header("Range")} code=${response.code} " +
"stream",
"range=${chain.request().header("Range")} code=${response.code} " +
"contentRange=${response.header("Content-Range")} " +
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
)
@@ -208,13 +223,50 @@ class PlaybackService : MediaSessionService() {
}
private companion object {
const val STREAM_TAG = "SubtracksPlayback"
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)
internal fun mediaDataSourceFactory(
context: Context,
cache: Cache?,
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
import com.subtracks.data.source.ServerActionSink
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -58,6 +59,7 @@ class ScrobblePolicy(
item?.id != songId ||
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
if (restarted) {
val alreadyAnnounced = nowPlayingSent && item?.id == songId
songId = item?.id
lastItemId = null
playedMs = 0
@@ -67,7 +69,7 @@ class ScrobblePolicy(
if (item == null || !isPlaying) return null
nowPlayingSent = true
startedAt = now()
return Scrobble.NowPlaying(item.id)
return if (alreadyAnnounced) null else Scrobble.NowPlaying(item.id)
}
lastPositionMs = positionMs
lastItemId = item?.id
@@ -128,22 +130,28 @@ class Scrobbler(
private suspend fun submit(event: Scrobble) {
when (event) {
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 -> {
attempt { recordPlay(event.songId, event.time / 1000) }
attempt { sink.scrobble(event.songId, event.time) }
Log.i("scrobble", "submitting ${event.songId}")
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 {
block()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
} catch (failure: Exception) {
Log.w("scrobble", "$label failed", failure)
}
}
}
@@ -87,9 +87,11 @@ fun CoverArt(
colorFilter: ColorFilter? = null,
) {
val context = LocalPlatformContext.current
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
val refKey = ref?.cacheKey
val thumbnailKey = thumbnailRef?.cacheKey
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 =
if (showPlaceholder || (failed && !thumbnailLoaded)) {
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
@@ -102,7 +104,7 @@ fun CoverArt(
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
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)) {
CoverArtContent(
ref = ref,
@@ -147,7 +149,7 @@ fun CoverArt(
val painter =
rememberAsyncImagePainter(
model =
remember(ref, thumbnailRef, target) {
remember(refKey, thumbnailKey, target) {
ref?.let {
imageRequest(
context,
@@ -231,7 +233,7 @@ private fun BoxScope.CoverArtContent(
}
if (thumbnailRef != null && thumbnailRef != ref) {
AsyncImage(
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
model = remember(thumbnailRef.cacheKey) { imageRequest(context, thumbnailRef, crossfade = false) },
contentDescription = null,
contentScale = contentScale,
colorFilter = colorFilter,
@@ -51,6 +51,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.baseArtworkColors
import com.subtracks.ui.theme.heroBarColor
import kotlinx.coroutines.launch
private const val FADE_DISTANCE_DP = 64
@@ -102,6 +104,7 @@ fun HeroDetailScaffold(
ArtworkTheme(effectiveArtwork) {
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val fill = rememberViewportFill(listState)
val density = LocalDensity.current
val screenHeightPx = with(density) { maxHeight.toPx() }
@@ -195,7 +198,8 @@ fun HeroDetailScaffold(
.clickable(
interactionSource = remember { MutableInteractionSource() },
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() }
}
/**
* 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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ShuffleFab
import com.subtracks.ui.library.PlaylistsContent
import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -110,6 +115,7 @@ fun HomeListRoute(
onPlaylistClick = onPlaylistClick,
onSongClick = viewModel::play,
onLongClick = { contextMenuHost?.show(it, actions) },
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
onGenreClick = onGenreClick,
onDecadeClick = onDecadeClick,
)
@@ -134,11 +140,14 @@ fun HomeListScreen(
onPlaylistClick: (Playlist) -> Unit = {},
onSongClick: (Song) -> Unit = {},
onLongClick: (MenuTarget) -> Unit = {},
onShuffle: (() -> Unit)? = null,
onGenreClick: (String) -> Unit = {},
onDecadeClick: (Long) -> Unit = {},
) {
val now = remember { System.currentTimeMillis() }
val resources = LocalResources.current
val scope = rememberCoroutineScope()
val songListState = rememberLazyListState()
Scaffold(
modifier = modifier,
topBar = {
@@ -149,8 +158,21 @@ fun HomeListScreen(
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 ->
val downloaded = request.downloaded
if (downloaded != null) {
@@ -184,6 +206,7 @@ fun HomeListScreen(
playingSongId = playingSongId,
onSongClick = onSongClick,
onLongClick = onLongClick,
listState = songListState,
modifier = Modifier.padding(padding),
)
}
@@ -237,6 +260,7 @@ fun HomeListScreen(
playingSongId = playingSongId,
onSongClick = onSongClick,
onLongClick = onLongClick,
listState = songListState,
modifier = Modifier.padding(padding),
)
}
@@ -254,6 +278,7 @@ fun HomeListScreen(
playingSongId = playingSongId,
onSongClick = onSongClick,
onLongClick = onLongClick,
listState = songListState,
modifier = Modifier.padding(padding),
)
} else {
@@ -297,6 +322,7 @@ private fun SongsList(
playingSongId: String?,
onSongClick: (Song) -> Unit,
onLongClick: (MenuTarget) -> Unit,
listState: LazyListState,
modifier: Modifier = Modifier,
) {
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
@@ -304,8 +330,9 @@ private fun SongsList(
return
}
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp),
contentPadding = PaddingValues(bottom = 88.dp),
) {
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
val item = songs[index]
@@ -21,9 +21,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class HomeListRequest(
val title: String,
@@ -51,6 +53,11 @@ class HomeListViewModel(
) : ViewModel() {
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>> =
sourceId
.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.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -53,11 +52,6 @@ class HomeViewModel(
private val syncManager: SyncManager,
private val playbackController: PlaybackController,
) : ViewModel() {
val syncing: StateFlow<Boolean> =
syncManager.status
.map { it == SyncStatus.Running }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val offline: StateFlow<Boolean> =
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
@@ -141,7 +135,9 @@ class HomeViewModel(
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
}
fun sync() = syncManager.requestSync()
fun sync() {
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
}
private data class PlayRows(
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.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.ScrollToTopOnRequest
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -56,6 +57,7 @@ fun AlbumsContent(
topInset: Dp = 0.dp,
onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
) {
when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -78,6 +80,7 @@ fun AlbumsContent(
else -> {
val gridState = rememberLazyGridState()
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
ScrollToTopOnRequest(scrollToTopKey, gridState)
val fill = rememberViewportFill(gridState)
LazyVerticalGrid(
state = gridState,
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.ScrollToTopOnRequest
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -53,6 +54,7 @@ fun ArtistsContent(
topInset: Dp = 0.dp,
onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
) {
when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -70,6 +72,7 @@ fun ArtistsContent(
else -> {
val listState = rememberLazyListState()
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
ScrollToTopOnRequest(scrollToTopKey, listState)
val fill = rememberViewportFill(listState)
LazyColumn(
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.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
@@ -243,6 +244,7 @@ fun LibraryRoute(
var previousTabName by remember { mutableStateOf(selectedTab.name) }
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
val listTab = selectedTab.listTab()
LaunchedEffect(selectedTab) {
if (previousTab != selectedTab) {
@@ -296,6 +298,7 @@ fun LibraryRoute(
selectedTab = selectedTab,
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
syncing = syncing,
refreshing = refreshing,
bottomInset = bottomInset,
artwork = artwork,
albums = viewModel.albums,
@@ -392,6 +395,7 @@ fun LibraryScreen(
onExitOffline: () -> Unit = {},
onItemLongClick: (MenuTarget) -> Unit = {},
syncing: Boolean = false,
refreshing: Boolean = false,
bottomInset: Dp = 0.dp,
listQuery: ListQuery = ListQuery(""),
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
@@ -418,6 +422,7 @@ fun LibraryScreen(
)
var showOptions by rememberSaveable { mutableStateOf(false) }
var searchActive by rememberSaveable { mutableStateOf(false) }
var topRequest by remember { mutableIntStateOf(0) }
val searchFocus = remember { FocusRequester() }
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
@@ -470,15 +475,16 @@ fun LibraryScreen(
) { page ->
val pageTab = LibraryTab.entries[page]
val resetKey = resetKeys[pageTab]
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = syncing,
isRefreshing = refreshing,
onRefresh = onSync,
state = pullToRefreshState,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = syncing,
isRefreshing = refreshing,
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
containerColor = headerColor,
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
@@ -507,6 +513,7 @@ fun LibraryScreen(
topInset = listTopInset,
onSync = onSync,
downloadStatuses = albumDownloads,
scrollToTopKey = scrollToTopKey,
)
}
@@ -526,6 +533,7 @@ fun LibraryScreen(
topInset = listTopInset,
onSync = onSync,
downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey,
)
}
@@ -545,6 +553,7 @@ fun LibraryScreen(
topInset = listTopInset,
onSync = onSync,
downloadStatuses = playlistDownloads,
scrollToTopKey = scrollToTopKey,
)
}
}
@@ -570,11 +579,14 @@ fun LibraryScreen(
) {
LibraryTabs(
pagerState = pagerState,
onTabSelected = onTabSelected,
onTabSelected = { tab ->
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
},
onOpenSettings = onOpenSettings,
offline = offline,
onExitOffline = onExitOffline,
artwork = artwork,
syncing = syncing,
)
}
}
@@ -841,6 +853,7 @@ internal fun LibraryTabs(
offline: Boolean = false,
onExitOffline: () -> Unit = {},
artwork: ArtworkColors?,
syncing: Boolean = false,
) {
val indicatorStretch = 18.dp
val density = LocalDensity.current
@@ -932,11 +945,20 @@ internal fun LibraryTabs(
}
}
IconButton(onClick = onOpenSettings) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Settings,
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> =
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)
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
@@ -206,7 +211,9 @@ class LibraryViewModel(
thumbnail: Boolean,
): 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
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.ScrollToTopOnRequest
import com.subtracks.ui.components.rememberViewportFill
@Composable
@@ -52,6 +53,7 @@ fun PlaylistsContent(
topInset: Dp = 0.dp,
onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
) {
when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -74,6 +76,7 @@ fun PlaylistsContent(
else -> {
val listState = rememberLazyListState()
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
ScrollToTopOnRequest(scrollToTopKey, listState)
val fill = rememberViewportFill(listState)
LazyColumn(
state = listState,
@@ -113,6 +113,8 @@ class QueueViewModel(
var generation by mutableIntStateOf(0)
private set
private var reorderPending = false
private var nextId = 0L
private val mutex = Mutex()
@@ -249,13 +251,20 @@ class QueueViewModel(
fun move(
from: Long,
to: Long,
) {
if (from == to) return
): Boolean {
if (from == to) return false
if (reorderPending) return false
reorderPending = true
viewModelScope.launch {
try {
playbackController.move(from, to)
mutex.withLock { reconcileLocked() }
} finally {
reorderPending = false
}
}
return true
}
fun remove(position: Long) {
viewModelScope.launch {
@@ -331,7 +340,7 @@ fun QueueScreen(
onPlay: (Long) -> Unit,
onRemove: (Long) -> Unit,
onReorder: (Int, Int) -> Unit,
onMove: (Long, Long) -> Unit,
onMove: (Long, Long) -> Boolean,
onLoadOlder: () -> Unit,
onLoadNewer: () -> Unit,
onUndo: () -> Unit,
@@ -347,7 +356,6 @@ fun QueueScreen(
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var dragId by remember { mutableStateOf<Long?>(null) }
var dragFrom by remember { mutableStateOf<Long?>(null) }
val undoLabel = stringResource(R.string.undo)
val queueReordered = stringResource(R.string.queue_reordered)
val queueRemoved = stringResource(R.string.queue_removed)
@@ -470,28 +478,14 @@ fun QueueScreen(
null
} else {
Modifier.draggableHandle(
onDragStarted = {
dragId = row.id
dragFrom = row.position
},
onDragStarted = { dragId = row.id },
onDragStopped = {
val id = dragId
val from = dragFrom
if (id != null && from != null) {
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?.let { id ->
rows.dropMoveFor(id)?.let { move ->
if (onMove(move.from, move.to)) showUndo(queueReordered)
}
}
dragId = null
dragFrom = null
},
)
},
@@ -563,6 +557,19 @@ internal fun dropTarget(
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")
@Composable
private fun QueueRowItem(
@@ -1,6 +1,7 @@
package com.subtracks.ui.settings
import android.content.res.Resources
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -47,11 +49,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
@@ -61,11 +65,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.BuildConfig
import com.subtracks.R
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.SyncMode
import com.subtracks.log.Log
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.rememberViewportFill
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.compose.viewmodel.koinViewModel
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 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
fun SettingsRoute(
@@ -91,32 +101,60 @@ fun SettingsRoute(
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
val mobileQuality by viewModel.mobileQuality.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 downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
SettingsScreen(
sources = sources,
activeSourceId = activeSourceId,
wifiQuality = wifiQuality,
mobileQuality = mobileQuality,
syncConcurrency = syncConcurrency,
syncMode = syncMode,
syncInterval = syncIntervalMinutes,
downloadQuality = downloadQuality,
downloadOverMetered = downloadOverMetered,
scrobbling = scrobbling,
offline = offline,
verboseLogging = verboseLogging,
onSelectSource = viewModel::selectSource,
onEditServer = onEditServer,
onWifiQualityChange = viewModel::setWifiQuality,
onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
onSyncModeChange = viewModel::setSyncMode,
onSyncIntervalChange = viewModel::setSyncIntervalMinutes,
onDownloadQualityChange = viewModel::setDownloadQuality,
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
onScrobblingChange = viewModel::setScrobbling,
onOfflineChange = viewModel::setOfflineMode,
onVerboseLoggingChange = viewModel::setVerboseLogging,
onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads,
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,
dismissals = dismissals,
)
@@ -135,18 +173,25 @@ fun SettingsScreen(
scrobbling: Boolean,
modifier: Modifier = Modifier,
offline: Boolean = false,
verboseLogging: Boolean = false,
syncMode: SyncMode = SyncMode.WifiOnly,
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit,
onSyncModeChange: (SyncMode) -> Unit = {},
onSyncIntervalChange: (Int) -> Unit = {},
onDownloadQualityChange: (StreamQuality) -> Unit,
onDownloadOverMeteredChange: (Boolean) -> Unit,
onScrobblingChange: (Boolean) -> Unit,
onOfflineChange: (Boolean) -> Unit = {},
onVerboseLoggingChange: (Boolean) -> Unit = {},
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onOpenLicenses: () -> Unit,
onShareLogs: () -> Unit,
onBack: () -> Unit,
dismissals: Flow<Unit> = emptyFlow(),
) {
@@ -255,6 +300,22 @@ fun SettingsScreen(
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 {
ListItem(
@@ -321,12 +382,50 @@ fun SettingsScreen(
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 {
ListItem(
headlineContent = { Text(stringResource(R.string.app_name)) },
supportingContent = {
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) },
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 -> {
Unit
}
@@ -429,11 +544,56 @@ private fun bitrateLabel(
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
private val syncIntervalOptions = listOf(15, 60, 360, 720, DEFAULT_SYNC_INTERVAL_MINUTES)
private fun concurrencyLabel(
resources: Resources,
value: Int,
): 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
private fun ConcurrencyDialog(
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
private fun QualityDialog(
title: String,
@@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Source
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.SyncMode
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
@@ -48,6 +50,16 @@ class SettingsViewModel(
.syncConcurrency()
.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> =
userPreferences
.scrobbling()
@@ -55,10 +67,19 @@ class SettingsViewModel(
val offline: StateFlow<Boolean> = sourceRepository.offline
val verboseLogging: StateFlow<Boolean> =
userPreferences
.verboseLogging()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
fun setOfflineMode(enabled: Boolean) {
sourceRepository.setOfflineMode(enabled)
}
fun setVerboseLogging(enabled: Boolean) {
viewModelScope.launch { userPreferences.setVerboseLogging(enabled) }
}
fun selectSource(id: Long) {
viewModelScope.launch { sourceRepository.selectSource(id) }
}
@@ -83,6 +104,14 @@ class SettingsViewModel(
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) {
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
}
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Pesquisar</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_share_logs">Compartilhar logs</string>
<string name="settings_about_actions_support">Apoie o desenvolvedor</string>
<string name="settings_about_name">Sobre</string>
<string name="settings_about_version">Versão %1$s</string>
@@ -28,6 +28,7 @@
<string name="search_input_placeholder">搜索</string>
<string name="settings_about_actions_licenses">许可</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_name">关于</string>
<string name="settings_about_version">版本 %1$s</string>
@@ -28,6 +28,7 @@
<string name="search_input_placeholder">搜尋</string>
<string name="settings_about_actions_licenses">許可</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_name">關於</string>
<string name="settings_about_version">版本 %1$s</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Suche</string>
<string name="settings_about_actions_licenses">Lizenzen</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_name">Über</string>
<string name="settings_about_version">Version %1$s</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Buscar</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_share_logs">Compartir registros</string>
<string name="settings_about_actions_support">Apoya al desarrollador</string>
<string name="settings_about_name">Información</string>
<string name="settings_about_version">versión %1$s</string>
+1
View File
@@ -29,6 +29,7 @@
<string name="search_input_placeholder">Bilatu</string>
<string name="settings_about_actions_licenses">Lizentziak</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_name">Honi buruz</string>
<string name="settings_about_version">%1$s bertsioa</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Recherche</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_share_logs">Partager les logs</string>
<string name="settings_about_actions_support">Soutenir le développeur</string>
<string name="settings_about_name">À propos</string>
<string name="settings_about_version">version %1$s</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Buscar</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_share_logs">Compartir rexistros</string>
<string name="settings_about_actions_support">Axuda ao desenvolvemento</string>
<string name="settings_about_name">Acerca de</string>
<string name="settings_about_version">versión %1$s</string>
+1
View File
@@ -25,6 +25,7 @@
<string name="search_input_placeholder">Keresés</string>
<string name="settings_about_actions_licenses">Licenszek</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_name">Az alkalmazásról</string>
<string name="settings_about_version">%1$s verzió</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Ricerca</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_share_logs">Condividi i log</string>
<string name="settings_about_actions_support">Sostieni lo sviluppatore</string>
<string name="settings_about_name">Informazioni</string>
<string name="settings_about_version">versione %1$s</string>
+1
View File
@@ -28,6 +28,7 @@
<string name="search_input_placeholder">검색</string>
<string name="settings_about_actions_licenses">저작권</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_name">subtracks에 대하여</string>
<string name="settings_about_version">버전: %1$s</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Søk</string>
<string name="settings_about_actions_licenses">Lisenser</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_name">Om</string>
<string name="settings_about_version">versjon %1$s</string>
+1
View File
@@ -32,6 +32,7 @@
<string name="search_input_placeholder">Поиск</string>
<string name="settings_about_actions_licenses">Лицензии</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_name">О Subtracks</string>
<string name="settings_about_version">версия %1$s</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Sök</string>
<string name="settings_about_actions_licenses">Licenser</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_name">Om</string>
<string name="settings_about_version">version %1$s</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">தேடல்</string>
<string name="settings_about_actions_licenses">உரிமங்கள்</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_name">பற்றி</string>
<string name="settings_about_version">பதிப்பு %1$s</string>
+1
View File
@@ -28,6 +28,7 @@
<string name="search_input_placeholder">ค้นหา</string>
<string name="settings_about_actions_licenses">ใบอนุญาต</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_name">เกี่ยวกับ</string>
<string name="settings_about_version">เวอร์ชั่น %1$s</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Ara</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_share_logs">Günlükleri paylaş</string>
<string name="settings_about_actions_support">Geliştiriciyi destekleyin</string>
<string name="settings_about_name">Hakkında</string>
<string name="settings_about_version">sürüm %1$s</string>
+1
View File
@@ -30,6 +30,7 @@
<string name="search_input_placeholder">Введіть заголовок/опис</string>
<string name="settings_about_actions_licenses">Ліцензії</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_name">Про застосунок</string>
<string name="settings_about_version">версія %1$s</string>
+1
View File
@@ -28,6 +28,7 @@
<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_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_name">Về ứng dụng</string>
<string name="settings_about_version">phiên bản %1$s</string>
+26
View File
@@ -2,6 +2,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">subtracks</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_delete">Delete</string>
@@ -20,6 +23,7 @@
<string name="controls_more">More</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_library">Library</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_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_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_music_name">Playback</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_parallel">Parallel server requests while syncing</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_over_mobile">Download over mobile data</string>
<string name="download_network_wifi_and_mobile">Wi-Fi and mobile</string>
+4
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

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))
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()
}
}
@@ -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.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository
@@ -44,6 +45,7 @@ class SyncManagerTest {
private lateinit var sourceRepository: SourceRepository
private lateinit var queueRepository: QueueRepository
private lateinit var manager: SyncManager
private lateinit var preferences: UserPreferences
private lateinit var scope: CoroutineScope
private val messages = CopyOnWriteArrayList<String>()
private lateinit var server: MockWebServer
@@ -62,12 +64,14 @@ class SyncManagerTest {
sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
queueRepository = QueueRepository(db)
preferences = fakeUserPreferences()
manager =
SyncManager(
db,
sourceRepository,
queueRepository,
NetworkServerActionSink(sourceRepository),
preferences = preferences,
showMessage = { messages += it.resolve(resources) },
scope = scope,
)
@@ -89,6 +93,16 @@ class SyncManagerTest {
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
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
@@ -114,6 +128,7 @@ class SyncManagerTest {
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
assertTrue(preferences.lastSyncAt().first() > 0)
}
@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.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.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -13,13 +18,16 @@ import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.charset.StandardCharsets
@RunWith(AndroidJUnit4::class)
class MediaDataSourceTest {
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
fun aDownloadedFileIsReadThroughTheStreamingChain() {
@@ -50,4 +58,44 @@ class MediaDataSourceTest {
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 androidx.media3.common.PlaybackException
import androidx.room3.Room
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
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())
}
@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
fun artistQueueRowsFollowTheFlatShuffleIdOrder() {
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(
albumId: String,
count: Int,
@@ -130,15 +130,54 @@ class ScrobblePolicyTest {
}
@Test
fun restartingTheSameTrackScrobblesAgain() {
fun restartingTheSameTrackResubmitsWithoutReannouncing() {
val item = track(durationMs = 200_000)
play(item, toPositionMs = 100_000)
clock = 120_000L
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())
}
@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
fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() {
val first = track(id = "s1")
@@ -190,4 +190,34 @@ class ScrobblerTest {
enabled.value = true
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 = {},
onOpenDownloads = {},
onOpenLicenses = {},
onShareLogs = {},
onBack = {},
)
}
@@ -505,7 +506,7 @@ class AppScreenshotTest {
onPlay = {},
onRemove = {},
onReorder = { _, _ -> },
onMove = { _, _ -> },
onMove = { _, _ -> true },
onLoadOlder = {},
onLoadNewer = {},
onUndo = {},
@@ -72,7 +72,9 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.awt.image.BufferedImage
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
@@ -269,6 +271,17 @@ class DemoScreenshotTest {
composeRule.waitForIdle()
}
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

Some files were not shown because too many files have changed in this diff Show More