diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef114695..4506906d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,12 +2,22 @@
## [1.6.1] - 2026-01-02
+### Added
+- **Background Download Service**: Downloads now continue running when app is in background
+ - Foreground service with wake lock prevents Android from killing downloads
+ - Persistent notification shows download progress
+ - No more "connection abort" errors when switching apps
+
### Fixed
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
- Download queue is now persisted to storage and automatically restored on app restart
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
- Changed launch mode to `singleTask` to reuse existing activity instead of restarting
- Added `onNewIntent` handler to properly receive new share intents
+- **Back Button During Loading**: Back button no longer clears state while loading shared URL
+
+### Changed
+- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
## [1.6.0] - 2026-01-02
@@ -42,6 +52,7 @@
### Improved
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
- **Dependencies Updated**:
+ - `share_plus`: 10.1.4 → 12.0.1
- `flutter_local_notifications`: 18.0.1 → 19.0.0
- `build_runner`: 2.4.15 → 2.10.4
diff --git a/README.md b/README.md
index a5c027c3..6eb5ee63 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma
## Disclaimer
-> **📱 iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
+> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
diff --git a/android/.kotlin/sessions/kotlin-compiler-11752605725870772170.salive b/android/.kotlin/sessions/kotlin-compiler-11752605725870772170.salive
deleted file mode 100644
index e69de29b..00000000
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 05b1f2ba..217348dc 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,6 +12,7 @@
+
= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, DownloadService::class.java).apply {
+ action = ACTION_STOP
+ }
+ context.startService(intent)
+ }
+
+ fun updateProgress(context: Context, trackName: String, artistName: String, progress: Long, total: Long, queueCount: Int) {
+ val intent = Intent(context, DownloadService::class.java).apply {
+ action = ACTION_UPDATE_PROGRESS
+ putExtra(EXTRA_TRACK_NAME, trackName)
+ putExtra(EXTRA_ARTIST_NAME, artistName)
+ putExtra(EXTRA_PROGRESS, progress)
+ putExtra(EXTRA_TOTAL, total)
+ putExtra(EXTRA_QUEUE_COUNT, queueCount)
+ }
+ context.startService(intent)
+ }
}
-
+
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var currentTrackName = ""
+ private var currentArtistName = ""
+ private var queueCount = 0
+
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
-
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
- ACTION_START -> startForegroundService()
- ACTION_STOP -> stopSelf()
+ ACTION_START -> {
+ currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: ""
+ currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: ""
+ queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, 0)
+ startForegroundService()
+ }
+ ACTION_STOP -> {
+ stopForegroundService()
+ }
+ ACTION_UPDATE_PROGRESS -> {
+ currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: currentTrackName
+ currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: currentArtistName
+ val progress = intent.getLongExtra(EXTRA_PROGRESS, 0)
+ val total = intent.getLongExtra(EXTRA_TOTAL, 0)
+ queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, queueCount)
+ updateNotification(progress, total)
+ }
}
- return START_NOT_STICKY
+ return START_STICKY
}
-
+
override fun onBind(intent: Intent?): IBinder? = null
-
+
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
- "Download Progress",
+ "Download Service",
NotificationManager.IMPORTANCE_LOW
).apply {
- description = "Shows download progress for SpotiFLAC"
+ description = "Shows download progress"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
-
+
private fun startForegroundService() {
- val notification = createNotification("Downloading...", 0)
+ isRunning = true
+
+ // Acquire wake lock to prevent CPU sleep
+ val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
+ wakeLock = powerManager.newWakeLock(
+ PowerManager.PARTIAL_WAKE_LOCK,
+ WAKELOCK_TAG
+ ).apply {
+ acquire(60 * 60 * 1000L) // 1 hour max
+ }
+
+ val notification = buildNotification(0, 0)
startForeground(NOTIFICATION_ID, notification)
}
-
- fun updateProgress(trackName: String, progress: Int) {
- val notification = createNotification(trackName, progress)
+
+ private fun stopForegroundService() {
+ isRunning = false
+ wakeLock?.let {
+ if (it.isHeld) {
+ it.release()
+ }
+ }
+ wakeLock = null
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ private fun updateNotification(progress: Long, total: Long) {
+ if (!isRunning) return
+
+ val notification = buildNotification(progress, total)
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, notification)
}
-
- private fun createNotification(title: String, progress: Int): Notification {
- val intent = Intent(this, MainActivity::class.java)
+
+ private fun buildNotification(progress: Long, total: Long): Notification {
val pendingIntent = PendingIntent.getActivity(
- this, 0, intent,
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
-
- val stopIntent = Intent(this, DownloadService::class.java).apply {
- action = ACTION_STOP
+
+ val title = if (queueCount > 1) {
+ "Downloading $queueCount tracks"
+ } else if (currentTrackName.isNotEmpty()) {
+ currentTrackName
+ } else {
+ "Downloading..."
}
- val stopPendingIntent = PendingIntent.getService(
- this, 0, stopIntent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
- )
-
- return NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle("SpotiFLAC")
- .setContentText(title)
+
+ val text = if (currentArtistName.isNotEmpty() && queueCount <= 1) {
+ currentArtistName
+ } else if (total > 0) {
+ val progressPercent = (progress * 100 / total).toInt()
+ val progressMB = progress / (1024.0 * 1024.0)
+ val totalMB = total / (1024.0 * 1024.0)
+ String.format("%.1f / %.1f MB (%d%%)", progressMB, totalMB, progressPercent)
+ } else {
+ "Preparing download..."
+ }
+
+ val builder = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(title)
+ .setContentText(text)
.setSmallIcon(android.R.drawable.stat_sys_download)
- .setProgress(100, progress, progress == 0)
- .setOngoing(true)
.setContentIntent(pendingIntent)
- .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent)
- .build()
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+
+ if (total > 0) {
+ builder.setProgress(100, (progress * 100 / total).toInt(), false)
+ } else {
+ builder.setProgress(0, 0, true)
+ }
+
+ return builder.build()
+ }
+
+ override fun onDestroy() {
+ isRunning = false
+ wakeLock?.let {
+ if (it.isHeld) {
+ it.release()
+ }
+ }
+ super.onDestroy()
}
}
diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
index 6f393d6e..988111ae 100644
--- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
@@ -167,6 +167,29 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
+ "startDownloadService" -> {
+ val trackName = call.argument("track_name") ?: ""
+ val artistName = call.argument("artist_name") ?: ""
+ val queueCount = call.argument("queue_count") ?: 0
+ DownloadService.start(this@MainActivity, trackName, artistName, queueCount)
+ result.success(null)
+ }
+ "stopDownloadService" -> {
+ DownloadService.stop(this@MainActivity)
+ result.success(null)
+ }
+ "updateDownloadServiceProgress" -> {
+ val trackName = call.argument("track_name") ?: ""
+ val artistName = call.argument("artist_name") ?: ""
+ val progress = call.argument("progress") ?: 0L
+ val total = call.argument("total") ?: 0L
+ val queueCount = call.argument("queue_count") ?: 0
+ DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
+ result.success(null)
+ }
+ "isDownloadServiceRunning" -> {
+ result.success(DownloadService.isServiceRunning())
+ }
else -> result.notImplemented()
}
} catch (e: Exception) {
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index ca7fe065..7ec1ec31 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -20,7 +20,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
- id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+ id("org.jetbrains.kotlin.android") version "2.3.0" apply false
}
include(":app")
diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart
index c5f5f044..fb68b050 100644
--- a/lib/providers/download_queue_provider.dart
+++ b/lib/providers/download_queue_provider.dart
@@ -348,6 +348,17 @@ class DownloadQueueNotifier extends Notifier {
progress: bytesReceived,
total: bytesTotal,
);
+
+ // Update foreground service notification (Android)
+ if (Platform.isAndroid) {
+ PlatformBridge.updateDownloadServiceProgress(
+ trackName: currentItem.track.name,
+ artistName: currentItem.track.artistName,
+ progress: bytesReceived,
+ total: bytesTotal,
+ queueCount: state.queuedCount,
+ ).catchError((_) {}); // Ignore errors
+ }
}
// Log progress
@@ -403,6 +414,17 @@ class DownloadQueueNotifier extends Notifier {
progress: bytesReceived,
total: bytesTotal > 0 ? bytesTotal : 1,
);
+
+ // Update foreground service notification (Android)
+ if (Platform.isAndroid) {
+ PlatformBridge.updateDownloadServiceProgress(
+ trackName: downloadingItems.first.track.name,
+ artistName: downloadingItems.first.track.artistName,
+ progress: bytesReceived,
+ total: bytesTotal > 0 ? bytesTotal : 1,
+ queueCount: state.queuedCount,
+ ).catchError((_) {}); // Ignore errors
+ }
}
}
} catch (e) {
@@ -742,6 +764,24 @@ class DownloadQueueNotifier extends Notifier {
// Track total items at start for notification
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
+ // Start foreground service to keep downloads running in background (Android only)
+ if (Platform.isAndroid && _totalQueuedAtStart > 0) {
+ final firstItem = state.items.firstWhere(
+ (item) => item.status == DownloadStatus.queued,
+ orElse: () => state.items.first,
+ );
+ try {
+ await PlatformBridge.startDownloadService(
+ trackName: firstItem.track.name,
+ artistName: firstItem.track.artistName,
+ queueCount: _totalQueuedAtStart,
+ );
+ _log.d('Foreground service started');
+ } catch (e) {
+ _log.e('Failed to start foreground service: $e');
+ }
+ }
+
// Ensure output directory is initialized before processing
if (state.outputDir.isEmpty) {
_log.d('Output dir empty, initializing...');
@@ -771,6 +811,16 @@ class DownloadQueueNotifier extends Notifier {
_stopProgressPolling();
+ // Stop foreground service (Android only)
+ if (Platform.isAndroid) {
+ try {
+ await PlatformBridge.stopDownloadService();
+ _log.d('Foreground service stopped');
+ } catch (e) {
+ _log.e('Failed to stop foreground service: $e');
+ }
+ }
+
// Final cleanup after queue finishes
if (_downloadCount > 0) {
_log.d('Final connection cleanup...');
diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart
index b4865aa9..a0dc616e 100644
--- a/lib/screens/main_shell.dart
+++ b/lib/screens/main_shell.dart
@@ -150,8 +150,9 @@ class _MainShellState extends ConsumerState {
return;
}
- // If on Search tab and has text in search bar or has content, clear it
- if (_currentIndex == 0 && (trackState.hasSearchText || trackState.hasContent || trackState.isLoading)) {
+ // If on Search tab and has text in search bar or has content (but not loading), clear it
+ // Don't clear while loading - this prevents clearing during share intent processing
+ if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
return;
}
@@ -162,6 +163,11 @@ class _MainShellState extends ConsumerState {
return;
}
+ // If loading, ignore back press
+ if (trackState.isLoading) {
+ return;
+ }
+
// Already at root, show exit dialog
final shouldPop = await _showExitDialog();
if (shouldPop && context.mounted) {
diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart
index 5fdec025..6012c291 100644
--- a/lib/screens/track_metadata_screen.dart
+++ b/lib/screens/track_metadata_screen.dart
@@ -938,9 +938,11 @@ class _TrackMetadataScreenState extends ConsumerState {
return;
}
- await Share.shareXFiles(
- [XFile(item.filePath)],
- text: '${item.trackName} - ${item.artistName}',
+ await SharePlus.instance.share(
+ ShareParams(
+ files: [XFile(item.filePath)],
+ text: '${item.trackName} - ${item.artistName}',
+ ),
);
}
diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart
index c1c41b1c..7b1b26d1 100644
--- a/lib/services/platform_bridge.dart
+++ b/lib/services/platform_bridge.dart
@@ -234,4 +234,45 @@ class PlatformBridge {
static Future cleanupConnections() async {
await _channel.invokeMethod('cleanupConnections');
}
+
+ /// Start foreground download service to keep downloads running in background
+ static Future startDownloadService({
+ String trackName = '',
+ String artistName = '',
+ int queueCount = 0,
+ }) async {
+ await _channel.invokeMethod('startDownloadService', {
+ 'track_name': trackName,
+ 'artist_name': artistName,
+ 'queue_count': queueCount,
+ });
+ }
+
+ /// Stop foreground download service
+ static Future stopDownloadService() async {
+ await _channel.invokeMethod('stopDownloadService');
+ }
+
+ /// Update download service notification progress
+ static Future updateDownloadServiceProgress({
+ required String trackName,
+ required String artistName,
+ required int progress,
+ required int total,
+ required int queueCount,
+ }) async {
+ await _channel.invokeMethod('updateDownloadServiceProgress', {
+ 'track_name': trackName,
+ 'artist_name': artistName,
+ 'progress': progress,
+ 'total': total,
+ 'queue_count': queueCount,
+ });
+ }
+
+ /// Check if download service is running
+ static Future isDownloadServiceRunning() async {
+ final result = await _channel.invokeMethod('isDownloadServiceRunning');
+ return result as bool;
+ }
}
diff --git a/pubspec.lock b/pubspec.lock
index 85542da6..ee612515 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -876,18 +876,18 @@ packages:
dependency: "direct main"
description:
name: share_plus
- sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
+ sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840"
url: "https://pub.dev"
source: hosted
- version: "10.1.4"
+ version: "12.0.1"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
- sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
+ sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
url: "https://pub.dev"
source: hosted
- version: "5.0.2"
+ version: "6.1.0"
shared_preferences:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 705fe589..fbae0b03 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -46,7 +46,7 @@ dependencies:
# Utils
url_launcher: ^6.3.1
device_info_plus: ^12.3.0
- share_plus: ^10.1.4
+ share_plus: ^12.0.1
receive_sharing_intent: ^1.8.1
logger: ^2.5.0