mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
perf(nas): process listings, artwork caches, and upload recovery natively
Move WebDAV/HTML parsing, content-addressed artwork caching, cover promotion, and streamed upload verification to native workers. Preserve proxy authentication, cancellation, cache limits, and deep XML fallback behavior.
This commit is contained in:
1 parent
867371cb67
commit
927fd033cd
15 files changed
+1349
-98
No files matched your search
@@ -1,7 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/network_metadata_service.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
@@ -119,28 +121,28 @@ class NetworkLibraryScanner {
|
||||
await File(cover).exists()) {
|
||||
// Identical embedded artwork shares an immutable file, including
|
||||
// across albums/sources. Cleanup retains all DB cover references.
|
||||
File? target;
|
||||
File? staged;
|
||||
try {
|
||||
final digest = await sha256.bind(File(cover).openRead()).first;
|
||||
target = File('${covers.path}/network_cover_$digest.image');
|
||||
staged = File(
|
||||
'${target.path}.${NetworkStorageService.newId()}.tmp',
|
||||
);
|
||||
if (!await target.exists() || await target.length() == 0) {
|
||||
await File(cover).copy(staged.path);
|
||||
await staged.rename(target.path);
|
||||
try {
|
||||
if (!PlatformBridge.supportsCoreBackend) {
|
||||
final directory = covers.path;
|
||||
coverPath = await Isolate.run(
|
||||
() => _promoteCover(cover, directory),
|
||||
);
|
||||
} else {
|
||||
final promoted = await PlatformBridge.runNativeDataJob({
|
||||
'operation': 'promote_network_cover',
|
||||
'path': cover,
|
||||
'directory': covers.path,
|
||||
});
|
||||
coverPath = promoted['cover_path'] as String;
|
||||
}
|
||||
} on MissingPluginException {
|
||||
final directory = covers.path;
|
||||
coverPath = await Isolate.run(
|
||||
() => _promoteCover(cover, directory),
|
||||
);
|
||||
}
|
||||
coverPath = target.path;
|
||||
} catch (_) {
|
||||
if (target != null && await target.exists()) {
|
||||
coverPath = target.path;
|
||||
}
|
||||
} finally {
|
||||
if (staged != null && await staged.exists()) {
|
||||
await staged.delete();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
await checkpoint();
|
||||
final row = networkLibraryRow(trackSource, entry, tags)
|
||||
@@ -173,6 +175,21 @@ class NetworkLibraryScanner {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promoteCover(String cover, String directory) async {
|
||||
final digest = await sha256.bind(File(cover).openRead()).first;
|
||||
final target = File('$directory/network_cover_$digest.image');
|
||||
final staged = File('${target.path}.${NetworkStorageService.newId()}.tmp');
|
||||
try {
|
||||
if (!await target.exists() || await target.length() == 0) {
|
||||
await File(cover).copy(staged.path);
|
||||
await staged.rename(target.path);
|
||||
}
|
||||
return target.path;
|
||||
} finally {
|
||||
if (await staged.exists()) await staged.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> networkLibraryRow(
|
||||
String source,
|
||||
NetworkEntry entry,
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -18,6 +20,7 @@ class NetworkMetadataService {
|
||||
NetworkTagReader? reader,
|
||||
Future<Directory> Function()? cacheDirectory,
|
||||
}) : _storage = storage ?? NetworkStorageService.instance,
|
||||
_useNativeReader = reader == null,
|
||||
_reader =
|
||||
reader ??
|
||||
((url, name) =>
|
||||
@@ -27,6 +30,7 @@ class NetworkMetadataService {
|
||||
static final instance = NetworkMetadataService();
|
||||
final NetworkStorageService _storage;
|
||||
final NetworkTagReader _reader;
|
||||
final bool _useNativeReader;
|
||||
final Future<Directory> Function() _directory;
|
||||
final _cache = <String, ({DateTime time, Map<String, dynamic> value})>{};
|
||||
final _pending = <String, Future<Map<String, dynamic>>>{};
|
||||
@@ -72,44 +76,41 @@ class NetworkMetadataService {
|
||||
name = '${name.substring(0, name.length - 5)}.m4a';
|
||||
}
|
||||
final url = await _storage.resolve(source);
|
||||
final metadata = Map<String, dynamic>.from(await _reader(url, name));
|
||||
Map<String, dynamic>? loaded;
|
||||
if (_useNativeReader) {
|
||||
try {
|
||||
loaded = await PlatformBridge.runNativeDataJob({
|
||||
'operation': 'network_metadata',
|
||||
'url': url,
|
||||
'display_name': name,
|
||||
'cache_directory': '${(await _directory()).path}/network_metadata',
|
||||
});
|
||||
} on MissingPluginException {
|
||||
// Test/unsupported hosts retain the same reader on an isolate.
|
||||
}
|
||||
}
|
||||
if (loaded == null) {
|
||||
final tags = await _reader(url, name);
|
||||
String directory = '';
|
||||
if (tags['cover_base64'] is String &&
|
||||
(tags['cover_base64'] as String).isNotEmpty) {
|
||||
try {
|
||||
directory = '${(await _directory()).path}/network_metadata';
|
||||
} catch (_) {}
|
||||
}
|
||||
loaded = await compute(_cacheNetworkMetadata, {
|
||||
'source': source,
|
||||
'directory': directory,
|
||||
'metadata': tags,
|
||||
});
|
||||
}
|
||||
final result = loaded ?? (throw StateError('Network metadata is empty'));
|
||||
final metadata = Map<String, dynamic>.from(result['metadata'] as Map);
|
||||
if (metadata['error']?.toString().isNotEmpty == true) {
|
||||
throw StateError('Network metadata could not be read');
|
||||
}
|
||||
final cover = metadata.remove('cover_base64');
|
||||
if (cover is String &&
|
||||
cover.isNotEmpty &&
|
||||
cover.length <= 6 * 1024 * 1024) {
|
||||
// A cover/cache failure must not discard successfully read tags or lyrics.
|
||||
try {
|
||||
final directory = Directory(
|
||||
'${(await _directory()).path}/network_metadata',
|
||||
);
|
||||
await directory.create(recursive: true);
|
||||
final file = File(
|
||||
'${directory.path}/${sha256.convert(utf8.encode(source))}.image',
|
||||
);
|
||||
await file.writeAsBytes(base64Decode(cover), flush: true);
|
||||
metadata['cover_path'] = file.path;
|
||||
final files = await directory
|
||||
.list()
|
||||
.where((entry) => entry is File)
|
||||
.cast<File>()
|
||||
.toList();
|
||||
final dated = <({File file, DateTime time})>[];
|
||||
for (final entry in files) {
|
||||
dated.add((file: entry, time: (await entry.stat()).modified));
|
||||
}
|
||||
dated.sort((a, b) => b.time.compareTo(a.time));
|
||||
for (final old in dated.skip(32)) {
|
||||
await old.file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
final value = Map<String, dynamic>.unmodifiable(metadata);
|
||||
// Oversized tag blocks can still be displayed for the current song, but
|
||||
// must not accumulate across a large NAS collection in the memory cache.
|
||||
if (utf8.encode(jsonEncode(value)).length > 128 * 1024) return value;
|
||||
if (result['cacheable'] != true) return value;
|
||||
_cache.remove(source);
|
||||
while (_cache.length >= 32) {
|
||||
_cache.remove(_cache.keys.first);
|
||||
@@ -118,3 +119,44 @@ class NetworkMetadataService {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility path keeps cover decoding/hash/directory sorting off the UI
|
||||
/// isolate too. Production receives only native file paths and small tags.
|
||||
Future<Map<String, dynamic>> _cacheNetworkMetadata(
|
||||
Map<String, dynamic> job,
|
||||
) async {
|
||||
final metadata = Map<String, dynamic>.from(job['metadata'] as Map);
|
||||
final cover = metadata.remove('cover_base64');
|
||||
if (cover is String &&
|
||||
cover.isNotEmpty &&
|
||||
(job['directory'] as String).isNotEmpty &&
|
||||
cover.length <= 6 * 1024 * 1024) {
|
||||
// A cover/cache failure must not discard successfully read tags or lyrics.
|
||||
try {
|
||||
final directory = Directory(job['directory'] as String);
|
||||
await directory.create(recursive: true);
|
||||
final file = File(
|
||||
'${directory.path}/${sha256.convert(utf8.encode(job['source'] as String))}.image',
|
||||
);
|
||||
await file.writeAsBytes(base64Decode(cover), flush: true);
|
||||
metadata['cover_path'] = file.path;
|
||||
final files = await directory
|
||||
.list()
|
||||
.where((entry) => entry is File)
|
||||
.cast<File>()
|
||||
.toList();
|
||||
final dated = <({File file, DateTime time})>[];
|
||||
for (final entry in files) {
|
||||
dated.add((file: entry, time: (await entry.stat()).modified));
|
||||
}
|
||||
dated.sort((a, b) => b.time.compareTo(a.time));
|
||||
for (final old in dated.skip(32)) {
|
||||
await old.file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return {
|
||||
'metadata': metadata,
|
||||
'cacheable': utf8.encode(jsonEncode(metadata)).length <= 128 * 1024,
|
||||
};
|
||||
}
|
||||
@@ -5,14 +5,44 @@ import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:spotiflac_android/services/network_certificate.dart';
|
||||
import 'package:spotiflac_android/services/network_smb_client.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
part 'network_storage_upload.dart';
|
||||
|
||||
List<NetworkEntry> _parseNetworkListingInBackground(
|
||||
Map<String, dynamic> request,
|
||||
) {
|
||||
final connection = NetworkConnection(
|
||||
id: 'listing',
|
||||
name: 'listing',
|
||||
protocol: NetworkProtocol.values.byName(request['protocol'] as String),
|
||||
address: request['address'] as String,
|
||||
);
|
||||
return NetworkStorageService.parseListing(
|
||||
connection,
|
||||
request['path'] as String,
|
||||
utf8.decode(request['bytes'] as Uint8List, allowMalformed: true),
|
||||
);
|
||||
}
|
||||
|
||||
List<NetworkEntry> _hydrateNetworkEntriesInBackground(List<dynamic> entries) =>
|
||||
[
|
||||
for (final raw in entries)
|
||||
NetworkEntry(
|
||||
(raw as Map)['path'] as String,
|
||||
raw['name'] as String,
|
||||
directory: raw['directory'] as bool,
|
||||
size: (raw['size'] as num?)?.toInt(),
|
||||
),
|
||||
];
|
||||
|
||||
enum NetworkProtocol { smb, webdav, http }
|
||||
|
||||
class NetworkConnection {
|
||||
@@ -470,11 +500,7 @@ class NetworkStorageService {
|
||||
}
|
||||
bytes.add(chunk);
|
||||
}
|
||||
return parseListing(
|
||||
c,
|
||||
path,
|
||||
utf8.decode(bytes.takeBytes(), allowMalformed: true),
|
||||
);
|
||||
return await parseListingInBackground(c, path, bytes.takeBytes());
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
@@ -570,6 +596,41 @@ class NetworkStorageService {
|
||||
return _sorted(found.values.toList());
|
||||
}
|
||||
|
||||
/// Keep DOM parsing and sorting away from the Flutter frame while retaining
|
||||
/// the synchronous parser for unsupported native hosts and contract tests.
|
||||
static Future<List<NetworkEntry>> parseListingInBackground(
|
||||
NetworkConnection connection,
|
||||
String path,
|
||||
Uint8List bytes,
|
||||
) async {
|
||||
List<dynamic> entries;
|
||||
try {
|
||||
final result = await PlatformBridge.runNativeDataJob({
|
||||
'operation': 'parse_network_listing',
|
||||
'protocol': connection.protocol.name,
|
||||
'base_url': networkTarget(connection, path).toString(),
|
||||
'path': path,
|
||||
}, bytes: bytes);
|
||||
entries = result['entries'] as List;
|
||||
} on MissingPluginException {
|
||||
return compute(_parseNetworkListingInBackground, {
|
||||
'protocol': connection.protocol.name,
|
||||
'address': connection.address,
|
||||
'path': path,
|
||||
'bytes': bytes,
|
||||
});
|
||||
} on PlatformException catch (error) {
|
||||
if (error.message?.contains('XML nesting exceeds') != true) rethrow;
|
||||
return compute(_parseNetworkListingInBackground, {
|
||||
'protocol': connection.protocol.name,
|
||||
'address': connection.address,
|
||||
'path': path,
|
||||
'bytes': bytes,
|
||||
});
|
||||
}
|
||||
return compute(_hydrateNetworkEntriesInBackground, entries);
|
||||
}
|
||||
|
||||
Future<void> _serve(HttpRequest request) async {
|
||||
HttpClient? client;
|
||||
NetworkSmbClient? smb;
|
||||
|
||||
@@ -121,11 +121,74 @@ extension NetworkStorageUpload on NetworkStorageService {
|
||||
File file,
|
||||
int length,
|
||||
void Function() checkpoint,
|
||||
) async {
|
||||
final url = await resolve(source);
|
||||
checkpoint();
|
||||
final requestId = 'upload_hash_${NetworkStorageService.newId()}';
|
||||
Object? cancellationError;
|
||||
StackTrace? cancellationStack;
|
||||
final guard = Timer.periodic(const Duration(milliseconds: 50), (timer) {
|
||||
try {
|
||||
checkpoint();
|
||||
} catch (error, stack) {
|
||||
cancellationError = error;
|
||||
cancellationStack = stack;
|
||||
timer.cancel();
|
||||
unawaited(_cancelUploadHash(requestId));
|
||||
}
|
||||
});
|
||||
void checkCancelled() {
|
||||
final error = cancellationError;
|
||||
if (error != null) {
|
||||
Error.throwWithStackTrace(error, cancellationStack!);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.runNativeDataJob({
|
||||
'operation': 'matches_network_upload',
|
||||
'url': url,
|
||||
'path': file.path,
|
||||
'length': length,
|
||||
'idle_timeout_ms': NetworkStorageService.timeout.inMilliseconds,
|
||||
}, requestId: requestId);
|
||||
checkCancelled();
|
||||
checkpoint();
|
||||
final matches = result['matches'];
|
||||
if (matches is! bool) {
|
||||
throw const FormatException('Invalid native upload verification');
|
||||
}
|
||||
return matches;
|
||||
} on MissingPluginException {
|
||||
guard.cancel();
|
||||
checkCancelled();
|
||||
return _matchesUploadInDart(url, file, length, checkpoint);
|
||||
} catch (_) {
|
||||
checkCancelled();
|
||||
rethrow;
|
||||
} finally {
|
||||
guard.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelUploadHash(String requestId) async {
|
||||
try {
|
||||
await PlatformBridge.cancelNativeDataJob(requestId);
|
||||
} catch (_) {
|
||||
// The worker is still awaited before the caller can release its file.
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _matchesUploadInDart(
|
||||
String url,
|
||||
File file,
|
||||
int length,
|
||||
void Function() checkpoint,
|
||||
) async {
|
||||
final client = _client();
|
||||
try {
|
||||
final request = await client
|
||||
.getUrl(Uri.parse(await resolve(source)))
|
||||
.getUrl(Uri.parse(url))
|
||||
.timeout(NetworkStorageService.timeout);
|
||||
final response = await request.close().timeout(
|
||||
NetworkStorageService.timeout,
|
||||
|
||||
@@ -53,13 +53,33 @@ fn run(
|
||||
|
||||
fn execute(
|
||||
request: &Value,
|
||||
_bytes: &[u8],
|
||||
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)?})),
|
||||
"matches_network_upload" => crate::network_tags::matches_upload(request, check),
|
||||
"network_metadata" => {
|
||||
let metadata = crate::network_tags::read_to_cache(
|
||||
string(request, "url")?,
|
||||
string(request, "display_name")?,
|
||||
string(request, "cache_directory")?,
|
||||
check,
|
||||
)?;
|
||||
let cacheable = serde_json::to_vec(&metadata)
|
||||
.map_err(|error| error.to_string())?
|
||||
.len()
|
||||
<= 128 * 1024;
|
||||
Ok(json!({"metadata": metadata, "cacheable": cacheable}))
|
||||
}
|
||||
"promote_network_cover" => crate::network_tags::promote_cover(
|
||||
string(request, "path")?,
|
||||
string(request, "directory")?,
|
||||
check,
|
||||
),
|
||||
"parse_network_listing" => crate::native_listing::execute(request, Some(bytes)),
|
||||
_ => Err(format!("Unknown native data operation: {operation}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ mod logging;
|
||||
mod lyrics;
|
||||
mod manager;
|
||||
mod metadata;
|
||||
mod native_listing;
|
||||
mod native_xml;
|
||||
mod network_tags;
|
||||
mod progress;
|
||||
mod repository;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Pure directory-listing parser. Transport and credentials remain owned by
|
||||
//! the platform; only a bounded response body enters this worker operation.
|
||||
use scraper::{Html, Selector};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use url::Url;
|
||||
|
||||
pub(crate) fn execute(request: &Value, bytes: Option<&[u8]>) -> Result<Value, String> {
|
||||
if bytes.map_or_else(
|
||||
|| request["body"].as_str().unwrap_or_default().len(),
|
||||
<[u8]>::len,
|
||||
) > 4 * 1024 * 1024
|
||||
{
|
||||
return Err("Directory listing is too large".into());
|
||||
}
|
||||
let body = match bytes {
|
||||
Some(bytes) => String::from_utf8_lossy(bytes).into_owned(),
|
||||
None => request["body"].as_str().unwrap_or_default().to_owned(),
|
||||
};
|
||||
let target = Url::parse(required(request, "base_url")?).map_err(|e| e.to_string())?;
|
||||
let path = required(request, "path")?;
|
||||
let mut base = target;
|
||||
if !base.path().ends_with('/') {
|
||||
base.set_path(&format!("{}/", base.path()));
|
||||
}
|
||||
let mut entries = Vec::<Value>::new();
|
||||
let mut positions = HashMap::<String, usize>::new();
|
||||
let mut add = |href: &str, directory: bool, size: Option<i64>| -> Result<(), String> {
|
||||
let uri = base.join(&dart_href(href)).map_err(|e| e.to_string())?;
|
||||
if uri.origin() != base.origin()
|
||||
|| uri.query().is_some()
|
||||
|| uri.fragment().is_some()
|
||||
|| !uri.username().is_empty()
|
||||
|| uri.password().is_some()
|
||||
|| !uri.path().starts_with(base.path())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let tail = decode_component(&uri.path()[base.path().len()..])?;
|
||||
let name = tail.strip_suffix('/').unwrap_or(&tail);
|
||||
if name.is_empty()
|
||||
|| name.contains('/')
|
||||
|| matches!(name, "." | "..")
|
||||
|| name.contains('\\')
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let relative = format!("{path}{name}{}", if directory { "/" } else { "" });
|
||||
let entry = json!({"path": relative, "name": name, "directory": directory, "size": size});
|
||||
if let Some(index) = positions.get(&relative) {
|
||||
entries[*index] = entry;
|
||||
} else {
|
||||
positions.insert(relative, entries.len());
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if request["protocol"] == "webdav" {
|
||||
crate::native_xml::check_depth(&body)?;
|
||||
let document = roxmltree::Document::parse(&body).map_err(|e| e.to_string())?;
|
||||
for response in document
|
||||
.descendants()
|
||||
.filter(|node| named(*node, "response"))
|
||||
{
|
||||
let href = response.descendants().find(|node| named(*node, "href"));
|
||||
let props: Vec<_> = response
|
||||
.descendants()
|
||||
.filter(|node| {
|
||||
named(*node, "propstat")
|
||||
&& node.children().any(|child| {
|
||||
named(child, "status") && inner_text(child).contains(" 200 ")
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if let Some(href) = href.filter(|_| !props.is_empty()) {
|
||||
let values: Vec<_> = props
|
||||
.iter()
|
||||
.flat_map(|node| node.descendants().skip(1))
|
||||
.collect();
|
||||
let directory = values.iter().any(|node| named(*node, "collection"));
|
||||
let size = values
|
||||
.iter()
|
||||
.find(|node| named(**node, "getcontentlength"))
|
||||
.and_then(|node| inner_text(*node).trim().parse::<i64>().ok());
|
||||
add(&inner_text(href), directory, size)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let document = Html::parse_document(&body);
|
||||
let selector = Selector::parse("a[href]").map_err(|e| e.to_string())?;
|
||||
for anchor in document.select(&selector) {
|
||||
let href = anchor.value().attr("href").unwrap_or_default();
|
||||
let path = href.split(['?', '#']).next().unwrap_or_default();
|
||||
let directory = path.ends_with('/') || path.ends_with('\\');
|
||||
add(href, directory, None)?;
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b["directory"]
|
||||
.as_bool()
|
||||
.cmp(&a["directory"].as_bool())
|
||||
.then_with(|| {
|
||||
// Dart String.compareTo orders UTF-16 code units, rather than UTF-8.
|
||||
dart_lower(a["name"].as_str().unwrap_or_default())
|
||||
.encode_utf16()
|
||||
.cmp(dart_lower(b["name"].as_str().unwrap_or_default()).encode_utf16())
|
||||
})
|
||||
});
|
||||
Ok(json!({"entries": entries}))
|
||||
}
|
||||
|
||||
fn dart_lower(value: &str) -> String {
|
||||
// String.toLowerCase uses simple character mappings (İ -> i), without
|
||||
// Rust's multi-character expansions or contextual final sigma.
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| ch.to_lowercase().next().unwrap_or(ch))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn required<'a>(request: &'a Value, key: &str) -> Result<&'a str, String> {
|
||||
request[key]
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("Missing listing {key}"))
|
||||
}
|
||||
|
||||
fn dart_href(href: &str) -> String {
|
||||
// Uri.resolve preserves edge spaces/controls and quotes invalid '%' as
|
||||
// a literal. WHATWG URL otherwise trims controls and keeps broken escapes.
|
||||
let mut result = String::with_capacity(href.len());
|
||||
let bytes = href.as_bytes();
|
||||
for (index, character) in href.char_indices() {
|
||||
if character == '%'
|
||||
&& !(bytes.get(index + 1).is_some_and(u8::is_ascii_hexdigit)
|
||||
&& bytes.get(index + 2).is_some_and(u8::is_ascii_hexdigit))
|
||||
{
|
||||
result.push_str("%25");
|
||||
} else if character <= ' ' || character == '\u{007f}' {
|
||||
result.push_str(&format!("%{:02X}", character as u32));
|
||||
} else {
|
||||
result.push(character);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn named(node: roxmltree::Node<'_, '_>, name: &str) -> bool {
|
||||
node.is_element() && node.tag_name().name() == name
|
||||
}
|
||||
|
||||
fn inner_text(node: roxmltree::Node<'_, '_>) -> String {
|
||||
node.descendants()
|
||||
.filter(|node| node.is_text())
|
||||
.filter_map(|node| node.text())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode_component(value: &str) -> Result<String, String> {
|
||||
let mut decoded = Vec::with_capacity(value.len());
|
||||
let mut bytes = value.bytes();
|
||||
while let Some(byte) = bytes.next() {
|
||||
if byte == b'%' {
|
||||
let first = bytes.next().and_then(hex).ok_or("Invalid percent escape")?;
|
||||
let second = bytes.next().and_then(hex).ok_or("Invalid percent escape")?;
|
||||
decoded.push(first * 16 + second);
|
||||
} else {
|
||||
decoded.push(byte);
|
||||
}
|
||||
}
|
||||
String::from_utf8(decoded).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn hex(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_dart_listing_fixtures_match_native_output() {
|
||||
let cases: Value = serde_json::from_str(include_str!(
|
||||
"../../../../test/fixtures/native_metadata_parsers.json"
|
||||
))
|
||||
.unwrap();
|
||||
for case in cases["listings"].as_array().unwrap() {
|
||||
let result = execute(case, None).unwrap();
|
||||
assert_eq!(result["entries"], case["entries"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_children_entities_unicode_and_origin_are_preserved() {
|
||||
let result = execute(&json!({"base_url":"https://nas.test/music/", "path":"", "protocol":"http", "body":
|
||||
"<a href='../'>parent</a><a href='Folder/'>folder</a><a href='A%20%2B%23%25.flac'>audio</a><a href='音.flac'>music</a><a href='nested/song.flac'>nested</a><a href='https://other.test/music/b.flac'>other</a><a href='bad.flac?q=1'>query</a><a href='A%20%2B%23%25.flac'>duplicate</a>"}), None).unwrap();
|
||||
assert_eq!(result["entries"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(result["entries"][0]["path"], "Folder/");
|
||||
assert_eq!(result["entries"][1]["name"], "A +#%.flac");
|
||||
assert_eq!(result["entries"][2]["name"], "音.flac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dav_only_accepts_successful_properties_and_immediate_children() {
|
||||
let result = execute(&json!({"base_url":"https://nas.test/dav/Album/", "path":"Album/", "protocol":"webdav", "body":
|
||||
"<d:multistatus xmlns:d='DAV:'><d:response><d:href>/dav/Album/A%20B.flac</d:href><d:propstat><d:prop><d:getcontentlength>123</d:getcontentlength></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>/dav/Album/Missing.flac</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>"}), None).unwrap();
|
||||
assert_eq!(
|
||||
result["entries"],
|
||||
json!([{"path":"Album/A B.flac", "name":"A B.flac", "directory":false, "size":123}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_slash_and_credentials_do_not_escape_the_root() {
|
||||
let result = execute(&json!({"base_url":"https://nas.test/music/", "path":"", "protocol":"http", "body":
|
||||
"<a href='a%2fb.flac'>nested</a><a href='https://user@nas.test/music/private.flac'>credentials</a><a href='ok.flac#part'>fragment</a>"}), None).unwrap();
|
||||
assert_eq!(result["entries"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_dav_response_is_rejected_before_xml_tokenization() {
|
||||
let body = format!(
|
||||
"<multistatus>{}ignored{}</multistatus>",
|
||||
"<wrapper>".repeat(1_000),
|
||||
"</wrapper>".repeat(1_000),
|
||||
);
|
||||
let error = execute(&json!({"base_url":"https://nas.test/dav/", "path":"", "protocol":"webdav", "body":body}), None).unwrap_err();
|
||||
assert_eq!(error, "XML nesting exceeds 16 elements");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Bounds for the recursive tokenizer used by our native XML readers.
|
||||
pub(crate) fn check_depth(input: &str) -> Result<(), String> {
|
||||
// roxmltree's tokenizer recurses through nested elements. Guard its input
|
||||
// before parsing so pathological metadata returns an error, not a native
|
||||
// stack overflow. Ordinary TTML is only a handful of elements deep.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
let bytes = input.as_bytes();
|
||||
let (mut index, mut depth) = (0, 0_usize);
|
||||
while index < bytes.len() {
|
||||
if bytes[index] != b'<' {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let tail = &input[index..];
|
||||
let ending = if tail.starts_with("<!--") {
|
||||
Some("-->")
|
||||
} else if tail.starts_with("<![CDATA[") {
|
||||
Some("]]>")
|
||||
} else if tail.starts_with("<?") {
|
||||
Some("?>")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(ending) = ending {
|
||||
let Some(end) = tail.find(ending) else {
|
||||
return Ok(()); // The XML parser reports unterminated markup.
|
||||
};
|
||||
index += end + ending.len();
|
||||
continue;
|
||||
}
|
||||
let Some(end) = markup_end(tail, tail.starts_with("<!DOCTYPE")) else {
|
||||
return Ok(());
|
||||
};
|
||||
if tail.starts_with("</") {
|
||||
depth = depth.saturating_sub(1);
|
||||
} else if !tail.starts_with("<!")
|
||||
&& !tail[..end - 1]
|
||||
.trim_end_matches([' ', '\t', '\r', '\n'])
|
||||
.ends_with('/')
|
||||
{
|
||||
depth += 1;
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(format!("XML nesting exceeds {MAX_DEPTH} elements"));
|
||||
}
|
||||
}
|
||||
index += end;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn markup_end(input: &str, doctype: bool) -> Option<usize> {
|
||||
let bytes = input.as_bytes();
|
||||
let (mut quote, mut brackets, mut position) = (None, 0_usize, 0);
|
||||
while position < bytes.len() {
|
||||
let byte = bytes[position];
|
||||
if let Some(current) = quote {
|
||||
if byte == current {
|
||||
quote = None;
|
||||
}
|
||||
} else {
|
||||
// A DOCTYPE's internal subset may contain comments or processing
|
||||
// instructions whose brackets and tag-like text have no meaning.
|
||||
let ending = if doctype && bytes[position..].starts_with(b"<!--") {
|
||||
Some(b"-->".as_slice())
|
||||
} else if doctype && bytes[position..].starts_with(b"<?") {
|
||||
Some(b"?>".as_slice())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(ending) = ending {
|
||||
let end = bytes[position..]
|
||||
.windows(ending.len())
|
||||
.position(|window| window == ending)?;
|
||||
position += end + ending.len();
|
||||
continue;
|
||||
}
|
||||
match byte {
|
||||
b'\'' | b'"' => quote = Some(byte),
|
||||
b'[' if doctype => brackets += 1,
|
||||
b']' if doctype => brackets = brackets.saturating_sub(1),
|
||||
b'>' if brackets == 0 => return Some(position + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bounds_actual_element_nesting_and_ignores_self_closing_nodes() {
|
||||
let maximum = format!("{}{}", "<a>".repeat(16), "</a>".repeat(16));
|
||||
check_depth(&maximum).unwrap();
|
||||
assert!(check_depth(&format!("<a>{maximum}</a>")).is_err());
|
||||
check_depth(&format!("<a>{}</a>", "<b/>".repeat(10_000))).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_attributes_and_opaque_sections_are_not_element_depth() {
|
||||
let markup = "<span>".repeat(1_000);
|
||||
check_depth(&format!(
|
||||
"<?xml version='1.0'?><!DOCTYPE a [<!-- ]> {markup} --> <?note ]>?> <!ENTITY b '{markup}'>]><a value='>'><!--{markup}--><![CDATA[{markup}]]><b value=\">\"/></a>"
|
||||
)).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Bounded, seekable metadata reads through the app's authenticated loopback
|
||||
//! proxy. The native reader never receives NAS credentials or external URLs.
|
||||
use base64::Engine;
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use spotiflac_core::tags;
|
||||
use spotiflac_network::{
|
||||
HttpRequest, NetworkService, NetworkSession, policy::NetworkPermissions, url::UrlParts,
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
io::{self, Read, Seek, SeekFrom},
|
||||
fs::{self, File},
|
||||
io::{self, Read, Seek, SeekFrom, Write},
|
||||
path::Path,
|
||||
sync::{Arc, OnceLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -18,10 +21,142 @@ const MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_REQUESTS: usize = 80;
|
||||
const DEADLINE: Duration = Duration::from_secs(20);
|
||||
|
||||
fn proxy_session(url: &str, timeout: Duration) -> Result<NetworkSession, String> {
|
||||
let parsed = UrlParts::parse(url).ok_or("invalid network proxy URL")?;
|
||||
let token = parsed.path.strip_prefix(b"/").unwrap_or_default();
|
||||
if parsed.scheme != "http"
|
||||
|| parsed.hostname != "127.0.0.1"
|
||||
|| parsed.port.is_none()
|
||||
|| parsed.has_credentials
|
||||
|| !parsed.raw_query.is_empty()
|
||||
|| !parsed.fragment.is_empty()
|
||||
|| token.len() != 48
|
||||
|| !token.iter().all(u8::is_ascii_hexdigit)
|
||||
{
|
||||
return Err("URL must reference the local network proxy".into());
|
||||
}
|
||||
static NETWORK: OnceLock<Result<Arc<NetworkService>, String>> = OnceLock::new();
|
||||
let service = NETWORK
|
||||
.get_or_init(|| {
|
||||
let service = NetworkService::new().map_err(|e| e.to_string())?;
|
||||
service.set_allow_private_network(true);
|
||||
Ok(service)
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(Clone::clone)?;
|
||||
Ok(service
|
||||
.session(
|
||||
NetworkPermissions {
|
||||
domains: vec!["127.0.0.1".into()],
|
||||
allow_http: true,
|
||||
},
|
||||
timeout,
|
||||
)
|
||||
.direct_media())
|
||||
}
|
||||
|
||||
/// Reconcile a lost NAS rename response with streamed bytes. Credentials and
|
||||
/// pins remain inside the app's existing proxy; neither file crosses Flutter.
|
||||
pub(crate) fn matches_upload(
|
||||
request: &Value,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
check()?;
|
||||
let url = crate::data_jobs::string(request, "url")?;
|
||||
let path = crate::data_jobs::string(request, "path")?;
|
||||
let length = request["length"].as_u64().ok_or("Missing upload length")?;
|
||||
let idle = request["idle_timeout_ms"]
|
||||
.as_u64()
|
||||
.filter(|ms| *ms > 0 && *ms <= 300_000)
|
||||
.ok_or("Invalid upload idle timeout")?;
|
||||
let metadata = fs::metadata(path).map_err(|error| error.to_string())?;
|
||||
if !metadata.is_file() || metadata.len() != length {
|
||||
return Ok(json!({"matches": false}));
|
||||
}
|
||||
let idle = Duration::from_millis(idle);
|
||||
let session = proxy_session(url, idle)?;
|
||||
let mut stream = session.open_stream(
|
||||
HttpRequest {
|
||||
url: url.into(),
|
||||
method: "GET".into(),
|
||||
body: String::new(),
|
||||
headers: BTreeMap::from([("Accept-Encoding".into(), "identity".into())]),
|
||||
default_json: false,
|
||||
user_agent: "SpotiFLAC/NetworkUploadRecovery".into(),
|
||||
},
|
||||
Duration::from_secs(u32::MAX.into()),
|
||||
idle,
|
||||
check,
|
||||
)?;
|
||||
let header = |name: &str| {
|
||||
stream
|
||||
.response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
||||
.and_then(|(_, values)| values.first())
|
||||
.map(String::as_str)
|
||||
};
|
||||
if stream.response.status != 200
|
||||
|| header("content-length")
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.is_some_and(|value| value != length)
|
||||
|| header("content-encoding").is_some_and(|value| !value.eq_ignore_ascii_case("identity"))
|
||||
{
|
||||
return Ok(json!({"matches": false}));
|
||||
}
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
let mut received = 0_u64;
|
||||
loop {
|
||||
check()?;
|
||||
let count = stream.read(&mut buffer, check)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
received = received
|
||||
.checked_add(count as u64)
|
||||
.ok_or("Upload length overflow")?;
|
||||
if received > length {
|
||||
return Err("Network destination changed".into());
|
||||
}
|
||||
hash.update(&buffer[..count]);
|
||||
}
|
||||
if received != length {
|
||||
return Ok(json!({"matches": false}));
|
||||
}
|
||||
let remote: String = hash
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let local = crate::data_jobs::hash_file(path, check)?;
|
||||
check()?;
|
||||
Ok(json!({"matches": remote == local}))
|
||||
}
|
||||
|
||||
pub(crate) fn read(
|
||||
url: &str,
|
||||
hint: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
read_internal(url, hint, None, check)
|
||||
}
|
||||
|
||||
pub(crate) fn read_to_cache(
|
||||
url: &str,
|
||||
hint: &str,
|
||||
cache_directory: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
read_internal(url, hint, Some(Path::new(cache_directory)), check)
|
||||
}
|
||||
|
||||
fn read_internal(
|
||||
url: &str,
|
||||
hint: &str,
|
||||
cache_directory: Option<&Path>,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut reader = RangeReader::new(url, check)?;
|
||||
let mut metadata = tags::read_file_metadata(&mut reader, hint, hint, check)?;
|
||||
@@ -30,13 +165,150 @@ pub(crate) fn read(
|
||||
&& !cover.data.is_empty()
|
||||
&& cover.data.len() <= 4 * 1024 * 1024
|
||||
{
|
||||
metadata["cover_base64"] = base64::engine::general_purpose::STANDARD
|
||||
.encode(cover.data)
|
||||
.into();
|
||||
if let Some(directory) = cache_directory {
|
||||
// Artwork I/O failures preserve successfully parsed tags/lyrics.
|
||||
if let Ok((path, digest)) = cache_cover(&cover.data, directory, check) {
|
||||
metadata["cover_path"] = path.into();
|
||||
metadata["cover_sha256"] = digest.into();
|
||||
}
|
||||
} else {
|
||||
metadata["cover_base64"] = base64::engine::general_purpose::STANDARD
|
||||
.encode(cover.data)
|
||||
.into();
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn cache_cover(
|
||||
bytes: &[u8],
|
||||
directory: &Path,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<(String, String), String> {
|
||||
check()?;
|
||||
if !directory.is_absolute() {
|
||||
return Err("Artwork cache must be absolute".into());
|
||||
}
|
||||
fs::create_dir_all(directory).map_err(|error| error.to_string())?;
|
||||
let digest: String = Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let target = directory.join(format!("network_cover_{digest}.image"));
|
||||
let mut staged =
|
||||
tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?;
|
||||
staged.write_all(bytes).map_err(|error| error.to_string())?;
|
||||
staged
|
||||
.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| error.to_string())?;
|
||||
check()?;
|
||||
staged.persist(&target).map_err(|error| error.to_string())?;
|
||||
let mut files = fs::read_dir(directory)
|
||||
.map_err(|error| error.to_string())?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
let legacy = name.strip_suffix(".image").is_some_and(|digest| {
|
||||
digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
});
|
||||
if cover_digest(&name).is_none() && !legacy {
|
||||
return None;
|
||||
}
|
||||
let metadata = entry.metadata().ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
Some((entry.path(), metadata.modified().ok()?))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
files.sort_by_key(|(_, time)| std::cmp::Reverse(*time));
|
||||
for (old, _) in files.into_iter().skip(32) {
|
||||
if old != target {
|
||||
let _ = fs::remove_file(old);
|
||||
}
|
||||
}
|
||||
Ok((target.to_string_lossy().into_owned(), digest))
|
||||
}
|
||||
|
||||
fn cover_digest(name: &str) -> Option<&str> {
|
||||
let digest = name
|
||||
.strip_prefix("network_cover_")?
|
||||
.strip_suffix(".image")?;
|
||||
(digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(digest)
|
||||
}
|
||||
|
||||
pub(crate) fn promote_cover(
|
||||
path: &str,
|
||||
directory: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
check()?;
|
||||
let source = Path::new(path);
|
||||
let destination = Path::new(directory);
|
||||
if !source.is_absolute() || !destination.is_absolute() {
|
||||
return Err("Artwork paths must be absolute".into());
|
||||
}
|
||||
if !source
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
|
||||
{
|
||||
return Err("Artwork input must be a nonempty regular file".into());
|
||||
}
|
||||
// Only our immutable content-addressed cache skips a second hash. Older
|
||||
// cache filenames use the same streamed hash as the previous Dart caller.
|
||||
let digest = source
|
||||
.parent()
|
||||
.filter(|parent| {
|
||||
parent
|
||||
.file_name()
|
||||
.is_some_and(|name| name == "network_metadata")
|
||||
})
|
||||
.and_then(|_| source.file_name())
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(cover_digest)
|
||||
.map(str::to_owned)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| crate::data_jobs::hash_file(path, check))?;
|
||||
fs::create_dir_all(destination).map_err(|error| error.to_string())?;
|
||||
let target = destination.join(format!("network_cover_{digest}.image"));
|
||||
if !target
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
|
||||
{
|
||||
let mut input = File::open(source).map_err(|error| error.to_string())?;
|
||||
if !input
|
||||
.metadata()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_file()
|
||||
{
|
||||
return Err("Artwork input must be a regular file".into());
|
||||
}
|
||||
let mut staged =
|
||||
tempfile::NamedTempFile::new_in(destination).map_err(|error| error.to_string())?;
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
check()?;
|
||||
let count = input.read(&mut buffer).map_err(|error| error.to_string())?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
staged
|
||||
.write_all(&buffer[..count])
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
staged
|
||||
.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| error.to_string())?;
|
||||
check()?;
|
||||
staged.persist(&target).map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(json!({"cover_path": target.to_string_lossy(), "sha256": digest}))
|
||||
}
|
||||
|
||||
struct RangeReader<'a> {
|
||||
url: String,
|
||||
session: NetworkSession,
|
||||
@@ -51,37 +323,7 @@ struct RangeReader<'a> {
|
||||
|
||||
impl<'a> RangeReader<'a> {
|
||||
fn new(url: &str, check: &'a dyn Fn() -> Result<(), String>) -> Result<Self, String> {
|
||||
let parsed = UrlParts::parse(url).ok_or("invalid metadata URL")?;
|
||||
let token = parsed.path.strip_prefix(b"/").unwrap_or_default();
|
||||
if parsed.scheme != "http"
|
||||
|| parsed.hostname != "127.0.0.1"
|
||||
|| parsed.port.is_none()
|
||||
|| parsed.has_credentials
|
||||
|| !parsed.raw_query.is_empty()
|
||||
|| !parsed.fragment.is_empty()
|
||||
|| token.len() != 48
|
||||
|| !token.iter().all(u8::is_ascii_hexdigit)
|
||||
{
|
||||
return Err("metadata URL must reference the local network proxy".into());
|
||||
}
|
||||
static NETWORK: OnceLock<Result<Arc<NetworkService>, String>> = OnceLock::new();
|
||||
let service = NETWORK
|
||||
.get_or_init(|| {
|
||||
let service = NetworkService::new().map_err(|e| e.to_string())?;
|
||||
service.set_allow_private_network(true);
|
||||
Ok(service)
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(Clone::clone)?;
|
||||
let session = service
|
||||
.session(
|
||||
NetworkPermissions {
|
||||
domains: vec!["127.0.0.1".into()],
|
||||
allow_http: true,
|
||||
},
|
||||
DEADLINE,
|
||||
)
|
||||
.direct_media();
|
||||
let session = proxy_session(url, DEADLINE)?;
|
||||
let mut reader = Self {
|
||||
url: url.into(),
|
||||
session,
|
||||
@@ -279,9 +521,14 @@ mod tests {
|
||||
let mut reader = BufReader::new(socket.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
let mut range = (0, data.len() - 1);
|
||||
let mut headers_complete = false;
|
||||
loop {
|
||||
line.clear();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
break;
|
||||
}
|
||||
if line == "\r\n" {
|
||||
headers_complete = true;
|
||||
break;
|
||||
}
|
||||
if let Some(value) = line.to_lowercase().strip_prefix("range: bytes=") {
|
||||
@@ -292,6 +539,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
// A connector may abandon an idle socket before sending a
|
||||
// request. Sending a response after EOF/timeout produces an
|
||||
// unsolicited HTTP message and a spurious client failure.
|
||||
if !headers_complete {
|
||||
continue;
|
||||
}
|
||||
if !ranges {
|
||||
range = (0, data.len() - 1);
|
||||
}
|
||||
@@ -339,6 +592,84 @@ mod tests {
|
||||
.concat()
|
||||
}
|
||||
#[test]
|
||||
fn fixture_does_not_respond_to_abandoned_connections() {
|
||||
let fixture = Fixture::new(b"abc".to_vec(), false);
|
||||
let address = fixture
|
||||
.url
|
||||
.strip_prefix("http://")
|
||||
.unwrap()
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap();
|
||||
let mut socket = std::net::TcpStream::connect(address).unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
assert_eq!(socket.read(&mut [0; 64]).unwrap(), 0);
|
||||
assert_eq!(fixture.bytes.load(Ordering::Relaxed), 0);
|
||||
let mut local = tempfile::NamedTempFile::new().unwrap();
|
||||
local.write_all(b"abc").unwrap();
|
||||
assert_eq!(
|
||||
matches_upload(
|
||||
&json!({"url": fixture.url,
|
||||
"path": local.path(), "length": 3, "idle_timeout_ms": 20000}),
|
||||
&|| Ok(())
|
||||
)
|
||||
.unwrap()["matches"],
|
||||
true
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cache_artwork_is_atomic_content_addressed_and_bounded() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cache = root.path().join("network_metadata");
|
||||
fs::create_dir(&cache).unwrap();
|
||||
// Legacy source-addressed entries participate in the same 32-file cap.
|
||||
for value in 0..35 {
|
||||
fs::write(cache.join(format!("{value:064x}.image")), [value]).unwrap();
|
||||
}
|
||||
fs::write(cache.join("unrelated.txt"), b"keep").unwrap();
|
||||
let (path, digest) = cache_cover(b"cover bytes", &cache, &|| Ok(())).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"cover bytes");
|
||||
assert_eq!(
|
||||
digest,
|
||||
crate::data_jobs::hash_file(&path, &|| Ok(())).unwrap()
|
||||
);
|
||||
let again = cache_cover(b"cover bytes", &cache, &|| Ok(())).unwrap();
|
||||
assert_eq!(again.0, path);
|
||||
assert_eq!(fs::read_dir(&cache).unwrap().count(), 33);
|
||||
let covers = root.path().join("library_covers");
|
||||
let promoted = promote_cover(&path, covers.to_str().unwrap(), &|| Ok(())).unwrap();
|
||||
let target = promoted["cover_path"].as_str().unwrap();
|
||||
assert_eq!(fs::read(target).unwrap(), b"cover bytes");
|
||||
fs::remove_file(&path).unwrap();
|
||||
assert_eq!(fs::read(target).unwrap(), b"cover bytes");
|
||||
assert!(cache_cover(b"cancelled", &cache, &|| Err("cancelled".into())).is_err());
|
||||
assert_eq!(fs::read_dir(&covers).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_upload_recovery_compares_content_and_checks_cancellation() {
|
||||
let fixture = Fixture::new(b"abc".to_vec(), false);
|
||||
let mut local = tempfile::NamedTempFile::new().unwrap();
|
||||
local.write_all(b"abc").unwrap();
|
||||
let request =
|
||||
json!({"url":fixture.url,"path":local.path(),"length":3,"idle_timeout_ms":20000});
|
||||
assert_eq!(
|
||||
matches_upload(&request, &|| Ok(())).unwrap()["matches"],
|
||||
true
|
||||
);
|
||||
fs::write(local.path(), b"abd").unwrap();
|
||||
assert_eq!(
|
||||
matches_upload(&request, &|| Ok(())).unwrap()["matches"],
|
||||
false
|
||||
);
|
||||
assert!(matches_upload(&request, &|| Err("cancelled".into())).is_err());
|
||||
let mut invalid = request.clone();
|
||||
invalid["url"] = "https://example.test/private".into();
|
||||
assert!(matches_upload(&invalid, &|| Ok(())).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn reads_flac_tags_lyrics_cover_without_audio_download() {
|
||||
let input = [
|
||||
b"fLaC".as_slice(),
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"listings": [
|
||||
{
|
||||
"protocol": "http",
|
||||
"address": "https://nas.test/music/",
|
||||
"base_url": "https://nas.test/music/",
|
||||
"path": "",
|
||||
"body": "<a href='../'>parent</a><a href='Folder/'>folder</a><a href='A%20%2B%23%25.flac'>audio</a><a href='音.flac'>music</a><a href='nested/song.flac'>nested</a><a href='https://other.test/music/b.flac'>other</a><a href='bad.flac?q=1'>query</a><a href='A%20%2B%23%25.flac'>duplicate</a>",
|
||||
"entries": [
|
||||
{"path": "Folder/", "name": "Folder", "directory": true, "size": null},
|
||||
{"path": "A +#%.flac", "name": "A +#%.flac", "directory": false, "size": null},
|
||||
{"path": "音.flac", "name": "音.flac", "directory": false, "size": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"protocol": "http",
|
||||
"address": "https://nas.test/music/",
|
||||
"base_url": "https://nas.test/music/",
|
||||
"path": "",
|
||||
"body": "<a href='ib.flac'>i</a><a href='İA.flac'>dotted capital i</a><a href='%EE%80%80.flac'>private use</a><a href='%F0%90%80%80.flac'>supplementary plane</a>",
|
||||
"entries": [
|
||||
{"path": "İA.flac", "name": "İA.flac", "directory": false, "size": null},
|
||||
{"path": "ib.flac", "name": "ib.flac", "directory": false, "size": null},
|
||||
{"path": "𐀀.flac", "name": "𐀀.flac", "directory": false, "size": null},
|
||||
{"path": ".flac", "name": ".flac", "directory": false, "size": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"protocol": "http",
|
||||
"address": "https://nas.test/music/",
|
||||
"base_url": "https://nas.test/music/",
|
||||
"path": "",
|
||||
"body": "<a href='Folder\\'>directory</a><a href=' song.flac '>edge spaces</a><a href='bad%zz.flac'>literal percent</a><a href='nested/%2E%2E/child.flac'>normalized child</a><a href='%2E%2E/outside.flac'>outside</a>",
|
||||
"entries": [
|
||||
{"path": "Folder/", "name": "Folder", "directory": true, "size": null},
|
||||
{"path": " song.flac ", "name": " song.flac ", "directory": false, "size": null},
|
||||
{"path": "bad%zz.flac", "name": "bad%zz.flac", "directory": false, "size": null},
|
||||
{"path": "child.flac", "name": "child.flac", "directory": false, "size": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"protocol": "webdav",
|
||||
"address": "https://nas.test/dav/",
|
||||
"base_url": "https://nas.test/dav/Album/",
|
||||
"path": "Album/",
|
||||
"body": "<d:multistatus xmlns:d='DAV:'><d:response><d:href>/dav/Album/A%20B.flac</d:href><d:propstat><d:prop><d:getcontentlength>123</d:getcontentlength></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>/dav/Album/Missing.flac</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>",
|
||||
"entries": [
|
||||
{"path": "Album/A B.flac", "name": "A B.flac", "directory": false, "size": 123}
|
||||
]
|
||||
},
|
||||
{
|
||||
"protocol": "http",
|
||||
"address": "https://nas.test/music/",
|
||||
"base_url": "https://nas.test/music/",
|
||||
"path": "",
|
||||
"body": "<a href='a%2fb.flac'>nested</a><a href='https://user@nas.test/music/private.flac'>credentials</a><a href='ok.flac#part'>fragment</a>",
|
||||
"entries": []
|
||||
}
|
||||
],
|
||||
"playlists": [
|
||||
{
|
||||
"format": "csv", "id_seed": 42,
|
||||
"body": "title,artist\nA,B\n\nC,D",
|
||||
"tracks": [
|
||||
{"id": "csv_42_1", "name": "A", "artistName": "B", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null},
|
||||
{"id": "csv_42_3", "name": "C", "artistName": "D", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"format": "csv", "id_seed": 10,
|
||||
"body": "\r\nTrack Name,Artist Name(s),Album Name,Track URI,ISRC\r\n\"Song, \"\"One\"\"\",Artist,Album,spotify:track:abc,AAABC1200001\r\nSecond,,Album,,\r\n",
|
||||
"tracks": [
|
||||
{"id": "abc", "name": "Song, \"One\"", "artistName": "Artist", "albumName": "Album", "duration": 0, "isrc": "AAABC1200001", "coverUrl": null},
|
||||
{"id": "", "name": "Second", "artistName": "", "albumName": "Album", "duration": 0, "isrc": "", "coverUrl": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"format": "m3u", "id_seed": 100,
|
||||
"body": "#EXTM3U\r#EXTINF:180.5,Artist - Song - Mix\r#comment\r\nfolder/a.flac\nMusic/02 Demo.flac\n#EXTINF:-1,\nx/.hidden",
|
||||
"tracks": [
|
||||
{"id": "m3u_100_0", "name": "Song - Mix", "artistName": "Artist", "albumName": "Unknown Album", "duration": 181, "isrc": null, "coverUrl": null},
|
||||
{"id": "m3u_100_1", "name": "02 Demo", "artistName": "Unknown Artist", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null},
|
||||
{"id": "m3u_100_2", "name": ".hidden", "artistName": "Unknown Artist", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final fixtures = jsonDecode(
|
||||
File('test/fixtures/native_metadata_parsers.json').readAsStringSync(),
|
||||
) as Map<String, dynamic>;
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
for (final (index, raw) in (fixtures['listings'] as List).indexed) {
|
||||
final fixture = Map<String, dynamic>.from(raw as Map);
|
||||
test('listing worker retains synchronous parser contract $index', () async {
|
||||
final connection = NetworkConnection(
|
||||
id: 'fixture',
|
||||
name: 'fixture',
|
||||
protocol: NetworkProtocol.values.byName(fixture['protocol'] as String),
|
||||
address: fixture['address'] as String,
|
||||
);
|
||||
final body = fixture['body'] as String;
|
||||
final path = fixture['path'] as String;
|
||||
final legacy = NetworkStorageService.parseListing(connection, path, body);
|
||||
final worker = await NetworkStorageService.parseListingInBackground(
|
||||
connection,
|
||||
path,
|
||||
Uint8List.fromList(utf8.encode(body)),
|
||||
);
|
||||
List<Map<String, dynamic>> rows(List<NetworkEntry> entries) => entries
|
||||
.map(
|
||||
(entry) => {
|
||||
'path': entry.path,
|
||||
'name': entry.name,
|
||||
'directory': entry.directory,
|
||||
'size': entry.size,
|
||||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
expect(rows(legacy), fixture['entries']);
|
||||
expect(rows(worker), fixture['entries']);
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'runNativeDataJob');
|
||||
final arguments = call.arguments as Map;
|
||||
final request = jsonDecode(arguments['request_json'] as String) as Map;
|
||||
expect(request, {
|
||||
'operation': 'parse_network_listing',
|
||||
'protocol': fixture['protocol'],
|
||||
'base_url': fixture['base_url'],
|
||||
'path': path,
|
||||
});
|
||||
expect(arguments['bytes'] as Uint8List, utf8.encode(body));
|
||||
return jsonEncode({'entries': fixture['entries']});
|
||||
});
|
||||
try {
|
||||
final nativeRows = await NetworkStorageService.parseListingInBackground(
|
||||
connection,
|
||||
path,
|
||||
Uint8List.fromList(utf8.encode(body)),
|
||||
);
|
||||
expect(rows(nativeRows), fixture['entries']);
|
||||
} finally {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('deep valid WebDAV uses the isolated compatibility parser', () async {
|
||||
final connection = NetworkConnection(
|
||||
id: 'deep-dav',
|
||||
name: 'deep-dav',
|
||||
protocol: NetworkProtocol.webdav,
|
||||
address: 'https://nas.test/dav/',
|
||||
);
|
||||
final body =
|
||||
'<d:multistatus xmlns:d="DAV:">${List.filled(18, '<d:wrapper>').join()}'
|
||||
'<d:response><d:href>/dav/Album/Song.flac</d:href>'
|
||||
'<d:propstat><d:prop><d:getcontentlength>123</d:getcontentlength>'
|
||||
'</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>'
|
||||
'</d:response>${List.filled(18, '</d:wrapper>').join()}</d:multistatus>';
|
||||
final bytes = Uint8List.fromList(utf8.encode(body));
|
||||
final legacy = NetworkStorageService.parseListing(
|
||||
connection,
|
||||
'Album/',
|
||||
body,
|
||||
);
|
||||
expect(legacy.single.name, 'Song.flac');
|
||||
var calls = 0;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'runNativeDataJob');
|
||||
calls++;
|
||||
throw PlatformException(
|
||||
code: 'data_job_error',
|
||||
message: 'XML nesting exceeds 16 elements',
|
||||
);
|
||||
});
|
||||
try {
|
||||
final actual = await NetworkStorageService.parseListingInBackground(
|
||||
connection,
|
||||
'Album/',
|
||||
bytes,
|
||||
);
|
||||
expect(actual.single.path, legacy.single.path);
|
||||
expect(actual.single.name, legacy.single.name);
|
||||
expect(actual.single.directory, legacy.single.directory);
|
||||
expect(actual.single.size, legacy.single.size);
|
||||
expect(calls, 1);
|
||||
messenger.setMockMethodCallHandler(channel, (_) async {
|
||||
throw PlatformException(
|
||||
code: 'data_job_error',
|
||||
message: 'Invalid listing document',
|
||||
);
|
||||
});
|
||||
await expectLater(
|
||||
NetworkStorageService.parseListingInBackground(
|
||||
connection,
|
||||
'Album/',
|
||||
bytes,
|
||||
),
|
||||
throwsA(isA<PlatformException>()),
|
||||
);
|
||||
} finally {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
import 'package:spotiflac_android/services/network_metadata_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late NetworkStorageService storage;
|
||||
late Directory directory;
|
||||
setUp(() async {
|
||||
@@ -25,10 +27,55 @@ void main() {
|
||||
directory = await Directory.systemTemp.createTemp('network-tag-test-');
|
||||
});
|
||||
tearDown(() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.zarz.spotiflac/backend'),
|
||||
null,
|
||||
);
|
||||
await storage.dispose();
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
|
||||
test(
|
||||
'native cache returns a file path and excludes oversized tags from memory',
|
||||
() async {
|
||||
var calls = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.zarz.spotiflac/backend'),
|
||||
(call) async {
|
||||
expect(call.method, 'runNativeDataJob');
|
||||
final args = call.arguments as Map;
|
||||
final request = jsonDecode(args['request_json'] as String) as Map;
|
||||
expect(request['operation'], 'network_metadata');
|
||||
expect(request['url'], startsWith('http://127.0.0.1:'));
|
||||
expect(
|
||||
request['cache_directory'],
|
||||
'${directory.path}/network_metadata',
|
||||
);
|
||||
expect(args['request_id'], isA<String>());
|
||||
calls++;
|
||||
return jsonEncode({
|
||||
'metadata': {
|
||||
'title': 'Native tags',
|
||||
'cover_path': '/native/cover.image',
|
||||
},
|
||||
'cacheable': calls != 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
final reader = NetworkMetadataService(
|
||||
storage: storage,
|
||||
cacheDirectory: () async => directory,
|
||||
);
|
||||
final source = NetworkStorageService.source('nas', 'song.flac');
|
||||
expect((await reader.read(source))['cover_path'], '/native/cover.image');
|
||||
await reader.read(source);
|
||||
await reader.read(source);
|
||||
expect(calls, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'playback and Lyrics share one read and embedded cover is cached as a file',
|
||||
() async {
|
||||
|
||||
@@ -29,6 +29,11 @@ NetworkConnection connection(
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
// Listing now crosses a method channel before the isolated host fallback.
|
||||
// Keep the in-process transport fixtures on real HTTP clients.
|
||||
HttpOverrides.global = null;
|
||||
|
||||
test('paths retain literal percent, spaces, Unicode, plus and hash', () {
|
||||
final c = connection('https://example.test/Music%20Library/');
|
||||
const path = '日本語/100% + #1.flac';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/network_download_staging.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
@@ -97,6 +99,10 @@ class _Dav {
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
// These integration tests use an authenticated in-process WebDAV server.
|
||||
HttpOverrides.global = null;
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
late _Dav dav;
|
||||
late NetworkStorageService storage;
|
||||
late NetworkDownloadStaging staging;
|
||||
@@ -126,6 +132,8 @@ void main() {
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
await storage.dispose();
|
||||
await dav.server.close(force: true);
|
||||
await temp.delete(recursive: true);
|
||||
@@ -327,6 +335,96 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'native recovery receives only the authenticated proxy and local path',
|
||||
() async {
|
||||
final file = await prepare(dir: '');
|
||||
dav.loseMoveResponse = true;
|
||||
await expectLater(publish(), throwsA(anything));
|
||||
var nativeHashes = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
final args = Map<String, dynamic>.from(call.arguments as Map);
|
||||
final request = jsonDecode(args['request_json'] as String) as Map;
|
||||
if (request['operation'] != 'matches_network_upload') {
|
||||
throw MissingPluginException();
|
||||
}
|
||||
final uri = Uri.parse(request['url'] as String);
|
||||
expect(uri.scheme, 'http');
|
||||
expect(uri.host, '127.0.0.1');
|
||||
expect(uri.hasPort, true);
|
||||
expect(uri.path, matches(r'^/[a-f0-9]{48}$'));
|
||||
expect(jsonEncode(request), isNot(contains('secret')));
|
||||
expect(request['length'], await file.length());
|
||||
expect(
|
||||
await File(request['path'] as String).readAsBytes(),
|
||||
await file.readAsBytes(),
|
||||
);
|
||||
expect(request['idle_timeout_ms'], 20000);
|
||||
nativeHashes++;
|
||||
return {'matches': true};
|
||||
});
|
||||
await publish();
|
||||
expect(nativeHashes, 1);
|
||||
expect(dav.puts, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'native recovery cancellation keeps staging until the worker completes',
|
||||
() async {
|
||||
final file = await prepare(dir: '');
|
||||
dav.loseMoveResponse = true;
|
||||
await expectLater(publish(), throwsA(anything));
|
||||
final started = Completer<void>();
|
||||
final cancelled = Completer<void>();
|
||||
final worker = Completer<Map<String, dynamic>>();
|
||||
String? requestId;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
final args = Map<String, dynamic>.from(call.arguments as Map);
|
||||
if (call.method == 'cancelNativeDataJob') {
|
||||
expect(args['request_id'], requestId);
|
||||
cancelled.complete();
|
||||
return null;
|
||||
}
|
||||
final request = jsonDecode(args['request_json'] as String) as Map;
|
||||
if (request['operation'] != 'matches_network_upload') {
|
||||
throw MissingPluginException();
|
||||
}
|
||||
requestId = args['request_id'] as String;
|
||||
started.complete();
|
||||
return worker.future;
|
||||
});
|
||||
var stop = false;
|
||||
var completed = false;
|
||||
final originalError = StateError('user cancelled verification');
|
||||
final result = publish(
|
||||
checkpoint: () {
|
||||
if (stop) throw originalError;
|
||||
},
|
||||
);
|
||||
final expectation = expectLater(result, throwsA(same(originalError)));
|
||||
unawaited(
|
||||
result.then(
|
||||
(_) => completed = true,
|
||||
onError: (Object _) {
|
||||
completed = true;
|
||||
},
|
||||
),
|
||||
);
|
||||
await started.future;
|
||||
stop = true;
|
||||
await cancelled.future;
|
||||
expect(completed, false);
|
||||
expect(await file.exists(), true);
|
||||
worker.completeError(PlatformException(code: 'cancelled'));
|
||||
await expectation;
|
||||
expect(dav.puts, 1);
|
||||
expect(await file.exists(), true);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'same-length conflicting file is never overwritten or adopted',
|
||||
() async {
|
||||
|
||||
@@ -7,6 +7,10 @@ import 'package:spotiflac_android/services/network_storage_error.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
// The native listing fallback needs a binding while TLS must use real I/O.
|
||||
HttpOverrides.global = null;
|
||||
|
||||
test(
|
||||
'self-signed WebDAV requires explicit trust for listing and streaming',
|
||||
() async {
|
||||
|
||||
Reference in new issue
Block a user