feat: background download service + queue persistence (v1.6.1)

- Add foreground service for background downloads with wake lock
- Persist download queue to SharedPreferences for app restart recovery
- Fix share intent causing app restart (singleTask + onNewIntent)
- Fix back button clearing state during loading
- Upgrade Kotlin to 2.3.0 for share_plus 12.0.1 compatibility
- Add WAKE_LOCK permission for foreground service
This commit is contained in:
zarzet
2026-01-02 18:14:19 +07:00
parent 39ddb7a14f
commit 2043370b6c
13 changed files with 311 additions and 49 deletions
+11
View File
@@ -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
+1 -1
View File
@@ -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.
+1
View File
@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="SpotiFLAC"
@@ -5,83 +5,211 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
/**
* Foreground service to keep downloads running when app is in background.
* This prevents Android from killing the download process or throttling network.
*/
class DownloadService : Service() {
companion object {
const val CHANNEL_ID = "spotiflac_download_channel"
const val NOTIFICATION_ID = 1
const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD"
const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD"
private const val CHANNEL_ID = "download_channel"
private const val NOTIFICATION_ID = 1001
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD"
const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD"
const val ACTION_UPDATE_PROGRESS = "com.zarz.spotiflac.action.UPDATE_PROGRESS"
const val EXTRA_TRACK_NAME = "track_name"
const val EXTRA_ARTIST_NAME = "artist_name"
const val EXTRA_PROGRESS = "progress"
const val EXTRA_TOTAL = "total"
const val EXTRA_QUEUE_COUNT = "queue_count"
private var isRunning = false
fun isServiceRunning(): Boolean = isRunning
fun start(context: Context, trackName: String = "", artistName: String = "", queueCount: Int = 0) {
val intent = Intent(context, DownloadService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_TRACK_NAME, trackName)
putExtra(EXTRA_ARTIST_NAME, artistName)
putExtra(EXTRA_QUEUE_COUNT, queueCount)
}
if (Build.VERSION.SDK_INT >= 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()
}
}
@@ -167,6 +167,29 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"startDownloadService" -> {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val queueCount = call.argument<Int>("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<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val progress = call.argument<Long>("progress") ?: 0L
val total = call.argument<Long>("total") ?: 0L
val queueCount = call.argument<Int>("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) {
+1 -1
View File
@@ -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")
@@ -348,6 +348,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
// 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<DownloadQueueState> {
_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...');
+8 -2
View File
@@ -150,8 +150,9 @@ class _MainShellState extends ConsumerState<MainShell> {
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<MainShell> {
return;
}
// If loading, ignore back press
if (trackState.isLoading) {
return;
}
// Already at root, show exit dialog
final shouldPop = await _showExitDialog();
if (shouldPop && context.mounted) {
+5 -3
View File
@@ -938,9 +938,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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}',
),
);
}
+41
View File
@@ -234,4 +234,45 @@ class PlatformBridge {
static Future<void> cleanupConnections() async {
await _channel.invokeMethod('cleanupConnections');
}
/// Start foreground download service to keep downloads running in background
static Future<void> 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<void> stopDownloadService() async {
await _channel.invokeMethod('stopDownloadService');
}
/// Update download service notification progress
static Future<void> 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<bool> isDownloadServiceRunning() async {
final result = await _channel.invokeMethod('isDownloadServiceRunning');
return result as bool;
}
}
+4 -4
View File
@@ -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:
+1 -1
View File
@@ -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