diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index dff1fae6..56ed75ad 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -58,6 +58,8 @@ class DownloadService : Service() { const val EXTRA_STATUS = "status" const val EXTRA_REQUESTS_JSON = "requests_json" const val EXTRA_SETTINGS_JSON = "settings_json" + const val EXTRA_REQUESTS_PATH = "requests_path" + const val EXTRA_SETTINGS_PATH = "settings_path" private const val NATIVE_WORKER_STATE_FILE = "native_download_worker_state.json" private const val NATIVE_WORKER_PROGRESS_FILE = "native_download_worker_progress.json" private const val NATIVE_REPLAYGAIN_JOURNAL_FILE = "native_replaygain_journal.json" @@ -117,6 +119,19 @@ class DownloadService : Service() { } } + fun startNativeQueueFromFiles(context: Context, requestsPath: String, settingsPath: String = "") { + val intent = Intent(context, DownloadService::class.java).apply { + action = ACTION_START_NATIVE_QUEUE + putExtra(EXTRA_REQUESTS_PATH, requestsPath) + putExtra(EXTRA_SETTINGS_PATH, settingsPath) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + fun pauseNativeQueue(context: Context) { val intent = Intent(context, DownloadService::class.java).apply { action = ACTION_PAUSE_NATIVE_QUEUE @@ -282,8 +297,18 @@ class DownloadService : Service() { stopForegroundService() } ACTION_START_NATIVE_QUEUE -> { - val requestsJson = intent.getStringExtra(EXTRA_REQUESTS_JSON) ?: "[]" - val settingsJson = intent.getStringExtra(EXTRA_SETTINGS_JSON) ?: "{}" + val requestsJson = readNativeQueuePayload( + intent, + EXTRA_REQUESTS_JSON, + EXTRA_REQUESTS_PATH, + "[]" + ) + val settingsJson = readNativeQueuePayload( + intent, + EXTRA_SETTINGS_JSON, + EXTRA_SETTINGS_PATH, + "{}" + ) startNativeWorker(requestsJson, settingsJson) } ACTION_PAUSE_NATIVE_QUEUE -> { @@ -366,6 +391,36 @@ class DownloadService : Service() { } return START_NOT_STICKY } + + private fun readNativeQueuePayload( + intent: Intent, + jsonExtra: String, + pathExtra: String, + defaultValue: String, + ): String { + val path = intent.getStringExtra(pathExtra).orEmpty() + if (path.isNotBlank()) { + return try { + val file = File(path) + val payload = file.readText() + if (!file.delete()) { + android.util.Log.w( + "DownloadService", + "Failed to delete native worker payload file: $path" + ) + } + payload.ifBlank { defaultValue } + } catch (e: Exception) { + android.util.Log.w( + "DownloadService", + "Failed to read native worker payload file: ${e.message}" + ) + defaultValue + } + } + + return intent.getStringExtra(jsonExtra) ?: defaultValue + } override fun onBind(intent: Intent?): IBinder? = null 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 7731c946..c771463b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2797,7 +2797,17 @@ class MainActivity: FlutterFragmentActivity() { "startNativeDownloadWorker" -> { val requestsJson = call.argument("requests_json") ?: "[]" val settingsJson = call.argument("settings_json") ?: "{}" - DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson) + val requestsPath = call.argument("requests_path") ?: "" + val settingsPath = call.argument("settings_path") ?: "" + if (requestsPath.isNotBlank()) { + DownloadService.startNativeQueueFromFiles( + this@MainActivity, + requestsPath, + settingsPath + ) + } else { + DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson) + } result.success(null) } "pauseNativeDownloadWorker" -> { diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 5a81cbec..d3b6cd64 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -25,9 +25,10 @@ const ( ) type ExtensionPermissions struct { - Network []string `json:"network"` - Storage bool `json:"storage"` - File bool `json:"file"` + Network []string `json:"network"` + Storage bool `json:"storage"` + File bool `json:"file"` + AllowHTTP bool `json:"allowHttp,omitempty"` } type ExtensionSetting struct { diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 97f0fcf7..9cf544df 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -258,7 +258,8 @@ func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout ti Jar: jar, } client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if req.URL.Scheme != "https" { + if req.URL.Scheme != "https" && + !(req.URL.Scheme == "http" && ext.Manifest.Permissions.AllowHTTP) { GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme) return fmt.Errorf("redirect blocked: only https is allowed") } diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index bfaa08c4..9b58ac71 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -44,7 +44,8 @@ func (r *extensionRuntime) validateDomain(urlStr string) error { if parsed.Scheme == "" { return fmt.Errorf("invalid URL: scheme is required") } - if parsed.Scheme != "https" { + if parsed.Scheme != "https" && + !(parsed.Scheme == "http" && r.manifest.Permissions.AllowHTTP) { return fmt.Errorf("network access denied: only https is allowed") } if parsed.User != nil { diff --git a/go_backend/extension_test.go b/go_backend/extension_test.go index 658cc2f7..46b897e8 100644 --- a/go_backend/extension_test.go +++ b/go_backend/extension_test.go @@ -144,6 +144,15 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) { if err := runtime.validateDomain("https://notallowed.com/path"); err == nil { t.Error("Expected notallowed.com to be denied") } + + if err := runtime.validateDomain("http://api.allowed.com/path"); err == nil { + t.Error("Expected http URL to be denied without allowHttp") + } + + ext.Manifest.Permissions.AllowHTTP = true + if err := runtime.validateDomain("http://api.allowed.com/path"); err != nil { + t.Errorf("Expected http URL to be allowed with allowHttp, got error: %v", err) + } } func TestExtensionRuntime_FileSandbox(t *testing.T) { diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 75be7b9e..c93f8d3a 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -841,10 +842,58 @@ class PlatformBridge { required List> requests, Map settings = const {}, }) async { - await _channel.invokeMethod('startNativeDownloadWorker', { - 'requests_json': jsonEncode(requests), - 'settings_json': jsonEncode(settings), - }); + final requestsJson = jsonEncode(requests); + final settingsJson = jsonEncode(settings); + final payloadDir = await _nativeWorkerPayloadDir(); + await _cleanupNativeWorkerPayloads(payloadDir); + final stamp = DateTime.now().microsecondsSinceEpoch; + final requestPath = '${payloadDir.path}/requests_$stamp.json'; + final settingsPath = '${payloadDir.path}/settings_$stamp.json'; + await File(requestPath).writeAsString(requestsJson, flush: true); + await File(settingsPath).writeAsString(settingsJson, flush: true); + try { + await _channel.invokeMethod('startNativeDownloadWorker', { + 'requests_path': requestPath, + 'settings_path': settingsPath, + }); + } catch (_) { + unawaited(_deleteFileIfExists(requestPath)); + unawaited(_deleteFileIfExists(settingsPath)); + rethrow; + } + } + + static Future _deleteFileIfExists(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + + static Future _nativeWorkerPayloadDir() async { + final tempDir = await getTemporaryDirectory(); + final payloadDir = Directory('${tempDir.path}/native_worker_payloads'); + if (!await payloadDir.exists()) { + await payloadDir.create(recursive: true); + } + return payloadDir; + } + + static Future _cleanupNativeWorkerPayloads(Directory payloadDir) async { + final cutoff = DateTime.now().subtract(const Duration(days: 1)); + try { + await for (final entity in payloadDir.list(followLinks: false)) { + if (entity is! File || !entity.path.endsWith('.json')) continue; + final stat = await entity.stat(); + if (stat.modified.isBefore(cutoff)) { + try { + await entity.delete(); + } catch (_) {} + } + } + } catch (_) {} } static Future pauseNativeDownloadWorker() async {