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