mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
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.
571 lines
33 KiB
Markdown
571 lines
33 KiB
Markdown
# 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](CODE_OF_CONDUCT.md) when participating in
|
|
the project.
|
|
|
|
## Before You Start
|
|
|
|
- Search the [existing issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/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](https://crowdin.com/project/spotiflac-mobile).
|
|
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](https://fvm.app/) 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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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
|
|
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](third_party/spotiflac_discord/README.md#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](scripts/README_ios_ffmpeg_capabilities.md).
|
|
|
|
### 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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```text
|
|
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](android/README.md) (native channel
|
|
contract), [rust_backend/README.md](rust_backend/README.md) (crate map),
|
|
[test/README.md](test/README.md),
|
|
[integration_test/README.md](integration_test/README.md),
|
|
[lib/theme/README.md](lib/theme/README.md), and the `SPOTIFLAC_PATCHES.md` file
|
|
in each `third_party/` fork, for example
|
|
[audio_service](third_party/audio_service/SPOTIFLAC_PATCHES.md).
|
|
|
|
## 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](#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](#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](#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](#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](#validation).
|
|
|
|
| Layer | Location | Notes |
|
|
| --- | --- | --- |
|
|
| Dart unit and widget | `test/` | [test/README.md](test/README.md); fixtures in `test/fixtures/`, helpers in `test/support/` |
|
|
| Device and performance | `integration_test/` | [integration_test/README.md](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](#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:
|
|
|
|
```bash
|
|
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
|
|
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:
|
|
|
|
```bash
|
|
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](https://www.conventionalcommits.org/), for example:
|
|
|
|
```text
|
|
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.
|