Files
SpotiFLAC-Mobile/CONTRIBUTING.md
T
zarzet d3a51a6a36 docs(contributing): add the codebase map and architecture guide
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.
2026-10-19 05:35:08 +07:00

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.toml and rust_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

  1. 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
    
  2. Install the pinned Flutter SDK and Dart dependencies:

    fvm install
    fvm flutter pub get
    
  3. Install Rust with rustup, then run (cd rust_backend && rustup show) to activate the pinned toolchain. Install Clang/libclang for the native bindings (libclang-dev on Ubuntu, Xcode command-line tools on macOS). Android Gradle builds the Rust libraries and bindings automatically. For a manual host build, run bash scripts/build_rust_backend.sh host.

  4. 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)

  1. downloadSingleTrack (lib/widgets/track_detail_actions.dart) calls DownloadQueueNotifier.addToQueue, which appends a DownloadItem, saves the queue, and schedules _processQueue.
  2. _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 _canUseAndroidNativeWorker holds: Android, nativeDownloadWorkerEnabled and useExtensionProviders on, an enabled extension with a download provider, a download tree URI when SAF storage is in use, and no network-folder items.
  3. It builds the first request with _buildAndroidNativeWorkerRequest and calls PlatformBridge.startNativeDownloadWorker, which writes requests and settings to JSON files and sends their paths. Remaining items follow with appendNativeDownloadWorkerRequests and finishNativeDownloadWorkerPreparation.
  4. MainActivity.handleStartNativeDownloadWorker (MainActivityServiceCalls.kt) calls DownloadService.startNativeQueueFromFiles, which starts the foreground service. onStartCommand runs startNativeWorker, then runNativeWorkerConcurrent and processConcurrentNativeRequest per item.
  5. SafDownloadHandler.handle calls CoreBackend.downloadByStrategy. The Rust implementation in SelectedCoreBackend.kt calls the UniFFI ExtensionManager.downloadByStrategy (mobile/src/manager.rs), which runs download_by_strategy in extensions/src/backend/downloads.rs.
  6. On success, NativeDownloadFinalizer.finalize finishes the file and, when save_download_history is set, writes the row with NativeHistoryStore.upsert. writeNativeWorkerSnapshot (DownloadServiceSnapshot.kt) publishes item state.
  7. NativeDownloadWorkerMonitor.run polls PlatformBridge.getNativeDownloadWorkerSnapshot. _applyAndroidNativeWorkerSnapshot updates the items and acknowledges them with acknowledgeNativeDownloadWorkerItems; _completeAndroidNativeWorkerItem calls reconcileNativeDownloadHistory (lib/services/native_download_history.dart) so DownloadHistoryNotifier adopts 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

  1. A screen calls PlaybackController (lib/providers/playback_provider.dart), for example playTrackList from library_tracks_folder_screen.dart. When playerMode is not internal and the source is not network://, it opens the file in an external app instead.
  2. MusicPlayerController.playAll (lib/providers/music_player_provider.dart) calls ensureInitialized, which runs MusicPlayerRuntime.initialize (lib/services/music_player_runtime.dart), which creates the MusicPlayerHandler through AudioService.init from the third_party/audio_service fork, then calls setQueueAndPlay.
  3. MusicPlayerHandler.setQueueAndPlay (lib/services/music_player_service.dart) publishes queue, then _playIndex and _loadIndex publish mediaItem and call _startResolvedSource.
  4. _startResolvedSource calls MusicPlaybackDeck.setSource or setNetworkSource and resume (lib/services/music_playback_deck.dart). The deck wraps an audioplayers AudioPlayer, served by the third_party/audioplayers_android and third_party/audioplayers_darwin overrides; with USB bit-perfect enabled on Android it first tries to prepare the file through UsbBitPerfectPlugin.kt.
  5. Player events reach _broadcastState, which updates playbackState. MusicPlayerRuntime.playbackStateEvents, mediaItemEvents, and queueEvents feed playbackStateProvider, currentMediaItemProvider, and playQueueProvider, 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 and copyWith), then run build_runner (see Generated Files) to refresh settings.g.dart.
  • SettingsNotifier stores AppSettings.toJson() as one JSON string under the SharedPreferences key app_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 _syncLyricsSettingsToBackend does.
  • Rewrites of existing stored values go in _runMigrations: bump _currentMigrationVersion and guard the step with if (lastMigration < N).
  • Add the row to the owning page in lib/screens/settings/ and a SettingsSearchEntry to the matching list in lib/utils/settings_search_catalog.dart.
  • Add strings to lib/l10n/arb/app_en.arb with an @key description and read them through context.l10n.
  • Test with the JSON round trip in test/app_settings_model_test.dart, setter and persistence cases like test/settings_persistence_test.dart (a SettingsNotifier subclass over a fake SharedPreferences), and test/settings_search_test.dart.

