Owner decision D1 keeps docs/ ignored, so the architecture guide lives in the tracked CONTRIBUTING.md as an extension of Project Boundaries: a map of lib/, the Rust crates and the native folders, where each feature lives, the download and playback data flows, recipes for adding a setting, a screen, a channel method, a lyrics provider and an extension-facing API, test layers, and code conventions. Folder READMEs written by unit W5-B are linked, not duplicated. Facts were taken from the code, not from audits: the lib/ dependency column comes from scanning every import line per folder, crate edges from each Cargo.toml, and each data-flow step from reading the call path function by function. A script checked that every backticked path added here exists (at the root or as a tracked-file suffix) and that every identifier is found with a whole-word grep; the remaining tokens are templates or literals that were confirmed by hand.
33 KiB
Contributing to SpotiFLAC Mobile
Thank you for helping improve SpotiFLAC Mobile. Bug reports, focused pull requests, documentation, and translations are all welcome.
Please follow the Code of Conduct when participating in the project.
Before You Start
- Search the existing issues before opening a new one.
- Use the issue template that best matches the problem.
- Keep pull requests focused. Separate unrelated fixes into separate PRs.
- Never commit credentials, signing files, downloaded media, or generated build artifacts.
Translations are managed through the
SpotiFLAC Mobile Crowdin project.
The English source strings live in lib/l10n/arb/app_en.arb.
Toolchain
The repository is the source of truth for tool versions:
- Flutter:
.fvmrc - Dart: bundled with the pinned Flutter SDK
- Rust:
rust_backend/rust-toolchain.tomlandrust_backend/Cargo.lock - Android SDK, NDK, and Java:
.github/workflows/ci.yml - Xcode: required only for iOS builds
FVM is recommended. If you do not use FVM, install the
exact Flutter version declared in .fvmrc and replace fvm flutter with
flutter (and fvm dart with dart) in the commands below.
Development Setup
-
Fork and clone the repository:
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git cd SpotiFLAC-Mobile git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git -
Install the pinned Flutter SDK and Dart dependencies:
fvm install fvm flutter pub get -
Install Rust with rustup, then run
(cd rust_backend && rustup show)to activate the pinned toolchain. Install Clang/libclang for the native bindings (libclang-devon Ubuntu, Xcode command-line tools on macOS). Android Gradle builds the Rust libraries and bindings automatically. For a manual host build, runbash scripts/build_rust_backend.sh host. -
Run the app:
fvm flutter run --dart-define="GIT_COMMIT=$(git rev-parse --short=8 HEAD)"
The About footer shows the short commit supplied through GIT_COMMIT at
compile time. The Android build script and iOS release workflow supply it
automatically. Include the same --dart-define when running Flutter build
commands directly; without it, the footer shows only the copyright.
Building the App
Android APKs
For production releases, stage the official Discord Social SDK with
bash scripts/setup_discord_sdk.sh /path/to/discord_social_sdk, then build:
bash scripts/build_android.sh --production
Production mode requires the staged SDK and rejects --lite. Release CI uses
this mode and requires Discord on both Android and iOS. Fresh hosted runners
must prepare the SDK before the release checks can pass. See
Discord build setup for
local staging and the CI decryption secret. Unencrypted SDK files are not
committed to the repository.
For development release APKs, use bash scripts/build_android.sh. Full builds
remain the default; add --lite to omit the optional Discord SDK while keeping
playback and downloads. Debug builds can also run without the SDK.
To build only ARM64, set SPOTIFLAC_RUST_ANDROID_ABIS=arm64-v8a. The script uses
the Flutter version in .fvmrc and audits the selected split APKs and universal
APK. Android Gradle builds the Rust native artifacts automatically.
iOS Builds
On macOS, run bash scripts/build_ios.sh, then (cd ios && pod install) before
opening ios/Runner.xcworkspace. The application uses the Rust backend.
For iOS Lite, run CocoaPods and the app build with SPOTIFLAC_DISCORD_SDK=0.
An existing Pods installation must be regenerated with that setting. Production
release CI requires the staged Discord SDK on iOS as well.
For native iOS codec checks, see the FFmpeg capability probe.
Android Crash Symbols
Android release builds, including plain flutter build apk --release, keep Dart
debug data in build/symbols/android/app.<architecture>.symbols to reduce APK
size. The release script also uses --obfuscate to shorten internal Dart names;
when building directly, add --obfuscate --split-debug-info=build/symbols/android
for the smaller APK. An explicit --split-debug-info path overrides the default;
plain --analyze-size builds retain Flutter's normal behavior.
Keep these symbols with the matching APK before another local build replaces them. Decode a trace with:
fvm flutter symbolize --debug-info=<symbols-file> --input=crash.txt
CI and release jobs retain an android-symbols-… artifact with the commit, APK
SHA-256 hashes, Dart symbols, and R8 mapping, identified by run and attempt.
Archive it before its 90-day retention expires and match the reported APK hash
when choosing symbols; equal version numbers do not guarantee a match.
Project Boundaries
lib/ Flutter UI, state, models, and platform orchestration
rust_backend/ Production backend, native bindings, and unit tests
android/ Android platform bridge and foreground worker
ios/ iOS platform bridge and application project
test/ Flutter unit and widget tests
integration_test/ Device and emulator tests (including performance drivers)
third_party/ Vendored and patched packages (audio_service, audioplayers forks,
Discord bridge and encrypted SDK)
assets/ Images, fonts, and bundled resources
docs/ Local documentation and migration archives (gitignored)
scripts/ Reproducible project build helpers
SpotiFLAC Mobile is extension-driven. Extension-specific behavior must be
declared through a generic manifest field, capability, or reusable app API.
Do not add provider-name checks such as if source == 'provider-name' to the
main app. The backend should parse and expose the generic declaration, and Dart
should consume that declaration without knowing which extension uses it.
The sections below expand this layout. Folder guides hold the details that are
not repeated here: android/README.md (native channel
contract), rust_backend/README.md (crate map),
test/README.md,
integration_test/README.md,
lib/theme/README.md, and the SPOTIFLAC_PATCHES.md file
in each third_party/ fork, for example
audio_service.
Codebase Map
Flutter (lib/)
App files import each other as package:spotiflac_android/.... The intended
direction is screens and widgets on top, then controllers, providers, services,
and models and utilities at the bottom. The last column is the current state
found by scanning import lines, including today's exceptions; do not add new
ones.
| Folder | What belongs there | Imports today |
|---|---|---|
main.dart, app.dart |
Startup, ProviderScope, SpotiFLACApp, and the GoRouter (/, /setup, /tutorial) |
Any folder |
screens/ |
Routes and tab roots; large screens split into <screen>_*.dart files; settings pages in screens/settings/ |
Any folder |
widgets/ |
Reusable widgets and design-system components | Any folder except screens/; exceptions: library_search_results.dart, artist_concerts_button.dart, mini_player.dart |
controllers/ |
UI logic shared by screens and widgets: selection mixins, batch actions, the metadata editor | controllers, widgets, providers, services, models, utils, theme, l10n; clickable_metadata.dart also imports screens/ |
providers/ |
Riverpod notifiers and state; large notifiers split into part files |
providers, services, models, utils, constants |
services/ |
PlatformBridge, SQLite stores, the music player, FFmpeg, network storage, notifications |
services, models, utils, constants; exceptions: re-export stubs into providers/ and controllers/, and notification_service.dart imports l10n/ |
models/ |
Data classes and their json_serializable output (*.g.dart) |
models, utils; library_database_models.dart imports sqlite_helpers.dart from services/ |
utils/ |
Small helpers, pure policies, and the settings search catalog | utils, models, constants, l10n; seven files import services/ (for example file_access.dart, logger.dart), saf_download_tree.dart imports providers/, three re-export stubs point into controllers/ |
theme/ |
AppTheme, AppTokens, motion, icons, and the Mornye theme |
theme, models; dynamic_color_wrapper.dart imports providers/, cover_palette.dart imports widgets/ and services/ |
l10n/ |
ARB sources in arb/, the context.l10n extension in l10n.dart, and supported_locales.dart; app_localizations*.dart is generated |
utils |
constants/ |
App info, language choices, MusicServices IDs |
l10n |
routes/ |
now_playing_route.dart |
services, theme |
No provider or service imports screens/ or widgets/. Among services, only
the navigation, orientation, music player, and player widget services import
Flutter's material.dart or widgets.dart; among providers, the preview
player, extension, download queue, download queue policy and theme providers
do.
Rust (rust_backend/crates/)
Workspace dependencies point only downward:
| Crate | Package | Role | Depends on |
|---|---|---|---|
core/ |
spotiflac-core |
Shared logic without bindings: tags, lyrics parsing and provider config, metadata, filenames, CUE, covers | none |
network/ |
spotiflac-network |
Shared, cancellable HTTP transport | none |
providers/ |
spotiflac-providers |
Built-in clients: Deezer, MusicBrainz, platform resolver, lyrics | core, network |
extensions/ |
spotiflac-extensions |
Extension JavaScript runtime (rquickjs), host functions, manager, repository, and backend operations such as downloads, library scan, and tags | core, network, providers |
mobile/ |
spotiflac-mobile |
UniFFI API, mostly methods on ExtensionManager; built as cdylib and staticlib; Kotlin package com.spotiflac.backend, Swift module SpotiFLACBackend |
core, network, extensions |
bindgen/ |
spotiflac-bindgen |
UniFFI bindgen CLI used by scripts/build_rust_backend.sh |
none |
Native
Android Kotlin lives in android/app/src/main/kotlin/com/zarz/spotiflac/.
android/app/build.gradle.kts adds android/app/src/rust/kotlin/com/zarz/spotiflac/
and the generated bindings to the same source set.
| Files | Role |
|---|---|
MainActivity.kt |
Backend channel com.zarz.spotiflac/backend, the nativeBackendMethods set, and the when (call.method) dispatch |
MainActivity*Calls.kt, MainActivitySaf*.kt, MainActivityProgressStreams.kt, MainActivitySmallChannels.kt |
MainActivity.handleX(call, result) extension functions grouped by area, progress EventChannels, small channels |
DownloadService*.kt, BackgroundWorkService.kt |
Foreground services: the native download worker and its snapshot, other background work |
NativeDownloadFinalizer.kt, NativeFinalizer*.kt, NativeHistoryStore.kt |
Post-download finalization and the native history write |
Saf*.kt |
Storage Access Framework helpers |
UsbBitPerfectPlugin.kt, Usb*.kt, BitPerfectPcm.kt, Dsd*.kt, HiResPlayback.kt, NativeAudio.kt |
Bit-perfect and USB playback (com.zarz.spotiflac/usb_pcm) |
PlayerWidget*.kt, DownloadQueueWidgetProvider.kt |
Home-screen widgets |
CoreBackend.kt; SelectedCoreBackend.kt, RustCoreBackendApplication.kt, NativeDataJobs.kt |
The CoreBackend interface and its Rust implementation RustCoreBackend, which maps method names to UniFFI calls |
iOS code lives in ios/Runner/: AppDelegate.swift (backend channel,
handleMethodCall and its osMethods set, progress EventChannels, background
tasks), CoreBackend.swift (Rust calls through SpotiFLACBackend,
invokeApplication), and smaller bridges such as PlayerWidgetBridge.swift
and ConcertCalendarBridge.swift. The WidgetKit extension is in
ios/PlayerWidget/.
Where Each Feature Lives
Dart paths are under lib/, Rust paths under rust_backend/crates/, and
Kotlin files in the Android folders above.
| Feature | Dart | Rust | Native |
|---|---|---|---|
| Search and URL import | providers/track_provider.dart (TrackNotifier), controllers/home_search_logic.dart |
extensions/src/backend/provider_metadata.rs, mobile/src/metadata.rs |
RustCoreBackendApplication.kt, CoreBackend.swift |
| Download | providers/download_queue_provider.dart and its download_queue_provider_*.dart parts, services/download_request_payload.dart |
extensions/src/backend/downloads.rs, mobile/src/manager.rs |
DownloadService.kt, SafDownloadHandler.kt, NativeDownloadFinalizer.kt |
| Queue persistence and history | services/download_queue_persistence.dart, providers/download_history_provider.dart, services/history_database.dart, services/history_schema.dart |
none | NativeHistoryStore.kt, NativeFinalizerHistory.kt, DownloadServiceSnapshot.kt |
| Local library scan | providers/local_library_provider.dart and its local_library_provider_*.dart parts, services/library_database.dart |
extensions/src/backend/library/scan.rs, mobile/src/tags.rs, mobile/src/native_library.rs |
MainActivityLibraryScanCalls.kt; SAF trees in MainActivitySafScan.kt and the Saf*.kt files |
| Playback and queue | providers/playback_provider.dart, providers/music_player_provider.dart, services/music_player_service.dart (automix and autoplay parts), services/music_player_runtime.dart, services/music_playback_deck.dart |
AutoMix analysis: core/src/native_audio_analysis.rs via mobile/src/data_jobs.rs |
UsbBitPerfectPlugin.kt; forks in third_party/audio_service, third_party/audioplayers_android, third_party/audioplayers_darwin |
| Lyrics | utils/lyrics_parser.dart, utils/lyrics_metadata_helper.dart, controllers/player_metadata_controller.dart |
providers/src/lyrics/, core/src/lyrics/, extensions/src/lyrics.rs, mobile/src/lyrics.rs, mobile/src/native_lyrics.rs |
SelectedCoreBackend.kt, CoreBackend.swift |
| Metadata editing and conversion | controllers/metadata_editor_controller.dart, screens/track_metadata_edit_sheet.dart, screens/track_metadata_convert.dart, services/ffmpeg_service.dart (FFmpeg Kit through services/ffmpeg_support.dart) |
core/src/tags/, extensions/src/backend/tags.rs, mobile/src/tags.rs |
MainActivityMetadataCalls.kt |
| Extensions and repository | providers/extension_provider.dart, providers/repo_provider.dart, models/extension.dart, screens/settings/extensions_page.dart, screens/repo_tab.dart |
extensions/src/manifest.rs, extensions/src/runtime.rs, extensions/src/manager/, extensions/src/repository/, mobile/src/extensions.rs, mobile/src/repository.rs |
RustCoreBackendApplication.kt, ExtensionAuthCallbacks.kt, MainActivityExtensionIntents.kt, ExtensionCallbackParser.swift |
| Settings | providers/settings_provider.dart, models/settings.dart, screens/settings/, utils/settings_search_catalog.dart |
none; values the backend needs are pushed by the _sync*ToBackend methods of SettingsNotifier |
none |
| Statistics and Rewind | providers/listening_statistics_provider.dart, services/listening_statistics.dart, providers/listening_rewind_provider.dart, services/listening_rewind.dart |
none | none |
| Widgets and notifications | services/player_widget_service.dart, services/notification_service.dart, services/playback_notification.dart, providers/playback_notification_provider.dart |
none | PlayerWidgetProvider.kt, DownloadQueueWidgetProvider.kt, notifications in DownloadService.kt, PlayerWidgetBridge.swift, ios/PlayerWidget/ |
Data Flow
Download (Android native worker)
downloadSingleTrack(lib/widgets/track_detail_actions.dart) callsDownloadQueueNotifier.addToQueue, which appends aDownloadItem, saves the queue, and schedules_processQueue._processQueueSingleFlight(lib/providers/download_queue_provider.dart) checks storage and network mode, then calls_tryProcessQueueWithAndroidNativeWorker(lib/providers/download_queue_provider_native_worker.dart). It proceeds only when_canUseAndroidNativeWorkerholds: Android,nativeDownloadWorkerEnabledanduseExtensionProviderson, an enabled extension with a download provider, a download tree URI when SAF storage is in use, and no network-folder items.- It builds the first request with
_buildAndroidNativeWorkerRequestand callsPlatformBridge.startNativeDownloadWorker, which writes requests and settings to JSON files and sends their paths. Remaining items follow withappendNativeDownloadWorkerRequestsandfinishNativeDownloadWorkerPreparation. MainActivity.handleStartNativeDownloadWorker(MainActivityServiceCalls.kt) callsDownloadService.startNativeQueueFromFiles, which starts the foreground service.onStartCommandrunsstartNativeWorker, thenrunNativeWorkerConcurrentandprocessConcurrentNativeRequestper item.SafDownloadHandler.handlecallsCoreBackend.downloadByStrategy. The Rust implementation inSelectedCoreBackend.ktcalls the UniFFIExtensionManager.downloadByStrategy(mobile/src/manager.rs), which runsdownload_by_strategyinextensions/src/backend/downloads.rs.- On success,
NativeDownloadFinalizer.finalizefinishes the file and, whensave_download_historyis set, writes the row withNativeHistoryStore.upsert.writeNativeWorkerSnapshot(DownloadServiceSnapshot.kt) publishes item state. NativeDownloadWorkerMonitor.runpollsPlatformBridge.getNativeDownloadWorkerSnapshot._applyAndroidNativeWorkerSnapshotupdates the items and acknowledges them withacknowledgeNativeDownloadWorkerItems;_completeAndroidNativeWorkerItemcallsreconcileNativeDownloadHistory(lib/services/native_download_history.dart) soDownloadHistoryNotifieradopts or reloads the native row.
Without the native worker (iOS, or when the gate fails), items run through
_downloadSingleItem (lib/providers/download_queue_provider_single_item.dart),
which calls PlatformBridge.downloadByStrategy, finalizes in Dart, and records
history with DownloadHistoryNotifier.addToHistory. Android handles that call
in handleDownloadByStrategy; iOS in CoreBackend.swift.
Playback
- A screen calls
PlaybackController(lib/providers/playback_provider.dart), for exampleplayTrackListfromlibrary_tracks_folder_screen.dart. WhenplayerModeis notinternaland the source is notnetwork://, it opens the file in an external app instead. MusicPlayerController.playAll(lib/providers/music_player_provider.dart) callsensureInitialized, which runsMusicPlayerRuntime.initialize(lib/services/music_player_runtime.dart), which creates theMusicPlayerHandlerthroughAudioService.initfrom thethird_party/audio_servicefork, then callssetQueueAndPlay.MusicPlayerHandler.setQueueAndPlay(lib/services/music_player_service.dart) publishesqueue, then_playIndexand_loadIndexpublishmediaItemand call_startResolvedSource._startResolvedSourcecallsMusicPlaybackDeck.setSourceorsetNetworkSourceandresume(lib/services/music_playback_deck.dart). The deck wraps an audioplayersAudioPlayer, served by thethird_party/audioplayers_androidandthird_party/audioplayers_darwinoverrides; with USB bit-perfect enabled on Android it first tries to prepare the file throughUsbBitPerfectPlugin.kt.- Player events reach
_broadcastState, which updatesplaybackState.MusicPlayerRuntime.playbackStateEvents,mediaItemEvents, andqueueEventsfeedplaybackStateProvider,currentMediaItemProvider, andplayQueueProvider, which the mini player and Now Playing screen watch.
How to Add Things
A Setting
- Add the field and its default to
AppSettings(lib/models/settings.dart, constructor andcopyWith), then runbuild_runner(see Generated Files) to refreshsettings.g.dart. SettingsNotifierstoresAppSettings.toJson()as one JSON string under the SharedPreferences keyapp_settings, so the JSON key is the Dart field name. Renaming a field changes the stored key. Use a@JsonKey(fromJson: ...)helper to clamp stored values, as the AutoMix fields do.- Add a setter to
SettingsNotifier(lib/providers/settings_provider.dart):state = state.copyWith(...)followed by_saveSettings(). If the backend needs the value, push it as_syncLyricsSettingsToBackenddoes. - Rewrites of existing stored values go in
_runMigrations: bump_currentMigrationVersionand guard the step withif (lastMigration < N). - Add the row to the owning page in
lib/screens/settings/and aSettingsSearchEntryto the matching list inlib/utils/settings_search_catalog.dart. - Add strings to
lib/l10n/arb/app_en.arbwith an@keydescription and read them throughcontext.l10n. - Test with the JSON round trip in
test/app_settings_model_test.dart, setter and persistence cases liketest/settings_persistence_test.dart(aSettingsNotifiersubclass over a fakeSharedPreferences), andtest/settings_search_test.dart.
A Screen
- Put it in
lib/screens/(settings pages inlib/screens/settings/) and split a large screen into<screen>_*.dartfiles. - Push it with
Navigator.pushand aMaterialPageRouteorslidePageRoute(lib/widgets/animation_utils.dart). Tabs keep their ownNavigatorfrom_TabNavigatorinlib/screens/main_shell.dart;GoRouterinlib/app.dartowns only/,/setup, and/tutorial. - Use the shared components:
AppSliverHeader,showAppBottomSheetandAppSheetHandle,SettingsGroup, and theAppTokensscale. - A track collection screen uses
CollectionScaffoldandSelectionModeMixin, and its file name joins thescreenslist in the "every collection screen goes through the shared shell" test. test/design_system_test.dartscans every file inlib/: eachIconButtonandAppIconButtonpassestooltip, and only allowlisted files may containexpandedTitleScale(app_sliver_header.dart), a hand-built drag handle (app_bottom_sheet.dart), a modal sheet shape (app_theme.dart), orOverlayEntry((selection_bottom_bar.dart,app_menu_button.dart).
A Platform Channel Method
- Dart: add a static method to
PlatformBridgeinlib/services/platform_bridge.dartthat calls_channel.invokeMethodwith snake_case argument keys; helper classes and codecs can go in a part underlib/services/platform_bridge/. Decode JSON results with_decodeJsonResultAsyncor_decodeRequiredMapResultAsync. - Rust-backed methods: on Android a name outside
nativeBackendMethodsgoes throughdispatchBackendApplication(SelectedCoreBackend.kt) toRustCoreBackend.dispatchApplication(RustCoreBackendApplication.kt); add the case there. On iOS add it toinvokeApplicationinios/Runner/CoreBackend.swift. - Android-only methods: add the name to
nativeBackendMethodsand a case to thewhen (call.method)block inMainActivity.kt, calling aMainActivity.handleXfunction in the matchingMainActivity*Calls.ktfile.MainActivityRoutingTest.ktfails when the set and the cases differ. - iOS-only methods: add the name to
osMethodsinhandleMethodCall(ios/Runner/AppDelegate.swift) and handle it there or ininvokeOsMethod.test/source_contracts_test.dartpins parts of that file. - Large JSON:
bridgeJsonResultinMainActivity.ktandAppDelegate.swiftwrites a result of 256 KiB or more to a temporary file and returns{"__json_file": "<path>"}; Dart reads and deletes it. Use it for any result that can grow with the library. - New Rust entry points are
#[uniffi::export]items inrust_backend/crates/mobile/src/; the build regenerates bindings (see Development Setup). - Dart tests stub
com.zarz.spotiflac/backendwithsetMockMethodCallHandler, for exampletest/extension_initialization_test.dart.
A Lyrics Provider
- Provider-specific sources fit an extension first: installed lyrics
extensions appear in the provider order as
extension:<id>entries, andInstalledLyricsFetcher(rust_backend/crates/extensions/src/lyrics.rs) lists and calls them. - A built-in provider is a
pub(super) fnonBuiltinLyricsClientin a file underrust_backend/crates/providers/src/lyrics/builtin/(apple.rs,direct.rs,proxy.rs,ttml.rs), taking&SearchRequestand&Check<'_>and returningResult<LyricsResponse, LyricsError>. - Add its match arm to
BuiltinLyricsClient::fetch_once(rust_backend/crates/providers/src/lyrics/builtin.rs). - Add the ID to
BUILTIN_PROVIDERSandavailable_providersinrust_backend/crates/core/src/lyrics/config.rs;normalize_provider_orderdrops unknown IDs.DEFAULT_PROVIDERSmirrors the Dart defaultAppSettings.lyricsProviders; changing either for existing users needs a settings migration (see Migration 13 in_runMigrations). - Dart UI:
_builtInProviderIdsand_getLyricsProviderInfoinlib/screens/settings/lyrics_provider_priority_page.dart,_providerDisplayNamesinlib/screens/settings/lyrics_settings_page.dart, and a description key likelyricsProviderLrclibDesc. - Tests:
rust_backend/crates/providers/src/lyrics/service/tests.rs, plus the shared usability cases described in Validation.
An Extension-Facing API
- The manifest contract is
rust_backend/crates/extensions/src/manifest.rs. Host functions are registered byregisterinrust_backend/crates/extensions/src/host.rsand the*_host.rsmodules; the JavaScript side isrust_backend/crates/extensions/src/prelude.js. - Surface results as generic fields, such as
has_download_providerfromrust_backend/crates/extensions/src/manager/info.rsread byExtension.fromJson(lib/models/extension.dart), never as extension-name checks (see Project Boundaries). - Treat existing host functions and manifest fields as a contract with installed extensions; add new ones rather than changing them.
- Tests:
rust_backend/crates/extensions/src/runtime/tests.rs,rust_backend/crates/extensions/src/manager/tests.rs,rust_backend/crates/extensions/tests/, and thetest/extension_*_test.dartfiles. - Keep tests provider-neutral: generic extension IDs, display names, namespaces, and example URLs; no copied extension source and no dependency on the separate extension repository. Platform names stay only where the fixed API, metadata schema, URL parsing, or legacy compatibility needs them.
Testing Each Layer
Commands are in Validation.
| Layer | Location | Notes |
|---|---|---|
| Dart unit and widget | test/ |
test/README.md; fixtures in test/fixtures/, helpers in test/support/ |
| Device and performance | integration_test/ |
integration_test/README.md |
| Kotlin JVM | android/app/src/test/kotlin/com/zarz/spotiflac/ |
Case tables in android/app/src/test/resources/ are also read by Dart tests |
| Rust | tests.rs modules beside the code, rust_backend/crates/extensions/tests/ |
For example rust_backend/crates/extensions/src/runtime/tests.rs |
| iOS | ios/RunnerTests/ |
RunnerTests.swift |
Conventions
- Split large Dart classes with
partfiles: the library declarespart 'name_area.dart';, the part starts withpart of 'name.dart';and holdsextension _Area on Name { ... }or private helper classes. Parts that use the notifier'sstatestart with// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member, as mostdownload_queue_provider_*.dartparts do. - Public notifier methods stay on the class. More than 120 test classes
subclass
DownloadQueueNotifier,DownloadHistoryNotifier,LocalLibraryNotifier,LibraryCollectionsNotifier,ExtensionNotifier, andSettingsNotifierto override them; only private members move to parts. - After moving a file, leave a one-line
exportof the new path at the old one while other files import it, aslib/services/batch_track_actions.dartdoes. - Source-text contracts:
test/source_contracts_test.dartpins literal text in files such asios/Runner/AppDelegate.swift,NativeDownloadFinalizer.kt,NativeHistoryStore.kt,DownloadServiceSnapshot.kt, andlib/services/history_schema.dart;test/design_system_test.dartpins file names;MainActivityRoutingTest.ktreadsMainActivity.kt. When code moves, update the path or assertion in the same commit and never delete the check. - Keep hand-written files under about 800 lines, split by responsibility.
Exceptions: generated or frozen tables such as
rust_backend/crates/core/src/metadata/musicbrainz/case_data.rs, the settings search cataloglib/utils/settings_search_catalog.dart, upstream code inthird_party/andrust_backend/vendor/, ARB files, and generated*.g.dartand localization files. - Never write the em dash character (U+2014) in code, comments, strings, docs, or commit messages; use a comma, colon, parentheses, or a new sentence.
- Commit messages follow Code and Commit Style.
Generated Files
- After changing ARB files, run
fvm flutter gen-l10n(orfvm flutter pub get) to refresh the localization sources. They are generated and not committed:.gitignorecarries/lib/l10n/app_localizations*.dart("Generated by Flutter from lib/l10n/arb during pub get/build."). Commit only the ARB files. - Run
fvm dart run build_runner build --delete-conflicting-outputsonly when a model or generator input changes, then commit the relevant generated source. - Do not commit
build/,.dart_tool/, AAR/XCFramework output, IDE state, or local research directories.
Validation
Run checks that cover the code you changed. Before opening a PR, the relevant commands should pass.
Cross-language lyric usability cases live in
android/app/src/test/resources/lyrics_usability_cases.tsv. Dart and Android
tests read the same cases; add a case there when changing
that policy.
Test layers:
| Layer | Location | Command |
|---|---|---|
| Dart unit and widget | test/ |
fvm flutter test |
| Device and emulator | integration_test/ |
fvm flutter test integration_test/<file> |
| Performance | integration_test/*_performance_test.dart |
fvm flutter drive --driver=integration_test/performance_driver.dart --target=integration_test/<file> --keep-app-running |
| Kotlin JVM | android/app/src/test/ |
gradle -p android :app:testDebugUnitTest |
| Rust | rust_backend/ |
bash scripts/check_rust_backend.sh |
| Python script tests | scripts/test_*.py |
python3 -m unittest discover -s scripts -p 'test_<name>.py' |
Flutter and Dart:
fvm dart format --output=none --set-exit-if-changed lib test integration_test
fvm flutter analyze
fvm flutter test
Some Dart tests render audio and need ffmpeg and ffprobe on PATH. Set
SPOTIFLAC_TEST_FFMPEG to an ffmpeg binary to override the path used by the
automix renderer test; the other ffmpeg tests read PATH directly.
Performance drive runs need a dedicated emulator, --profile on Android and
--debug on iOS simulators. See the header of
integration_test/performance_driver.dart.
Rust formatting, Clippy, and unit tests:
bash scripts/check_rust_backend.sh
Android native code (Gradle builds the Rust artifacts automatically). The
repository has no Gradle wrapper; install the Gradle version CI uses
(.github/workflows/ci.yml) and generate the Flutter configuration first:
fvm flutter pub get
fvm flutter build apk --config-only
gradle -p android :app:compileDebugKotlin :app:testDebugUnitTest
For user-facing changes, add or update tests where practical and include before/after screenshots for UI changes.
Code and Commit Style
-
Follow
analysis_options.yaml,.editorconfig, and existing module patterns. -
Keep user-facing strings in the localization files.
-
Prefer small functions and explicit error handling at platform boundaries.
-
Use Conventional Commits, for example:
feat(download): add batch selection fix(storage): handle revoked folder access docs(contributing): refresh Android setup
Pull Requests
- Create a branch from an up-to-date
main. - Make one focused change and include tests or verification evidence.
- Complete the pull request template, including any checks that were not run and why.
- Link related issues with
Fixes #123where appropriate. - Respond to review feedback with follow-up commits; maintainers may squash commits when merging.
When reporting a crash, include the SpotiFLAC Mobile version, release channel,
device/OS, exact reproduction steps, storage mode, and exported app logs. For a
cold-start Android crash, adb logcat -b crash -d is especially useful.