Files
SpotiFLAC-Mobile/android

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_BYTES in MainActivity.kt and AppDelegate.swift). Keep both in step.
  • Android writes a bridge_json_ file (nanoTime suffix) in cacheDir, through bridgeJsonResult or the streaming SpillJsonWriter (which inlines a small result and deletes its file). Users: scanLibraryFolderIncremental, scanSafTreeIncremental, and runNativeDataJob (android/app/src/rust/kotlin/com/zarz/spotiflac/NativeDataJobs.kt).
  • iOS writes a bridge_json_ file (UUID suffix) in the temporary directory. Users: scanLibraryFolderIncremental and runNativeDataJob.
  • 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_ and ms_ files older than one hour from cacheDir at 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: pickSafTree launches ACTION_OPEN_DOCUMENT_TREE with persistable read and write flags and calls takePersistableUriPermission. The reply carries tree_uri, display_name (a readable path such as /storage/emulated/0/Music), volume_id and is_removable. Only one picker can be pending (saf_pending).
  • Setup folder selection: while setup is picking (for_setup), the result is stored in the setup_folder_picker SharedPreferences (SetupFolderPickerStore) so it survives process death. setupFolderSelectionChanged tells Dart; Dart reads it with readSetupFolderSelection and clears it with acknowledgeSetupFolderSelection.
  • Playback leases: safOpenPlaybackLease opens a seekable read descriptor and returns its /proc/self/fd/<fd> path and a token. At most four leases stay open (the oldest is closed first) and all are closed in onDestroy (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 returns temp_path and saf_uri and Dart finishes with FFmpeg and writeTempToSaf.
  • 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) and SafBatchQueries.kt (batch exists and modtime queries). URI IO helpers live in MainActivitySafIo.kt in 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, and RustCoreBackend, the CoreBackend implementation over the generated bindings.
  • android/app/src/rust/kotlin/com/zarz/spotiflac/RustCoreBackendApplication.kt: dispatchApplication, the method-name switch behind invokeApplication.
  • android/app/src/rust/kotlin/com/zarz/spotiflac/NativeDataJobs.kt: runNativeDataJob and 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:

  1. setScreenAwake sets isIdleTimerDisabled.
  2. Names outside the osMethods set go to Rust: coreBackend.invokeApplication in ios/Runner/CoreBackend.swift. Unknown names fail there with "Rust application method is not connected yet" as a FlutterError with code ERROR. iOS never replies notImplemented on this channel.
  3. osMethods names run in AppDelegate: background tasks, the folder picker and startWebAuthSession inline, the rest in invokeOsMethod.

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