A Screen

  • Put it in lib/screens/ (settings pages in lib/screens/settings/) and split a large screen into <screen>_*.dart files.
  • Push it with Navigator.push and a MaterialPageRoute or slidePageRoute (lib/widgets/animation_utils.dart). Tabs keep their own Navigator from _TabNavigator in lib/screens/main_shell.dart; GoRouter in lib/app.dart owns only /, /setup, and /tutorial.
  • Use the shared components: AppSliverHeader, showAppBottomSheet and AppSheetHandle, SettingsGroup, and the AppTokens scale.
  • A track collection screen uses CollectionScaffold and SelectionModeMixin, and its file name joins the screens list in the "every collection screen goes through the shared shell" test.
  • test/design_system_test.dart scans every file in lib/: each IconButton and AppIconButton passes tooltip, and only allowlisted files may contain expandedTitleScale (app_sliver_header.dart), a hand-built drag handle (app_bottom_sheet.dart), a modal sheet shape (app_theme.dart), or OverlayEntry( (selection_bottom_bar.dart, app_menu_button.dart).

A Platform Channel Method

  • Dart: add a static method to PlatformBridge in lib/services/platform_bridge.dart that calls _channel.invokeMethod with snake_case argument keys; helper classes and codecs can go in a part under lib/services/platform_bridge/. Decode JSON results with _decodeJsonResultAsync or _decodeRequiredMapResultAsync.
  • Rust-backed methods: on Android a name outside nativeBackendMethods goes through dispatchBackendApplication (SelectedCoreBackend.kt) to RustCoreBackend.dispatchApplication (RustCoreBackendApplication.kt); add the case there. On iOS add it to invokeApplication in ios/Runner/CoreBackend.swift.
  • Android-only methods: add the name to nativeBackendMethods and a case to the when (call.method) block in MainActivity.kt, calling a MainActivity.handleX function in the matching MainActivity*Calls.kt file. MainActivityRoutingTest.kt fails when the set and the cases differ.
  • iOS-only methods: add the name to osMethods in handleMethodCall (ios/Runner/AppDelegate.swift) and handle it there or in invokeOsMethod. test/source_contracts_test.dart pins parts of that file.
  • Large JSON: bridgeJsonResult in MainActivity.kt and AppDelegate.swift writes 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 in rust_backend/crates/mobile/src/; the build regenerates bindings (see Development Setup).
  • Dart tests stub com.zarz.spotiflac/backend with setMockMethodCallHandler, for example test/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, and InstalledLyricsFetcher (rust_backend/crates/extensions/src/lyrics.rs) lists and calls them.
  • A built-in provider is a pub(super) fn on BuiltinLyricsClient in a file under rust_backend/crates/providers/src/lyrics/builtin/ (apple.rs, direct.rs, proxy.rs, ttml.rs), taking &SearchRequest and &Check<'_> and returning Result<LyricsResponse, LyricsError>.
  • Add its match arm to BuiltinLyricsClient::fetch_once (rust_backend/crates/providers/src/lyrics/builtin.rs).
  • Add the ID to BUILTIN_PROVIDERS and available_providers in rust_backend/crates/core/src/lyrics/config.rs; normalize_provider_order drops unknown IDs. DEFAULT_PROVIDERS mirrors the Dart default AppSettings.lyricsProviders; changing either for existing users needs a settings migration (see Migration 13 in _runMigrations).
  • Dart UI: _builtInProviderIds and _getLyricsProviderInfo in lib/screens/settings/lyrics_provider_priority_page.dart, _providerDisplayNames in lib/screens/settings/lyrics_settings_page.dart, and a description key like lyricsProviderLrclibDesc.
  • 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 by register in rust_backend/crates/extensions/src/host.rs and the *_host.rs modules; the JavaScript side is rust_backend/crates/extensions/src/prelude.js.
  • Surface results as generic fields, such as has_download_provider from rust_backend/crates/extensions/src/manager/info.rs read by Extension.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 the test/extension_*_test.dart files.
  • 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 part files: the library declares part 'name_area.dart';, the part starts with part of 'name.dart'; and holds extension _Area on Name { ... } or private helper classes. Parts that use the notifier's state start with // ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member, as most download_queue_provider_*.dart parts do.
  • Public notifier methods stay on the class. More than 120 test classes subclass DownloadQueueNotifier, DownloadHistoryNotifier, LocalLibraryNotifier, LibraryCollectionsNotifier, ExtensionNotifier, and SettingsNotifier to override them; only private members move to parts.
  • After moving a file, leave a one-line export of the new path at the old one while other files import it, as lib/services/batch_track_actions.dart does.
  • Source-text contracts: test/source_contracts_test.dart pins literal text in files such as ios/Runner/AppDelegate.swift, NativeDownloadFinalizer.kt, NativeHistoryStore.kt, DownloadServiceSnapshot.kt, and lib/services/history_schema.dart; test/design_system_test.dart pins file names; MainActivityRoutingTest.kt reads MainActivity.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 catalog lib/utils/settings_search_catalog.dart, upstream code in third_party/ and rust_backend/vendor/, ARB files, and generated *.g.dart and 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 (or fvm flutter pub get) to refresh the localization sources. They are generated and not committed: .gitignore carries /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-outputs only 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

  1. Create a branch from an up-to-date main.
  2. Make one focused change and include tests or verification evidence.
  3. Complete the pull request template, including any checks that were not run and why.
  4. Link related issues with Fixes #123 where appropriate.
  5. 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.