Native Channel Contract
How the Flutter app talks to the Android and iOS hosts. Channel names, method names, argument keys and reply keys below are a compatibility contract: Dart, Kotlin and Swift must change together.
Kotlin sources live in android/app/src/main/kotlin/com/zarz/spotiflac/
(abbreviated kotlin/ below). Swift sources live in ios/Runner/.
The Backend Channel
All app-level calls use one MethodChannel named com.zarz.spotiflac/backend.
| Side | Where |
|---|---|
| Dart caller | lib/services/platform_bridge.dart (PlatformBridge) and its part files in lib/services/platform_bridge/ |
| Android dispatcher | android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt, configureFlutterEngine |
| Android handlers | MainActivity*.kt extension functions in kotlin/ (table below) |
| Android Rust route | android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt |
| Routing test | android/app/src/test/kotlin/com/zarz/spotiflac/MainActivityRoutingTest.kt |
| iOS dispatcher | ios/Runner/AppDelegate.swift, handleMethodCall |
Routing on Android
MainActivity keeps a nativeBackendMethods set. A call whose name is in the
set is handled by the backend when (call.method) in MainActivity.kt.
Every other name goes to dispatchBackendApplication in
SelectedCoreBackend.kt, which runs it on Dispatchers.IO through
coreBackend.invokeApplication (the Rust backend). That function also handles
runNativeDataJob, cancelNativeDataJob, and the SAF temp-copy wrapper for
getLyricsLRC and getLyricsLRCWithSource. A Rust-side name that is not
connected fails with "Rust application method is not connected yet".
Exceptions from either path are returned as result.error(code, message).
The code is foreground_service_start_not_allowed when Android refused a
foreground service start, otherwise ERROR (ForegroundServiceStartPolicy).
MainActivityRoutingTest reads MainActivity.kt and fails when the set and
the when case labels differ. To add a native method, add the name to both,
put the handler in the matching MainActivity*.kt file, and run the JVM tests:
gradle -p android :app:testDebugUnitTest --tests 'com.zarz.spotiflac.MainActivityRoutingTest'
Native Method Table
Handler file key: Lifecycle = android/app/src/main/kotlin/com/zarz/spotiflac/MainActivityLifecycleCalls.kt,
Service = android/app/src/main/kotlin/com/zarz/spotiflac/MainActivityServiceCalls.kt,
Scan = android/app/src/main/kotlin/com/zarz/spotiflac/MainActivityLibraryScanCalls.kt,
SAF = android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafCalls.kt,
Metadata = android/app/src/main/kotlin/com/zarz/spotiflac/MainActivityMetadataCalls.kt,
Picker = android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafTreePicker.kt,
inline = a case body in MainActivity.kt.
Reply key: "JSON text" is a String that Dart decodes; "spill" means the
reply may instead be a __json_file map (see below); "map", "bool", "string"
and "null" are typed channel values. Arguments are map keys unless noted.
| Method | Purpose | Handler | Reply |
|---|---|---|---|
setScreenAwake |
Adds or clears FLAG_KEEP_SCREEN_ON (enabled) |
Lifecycle | null |
getBackendImplementations |
Implementation name for filename, file_metadata, extensions, downloads |
Lifecycle | map |
ensureInstallMarker |
Creates the no-backup install marker; returns marker_existed, marker_created, fresh_package_install |
Lifecycle | map |
prepareRuntimeState |
Builds runtime state from data_dir signed sessions and hands it to the backend |
Lifecycle | map {ready} |
downloadByStrategy |
Runs one download; SAF requests go through SafDownloadHandler. The argument is the request JSON string |
Service | JSON text |
runPostProcessingV2 |
Post-processing for input and metadata; content URIs use a temp copy |
Metadata | JSON text |
readAudioMetadata |
Audio metadata for file_path (SAF read through the URI) |
Metadata | JSON text |
readFileMetadata |
Tag metadata for file_path, display_name |
Metadata | JSON text |
checkHiResAuthenticity |
Hi-Res check for file_path, options_json |
Metadata | JSON text |
editFileMetadata |
Writes metadata_json; SAF edits a temp copy (see SAF Model) |
Metadata | JSON text |
reEnrichFile |
Re-enriches from online metadata (request_json); may rewrite the LRC sidecar |
Metadata | JSON text |
setLibraryCoverCacheDir |
Sets the library cover cache directory (cache_dir) |
Scan | null |
scanLibraryFolderToNDJSONFile |
Full scan of folder_path into output_path |
Scan | map {path, count, error_count} |
scanLibraryFolderIncremental |
Incremental scan of folder_path against existing_files |
Scan | JSON text, spill |
scanSafTreeToNDJSONFile |
Full SAF scan of tree_uri into output_path, with a resume checkpoint |
Scan | map {path, count, error_count} |
scanSafTreeIncremental |
Incremental SAF scan of tree_uri against existing_files |
Scan | JSON text, spill |
getLibraryScanProgress |
SAF scan progress while a SAF scan is active, else the backend's | Scan | map |
cancelLibraryScan |
Cancels the SAF and backend scans | Scan | null |
pauseLibraryScan |
Pauses the SAF and backend scans | Scan | null |
resumeLibraryScan |
Resumes the SAF and backend scans | Scan | null |
parseCueSheet |
Parses cue_path; for SAF, finds the sibling audio document |
Metadata | JSON text |
pickSafTree |
Opens the folder picker (for_setup) and persists the grant |
Picker | JSON text or null |
readSetupFolderSelection |
Picker result saved for setup by SetupFolderPickerStore |
inline | string or null |
acknowledgeSetupFolderSelection |
Clears the saved result when it equals payload |
inline | null |
processPendingExtensionCallbacks |
Completes queued extension OAuth and session-grant callbacks | inline | null |
safExists |
Whether the document at uri exists |
SAF | bool |
safExistsBatch |
found, missing or unknown per URI in uris_json |
SAF | JSON text |
isSafTreeAccessible |
Persisted read and write grant and a writable tree for tree_uri |
SAF | bool |
probeSafTreeReadAccess |
Read probe of tree_uri; null when inconclusive |
SAF | bool or null |
safDelete |
Deletes the document at uri |
SAF | bool |
safStat |
Size, modification time and MIME type of uri |
SAF | JSON text |
resolveSafFile |
Finds file_name under tree_uri and relative_dir, then a bounded tree walk |
SAF | JSON text {uri, relative_dir} |
inspectSafFiles |
Batch lookup for requests_json, walking each tree at most once |
SAF | JSON text |
safCopyToTemp |
Copies uri to an app temp file |
SAF | string path or null |
safOpenPlaybackLease |
Opens a playback descriptor lease for uri |
SAF | map {token, path, display_name} or null |
safClosePlaybackLease |
Closes the lease token |
SAF | null |
openContentUri |
ACTION_VIEW for uri, mime_type (error open_failed) |
SAF | null |
shareContentUri |
ACTION_SEND chooser for uri, title (error share_failed) |
SAF | bool |
getSafFileModTimes |
URI to last-modified millis for uris |
SAF | JSON text |
safCreateFromPath |
Creates or reuses a document and copies src_path into it |
SAF | string URI or null |
safCreateIfAbsentFromPath |
Publishes src_path only when the name is free |
SAF | JSON text or null |
safCreateUniqueFromPath |
Publishes under a unique name (preserved_suffix) |
SAF | JSON text or null |
safCreateCollisionAwareFromPath |
Publishes as clean_file_name or variant_file_name |
SAF | JSON text or null |
writeTempToSaf |
Copies temp_path to saf_uri, then deletes the temp file |
SAF | JSON text {success, error} |
writeSafSidecarLrc |
Writes lyrics as a .lrc beside saf_uri |
SAF | JSON text {success, error} |
downloadCoverToFile |
Downloads cover_url (max_dimension); a temp file when output_path is empty |
Metadata | JSON text |
extractCoverToFile |
Extracts the embedded cover of audio_path to output_path |
Metadata | JSON text |
rewriteSplitArtistTags |
Rewrites artist and album_artist tags |
Metadata | JSON text |
writeM4AFreeformTags |
Writes M4A freeform tags from metadata_json |
Metadata | JSON text |
ensureAC4Config |
Ensures the AC-4 configuration, using source_path |
Metadata | JSON text |
writeAC4Metadata |
Writes AC-4 tags and cover_path |
Metadata | JSON text |
releaseMemory |
Releases idle backend resources | Lifecycle | null |
releaseMemoryUnderPressure |
Releases backend memory under system pressure | Lifecycle | null |
startDownloadService |
Starts DownloadService (track_name, artist_name, queue_count) |
Service | null |
stopDownloadService |
Stops DownloadService |
inline | null |
updateDownloadServiceProgress |
Updates the download notification (progress, total, status) |
Service | null |
isDownloadServiceRunning |
Whether DownloadService is running |
inline | bool |
startBackgroundWork |
Starts BackgroundWorkService for kind (title, text) |
Service | bool |
updateBackgroundWork |
Updates the kind notification (progress, negative for indeterminate) |
Service | null |
stopBackgroundWork |
Ends the kind work |
inline | null |
startNativeDownloadWorker |
Starts the native queue from inline JSON or requests_path, settings_path |
Service | null |
appendNativeDownloadWorkerRequests |
Appends requests_path to run run_id |
Service | null |
finishNativeDownloadWorkerPreparation |
Marks run run_id as fully queued |
Service | null |
acknowledgeNativeDownloadWorkerItems |
Acknowledges item_ids_json for run run_id |
Service | null |
pauseNativeDownloadWorker |
Pauses the native queue | inline | null |
resumeNativeDownloadWorker |
Resumes the native queue | inline | null |
cancelNativeDownloadWorker |
Cancels the native queue | inline | null |
getNativeDownloadWorkerSnapshot |
Worker snapshot since since_state_serial, decoded off the main thread |
Service | map |
consumeVerificationNotification |
Returns and clears the pending verification notification payload | Lifecycle | string or null |
exitApp |
Disables the back callback and calls finishAndRemoveTask |
Lifecycle | null |
Host-to-Dart Events
Native code calls Dart over the same channel. PlatformBridge handles them in
_ensureBackendEventHandler.
| Method | Sent by | Payload |
|---|---|---|
extensionAuthCallbackReceived |
Android | none |
extensionSessionGrantCompleted |
Android, iOS | {extension_id, success}; iOS only sends success: true |
extensionVerificationNotificationTapped |
Android | none |
setupFolderSelectionChanged |
Android | none |
libraryScanError |
Android | {path, operation, message} |
libraryStorageChanged |
Android | {action} (media mount and unmount broadcasts) |
iosBackgroundDownloadExpired |
iOS | JSON list of cancelled item IDs |
Session-grant events raised before the channel exists are queued and sent when the engine is configured, on both platforms.
The __json_file Spill
Large JSON replies are written to a file and the reply becomes
{"__json_file": "<absolute path>"}.
- Threshold: 256 KiB of UTF-8 (
LARGE_JSON_RESULT_FILE_THRESHOLD_BYTESin MainActivity.kt and AppDelegate.swift). Keep both in step. - Android writes a
bridge_json_file (nanoTime suffix) incacheDir, throughbridgeJsonResultor the streamingSpillJsonWriter(which inlines a small result and deletes its file). Users:scanLibraryFolderIncremental,scanSafTreeIncremental, andrunNativeDataJob(android/app/src/rust/kotlin/com/zarz/spotiflac/NativeDataJobs.kt). - iOS writes a
bridge_json_file (UUID suffix) in the temporary directory. Users:scanLibraryFolderIncrementalandrunNativeDataJob. - Dart reads and deletes the file in
_decodeJsonResultAsync(lib/services/platform_bridge.dart), decoding it in a background isolate. - Android also sweeps
bridge_json_,saf_,native_saf_andms_files older than one hour fromcacheDirat engine setup, for files orphaned by a killed process.
Progress Streams
Two EventChannels, registered on Android in
android/app/src/main/kotlin/com/zarz/spotiflac/MainActivityProgressStreams.kt
and on iOS in AppDelegate.swift. Dart listens in platform_bridge.dart.
| Channel | Payload | Emission |
|---|---|---|
com.zarz.spotiflac/download_progress_stream |
Backend delta: seq, optional reset, items (by item ID), removed |
Long poll waitDelta(seq, 15000 ms). Android pauses 250 ms after each delivered change or error. iOS (ios/Runner/DownloadProgressSubscription.swift) pauses 0.25 s before every next wait |
com.zarz.spotiflac/library_scan_progress_stream |
total_files, scanned_files, current_file, error_count, progress_pct, is_complete |
Polled; sent only when the JSON changed. Android: every 400 ms (LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS). iOS: every 800 ms (timer in startLibraryScanProgressStream) |
The library-scan poll interval differs between the platforms (400 ms versus 800 ms). Whether that is intended is an open question; do not "fix" one side without checking with the maintainer.
Other Android Channels
| Channel | Owner |
|---|---|
com.zarz.spotiflac/concert_calendar, com.zarz.spotiflac/audio_output |
android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySmallChannels.kt |
com.zarz.spotiflac/usb_pcm, com.zarz.spotiflac/usb_pcm/events (EventChannel), com.zarz.spotiflac/audio_route |
android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt |
com.zarz.spotiflac/haptics |
android/app/src/main/kotlin/com/zarz/spotiflac/MusicHapticsPlugin.kt |
com.zarz.spotiflac/local_network |
android/app/src/main/kotlin/com/zarz/spotiflac/LocalNetworkPlugin.kt |
com.zarz.spotiflac/player_widget |
android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt |
Foreground Services
Both services are declared with foregroundServiceType="dataSync" in
android/app/src/main/AndroidManifest.xml and call startForeground with
FOREGROUND_SERVICE_TYPE_DATA_SYNC on API 29 and later.
DownloadService |
BackgroundWorkService |
|
|---|---|---|
| File | android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt |
android/app/src/main/kotlin/com/zarz/spotiflac/BackgroundWorkService.kt |
| Channels | download_channel (progress), download_alerts_v1 (alerts) |
background_work |
| Notification IDs | 1001 (foreground), 1 (result), 4 (verification required) | 1002 |
| Wake lock tag | SpotiFLAC:DownloadWakeLock |
SpotiFLAC:BackgroundWorkWakeLock |
| Wake lock timing | 30 min timeout, renewed on progress updates once 15 min have passed | 30 min timeout, re-acquired by a 15 min timer |
| Started by | startDownloadService, startNativeDownloadWorker (intent actions com.zarz.spotiflac.action.*) |
startBackgroundWork for a kind (Dart uses library_scan and app_update) |
| Stopped by | stopDownloadService, the native queue finishing or being cancelled, an invalid queue payload, a null restart intent, onTimeout |
stopBackgroundWork for the last active kind, onTimeout |
BackgroundWorkService.start returns false when Android refuses the start;
the work then continues without the service. onTimeout (Android 15 dataSync
limit) stops both services cleanly. The native download worker also writes
its state to native_download_worker_state.json in filesDir for
getNativeDownloadWorkerSnapshot.
SAF Model
- Folder grants:
pickSafTreelaunchesACTION_OPEN_DOCUMENT_TREEwith persistable read and write flags and callstakePersistableUriPermission. The reply carriestree_uri,display_name(a readable path such as/storage/emulated/0/Music),volume_idandis_removable. Only one picker can be pending (saf_pending). - Setup folder selection: while setup is picking (
for_setup), the result is stored in thesetup_folder_pickerSharedPreferences (SetupFolderPickerStore) so it survives process death.setupFolderSelectionChangedtells Dart; Dart reads it withreadSetupFolderSelectionand clears it withacknowledgeSetupFolderSelection. - Playback leases:
safOpenPlaybackLeaseopens a seekable read descriptor and returns its/proc/self/fd/<fd>path and atoken. At most four leases stay open (the oldest is closed first) and all are closed inonDestroy(android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafLeases.kt). - Temp copies: metadata calls that need a file path copy the document to a
temp file, run the backend, and write the result back. For MP3 and Opus the
backend replies with
method: "ffmpeg"; Android then returnstemp_pathandsaf_uriand Dart finishes with FFmpeg andwriteTempToSaf. - Scanning: the full and incremental scans and their progress state live in
android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt. The helpers sit beside it:SafTreeListing.kt(tree walking and thread pools),SafFileInspection.kt(resolving download history files inside a tree),SafScanCheckpoint.kt(resume checkpoints),SafCueScan.kt(CUE sheet resolution) andSafBatchQueries.kt(batch exists and modtime queries). URI IO helpers live inMainActivitySafIo.ktin the same folder.
Rust Bridge Files
android/app/build.gradle.kts adds android/app/src/rust/kotlin as a source
directory, plus the generated UniFFI Kotlin bindings in
rust_backend/target/bindings/kotlin/ (package com.spotiflac.backend) and
the native libraries in rust_backend/target/android/jniLibs/. The
buildRustBackend task runs scripts/build_rust_backend.sh android before
preBuild.
android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt:createCoreBackend,dispatchBackendApplication, andRustCoreBackend, theCoreBackendimplementation over the generated bindings.android/app/src/rust/kotlin/com/zarz/spotiflac/RustCoreBackendApplication.kt:dispatchApplication, the method-name switch behindinvokeApplication.android/app/src/rust/kotlin/com/zarz/spotiflac/NativeDataJobs.kt:runNativeDataJoband its cancellation registry.
The generated bindings load libspotiflac_mobile.so through JNA
(Native.register with the cdylib_name from
rust_backend/crates/mobile/uniffi.toml). The app depends on
net.java.dev.jna:jna for this, and android/app/proguard-rules.pro keeps
com.sun.jna.
iOS
ios/Runner/AppDelegate.swift registers the same com.zarz.spotiflac/backend
channel and both progress EventChannels on the spotiflac-player engine.
handleMethodCall routes in this order:
setScreenAwakesetsisIdleTimerDisabled.- Names outside the
osMethodsset go to Rust:coreBackend.invokeApplicationinios/Runner/CoreBackend.swift. Unknown names fail there with "Rust application method is not connected yet" as aFlutterErrorwith codeERROR. iOS never repliesnotImplementedon this channel. osMethodsnames run inAppDelegate: background tasks, the folder picker andstartWebAuthSessioninline, the rest ininvokeOsMethod.
Handled in AppDelegate on iOS: getBackendImplementations,
rewriteSplitArtistTags, writeM4AFreeformTags, ensureAC4Config,
writeAC4Metadata, downloadCoverToFile, extractCoverToFile,
reEnrichFile, checkHiResAuthenticity, releaseMemory,
releaseMemoryUnderPressure, setLibraryCoverCacheDir,
scanLibraryFolderToNDJSONFile, scanLibraryFolderIncremental,
getLibraryScanProgress, cancelLibraryScan, pauseLibraryScan,
resumeLibraryScan, parseCueSheet, startBackgroundWork,
updateBackgroundWork (a no-op) and stopBackgroundWork.
Android-native names that iOS sends to Rust: downloadByStrategy,
readFileMetadata, editFileMetadata, readAudioMetadata,
runPostProcessingV2.
Android-native names with no iOS implementation (they reach Rust and fail):
every saf* method, pickSafTree, isSafTreeAccessible,
probeSafTreeReadAccess, resolveSafFile, inspectSafFiles,
getSafFileModTimes, scanSafTreeToNDJSONFile, scanSafTreeIncremental,
openContentUri, shareContentUri, writeTempToSaf, writeSafSidecarLrc,
readSetupFolderSelection, acknowledgeSetupFolderSelection,
ensureInstallMarker, prepareRuntimeState,
processPendingExtensionCallbacks, consumeVerificationNotification,
exitApp, the download service methods and the native download worker
methods. Dart must guard these with Platform.isAndroid.
iOS-only names: beginBackgroundDownloadTask, endBackgroundDownloadTask
(a UIApplication background task while downloads run), pickIosDirectory
(returns {path, bookmark}), startAccessingIosBookmark (returns
{path, token}, a security-scoped lease), stopAccessingIosBookmark
(token) and startWebAuthSession (url, callback_scheme).
On iOS startBackgroundWork begins a UIApplication background task per
kind and returns true; when a library_scan task expires, the scan is
cancelled. Other iOS channels: ios/Runner/ConcertCalendarBridge.swift,
ios/Runner/PlayerWidgetBridge.swift, ios/Runner/MusicHaptics.swift, and
ios/Runner/AudioOutputView.swift (playback_notification, audio_route,
and the per-view com.zarz.spotiflac/audio_output/<id>).