feat(native): add cancellable data workers and streamed file hashing

Introduce Android, iOS, and Dart worker dispatch with queued cancellation, binary inputs, and background JSON codecs. Move APK verification to streamed native SHA256 while retaining the isolated fallback.
This commit is contained in:
zarzet committed 2026-10-06 23:25:39 +07:00
1 parent 2d2e66a8b6
commit 867371cb67
17 files changed
+694 -20

No files matched your search

@@ -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.
@@ -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<String, MutableList<RequestLease>>()
@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))
}
@@ -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<Any, Any>()
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<Any, Any>()
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<ExtensionManager, RequestLease> =
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
+51
View File
@@ -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.
+18 -1
View File
@@ -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)) }
+53
View File
@@ -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() }
+4
View File
@@ -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',
+23 -2
View File
@@ -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<String> _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<String> _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<Map<String, dynamic>?> _readResumeMetadata(File file) async {
try {
if (!await file.exists()) return null;
+60
View File
@@ -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<String, dynamic> 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 = <String, Set<_NativeDataJobState>>{};
/// A single background native operation. Binary inputs never become base64
/// JSON, and large request/result codecs stay off the UI isolate.
static Future<Map<String, dynamic>> runNativeDataJob(
Map<String, dynamic> 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<void> 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<void>('cancelNativeDataJob', {
'request_id': requestId,
});
}
}
static Future<void> setScreenAwake(bool enabled) async {
if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) {
+1
View File
@@ -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
+98 -2
View File
@@ -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"
+4
View File
@@ -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
+9
View File
@@ -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"] }
+126
View File
@@ -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<u8>,
lease: Option<Arc<RequestLease>>,
) -> Result<String, NativeDataJobError> {
run(&request_json, &bytes, lease.as_deref(), None)
}
#[uniffi::export]
impl ExtensionManager {
pub fn run_native_data_job(
&self,
request_json: String,
bytes: Vec<u8>,
lease: Option<Arc<RequestLease>>,
) -> Result<String, NativeDataJobError> {
run(&request_json, &bytes, lease.as_deref(), Some(self))
}
}
fn run(
request_json: &str,
bytes: &[u8],
lease: Option<&RequestLease>,
manager: Option<&ExtensionManager>,
) -> Result<String, NativeDataJobError> {
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<Value, String> {
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<String, String> {
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());
}
}
+1
View File
@@ -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;
+66
View File
@@ -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 = <int>[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<String, dynamic>.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,
);
},
);
}
+92
View File
@@ -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<String> 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<PlatformException>().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<void>();
final finished = Completer<String>();
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<Object>()),
);
await PlatformBridge.cancelNativeDataJob('invalid');
expect(calls, isEmpty);
expect(
await PlatformBridge.runNativeDataJob({
'operation': 'palette',
}, requestId: 'invalid'),
{'value': 1},
);
});
}