diff --git a/integration_test/network_audio_analysis_test.dart b/integration_test/network_audio_analysis_test.dart new file mode 100644 index 00000000..84983332 --- /dev/null +++ b/integration_test/network_audio_analysis_test.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/hires_check_service.dart'; +import 'package:spotiflac_android/services/network_storage_service.dart'; +import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; +import 'package:spotiflac_android/widgets/hires_check_card.dart'; + +Uint8List _hiResWav() { + const sampleRate = 96000; + const channels = 2; + const seconds = 5; + const frameBytes = channels * 3; + final bytes = ByteData(44 + sampleRate * seconds * frameBytes); + void text(int offset, String value) { + for (var i = 0; i < value.length; i++) { + bytes.setUint8(offset + i, value.codeUnitAt(i)); + } + } + + text(0, 'RIFF'); + bytes.setUint32(4, bytes.lengthInBytes - 8, Endian.little); + text(8, 'WAVEfmt '); + bytes.setUint32(16, 16, Endian.little); + bytes.setUint16(20, 1, Endian.little); + bytes.setUint16(22, channels, Endian.little); + bytes.setUint32(24, sampleRate, Endian.little); + bytes.setUint32(28, sampleRate * frameBytes, Endian.little); + bytes.setUint16(32, frameBytes, Endian.little); + bytes.setUint16(34, 24, Endian.little); + text(36, 'data'); + bytes.setUint32(40, bytes.lengthInBytes - 44, Endian.little); + for (var frame = 0; frame < sampleRate * seconds; frame++) { + for (var channel = 0; channel < channels; channel++) { + final value = + (3000000 * + math.sin( + 2 * math.pi * (1000 + 23000 * channel) * frame / sampleRate, + )) + .round(); + final offset = 44 + frame * frameBytes + channel * 3; + bytes.setUint8(offset, value & 255); + bytes.setUint8(offset + 1, (value >> 8) & 255); + bytes.setUint8(offset + 2, (value >> 16) & 255); + } + } + return bytes.buffer.asUint8List(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const smbAddress = String.fromEnvironment('SMB_TEST_URL'); + testWidgets('network cards run real FFmpeg and Rust analysers', ( + tester, + ) async { + final local = await Directory.systemTemp.createTemp('network-reference-'); + var file = File('${local.path}/reference.wav'); + await file.writeAsBytes(_hiResWav()); + if (smbAddress.isEmpty) { + final flac = File('${local.path}/reference.flac'); + final session = await FFmpegKit.executeWithArguments([ + '-i', + file.path, + '-c:a', + 'flac', + flac.path, + ]); + expect(ReturnCode.isSuccess(await session.getReturnCode()), true); + file = flac; + } + final audio = await file.readAsBytes(); + final reference = await HiResCheckService.check(file.path); + final storage = NetworkStorageService.instance; + final id = 'analysis-fixture-${DateTime.now().microsecondsSinceEpoch}'; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + var fullReads = 0; + final requests = <({String method, String? authorization})>[]; + server.listen((request) async { + requests.add(( + method: request.method, + authorization: request.headers.value(HttpHeaders.authorizationHeader), + )); + request.response.contentLength = audio.length; + if (request.method == 'GET') { + fullReads++; + request.response.add(audio); + } + await request.response.close(); + }); + try { + await storage.save( + NetworkConnection( + id: id, + name: 'Analysis fixture', + protocol: smbAddress.isEmpty + ? NetworkProtocol.webdav + : NetworkProtocol.smb, + address: smbAddress.isEmpty + ? 'http://127.0.0.1:${server.port}/' + : smbAddress, + username: 'test', + password: 'secret', + ), + ); + final source = NetworkStorageService.source( + id, + smbAddress.isEmpty ? 'Rock & Roll.flac' : 'A B.wav', + ); + final remote = await HiResCheckService.check(source); + expect(remote.supported, true); + expect(remote.declaredSampleRate, reference.declaredSampleRate); + expect(remote.declaredBitDepth, reference.declaredBitDepth); + expect(remote.effectiveBitDepth, reference.effectiveBitDepth); + expect(remote.cutoffFrequencyHz, reference.cutoffFrequencyHz); + expect(remote.verdict, reference.verdict); + fullReads = 0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark(useMaterial3: true), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: SingleChildScrollView( + child: Column( + children: [ + HiResCheckCard(filePath: source), + AudioAnalysisCard(filePath: source), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(fullReads, 0, reason: 'opening cards must not fetch audio'); + await tester.tap(find.text('Check')); + for ( + var i = 0; + i < 300 && find.text('96.0 kHz').evaluate().isEmpty; + i++ + ) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(find.text('96.0 kHz'), findsOneWidget); + if (smbAddress.isEmpty) expect(fullReads, 1); + await tester.ensureVisible(find.byType(AudioAnalysisCard)); + await tester.tap(find.text('Audio Quality Analysis')); + for (var i = 0; i < 600 && find.text('24-bit').evaluate().isEmpty; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(find.text('24-bit'), findsOneWidget); + expect(find.text('96.0 kHz'), findsNWidgets(2)); + expect(find.text('Dynamic Range: '), findsOneWidget); + expect(find.text('Ch 1'), findsOneWidget); + if (smbAddress.isEmpty) expect(fullReads, 2); + await tester.ensureVisible(find.text('Ch 1')); + await tester.tap(find.text('Ch 1')); + await tester.pump(); + for ( + var i = 0; + i < 600 && find.byType(LinearProgressIndicator).evaluate().isNotEmpty; + i++ + ) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(find.byType(LinearProgressIndicator), findsNothing); + if (smbAddress.isEmpty) expect(fullReads, 3); + expect(requests.every((r) => ['GET', 'HEAD'].contains(r.method)), true); + expect( + requests.every( + (r) => + r.authorization == + 'Basic ${base64Encode(utf8.encode('test:secret'))}', + ), + true, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: SingleChildScrollView( + child: AudioAnalysisCard(filePath: source), + ), + ), + ), + ); + for (var i = 0; i < 300 && find.text('24-bit').evaluate().isEmpty; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(find.text('24-bit'), findsOneWidget); + expect(find.text('Ch 1'), findsOneWidget); + if (smbAddress.isEmpty) { + expect(fullReads, 3, reason: 'reopen uses cached analysis'); + } + final temporary = await getTemporaryDirectory(); + expect( + await temporary + .list() + .where( + (entry) => entry.uri.pathSegments + .where((part) => part.isNotEmpty) + .last + .startsWith('network-analysis-'), + ) + .toList(), + isEmpty, + ); + await tester.pumpWidget(const SizedBox()); + } finally { + await storage.remove(id); + await server.close(force: true); + await local.delete(recursive: true); + } + }); +} diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index e879ba09..0323e514 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -91,7 +91,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { _buildLyricsCard(context, colorScheme), - if (_fileExists && !_isNetworkItem) ...[ + if (_fileExists) ...[ const SizedBox(height: 16), AudioAnalysisCard( filePath: _filePath, diff --git a/lib/services/hires_check_service.dart b/lib/services/hires_check_service.dart index 60965d8f..caac8cd6 100644 --- a/lib/services/hires_check_service.dart +++ b/lib/services/hires_check_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/network_audio_analysis_input.dart'; class HiResOriginalRestoreException implements Exception { const HiResOriginalRestoreException(this.backupPath, this.cause); @@ -111,7 +112,16 @@ class HiResCheckService { /// Runs the check; throws on failure. `supported == false` for formats /// the Rust checker cannot decode (anything but FLAC and PCM WAV). - static Future check(String filePath) async { + static Future check( + String filePath, { + NetworkAudioAnalysisInput? networkInput, + }) async { + if (NetworkAudioAnalysisInput.isNetwork(filePath)) { + return (networkInput ?? NetworkAudioAnalysisInput()).withFile( + filePath, + (path) => check(path), + ); + } final json = await PlatformBridge.checkHiResAuthenticity(filePath); final error = json['error']; if (error != null) throw StateError(error.toString()); diff --git a/lib/services/network_audio_analysis_input.dart b/lib/services/network_audio_analysis_input.dart new file mode 100644 index 00000000..4795125c --- /dev/null +++ b/lib/services/network_audio_analysis_input.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; +import 'package:spotiflac_android/services/network_storage_service.dart'; + +/// Supplies seekable, unmodified audio to the existing analysers. Credentials +/// stay in the storage proxy; only an owned temporary file reaches the decoder. +class NetworkAudioAnalysisInput { + NetworkAudioAnalysisInput({ + NetworkStorageService? storage, + Future Function()? temporaryDirectory, + this.timeout = const Duration(seconds: 20), + }) : _storage = storage ?? NetworkStorageService.instance, + _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory; + + final NetworkStorageService _storage; + final Future Function() _temporaryDirectory; + final Duration timeout; + final Set _clients = {}; + int _generation = 0; + + static bool isNetwork(String source) => source.startsWith('network://'); + + /// Cancels only transfers owned by this analyser, leaving playback intact. + void cancel() { + _generation++; + for (final client in _clients) { + client.close(force: true); + } + } + + Future _withResponse( + String source, + String method, + Future Function(HttpClientResponse, void Function()) consume, + ) async { + final generation = _generation; + void checkpoint() { + if (generation != _generation) throw const AudioAnalysisCancelled(); + } + + final client = HttpClient() + ..connectionTimeout = timeout + ..autoUncompress = false; + _clients.add(client); + try { + final url = await _storage.resolve(source); + checkpoint(); + final request = await client + .openUrl(method, Uri.parse(url)) + .timeout(timeout); + request.followRedirects = false; + request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity'); + checkpoint(); + final response = await request.close().timeout(timeout); + checkpoint(); + if (response.statusCode != HttpStatus.ok) { + throw HttpException('Audio server returned ${response.statusCode}'); + } + return await consume(response, checkpoint); + } catch (_) { + checkpoint(); + rethrow; + } finally { + _clients.remove(client); + client.close(force: true); + } + } + + Future length(String source) => + _withResponse(source, 'HEAD', (response, checkpoint) async { + await response.drain().timeout(timeout); + checkpoint(); + return response.contentLength; + }); + + /// Streams to disk with backpressure, then retains the file until all native + /// reads finish. A partial/failed/cancelled transfer never reaches analysis. + Future withFile( + String source, + Future Function(String path) analyze, + ) => _withResponse(source, 'GET', (response, checkpoint) async { + Directory? directory; + try { + final root = await _temporaryDirectory(); + checkpoint(); + directory = await root.createTemp('network-analysis-'); + checkpoint(); + final name = Uri.parse(source).pathSegments.last; + final file = File(p.join(directory.path, 'audio${p.extension(name)}')); + var received = 0; + final sink = file.openWrite(); + try { + await sink.addStream( + response.timeout(timeout).map((chunk) { + checkpoint(); + received += chunk.length; + return chunk; + }), + ); + } finally { + await sink.close(); + } + checkpoint(); + if (received == 0 || + (response.contentLength >= 0 && received != response.contentLength)) { + throw const HttpException('Incomplete network audio file'); + } + final result = await analyze(file.path); + checkpoint(); + return result; + } finally { + if (directory != null) { + try { + await directory.delete(recursive: true); + } on FileSystemException { + // Temporary cleanup must not replace the analysis result or error. + } + } + } + }); +} diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 07943fcb..b6c0c931 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -14,6 +14,7 @@ import 'package:flutter/services.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; import 'package:spotiflac_android/services/audio_analysis_cache.dart'; +import 'package:spotiflac_android/services/network_audio_analysis_input.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; @@ -609,6 +610,7 @@ class _AudioAnalysisCardState extends State { bool _spectrogramChannelLoading = false; int _spectrogramRequestId = 0; String? _unsupportedCodec; + final _networkInput = NetworkAudioAnalysisInput(); final _analysisJobs = AudioAnalysisJobs( start: (arguments) async { final completed = Completer(); @@ -697,6 +699,7 @@ class _AudioAnalysisCardState extends State { _spectrogramRequestId++; _analysisJobs.invalidate(); + _networkInput.cancel(); _cancelNativeAnalysis(); _spectrogramImage?.dispose(); _spectrogramImage = null; @@ -717,6 +720,7 @@ class _AudioAnalysisCardState extends State { void dispose() { _spectrogramRequestId++; _analysisJobs.dispose(); + _networkInput.cancel(); _cancelNativeAnalysis(); _spectrogramImage?.dispose(); super.dispose(); @@ -753,6 +757,10 @@ class _AudioAnalysisCardState extends State { expectedPath, channel: _spectrogramChannel, ); + if (image == null && + NetworkAudioAnalysisInput.isNetwork(expectedPath)) { + return; // Opening metadata must not fetch the complete remote audio. + } image ??= await _generateAndCacheSpectrogram( filePath: expectedPath, analysisData: cached, @@ -806,6 +814,7 @@ class _AudioAnalysisCardState extends State { final releaseCache = _cacheBudget.retain(_cacheKey(sourcePath)); final requestId = ++_spectrogramRequestId; _analysisJobs.invalidate(); + _networkInput.cancel(); _cancelNativeAnalysis(); setState(() { _analyzing = true; @@ -938,7 +947,7 @@ class _AudioAnalysisCardState extends State { } } - static Future _loadFromCache(String filePath) async { + Future _loadFromCache(String filePath) async { try { final dir = await _cacheDir(); final key = _cacheKey(filePath); @@ -953,7 +962,10 @@ class _AudioAnalysisCardState extends State { } final cachedSize = json['fileSize'] as int; - if (!filePath.startsWith('content://')) { + if (NetworkAudioAnalysisInput.isNetwork(filePath)) { + final currentSize = await _networkInput.length(filePath); + if (currentSize <= 0 || currentSize != cachedSize) return null; + } else if (!filePath.startsWith('content://')) { final currentSize = await File(filePath).length(); if (currentSize != cachedSize) return null; } else { @@ -1027,9 +1039,16 @@ class _AudioAnalysisCardState extends State { Future<_AudioAnalysisRunResult> _runAnalysis( String filePath, - int requestId, - ) async { + int requestId, { + String? sourcePath, + }) async { _checkAnalysisRequest(requestId); + if (NetworkAudioAnalysisInput.isNetwork(filePath)) { + return _networkInput.withFile( + filePath, + (path) => _runAnalysis(path, requestId, sourcePath: filePath), + ); + } String workingPath = filePath; String? tempCopy; if (filePath.startsWith('content://')) { @@ -1078,7 +1097,7 @@ class _AudioAnalysisCardState extends State { return _AudioAnalysisRunResult( data: AudioAnalysisData( - filePath: filePath, + filePath: sourcePath ?? filePath, fileSize: info.fileSize, codec: info.codec, container: info.container, @@ -1126,6 +1145,19 @@ class _AudioAnalysisCardState extends State { int? channels, }) async { _checkAnalysisRequest(requestId); + if (NetworkAudioAnalysisInput.isNetwork(filePath)) { + return _networkInput.withFile( + filePath, + (path) => _generateSpectrogramForFile( + path, + requestId: requestId, + channel: channel, + durationSeconds: durationSeconds, + sampleRate: sampleRate, + channels: channels, + ), + ); + } String workingPath = filePath; String? tempCopy; if (filePath.startsWith('content://')) { diff --git a/lib/widgets/hires_check_card.dart b/lib/widgets/hires_check_card.dart index 71d939cc..8b72aef9 100644 --- a/lib/widgets/hires_check_card.dart +++ b/lib/widgets/hires_check_card.dart @@ -2,6 +2,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/hires_check_service.dart'; +import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; +import 'package:spotiflac_android/services/network_audio_analysis_input.dart'; import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_action_button.dart'; @@ -32,18 +34,27 @@ class _HiResCheckCardState extends State { bool _checking = false; String? _error; int _requestId = 0; + final _networkInput = NetworkAudioAnalysisInput(); @override void didUpdateWidget(covariant HiResCheckCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.filePath != widget.filePath) { _requestId++; + _networkInput.cancel(); _result = null; _error = null; _checking = false; } } + @override + void dispose() { + _requestId++; + _networkInput.cancel(); + super.dispose(); + } + Future _check() async { final requestId = ++_requestId; setState(() { @@ -53,7 +64,12 @@ class _HiResCheckCardState extends State { String? error; HiResCheckResult? result; try { - result = await HiResCheckService.check(widget.filePath); + result = await HiResCheckService.check( + widget.filePath, + networkInput: _networkInput, + ); + } on AudioAnalysisCancelled { + return; } on StateError catch (e) { error = e.message; } catch (e) { diff --git a/test/network_audio_analysis_input_test.dart b/test/network_audio_analysis_input_test.dart new file mode 100644 index 00000000..fb7006bf --- /dev/null +++ b/test/network_audio_analysis_input_test.dart @@ -0,0 +1,224 @@ +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/audio_analysis_jobs.dart'; +import 'package:spotiflac_android/services/hires_check_service.dart'; +import 'package:spotiflac_android/services/network_audio_analysis_input.dart'; +import 'package:spotiflac_android/services/network_storage_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + HttpOverrides.global = null; + late Directory temporary; + late HttpServer server; + late NetworkStorageService storage; + late NetworkAudioAnalysisInput input; + final requests = []; + const name = 'हिन्दी & Rock + #1.wav'; + final audio = List.generate(128 * 1024, (i) => i % 256); + var status = HttpStatus.ok; + var empty = false; + Completer? release; + Completer? started; + + setUp(() async { + requests.clear(); + status = HttpStatus.ok; + empty = false; + release = null; + started = null; + temporary = await Directory.systemTemp.createTemp('network-analysis-test-'); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) async { + try { + requests.add(request.method); + expect(request.uri.pathSegments.last, name); + expect( + request.headers.value(HttpHeaders.authorizationHeader), + 'Basic ${base64Encode(utf8.encode('tester:secret'))}', + ); + request.response.statusCode = status; + if (status != HttpStatus.ok) return; + request.response.contentLength = empty ? 0 : audio.length; + if (request.method == 'GET' && !empty) { + request.response.add(audio.sublist(0, 1024)); + await request.response.flush(); + started?.complete(); + if (release != null) await release!.future; + request.response.add(audio.sublist(1024)); + } + } finally { + try { + await request.response.close(); + } on IOException { + // Cancellation intentionally closes the client mid-transfer. + } + } + }); + storage = NetworkStorageService( + read: () async => null, + write: (_) async {}, + ); + await storage.save( + NetworkConnection( + id: 'fixture', + name: 'Fixture', + protocol: NetworkProtocol.webdav, + address: 'http://127.0.0.1:${server.port}/music/', + username: 'tester', + password: 'secret', + ), + ); + input = NetworkAudioAnalysisInput( + storage: storage, + temporaryDirectory: () async => temporary, + ); + }); + + tearDown(() async { + if (release != null && !release!.isCompleted) release!.complete(); + input.cancel(); + await storage.dispose(); + await server.close(force: true); + await temporary.delete(recursive: true); + }); + + String source() => NetworkStorageService.source('fixture', name); + + test('authenticated staging preserves bytes and is read-only', () async { + expect(await input.length(source()), audio.length); + String? stagedPath; + final result = await input.withFile(source(), (path) async { + stagedPath = path; + expect(path.endsWith('.wav'), true); + expect(await File(path).readAsBytes(), audio); + // Native analysis may reopen/seek the input more than once. + final handle = await File(path).open(); + try { + await handle.setPosition(1234); + expect(await handle.read(10), audio.sublist(1234, 1244)); + } finally { + await handle.close(); + } + return 'analysed'; + }); + expect(result, 'analysed'); + expect(await File(stagedPath!).exists(), false); + expect(await temporary.list().toList(), isEmpty); + expect(requests, ['HEAD', 'GET']); + }); + + test('Hi-Res checker receives the staged file, never a proxy URL', () async { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'checkHiResAuthenticity'); + final path = (call.arguments as Map)['file_path'] as String; + expect(await File(path).readAsBytes(), audio); + return {'supported': true, 'declared_sample_rate': 96000}; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final result = await HiResCheckService.check(source(), networkInput: input); + expect(result.supported, true); + expect(result.declaredSampleRate, 96000); + expect(await temporary.list().toList(), isEmpty); + }); + + test('decoder errors clean up the input', () async { + await expectLater( + input.withFile(source(), (_) async => throw StateError('decoder')), + throwsStateError, + ); + expect(await temporary.list().toList(), isEmpty); + }); + + test('server errors and empty audio never run a decoder', () async { + var decoded = false; + for (final code in [404, 401, 500, 206, 200]) { + status = code; + empty = true; + await expectLater( + input.withFile(source(), (_) async => decoded = true), + throwsA(isA()), + ); + expect(decoded, false); + expect(await temporary.list().toList(), isEmpty); + } + }); + + test('cancelling a transfer leaves another analyser usable', () async { + release = Completer(); + started = Completer(); + var decoded = false; + final transfer = input.withFile( + source(), + (_) async => decoded = true, + ); + final cancelled = expectLater( + transfer, + throwsA(isA()), + ); + await started!.future; + input.cancel(); + await cancelled.timeout(const Duration(seconds: 2)); + expect(decoded, false); + expect(await temporary.list().toList(), isEmpty); + release!.complete(); + release = null; + started = null; + final other = NetworkAudioAnalysisInput( + storage: storage, + temporaryDirectory: () async => temporary, + ); + expect(await other.length(source()), audio.length); + expect(await input.withFile(source(), (_) async => 7), 7); + }); + + test( + 'cancel waits for an active decoder before deleting its input', + () async { + final entered = Completer(); + final completed = Completer(); + String? stagedPath; + final task = input.withFile(source(), (path) async { + stagedPath = path; + entered.complete(); + await completed.future; + expect(await File(path).exists(), true); + }); + final cancelled = expectLater( + task, + throwsA(isA()), + ); + await entered.future; + input.cancel(); + expect(await File(stagedPath!).exists(), true); + completed.complete(); + await cancelled; + expect(await temporary.list().toList(), isEmpty); + }, + ); + + test('a stalled stream times out and removes partial audio', () async { + release = Completer(); + started = Completer(); + final bounded = NetworkAudioAnalysisInput( + storage: storage, + temporaryDirectory: () async => temporary, + timeout: const Duration(milliseconds: 100), + ); + var decoded = false; + await expectLater( + bounded.withFile(source(), (_) async => decoded = true), + throwsA(isA()), + ); + expect(decoded, false); + expect(await temporary.list().toList(), isEmpty); + }); +} diff --git a/test/track_metadata_network_analysis_test.dart b/test/track_metadata_network_analysis_test.dart new file mode 100644 index 00000000..99ac51a8 --- /dev/null +++ b/test/track_metadata_network_analysis_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; +import 'package:spotiflac_android/widgets/hires_check_card.dart'; + +void main() { + for (final mornye in [false, true]) { + for (final format in ['flac', 'wav', 'mp3']) { + testWidgets( + 'network $format exposes supported analysers (Mornye=$mornye)', + (tester) async { + final item = LocalLibraryItem( + id: 'network://fixture/track.$format', + filePath: 'network://fixture/track.$format', + trackName: 'Network track', + artistName: 'Artist', + albumName: 'Album', + scannedAt: DateTime(2026), + ); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark) + : ThemeData.dark(useMaterial3: true), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TrackMetadataScreen(localItem: item), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.scrollUntilVisible( + find.byType(AudioAnalysisCard), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.byType(AudioAnalysisCard), findsOneWidget); + expect( + tester + .widget(find.byType(AudioAnalysisCard)) + .filePath, + item.filePath, + ); + expect( + find.byType(HiResCheckCard), + format == 'mp3' ? findsNothing : findsOneWidget, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + } +}