MainActivitySafScan.kt mixes the library scan with unrelated SAF
helpers. resolveSafFile and inspectSafFiles (with its private request
class) serve history file checks, not the scan, so they move verbatim
into SafFileInspection.kt. Signatures and visibility are unchanged.
The 2,100-line file mixed four unrelated groups with the AutoMix tests.
The native-channel fake, analyzer and renderer fakes, the shared setUp
and tearDown, and fakeTimeTest move to
test/support/music_player_automix_harness.dart with public names. Each
top-level group gets its own file (haptics transport, playback failures,
system volume, USB transport); the ungrouped AutoMix, Autoplay, queue
and notification tests stay in music_player_automix_test.dart.
Test bodies are moved verbatim; each file aliases the shared tracks and
wait helper to their previous private names. The test count is
unchanged at 81 (50 + 4 + 5 + 6 + 16).
The startup flush test slept 1.24 s, mostly three 400 ms windows that
must outlast the 350 ms persistence debounce. The body now runs in
VirtualTime, so those waits and the debounce timer advance on virtual
time while platform-channel replies stay real. No production change;
the test body and assertions are unchanged.
The AutoMix tests slept about 17 s of wall time waiting for fades, tempo
recovery and "nothing happens" windows, and the haptics tests 1.15 s.
fake_async is not a direct dependency, so the analyzer rejects importing
it. Haptics tests run as testWidgets on the binding's FakeAsync clock,
which also drives the PlaybackState position extrapolation.
The AutoMix player shares process-wide runtime state (the listening
recorder and personalization write chains), so a FakeAsync zone that
stops after one test would strand later tests. test/support/virtual_time
adds a zone with virtual timers and real microtasks instead: setUp builds
the player in that zone, and fakeTimeTest runs a body with its timers
fired in due order, skipping idle time. Pending timers continue on real
time afterwards.
AutoMix reads its fade and readiness clocks through autoMixStopwatch,
which defaults to Stopwatch.new; tests point it, and the native fake's
deck clocks, at the virtual clock. Test bodies and assertions are
unchanged.
The lookup cache test waited out the 500 ms production debounce four
times (2.7 s). A @visibleForTesting setter lets the test use a 10 ms
debounce while keeping the same 50 to 250 ms margin after the timer
fires for the background encode. The production default stays 500 ms
and the assertions are unchanged.
ExtensionMimeTablesTest fixes the outputs of extFromFileName,
extFromMimeType, SafDownloadHandler.normalizeExt and mimeTypeForExt, and
NativeFinalizationPolicy.normalizeExtension for every table entry plus
unknown, blank, uppercase and dotted inputs. The expected values come
from reading the code; the tables are not inverse maps, so each keeps
its own entries.
The two normalizers trim, lowercase with Locale.ROOT and add one leading
dot, with "" for blank input; checking the dot before or after
lowercasing cannot differ. normalizeExt now delegates to
normalizeExtension, which becomes internal. The test passed against
both original bodies before the delegation.
The MainActivitySafIo helpers declared a MainActivity receiver although
they only use Context members and the core backend. They now take a
Context receiver, and the ones that reach the backend take it as an
explicit first parameter, so a service can call them with its own
backend. buildLibraryCoverCacheKey used neither and is now a plain
function.
Bodies are unchanged apart from passing the backend through to the
helpers they call. Activity callers pass their own coreBackend, so the
same instances reach the same code. The finalizer keeps using
SafDownloadHandler.copyContentUriToTemp; switching it would add the
MediaStore fallback and MIME-based extension, which is a behavior change.
DownloadService and BackgroundWorkService each built the same partial
wake lock (30-minute timeout, 15-minute renew, not reference-counted),
the same dataSync startForeground SDK branch and the same content
PendingIntent to MainActivity. ForegroundWorkLock now holds the lock and
the shared constants, and two small helpers cover the start branch and
the intent.
The renew strategies stay per service: DownloadService renews on its
progress and maintenance calls once 15 minutes have passed, while
BackgroundWorkService re-acquires from a main-thread Handler timer.
Wake-lock tags, channel IDs, notification IDs, intent flags and request
codes are unchanged, and the verification intent keeps its own request
code and extras.
The lyrics, MusicBrainz, platform resolver and availability services each
carried their own copy of the same single-flight machinery: a Flight with
an atomic waiter count, a result slot and condvar, a Waiter drop guard, a
worker check that fails once the last waiter leaves, a 25 ms wait_timeout
poll loop, a MAX_FLIGHTS (64) bound and a finish step that publishes the
result, removes the current flight, decrements the active count and wakes
shutdown.
Move that into crate::coalescer (Flight, Waiter, Coalescer). The table
stays inside each service's own state mutex, so cache lookups, joins and
finish remain atomic with that service's cache and generation. Per-site
rules stay at the call sites: cache keys, what finish caches and when,
error values, thread names, worker post-checks, panic messages,
MusicBrainz's atomic try_join, its spawn under the state lock and its
uncached spawn-failure path. Existing tests only change their access path
to the waiter and active counts.
Drop 63 keys (and their @metadata) that no hand-written code references,
from app_en.arb and every locale file. Groups: 24 upgradeIntro*, 11
downloadLossy*, 10 hiResCheck*, 4 storageMode*, and 14 single keys
(aboutKeepAndroidOpen, actionGo, audioInfoSamples,
backupRestoreConfirmMessage, discographySelectedCount,
editMetadataAutoFillDone/Fetch, historySearchHint, homeSubtitle,
libraryBuiltInPlayerInfo, libraryFolderNotExist, mornyeShowTabs,
selectionShareNoFiles, trackReEnrichOnlineSubtitle).
Usage was verified per key with a whole-word search over lib (excluding
lib/l10n), test, integration_test, android, ios, rust_backend, assets and
third_party. selectionShareCount stays: test/downloaded_album_reenrich_test.dart
calls the generated getter in a findsNothing assertion, so removing it would
break compilation.
flutter gen-l10n, flutter analyze and the l10n-using tests pass. Crowdin
will drop the removed strings on the next sync.
MainActivity.kt is now the dispatcher only. The screen wake,
verification notification, install marker, runtime state, exit,
backend implementation and memory release cases keep their labels and
order in the backend `when` and now call MainActivity.handle<Name>
functions in MainActivityLifecycleCalls.kt; processPendingExtensionCallbacks
stays inline.
Bodies move verbatim. prepareRuntimeState takes the backend that
configureFlutterEngine selected as a parameter, as the case body
captured it before, and flutterBackCallback is widened to internal for
exitApp.
MainActivity.kt should be the dispatcher only. downloadByStrategy, the
download foreground service, background work and native download worker
cases keep their labels and order in the backend `when`; the multi-line
ones now call MainActivity.handle<Name> functions in
MainActivityServiceCalls.kt, and the one-line stop/pause/resume/cancel
cases stay inline.
Bodies move verbatim apart from the receiver label for this@MainActivity.
MainActivity.kt should be the dispatcher only. The cover cache, folder
and SAF tree scan, and scan progress/cancel/pause/resume cases keep
their labels and order in the backend `when` and now call
MainActivity.handle<Name> functions in MainActivityLibraryScanCalls.kt.
Bodies move verbatim. Every handler takes (call, result) so the
dispatch lines stay uniform, even where a body ignores the call.
MainActivity.kt should be the dispatcher only. The metadata, tag,
cover, re-enrich, post-processing and CUE cases keep their labels and
order in the backend `when` and now call MainActivity.handle<Name>
functions in MainActivityMetadataCalls.kt.
Bodies move verbatim apart from the receiver label for this@MainActivity
and re-indenting the FLAC copy-back lines of editFileMetadata, which sat
at the wrong depth.
MainActivity.kt should be the dispatcher only. The backend `when` keeps
every case label in the same order, so the routing set check and the
dispatch order are unchanged, and each SAF case now calls a
MainActivity.handle<Name> function in MainActivitySafCalls.kt.
Bodies move verbatim; the only edit is this@MainActivity becoming the
extension function's own receiver label.
MainActivity.kt mixed the Activity lifecycle and the backend dispatcher
with several self-contained helpers. Give each its own file as
MainActivity extension functions, following the W2-N split:
- MainActivityInstallState.kt: ensureInstallMarker, prepareRuntimeState
- MainActivityExtensionIntents.kt: verification and OAuth intent
handling, processPendingExtensionCallbacks, session-grant events
- MainActivitySafLeases.kt: SAF playback descriptor leases
- MainActivitySmallChannels.kt: the concert_calendar and audio_output
MethodChannels, still registered at the same point and in the same
order in configureFlutterEngine
The state these helpers touch stays in MainActivity and is widened from
private to internal. Bodies move verbatim except that companion members
are qualified with MainActivity. outside the class.
The provider file mixed source management, the scan pipeline and the
cover lookup providers. Move the private notifier methods into
extensions in two parts (_LocalLibrarySources, _LocalLibraryScan) and the
cover request types and providers into a third part. The moves are
verbatim except for one reference to the static heartbeat constant,
which an extension must qualify with the class name. Public notifier
methods, which test subclasses override, stay on the class in the
library file.
The full-scan and incremental branches of _runScan ended with the same
sequence: source state update, last-scanned prefs, summary refresh, the
mounted check, the final state, log and completion notification. Move
that sequence into _finishScan. The only differences, the cover cache
prune after a full scan and the summary text, become parameters; the
summary stays a closure so it still reads the state after the final
update. Each call is the last statement of its branch, so the early
return on unmount keeps the same effect.
The Dart incremental ingestion branch runs only when
PlatformBridge.supportsCoreBackend (Platform.isAndroid || Platform.isIOS)
is false. Its useSnapshotBridge flag required Platform.isAndroid, so it
could never be true there, and the native handlers for
scanSafTreeIncrementalFromSnapshot and
scanLibraryFolderIncrementalFromSnapshot are already gone.
Remove the flag, both PlatformBridge methods, and
LibraryDatabase.writeFileModTimesSnapshot(Map), whose only other user was
test/library_scan_snapshot_test.dart (deleted with it). The live native
snapshot path (writeNativeFileModTimesSnapshot) and the host-test branch
with _backfillLegacyFileModTimes are unchanged.
The track metadata screen's _performConversion re-implemented the batch
conversion pipeline step by step (metadata merge, lyrics, cover temp file,
SAF temp copy, FFmpeg, SAF publish, history or library update, temp
cleanup). Both now call convertLibraryTrack in the batch controller, which
returns a LibraryTrackConversionResult that each caller maps to its own
reporting: batch failure summaries and log lines, or the screen's
existing snackbars.
Caller differences stay as parameters: the cover temp prefix
(batch_cover_ or convert_cover_), whether an empty SAF temp path fails,
the history SAF file name fallback, the screen's eager temp deletes, its
provider reload right after the row is written, its warnings, its cleanup
log text and its lazy artistTagMode read.
One deliberate difference: the screen now reads the track path, item and
audio fields once before the first await instead of re-reading widget state
after each await. Swiping to another track mid-conversion previously published
and recorded the output against the newly shown track; it now stays on the original.
The completion bridge maps, the precache set, the History row matcher and the
bodies of the queue lookup and History index listeners lived on the Library
tab State. CompletionBridgeTracker in lib/controllers now owns them. The two
ref.listen registrations stay in build at the same positions and call into
the tracker; the State forwards the old field names as getters so the part
files read the same collections.
Guards, the setState that drops removed items, the file check refresh order
(file cache, then playable probe), the offset reset and the mounted rebuild
after a History index change are unchanged.
The Library tab State held the page offsets, the load-more guard, the count
and page snapshot caches, the blank-library repair throttle and their trim
and scope rules inline. QueueLibraryPager in lib/controllers now owns that
state and logic; the State keeps one _pager field and forwards, passing
setState, mounted and the provider reloads as callbacks.
The page request and page data types implement two small interfaces so the
pager can compare scopes and test emptiness without seeing the private
types. Provider reads, page size, trim limits, the repair throttle and the
order of cache writes and setState calls are unchanged.
The map and list lookup caches had twin entry classes and twin put and
prune helpers that differed only in the value type. Replace them with
_BridgeCacheEntry<T>, _putMemoryCached<T> and _pruneExpiredBridgeCache<T>.
Each cache keeps its own map, TTL and budget accounting; the byte estimate,
shared budget constants and oldest-first eviction are unchanged.
LibraryCollectionsNotifier carried about 130 lines of File and Directory
work for playlist covers. PlaylistCoverStore now owns the playlist_covers
directory, the <playlistId>.jpg normalization with its temp file, the
backup restore copy or base64 write, and the delete helpers. The
directory is still resolved through getApplicationSupportDirectory on
each call, at the same points as before.
The notifier keeps setPlaylistCover, removePlaylistCover,
exportPlaylistCoverFiles and restoreFromBackup with unchanged signatures,
mutation ordering and error handling: a failed restore of one cover still
drops that cover path, removePlaylistCover still propagates delete
errors, and replacing a cover still deletes the old file quietly.
The cache management page walked and deleted directories itself. Move
_scanDirectory and _clearDirectoryContents verbatim into
CacheMaintenanceService.scanDirectory and clearDirectoryContents in
lib/services/cache_maintenance_service.dart, with _DirectoryStats
becoming the public DirectoryStats it returns.
The page calls the service at the same points with the same arguments,
so scan concurrency, the 24-entity delete chunks, the directory
re-creation after clearing and every action key are unchanged.
The card ran ffprobe, both FFmpeg sessions, SAF temp copies, the
native spectral-cutoff job and its cancellation inside State. Move that
pipeline verbatim into AudioAnalysisRunner in
lib/services/audio_analysis_runner.dart together with the private
per-run records from the widget's models part, which is now empty and
removed.
The card keeps request ids and every setState. The runner takes a
`bool Function() isCancelled` that the card builds from the same
`!mounted || requestId != _spectrogramRequestId` test, polled at the
same points that used to call _checkAnalysisRequest. cancel() and
dispose() run the jobs invalidate/dispose, network cancel and native
job cancel in the original order.
Subprocess order, temp file names and cleanup, SAF copy and the native
data job payload are unchanged.
The card owned the bundle storage (key derivation, {key}.json,
{key}.png and {key}_ch{n}.png, the shared trim budget) as static
members, and the cache page spelled the audio_analysis_cache directory
literal twice more.
Move the storage verbatim into AudioAnalysisDiskCache next to the
existing lease/trim policy. AudioAnalysisDiskCache.directoryIn is now
the only place that names the directory; the card's cacheDir and both
cache page call sites use it, with the page keeping its non-creating
behavior and await order. loadFromCache takes the card's network input
as a parameter instead of reading the field.
The 63-bit FNV-1a cache key stays local: lib/utils/hash_utils.dart
fnv1a32 uses a different basis, prime and width, so reusing it would
rename every cached bundle on devices.
The audio analysis card mixed FFmpeg argument builders, astats and
metadata parsers and the spectral cutoff estimator with widget state.
Move them verbatim into lib/services/audio_analysis_ffmpeg.dart so the
runner and cache can depend on them without importing a widget.
The parsers return ChannelAnalysisStats, so the two public result
models (AudioAnalysisData, ChannelAnalysisStats) move verbatim from the
widget's models part into lib/services/audio_analysis_models.dart. The
per-run private records stay in the part for now.
Only two names change: the unsupported-codec exception loses its
leading underscore because the widget and the future runner live in
different libraries, and the in-class _parseChannelStats becomes the
top-level parseChannelStats. The widget re-exports both service files,
so tests and importers are unchanged.
The listening summary and system volume providers lived in UI files.
They move verbatim to lib/providers with the same names; the old files
re-export them so tests and other importers are unchanged.
More than half of the crate files had no module doc, so a contributor had to
read a large file top to bottom to learn what it owns. Every file over 300
lines now states its responsibility in one or two lines.
The store list and the detail screen both built the temp and extension
directories before calling the notifier. The notifier now offers
installExtensionToDefaultPaths and updateExtensionToDefaultPaths; the
existing methods keep their signatures.
The lyrics card and the rewind slide both rendered a RepaintBoundary to a
temp PNG. captureBoundaryToPngFile does that; pixel ratio, file name, frame
waiting and share parameters stay with each caller.
The create, picker and rename dialogs were three copies of one form. They
now call promptPlaylistName, whose controller lives in a dialog state and is
disposed with it.
artist_screen.dart mixed the artist page with the whole discography
download flow (option sheet, album fetching, queueing and its progress
dialog). Moving that flow into its own part keeps one responsibility per
file and shrinks the screen file. Bodies are moved verbatim.
Three screens repeated the pick, parse and store sequence. The helper keeps
each site's call order; the SAF migration prompt skips the storage mode
switch as before.
Search bucketing, refresh policy, folder data, history snapshot and the
selection and collapsing-header mixins hold no widgets, so they move to
controllers; the settings search catalog is pure presentation metadata and
moves to utils. Files with many importers keep a re-export in screens.
library_database_models and ffmpeg_models are plain data types, so they live in
models (re-exports stay at the old paths). deleted_library_files uses providers
and download_progress uses provider state, so both move to providers, and
ffmpeg_reenrich is a service. models no longer import from services for these.
Batch actions, clickable metadata, playback, auth launcher, action forms and
the glass warmup drive UI flows, so they belong with the controllers. Files
with more than three importers (or an importer that must stay untouched) keep
a one-line re-export at the old path.
Six writers repeated the same publication sequence: create a temporary file
in the target directory, write it, sync_all, optionally run a cancellation
check, then NamedTempFile::persist over the target. `core::fs::write_atomic`
runs exactly that order and optionally fsyncs the directory afterwards
(best effort, as extension storage did).
Each site keeps its temporary-file prefix (`.spotiflac-` for extension
storage, `.spotiflac-m3u-` for M3U export, tempfile's `.tmp` default,
exposed as `DEFAULT_TEMP_PREFIX`, for the registry cache, lyrics cache and
both network cover writers), its check placement between fsync and rename,
and its error text: `WriteAtomicError` displays a persist failure with
tempfile's own message, and converts to the underlying io::Error where the
site previously used `PersistError::error`.
Not converted, because their publication differs: playlist staging and
collection snapshots use persist_noclobber (collections also skips fsync),
the backup restore stages into a temporary directory, and the extension
package download maps each step to its own error context.
spotiflac-core now depends on the workspace `tempfile` already used by the
other crates; the lockfile only gains that edge.
Keep one copy of helpers that were implemented several times, each proven
equivalent to the copies it replaces by a test that runs the removed
implementation (kept inline in the test module) against the survivor:
- Go-style lexical path clean: new `core::fs::clean`, used by
`isrc::native_files` and re-exported as `extensions::files::clean`.
- Dart `String.trim` whitespace: `path_keys::dart_whitespace` and
`dart_trim` now back the lyrics parser's explicit character list and the
playlist importer's private `trim`; the test compares every Unicode
scalar value.
- `normalized()` (Dart trim then simple lowercase): the native backup
importer uses the identical `native_library::normalized`.
- Lowercase hex encoding: `spotiflac_core::lower_hex` replaces the per-byte
`format!` collectors in data_jobs and network_tags, the signed-session
`hex` and the `hex` arm of `binary::encode`; checked for all 256 bytes.
- ID3 syncsafe decode: the tag writer's `size` reuses the reader's
`tags::id3::syncsafe`; checked across the 0x7F/0x80 boundaries per byte.
- Bounded body reads: `HttpStream::read_limited` replaces the read-to-limit
loops of the lyrics HTTP client, the resolver HTTP client and the
extension registry reader. Callers keep their buffer sizes (16 KiB,
16 KiB, 64 KiB), their own error mapping and their messages; a loopback
test compares it with the previous loop at and around the limit.
The mobile ID3 `synchsafe` is not merged: it validates the length and the
high bits and returns `None`, unlike the core decoder.
The case-folding, null-tolerant struct decoder (`go_deserialize!` with
`field_name`, `KeySeed`, `Update` and `Field`) lived in `core::lyrics::json`
although downloads, metadata and resolver payloads depend on it. Move it
unchanged to a neutral `core::json` module and name the macro after its
behavior, `lenient_deserialize!`. All users are inside spotiflac-core, so no
compatibility re-export is kept. The lyrics module keeps only its
surrogate-normalizing `decode` helper.
`field_name` is now public so the extension registry decoder reuses it
instead of its own copy of the same folding. Its matcher folds the key once
and compares ASCII case-insensitively against the wire name, which a new
test checks against the previous matcher on edge inputs (empty, long s,
Kelvin sign, combining marks, non-ASCII letters, BOM, NUL).
The visitor's `expecting` text is unchanged so decode errors stay
byte-identical.
_saveCoverArt and _saveLyrics repeated the same SAF/local publish
skeleton: render into a temp dir and publish it to the download tree
with createSafFileFromPath (or write next to the audio file), clean the
temp dir, then report the outcome. Move that skeleton into
_publishSidecarFile with parameters for the temp prefix, extension, mime
type, success message and whether a snackbar hides the current one.
Each caller keeps its own try/catch, base name, lyrics progress
snackbar and the render step. The cover render returns null after its
no-source snackbar so the helper stops at the same point as before
(without deleting the temp dir, as before). Await order, mounted checks,
messages and cleanup placement are unchanged.
NT-16 Kotlin part. The application method dispatch (about 290 lines) moves
verbatim into RustCoreBackendApplication.kt as an internal extension
`RustCoreBackend.dispatchApplication`. The `invokeApplication` override
stays in SelectedCoreBackend.kt as a one-line delegate. `synchronized(this)`
still locks the RustCoreBackend object.
The object state and helpers the dispatch uses are widened from private to
internal; method names, argument keys, results and error strings are
unchanged.