feat: add experimental Android native download worker

Introduce a service-owned download worker that offloads the full
download-and-finalize pipeline to DownloadService on Android, keeping
downloads alive independently of the Flutter UI process.

Key changes:
- Extract SAF download logic from MainActivity into SafDownloadHandler
- Add NativeDownloadFinalizer for Kotlin-side decryption, format
  conversion, metadata embedding, ReplayGain, post-processing, and
  history persistence
- Extend DownloadService with native queue management (start, pause,
  resume, cancel) using coroutine-based worker with AtomicFile snapshots
- Add Dart-side orchestration: snapshot polling, run-id correlation,
  adoption on app restart, and fallback to Dart queue
- Forward embedReplayGain, tidalHighFormat, and postProcessingEnabled
  through Go backend DownloadRequest struct
- Add nativeDownloadWorkerEnabled setting with UI toggle
- Make DownloadQueueLookup collections unmodifiable
This commit is contained in:
zarzet
2026-05-05 02:41:00 +07:00
parent b306056995
commit 6b342aeac6
18 changed files with 4716 additions and 368 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ abstract class AppLocalizations {
/// App name - DO NOT TRANSLATE
///
/// In en, this message translates to:
/// **'SpotiFLAC'**
/// **'SpotiFLAC Mobile'**
String get appName;
/// Bottom navigation - Home tab
+6
View File
@@ -58,6 +58,8 @@ class AppSettings {
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
final String
songLinkRegion; // SongLink userCountry region code used for platform lookup
final bool
nativeDownloadWorkerEnabled; // Experimental Android service-owned worker
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
@@ -133,6 +135,7 @@ class AppSettings {
this.downloadNetworkMode = 'any',
this.networkCompatibilityMode = false,
this.songLinkRegion = 'US',
this.nativeDownloadWorkerEnabled = false,
this.localLibraryEnabled = false,
this.localLibraryPath = '',
this.localLibraryBookmark = '',
@@ -196,6 +199,7 @@ class AppSettings {
String? downloadNetworkMode,
bool? networkCompatibilityMode,
String? songLinkRegion,
bool? nativeDownloadWorkerEnabled,
bool? localLibraryEnabled,
String? localLibraryPath,
String? localLibraryBookmark,
@@ -269,6 +273,8 @@ class AppSettings {
networkCompatibilityMode:
networkCompatibilityMode ?? this.networkCompatibilityMode,
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
nativeDownloadWorkerEnabled:
nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled,
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
+3
View File
@@ -58,6 +58,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
nativeDownloadWorkerEnabled:
json['nativeDownloadWorkerEnabled'] as bool? ?? false,
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
@@ -129,6 +131,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'downloadNetworkMode': instance.downloadNetworkMode,
'networkCompatibilityMode': instance.networkCompatibilityMode,
'songLinkRegion': instance.songLinkRegion,
'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled,
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryBookmark': instance.localLibraryBookmark,
File diff suppressed because it is too large Load Diff
+3
View File
@@ -263,6 +263,9 @@ class Extension {
bool get hasServiceHealth => serviceHealth.isNotEmpty;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
bool get requiresNativeContainerConversion =>
capabilities['requiresContainerConversion'] == true ||
capabilities['requiresNativeContainerConversion'] == true;
List<String> get replacesBuiltInProviders {
final value = capabilities['replacesBuiltInProviders'];
if (value is! List) return const [];
+5
View File
@@ -569,6 +569,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setNativeDownloadWorkerEnabled(bool enabled) {
state = state.copyWith(nativeDownloadWorkerEnabled: enabled);
_saveSettings();
}
void setLocalLibraryEnabled(bool enabled) {
state = state.copyWith(localLibraryEnabled: enabled);
_saveSettings();
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -23,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final hasDownloadExtensions = extensionState.extensions.any(
(extension) => extension.enabled && extension.hasDownloadProvider,
);
final nativeWorkerAvailable = Platform.isAndroid && hasDownloadExtensions;
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
@@ -141,6 +144,22 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
settings.downloadNetworkMode,
),
),
if (Platform.isAndroid)
SettingsSwitchItem(
icon: Icons.downloading_outlined,
title: 'Native download worker',
titleTrailing: const _BetaBadge(),
subtitle: hasDownloadExtensions
? 'Beta Android service worker for extension downloads'
: context.l10n.extensionsNoDownloadProvider,
value:
settings.nativeDownloadWorkerEnabled &&
nativeWorkerAvailable,
enabled: nativeWorkerAvailable,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setNativeDownloadWorkerEnabled(value),
),
SettingsSwitchItem(
icon: Icons.security_outlined,
title: context.l10n.downloadNetworkCompatibilityMode,
@@ -594,6 +613,29 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
// ── Private widgets (reused from original) ─────────────────────────────────
class _BetaBadge extends StatelessWidget {
const _BetaBadge();
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'BETA',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _ServiceSelector extends ConsumerWidget {
final String currentService;
final ValueChanged<String> onChanged;
@@ -1,4 +1,7 @@
class DownloadRequestPayload {
static const int nativeWorkerContractVersion = 1;
final int contractVersion;
final String isrc;
final String service;
final String spotifyId;
@@ -14,6 +17,9 @@ class DownloadRequestPayload {
final String artistTagMode;
final bool embedLyrics;
final bool embedMaxQualityCover;
final bool embedReplayGain;
final bool postProcessingEnabled;
final String tidalHighFormat;
final int trackNumber;
final int discNumber;
final int totalTracks;
@@ -37,9 +43,12 @@ class DownloadRequestPayload {
final String safRelativeDir;
final String safFileName;
final String safOutputExt;
final bool stageSafOutput;
final bool requiresContainerConversion;
final String songLinkRegion;
const DownloadRequestPayload({
this.contractVersion = nativeWorkerContractVersion,
this.isrc = '',
this.service = '',
this.spotifyId = '',
@@ -55,6 +64,9 @@ class DownloadRequestPayload {
this.artistTagMode = 'joined',
this.embedLyrics = true,
this.embedMaxQualityCover = true,
this.embedReplayGain = false,
this.postProcessingEnabled = false,
this.tidalHighFormat = 'mp3_320',
this.trackNumber = 0,
this.discNumber = 0,
this.totalTracks = 1,
@@ -78,11 +90,14 @@ class DownloadRequestPayload {
this.safRelativeDir = '',
this.safFileName = '',
this.safOutputExt = '',
this.stageSafOutput = false,
this.requiresContainerConversion = false,
this.songLinkRegion = 'US',
});
Map<String, dynamic> toJson() {
return {
'contract_version': contractVersion,
'isrc': isrc,
'service': service,
'spotify_id': spotifyId,
@@ -98,6 +113,9 @@ class DownloadRequestPayload {
'artist_tag_mode': artistTagMode,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'embed_replaygain': embedReplayGain,
'post_processing_enabled': postProcessingEnabled,
'tidal_high_format': tidalHighFormat,
'track_number': trackNumber,
'disc_number': discNumber,
'total_tracks': totalTracks,
@@ -121,6 +139,8 @@ class DownloadRequestPayload {
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
'stage_saf_output': stageSafOutput,
'requires_container_conversion': requiresContainerConversion,
'songlink_region': songLinkRegion,
};
}
@@ -130,6 +150,7 @@ class DownloadRequestPayload {
bool? useFallback,
}) {
return DownloadRequestPayload(
contractVersion: contractVersion,
isrc: isrc,
service: service,
spotifyId: spotifyId,
@@ -145,6 +166,9 @@ class DownloadRequestPayload {
artistTagMode: artistTagMode,
embedLyrics: embedLyrics,
embedMaxQualityCover: embedMaxQualityCover,
embedReplayGain: embedReplayGain,
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: tidalHighFormat,
trackNumber: trackNumber,
discNumber: discNumber,
totalTracks: totalTracks,
@@ -168,6 +192,8 @@ class DownloadRequestPayload {
safRelativeDir: safRelativeDir,
safFileName: safFileName,
safOutputExt: safOutputExt,
stageSafOutput: stageSafOutput,
requiresContainerConversion: requiresContainerConversion,
songLinkRegion: songLinkRegion,
);
}
+29
View File
@@ -837,6 +837,35 @@ class PlatformBridge {
return result as bool;
}
static Future<void> startNativeDownloadWorker({
required List<Map<String, dynamic>> requests,
Map<String, dynamic> settings = const {},
}) async {
await _channel.invokeMethod('startNativeDownloadWorker', {
'requests_json': jsonEncode(requests),
'settings_json': jsonEncode(settings),
});
}
static Future<void> pauseNativeDownloadWorker() async {
await _channel.invokeMethod('pauseNativeDownloadWorker');
}
static Future<void> resumeNativeDownloadWorker() async {
await _channel.invokeMethod('resumeNativeDownloadWorker');
}
static Future<void> cancelNativeDownloadWorker() async {
await _channel.invokeMethod('cancelNativeDownloadWorker');
}
static Future<Map<String, dynamic>> getNativeDownloadWorkerSnapshot() async {
final result = await _channel.invokeMethod(
'getNativeDownloadWorkerSnapshot',
);
return _decodeMapResult(result);
}
static Future<void> preWarmTrackCache(
List<Map<String, String>> tracks,
) async {
+55 -32
View File
@@ -4,20 +4,22 @@ class SettingsGroup extends StatelessWidget {
final List<Widget> children;
final EdgeInsetsGeometry? margin;
const SettingsGroup({
super.key,
required this.children,
this.margin,
});
const SettingsGroup({super.key, required this.children, this.margin});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.04),
colorScheme.surface,
);
return Container(
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
@@ -28,10 +30,7 @@ class SettingsGroup extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
child: Column(mainAxisSize: MainAxisSize.min, children: children),
),
);
}
@@ -58,7 +57,7 @@ class SettingsItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -78,17 +77,13 @@ class SettingsItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(title, style: Theme.of(context).textTheme.bodyLarge),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
],
@@ -99,7 +94,10 @@ class SettingsItem extends StatelessWidget {
trailing!,
] else if (onTap != null) ...[
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
],
],
),
@@ -121,6 +119,7 @@ class SettingsItem extends StatelessWidget {
class SettingsSwitchItem extends StatelessWidget {
final IconData? icon;
final String title;
final Widget? titleTrailing;
final String? subtitle;
final bool value;
final ValueChanged<bool>? onChanged;
@@ -131,6 +130,7 @@ class SettingsSwitchItem extends StatelessWidget {
super.key,
this.icon,
required this.title,
this.titleTrailing,
this.subtitle,
required this.value,
this.onChanged,
@@ -142,7 +142,7 @@ class SettingsSwitchItem extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDisabled = !enabled || onChanged == null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -157,26 +157,49 @@ class SettingsSwitchItem extends StatelessWidget {
child: Row(
children: [
if (icon != null) ...[
Icon(icon, color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, size: 24),
Icon(
icon,
color: isDisabled
? colorScheme.outline
: colorScheme.onSurfaceVariant,
size: 24,
),
const SizedBox(width: 16),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: isDisabled ? colorScheme.outline : null,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
title,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: isDisabled
? colorScheme.outline
: null,
),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
titleTrailing!,
],
],
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: isDisabled
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
),
],
],