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 aba4b1bf..cd3f8204 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -487,7 +487,7 @@ class MainActivity: FlutterFragmentActivity() { } } - private fun bridgeJsonResult(payload: String): Any { + internal fun bridgeJsonResult(payload: String): Any { // Decide on char count where possible: UTF-8 size is >= length and // <= 3*length, so only the ambiguous band needs the full encode — // avoids duplicating multi-MB payloads just to measure them. diff --git a/android/app/src/rust/kotlin/com/zarz/spotiflac/NativeDataJobs.kt b/android/app/src/rust/kotlin/com/zarz/spotiflac/NativeDataJobs.kt new file mode 100644 index 00000000..1c4eecf1 --- /dev/null +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/NativeDataJobs.kt @@ -0,0 +1,49 @@ +package com.zarz.spotiflac + +import com.spotiflac.backend.CancellationDomain +import com.spotiflac.backend.CancellationRegistry +import com.spotiflac.backend.RequestLease +import org.json.JSONObject + +// Independent of extension initialization: palette/metadata jobs can start +// while app initialization is still running. Acquire before queuing on IO, so +// a following channel cancellation also reaches work that has not started. +internal object NativeDataJobs { + private val registry = CancellationRegistry(CancellationDomain.EXTENSION_REQUEST) + private val active = mutableMapOf>() + + @Synchronized + fun acquire(id: String): RequestLease { + val lease = registry.acquire(id) + active.getOrPut(id) { mutableListOf() }.add(lease) + return lease + } + + @Synchronized + fun cancel(id: String) { + // The shared extension registry supports pre-cancel sentinels. Data + // jobs are acquired in channel order instead and must not retain them. + if (active[id]?.isNotEmpty() == true) registry.cancel(id) + } + + @Synchronized + fun release(id: String, lease: RequestLease) { + val leases = active[id] ?: return + val index = leases.indexOfFirst { it === lease } + if (index < 0) return + leases.removeAt(index) + try { + lease.release() + } finally { + try { lease.close() } + finally { if (leases.isEmpty()) active.remove(id) } + } + } +} + +internal fun MainActivity.runNativeDataJobPlatform(arguments: Any?, lease: RequestLease): Any { + val args = arguments as? Map<*, *> ?: error("Missing data job arguments") + val request = JSONObject(args["request_json"] as? String ?: error("Missing data job request")) + val bytes = args["bytes"] as? ByteArray ?: byteArrayOf() + return bridgeJsonResult(RustCoreBackend.runDataJob(request, bytes, lease)) +} diff --git a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt index 8fd7f0ac..ecd579df 100644 --- a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt @@ -21,23 +21,39 @@ import org.json.JSONObject internal fun createCoreBackend(context: Context): CoreBackend = RustCoreBackend.initialize(context) internal suspend fun MainActivity.dispatchBackendApplication(call: MethodCall, result: MethodChannel.Result): Boolean { - val response = withContext(Dispatchers.IO) { - when (call.method) { - "getLyricsLRC", "getLyricsLRCWithSource" -> { - val arguments = call.arguments as? Map<*, *> ?: emptyMap() - val path = arguments["file_path"] as? String ?: "" - readLyricsWithSafCopy( - path, - copyToTemp = { copyUriToTemp(Uri.parse(it))?.let(::File) }, - read = { localPath -> - coreBackend.invokeApplication(call.method, arguments + ("file_path" to localPath)) - }, - ) ?: if (call.method == "getLyricsLRC") "" else { - """{"lyrics":"","source":"","sync_type":"","instrumental":false}""" + if (call.method == "cancelNativeDataJob") { + val args = call.arguments as? Map<*, *> + NativeDataJobs.cancel(args?.get("request_id") as? String ?: "") + result.success(null) + return true + } + val jobId = if (call.method == "runNativeDataJob") { + (call.arguments as? Map<*, *>)?.get("request_id") as? String + ?: error("Missing data job ID") + } else null + val jobLease = jobId?.let { NativeDataJobs.acquire(it) } + val response = try { + withContext(Dispatchers.IO) { + when (call.method) { + "runNativeDataJob" -> runNativeDataJobPlatform(call.arguments, checkNotNull(jobLease)) + "getLyricsLRC", "getLyricsLRCWithSource" -> { + val arguments = call.arguments as? Map<*, *> ?: emptyMap() + val path = arguments["file_path"] as? String ?: "" + readLyricsWithSafCopy( + path, + copyToTemp = { copyUriToTemp(Uri.parse(it))?.let(::File) }, + read = { localPath -> + coreBackend.invokeApplication(call.method, arguments + ("file_path" to localPath)) + }, + ) ?: if (call.method == "getLyricsLRC") "" else { + """{"lyrics":"","source":"","sync_type":"","instrumental":false}""" + } } + else -> coreBackend.invokeApplication(call.method, call.arguments) } - else -> coreBackend.invokeApplication(call.method, call.arguments) } + } finally { + if (jobLease != null) NativeDataJobs.release(checkNotNull(jobId), jobLease) } result.success(response) return true @@ -120,6 +136,10 @@ internal object RustCoreBackend : CoreBackend { private fun acquireRequest(id: String): Pair = owner() to requestRegistry().acquire(id) + internal fun runDataJob(request: JSONObject, bytes: ByteArray, lease: RequestLease): String { + return com.spotiflac.backend.runNativeDataJob(request.toString(), bytes, lease) + } + @Synchronized private fun repositoryOwner(): ExtensionRepository = checkNotNull(repository) { "Extension repository is not initialized" } @@ -592,6 +612,10 @@ internal object RustCoreBackend : CoreBackend { return (0 until values.length()).map { values.getString(it) } } when (method) { + "cancelNativeDataJob" -> { + NativeDataJobs.cancel(string("request_id")) + return null + } "cancelExtensionRequest" -> synchronized(this) { requestRegistry().cancel(string("request_id")) return null diff --git a/assets/licenses/native_data_jobs.txt b/assets/licenses/native_data_jobs.txt new file mode 100644 index 00000000..8e3bc901 --- /dev/null +++ b/assets/licenses/native_data_jobs.txt @@ -0,0 +1,51 @@ +Native data jobs dependencies + +rusqlite 0.40.2 +Source: https://github.com/rusqlite/rusqlite + +Copyright (c) 2014 The rusqlite developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +roxmltree 0.21.1 +Source: https://github.com/RazrFalcon/roxmltree +Distributed under the MIT license alternative. + +The MIT License (MIT) + +Copyright (c) 2018 Yevhenii Reizner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 254af577..8ec2ba35 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -274,9 +274,26 @@ import UniformTypeIdentifiers return } if coreBackend.routesApplication && !osMethods.contains(call.method) { + if call.method == "cancelNativeDataJob" { + do { + result(try coreBackend.invokeApplication(method: call.method, arguments: call.arguments)) + } catch { + result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil)) + } + return + } + let arguments: Any? + do { + arguments = try coreBackend.prepareApplicationArguments(method: call.method, arguments: call.arguments) + } catch { + result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil)) + return + } DispatchQueue.global(qos: .userInitiated).async { do { - let response = try self.coreBackend.invokeApplication(method: call.method, arguments: call.arguments) + let value = try self.coreBackend.invokeApplication(method: call.method, arguments: arguments) + let response = call.method == "runNativeDataJob" && value is String + ? self.bridgeJsonResult(value as! String) : value DispatchQueue.main.async { result(response) } } catch { DispatchQueue.main.async { result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil)) } diff --git a/ios/Runner/CoreBackend.swift b/ios/Runner/CoreBackend.swift index bfd6d2f4..e0921479 100644 --- a/ios/Runner/CoreBackend.swift +++ b/ios/Runner/CoreBackend.swift @@ -3,6 +3,7 @@ import CryptoKit import Darwin import ffmpegkit import SpotiFLACBackend +import Flutter struct CoreFFmpegCommand: Decodable { let command_id: String @@ -154,6 +155,7 @@ final class CoreDirectoryScope { protocol CoreBackend { var implementation: String { get } var routesApplication: Bool { get } + func prepareApplicationArguments(method: String, arguments: Any?) throws -> Any? func invokeApplication(method: String, arguments: Any?) throws -> Any? func completeAuthCallback(state: String, code: String, sessionGrant: Bool, onResolved: (String) -> Void) throws func openDownloadProgress() throws -> CoreDownloadProgress @@ -190,6 +192,8 @@ protocol CoreBackend { extension CoreBackend { var routesApplication: Bool { false } + func prepareApplicationArguments(method: String, arguments: Any?) throws -> Any? { arguments } + func invokeApplication(method: String, arguments: Any?) throws -> Any? { throw NSError(domain: "CoreBackend", code: 1, userInfo: [NSLocalizedDescriptionKey: "Application routing is unavailable for \(method)"]) } @@ -205,6 +209,9 @@ final class RustCoreBackend: CoreBackend { private var manager: ExtensionManager? private var repository: ExtensionRepository? private var requests: CancellationRegistry? + private let dataJobs = CancellationRegistry(domain: .extensionRequest) + private let dataJobLock = NSLock() + private var activeDataJobs: [String: Int] = [:] private var identity: [String]? private var loggingEnabled = false private var allowPrivateNetwork = false @@ -260,6 +267,48 @@ final class RustCoreBackend: CoreBackend { return (manager, try requestRegistryLocked().acquire(id: id)) } + func prepareApplicationArguments(method: String, arguments: Any?) throws -> Any? { + guard method == "runNativeDataJob" else { return arguments } + guard var args = arguments as? [String: Any], let id = args["request_id"] as? String, + !id.isEmpty else { throw failure("Missing native data job ID") } + // Only acquire the cancellation lease here. JSON, file I/O and native + // processing remain on the background queue. This preserves queued + // cancellation without retaining sentinels for already-finished jobs. + dataJobLock.lock() + defer { dataJobLock.unlock() } + let lease = try dataJobs.acquire(id: id) + activeDataJobs[id, default: 0] += 1 + args["_native_data_job_lease"] = lease + return args + } + + private func finishDataJob(_ id: String, _ lease: RequestLease) { + dataJobLock.lock() + defer { dataJobLock.unlock() } + lease.release() + let remaining = (activeDataJobs[id] ?? 1) - 1 + activeDataJobs[id] = remaining > 0 ? remaining : nil + } + + private func cancelDataJob(_ id: String) throws { + dataJobLock.lock() + defer { dataJobLock.unlock() } + if activeDataJobs[id] != nil { try dataJobs.cancel(id: id) } + } + + private func runDataJob(_ args: [String: Any]) throws -> String { + guard let id = args["request_id"] as? String, + let lease = args["_native_data_job_lease"] as? RequestLease else { + throw failure("Missing native data job lease") + } + defer { finishDataJob(id, lease) } + guard let raw = args["request_json"] as? String else { + throw failure("Missing native data job request") + } + let bytes = (args["bytes"] as? FlutterStandardTypedData)?.data ?? Data() + return try SpotiFLACBackend.runNativeDataJob(requestJson: raw, bytes: bytes, lease: lease) + } + private func repositoryOwner() throws -> ExtensionRepository { ownerLock.lock() defer { ownerLock.unlock() } @@ -600,6 +649,10 @@ final class RustCoreBackend: CoreBackend { return value } switch method { + case "runNativeDataJob": return try runDataJob(args) + case "cancelNativeDataJob": + try cancelDataJob(string("request_id")) + return nil case "cancelExtensionRequest": ownerLock.lock() defer { ownerLock.unlock() } diff --git a/lib/main.dart b/lib/main.dart index efa37029..0cdec106 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,6 +51,10 @@ void main() { yield LicenseEntryWithLineBreaks(const [ 'libsmb2', ], await rootBundle.loadString('assets/licenses/libsmb2.txt')); + yield LicenseEntryWithLineBreaks(const [ + 'rusqlite', + 'roxmltree', + ], await rootBundle.loadString('assets/licenses/native_data_jobs.txt')); if (Platform.isAndroid) { yield LicenseEntryWithLineBreaks(const [ 'libusb', diff --git a/lib/services/apk_downloader.dart b/lib/services/apk_downloader.dart index 826f22e6..6feff7ac 100644 --- a/lib/services/apk_downloader.dart +++ b/lib/services/apk_downloader.dart @@ -2,15 +2,21 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('ApkDownloader'); typedef ProgressCallback = void Function(int received, int total); +Future _hashApkInBackground(String path) async => + (await sha256.bind(File(path).openRead()).first).toString(); + class ApkDownloader { static const _streamIdleTimeout = Duration(seconds: 60); @@ -125,8 +131,7 @@ class ApkDownloader { await _discardPartial(partFile, metadataFile); return null; } - final actual = (await sha256.bind(partFile.openRead()).first) - .toString(); + final actual = await _hashApk(partFile); if (actual != expected) { _log.e('APK SHA-256 verification failed'); await _discardPartial(partFile, metadataFile); @@ -156,6 +161,22 @@ class ApkDownloader { } } + static Future _hashApk(File file) async { + try { + final result = await PlatformBridge.runNativeDataJob({ + 'operation': 'hash_file', + 'path': file.path, + }); + final digest = result['sha256']; + if (digest is! String || !RegExp(r'^[a-f0-9]{64}$').hasMatch(digest)) { + throw const FormatException('Invalid native APK digest'); + } + return digest; + } on MissingPluginException { + return compute(_hashApkInBackground, file.path); + } + } + static Future?> _readResumeMetadata(File file) async { try { if (!await file.exists()) return null; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index cc6701b7..996d7d0b 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -22,6 +22,11 @@ bool isForegroundServiceStartNotAllowed(Object error) { Object? _decodeJsonInBackground(String json) => jsonDecode(json); String _encodeJsonInBackground(Object? value) => jsonEncode(value); +class _NativeDataJobState { + bool dispatched = false; + bool cancelled = false; +} + String _encodeBoundedLookupCacheInBackground(Map entries) { final result = StringBuffer('{'); var bytes = 2; // Opening and closing braces. @@ -209,6 +214,61 @@ class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); + static int _nativeDataJobSerial = 0; + static final _nativeDataJobs = >{}; + + /// A single background native operation. Binary inputs never become base64 + /// JSON, and large request/result codecs stay off the UI isolate. + static Future> runNativeDataJob( + Map request, { + Uint8List? bytes, + String? requestId, + }) async { + final id = + requestId ?? + 'data_${DateTime.now().microsecondsSinceEpoch}_${_nativeDataJobSerial++}'; + final state = _NativeDataJobState(); + final states = _nativeDataJobs.putIfAbsent(id, () => {}); + states.add(state); + try { + final encoded = await compute(_encodeJsonInBackground, request); + if (state.cancelled) { + throw PlatformException( + code: 'CANCELLED', + message: 'Native data job cancelled', + ); + } + state.dispatched = true; + Object? result; + try { + result = await _channel.invokeMethod('runNativeDataJob', { + 'request_json': encoded, + 'request_id': id, + 'bytes': ?bytes, + }); + } finally { + state.dispatched = false; + } + return await _decodeRequiredMapResultAsync(result, 'runNativeDataJob'); + } finally { + states.remove(state); + if (states.isEmpty) _nativeDataJobs.remove(id); + } + } + + static Future cancelNativeDataJob(String requestId) async { + final states = _nativeDataJobs[requestId]; + if (states == null) return; + for (final state in states) { + state.cancelled = true; + } + if (states.any((state) => state.dispatched)) { + await _channel.invokeMethod('cancelNativeDataJob', { + 'request_id': requestId, + }); + } + } + static Future setScreenAwake(bool enabled) async { if (defaultTargetPlatform != TargetPlatform.android && defaultTargetPlatform != TargetPlatform.iOS) { diff --git a/pubspec.yaml b/pubspec.yaml index 5a9edd35..639db362 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -130,6 +130,7 @@ flutter: - assets/licenses/oboe.txt - assets/licenses/wavpack.txt - assets/licenses/libsmb2.txt + - assets/licenses/native_data_jobs.txt fonts: - family: Inter diff --git a/rust_backend/Cargo.lock b/rust_backend/Cargo.lock index b2d88b76..18bd818f 100644 --- a/rust_backend/Cargo.lock +++ b/rust_backend/Cargo.lock @@ -755,6 +755,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -957,6 +969,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -968,6 +989,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -1287,7 +1317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1426,6 +1456,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libusb1-sys" version = "0.7.0" @@ -1928,6 +1969,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rquickjs" version = "0.14.0" @@ -1944,7 +1994,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6657979a24d543e5fc66f32cef2f47ca82ce8eb55cec5f7d59a12b7b570bbf24" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", "relative-path", "rquickjs-sys", ] @@ -1974,6 +2024,31 @@ dependencies = [ "cc", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2432,15 +2507,24 @@ name = "spotiflac-mobile" version = "0.1.0" dependencies = [ "base64", + "chrono", "libc", "libusb1-sys", + "regex", + "roxmltree", + "rusqlite", "rustix", + "scraper", + "serde", "serde_json", + "sha2", "spotiflac-core", "spotiflac-extensions", "spotiflac-network", + "tempfile", "thiserror", "uniffi", + "url", "zeroize", ] @@ -2490,6 +2574,18 @@ dependencies = [ "url", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/rust_backend/NOTICE b/rust_backend/NOTICE index f5f4b1fe..e9f3f4b8 100644 --- a/rust_backend/NOTICE +++ b/rust_backend/NOTICE @@ -1,5 +1,9 @@ # Third-party notices +The native SQLite/TTML data jobs use +rusqlite 0.40.2 and roxmltree 0.21.1 under their MIT licenses, packaged in +`assets/licenses/native_data_jobs.txt`. + The English title-casing control flow in `crates/core/src/metadata/musicbrainz/casing.rs` follows `golang.org/x/text/cases`. Its frozen Unicode 15 data is generated using the diff --git a/rust_backend/crates/mobile/Cargo.toml b/rust_backend/crates/mobile/Cargo.toml index 93ab7163..68b6562a 100644 --- a/rust_backend/crates/mobile/Cargo.toml +++ b/rust_backend/crates/mobile/Cargo.toml @@ -17,8 +17,17 @@ spotiflac-extensions.workspace = true thiserror.workspace = true uniffi.workspace = true serde_json.workspace = true +serde.workspace = true zeroize.workspace = true rustix.workspace = true +sha2.workspace = true +tempfile.workspace = true +scraper.workspace = true +url.workspace = true +chrono.workspace = true +regex.workspace = true +roxmltree = "=0.21.1" +rusqlite = { version = "=0.40.2", features = ["bundled", "modern_sqlite", "hooks"] } [target.'cfg(target_os = "android")'.dependencies] libusb1-sys = { version = "=0.7.0", features = ["vendored"] } diff --git a/rust_backend/crates/mobile/src/data_jobs.rs b/rust_backend/crates/mobile/src/data_jobs.rs new file mode 100644 index 00000000..6f0498d2 --- /dev/null +++ b/rust_backend/crates/mobile/src/data_jobs.rs @@ -0,0 +1,126 @@ +//! CPU and file work submitted by Flutter. Platform dispatch owns the worker +//! thread and the cancellation lease; widgets continue to own presentation. +use crate::cancellation::RequestLease; +use crate::manager::ExtensionManager; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{fs::File, io::Read, sync::Arc}; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi(flat_error)] +pub enum NativeDataJobError { + #[error("{0}")] + Operation(String), +} + +#[uniffi::export] +pub fn run_native_data_job( + request_json: String, + bytes: Vec, + lease: Option>, +) -> Result { + run(&request_json, &bytes, lease.as_deref(), None) +} + +#[uniffi::export] +impl ExtensionManager { + pub fn run_native_data_job( + &self, + request_json: String, + bytes: Vec, + lease: Option>, + ) -> Result { + run(&request_json, &bytes, lease.as_deref(), Some(self)) + } +} + +fn run( + request_json: &str, + bytes: &[u8], + lease: Option<&RequestLease>, + manager: Option<&ExtensionManager>, +) -> Result { + let check = || crate::tags::check_lease(lease); + check().map_err(NativeDataJobError::Operation)?; + let request: Value = serde_json::from_str(request_json) + .map_err(|error| NativeDataJobError::Operation(error.to_string()))?; + let value = execute(&request, bytes, &check, manager).map_err(NativeDataJobError::Operation)?; + if value.get("committed") != Some(&Value::Bool(true)) { + check().map_err(NativeDataJobError::Operation)?; + } + serde_json::to_string(&value).map_err(|error| NativeDataJobError::Operation(error.to_string())) +} + +fn execute( + request: &Value, + _bytes: &[u8], + check: &(dyn Fn() -> Result<(), String> + Sync), + _manager: Option<&ExtensionManager>, +) -> Result { + let operation = string(request, "operation")?; + match operation { + "hash_file" => Ok(json!({"sha256": hash_file(string(request, "path")?, check)?})), + _ => Err(format!("Unknown native data operation: {operation}")), + } +} + +pub(crate) fn string<'a>(request: &'a Value, name: &str) -> Result<&'a str, String> { + request + .get(name) + .and_then(Value::as_str) + .ok_or_else(|| format!("Missing native data field: {name}")) +} + +pub(crate) fn hash_file( + path: &str, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + check()?; + if !std::fs::metadata(path) + .map_err(|error| error.to_string())? + .is_file() + { + return Err("Hash input must be a regular file".into()); + } + let mut file = File::open(path).map_err(|error| error.to_string())?; + if !file + .metadata() + .map_err(|error| error.to_string())? + .is_file() + { + return Err("Hash input must be a regular file".into()); + } + let mut hash = Sha256::new(); + let mut buffer = [0; 64 * 1024]; + loop { + check()?; + let count = file.read(&mut buffer).map_err(|error| error.to_string())?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + check()?; + Ok(hash + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn hashes_streamed_files_and_obeys_cancellation() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"abc").unwrap(); + assert_eq!( + hash_file(file.path().to_str().unwrap(), &|| Ok(())).unwrap(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert!(hash_file(file.path().to_str().unwrap(), &|| Err("cancelled".into())).is_err()); + } +} diff --git a/rust_backend/crates/mobile/src/lib.rs b/rust_backend/crates/mobile/src/lib.rs index 284ef4ab..7052f49f 100644 --- a/rust_backend/crates/mobile/src/lib.rs +++ b/rust_backend/crates/mobile/src/lib.rs @@ -1,6 +1,7 @@ //! Migration APIs. Instances here are not connected to the app's Go-owned work. mod cancellation; +mod data_jobs; mod extensions; mod ffmpeg; mod filename; diff --git a/test/apk_downloader_test.dart b/test/apk_downloader_test.dart index 63af8d01..3fc78542 100644 --- a/test/apk_downloader_test.dart +++ b/test/apk_downloader_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -9,6 +10,13 @@ import 'package:path/path.dart' as p; import 'package:spotiflac_android/services/apk_downloader.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + test('APK updater resumes a matching partial and verifies SHA-256', () async { final directory = await Directory.systemTemp.createTemp( 'spotiflac-updater-test-', @@ -68,4 +76,62 @@ void main() { expect(await part.exists(), isFalse); expect(await File('$finalPath.part.json').exists(), isFalse); }); + + test( + 'native hashing verifies the completed partial before publishing', + () async { + final directory = await Directory.systemTemp.createTemp( + 'spotiflac-native-updater-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final payload = [0x50, 0x4b, 0x03, 0x04, 1, 2, 3]; + final expected = sha256.convert(payload).toString(); + var hashes = 0; + var nativeDigest = expected; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'runNativeDataJob'); + final args = Map.from(call.arguments as Map); + final request = jsonDecode(args['request_json'] as String) as Map; + expect(request['operation'], 'hash_file'); + final file = File(request['path'] as String); + expect(file.path, endsWith('.apk.part')); + expect(await file.readAsBytes(), payload); + hashes++; + return {'sha256': nativeDigest}; + }); + final client = MockClient( + (request) async => http.Response.bytes(payload, 200, request: request), + ); + final downloaded = await ApkDownloader.downloadApk( + url: 'https://updates.example.test/update.apk', + version: 'native', + expectedSha256: expected, + client: client, + downloadDirectory: directory, + ); + expect(downloaded, isNotNull); + expect(await File(downloaded!).readAsBytes(), payload); + nativeDigest = '0' * 64; + final rejected = await ApkDownloader.downloadApk( + url: 'https://updates.example.test/update.apk', + version: 'mismatch', + expectedSha256: expected, + client: client, + downloadDirectory: directory, + ); + expect(rejected, isNull); + expect(hashes, 2); + expect( + await File('${directory.path}/SpotiFLAC-Mobile-mismatch.apk').exists(), + false, + ); + expect( + await File( + '${directory.path}/SpotiFLAC-Mobile-mismatch.apk.part', + ).exists(), + false, + ); + }, + ); } diff --git a/test/native_data_job_bridge_test.dart b/test/native_data_job_bridge_test.dart new file mode 100644 index 00000000..e027ec02 --- /dev/null +++ b/test/native_data_job_bridge_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late List calls; + + setUp(() { + calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return call.method == 'runNativeDataJob' ? '{"value":1}' : null; + }); + }); + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test( + 'cancel before encoding completes never dispatches an idle job', + () async { + final expectation = expectLater( + PlatformBridge.runNativeDataJob({ + 'operation': 'hash_file', + 'path': '/unused', + }, requestId: 'encoding'), + throwsA( + isA().having((e) => e.code, 'code', 'CANCELLED'), + ), + ); + await PlatformBridge.cancelNativeDataJob('encoding'); + await expectation; + expect(calls, isEmpty); + expect( + await PlatformBridge.runNativeDataJob({ + 'operation': 'hash_file', + 'path': '/unused', + }, requestId: 'encoding'), + {'value': 1}, + ); + }, + ); + + test( + 'active cancellation reaches native and preserves committed results', + () async { + final started = Completer(); + final finished = Completer(); + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + if (call.method == 'runNativeDataJob') { + started.complete(); + return finished.future; + } + return null; + }); + final job = PlatformBridge.runNativeDataJob({ + 'operation': 'library_import', + }, requestId: 'active'); + await started.future; + await PlatformBridge.cancelNativeDataJob('active'); + finished.complete('{"committed":true}'); + expect(await job, {'committed': true}); + expect(calls, ['runNativeDataJob', 'cancelNativeDataJob']); + await PlatformBridge.cancelNativeDataJob('active'); + await PlatformBridge.cancelNativeDataJob('unknown'); + expect(calls, ['runNativeDataJob', 'cancelNativeDataJob']); + }, + ); + + test('failed encoding releases its request identity', () async { + await expectLater( + PlatformBridge.runNativeDataJob({ + 'operation': Object(), + }, requestId: 'invalid'), + throwsA(isA()), + ); + await PlatformBridge.cancelNativeDataJob('invalid'); + expect(calls, isEmpty); + expect( + await PlatformBridge.runNativeDataJob({ + 'operation': 'palette', + }, requestId: 'invalid'), + {'value': 1}, + ); + }); +}