fix(library): persist artwork previews and stabilize completion

This commit is contained in:
zarzet
2026-08-27 21:42:38 +07:00
parent fca8e46412
commit 933a3c49e2
28 changed files with 1906 additions and 256 deletions
@@ -0,0 +1,528 @@
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/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const backendChannel = MethodChannel('com.zarz.spotiflac/backend');
final sourcePaths = <String>[];
final tempDirectories = <Directory>[];
late Directory persistentCacheDirectory;
setUp(() async {
persistentCacheDirectory = await Directory.systemTemp.createTemp(
'embedded_cover_persistent_test_',
);
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
persistentCacheDirectory,
);
});
Future<String> createAudioFixture(String name) async {
final directory = await Directory.systemTemp.createTemp(
'embedded_cover_resolver_test_',
);
tempDirectories.add(directory);
final source = File('${directory.path}${Platform.pathSeparator}$name.flac');
await source.writeAsBytes(const [0, 1, 2, 3]);
sourcePaths.add(source.path);
return source.path;
}
tearDown(() async {
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting(
preservePersistentFiles: false,
);
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, null);
sourcePaths.clear();
for (final directory in tempDirectories) {
if (await directory.exists()) {
await directory.delete(recursive: true);
}
}
tempDirectories.clear();
if (await persistentCacheDirectory.exists()) {
await persistentCacheDirectory.delete(recursive: true);
}
});
test('metadata artwork prefers embedded, then local, then remote', () {
expect(
resolveMetadataArtworkSource(
embeddedCoverPath: ' C:/covers/embedded.jpg ',
localCoverPath: 'C:/covers/local.jpg',
remoteCoverUrl: 'https://example.com/online.jpg',
),
'C:/covers/embedded.jpg',
);
expect(
resolveMetadataArtworkSource(
embeddedCoverPath: ' ',
localCoverPath: ' C:/covers/local.jpg ',
remoteCoverUrl: 'https://example.com/online.jpg',
),
'C:/covers/local.jpg',
);
expect(
resolveMetadataArtworkSource(
remoteCoverUrl: ' https://example.com/online.jpg ',
),
'https://example.com/online.jpg',
);
expect(resolveMetadataArtworkSource(), isNull);
});
test(
'cold resolve starts extraction and reports the cached preview',
() async {
final sourcePath = await createAudioFixture('cold-cache');
var extractionCalls = 0;
final changed = Completer<void>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
extractionCalls++;
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [4, 5, 6]);
return jsonEncode({'success': true});
});
final initial = DownloadedEmbeddedCoverResolver.resolve(
sourcePath,
onChanged: () {
if (!changed.isCompleted) changed.complete();
},
);
expect(initial, isNull);
await changed.future.timeout(const Duration(seconds: 2));
final cached = DownloadedEmbeddedCoverResolver.resolve(sourcePath);
expect(extractionCalls, 1);
expect(cached, isNotNull);
expect(await File(cached!).exists(), isTrue);
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(cached),
isTrue,
);
},
);
test('concurrent resolveOrExtract calls share one extraction', () async {
final sourcePath = await createAudioFixture('concurrent');
var extractionCalls = 0;
var callbacks = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
extractionCalls++;
final arguments = call.arguments as Map<Object?, Object?>;
await Future<void>.delayed(const Duration(milliseconds: 20));
await File(
arguments['output_path']! as String,
).writeAsBytes(const [7, 8, 9]);
return jsonEncode({'success': true});
});
final results = await Future.wait([
DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
onChanged: () => callbacks++,
),
DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
onChanged: () => callbacks++,
),
DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
onChanged: () => callbacks++,
),
]);
expect(extractionCalls, 1);
expect(callbacks, 3);
expect(results.toSet(), hasLength(1));
expect(results.first, isNotNull);
expect(await File(results.first!).exists(), isTrue);
});
test(
'background cover extraction is limited to two concurrent jobs',
() async {
final paths = await Future.wait([
for (var index = 0; index < 5; index++)
createAudioFixture('bounded-$index'),
]);
final release = Completer<void>();
final firstTwoStarted = Completer<void>();
final allCompleted = Completer<void>();
var calls = 0;
var active = 0;
var maxActive = 0;
var completed = 0;
addTearDown(() {
if (!release.isCompleted) release.complete();
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
calls++;
active++;
if (active > maxActive) maxActive = active;
if (calls == 2 && !firstTwoStarted.isCompleted) {
firstTwoStarted.complete();
}
try {
await release.future;
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [13, 14, 15]);
return jsonEncode({'success': true});
} finally {
active--;
}
});
void onChanged() {
completed++;
if (completed == paths.length && !allCompleted.isCompleted) {
allCompleted.complete();
}
}
for (final path in paths) {
expect(
DownloadedEmbeddedCoverResolver.resolve(path, onChanged: onChanged),
isNull,
);
}
await firstTwoStarted.future.timeout(const Duration(seconds: 2));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(calls, 2);
expect(maxActive, 2);
release.complete();
await allCompleted.future.timeout(const Duration(seconds: 2));
expect(calls, paths.length);
expect(maxActive, 2);
},
);
test('foreground extraction is promoted ahead of background jobs', () async {
final paths = await Future.wait([
for (var index = 0; index < 4; index++)
createAudioFixture('priority-$index'),
]);
final releaseFirst = Completer<void>();
final releaseSecond = Completer<void>();
final firstTwoStarted = Completer<void>();
final thirdStarted = Completer<void>();
final allCompleted = Completer<void>();
final startedPaths = <String>[];
var completed = 0;
addTearDown(() {
if (!releaseFirst.isCompleted) releaseFirst.complete();
if (!releaseSecond.isCompleted) releaseSecond.complete();
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
final arguments = call.arguments as Map<Object?, Object?>;
final audioPath = arguments['audio_path']! as String;
startedPaths.add(audioPath);
final callIndex = startedPaths.length - 1;
if (startedPaths.length == 2 && !firstTwoStarted.isCompleted) {
firstTwoStarted.complete();
}
if (startedPaths.length == 3 && !thirdStarted.isCompleted) {
thirdStarted.complete();
}
if (callIndex == 0) {
await releaseFirst.future;
} else if (callIndex == 1) {
await releaseSecond.future;
}
await File(
arguments['output_path']! as String,
).writeAsBytes(const [16, 17, 18]);
return jsonEncode({'success': true});
});
void onChanged() {
completed++;
if (completed == paths.length && !allCompleted.isCompleted) {
allCompleted.complete();
}
}
DownloadedEmbeddedCoverResolver.resolve(paths[0], onChanged: onChanged);
DownloadedEmbeddedCoverResolver.resolve(paths[1], onChanged: onChanged);
await firstTwoStarted.future.timeout(const Duration(seconds: 2));
DownloadedEmbeddedCoverResolver.resolve(paths[2], onChanged: onChanged);
DownloadedEmbeddedCoverResolver.resolve(paths[3], onChanged: onChanged);
final foreground = DownloadedEmbeddedCoverResolver.resolveOrExtract(
paths[3],
);
releaseFirst.complete();
await thirdStarted.future.timeout(const Duration(seconds: 2));
expect(startedPaths[2], paths[3]);
releaseSecond.complete();
expect(await foreground, isNotNull);
await allCompleted.future.timeout(const Duration(seconds: 2));
expect(startedPaths, hasLength(paths.length));
});
test('force refresh drops a stale cover when artwork is removed', () async {
final sourcePath = await createAudioFixture('removed-cover');
var extractionCalls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
extractionCalls++;
if (extractionCalls == 1) {
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [10, 11, 12]);
return jsonEncode({'success': true});
}
return jsonEncode({'error': 'embedded cover not found'});
});
final original = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(original, isNotNull);
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(original),
isTrue,
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
sourcePath,
force: true,
);
final refreshed = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(extractionCalls, 2);
expect(refreshed, isNull);
expect(await File(original!).exists(), isFalse);
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(original),
isTrue,
);
expect(DownloadedEmbeddedCoverResolver.resolve(sourcePath), isNull);
});
test(
'persistent preview survives memory reset and revalidates source changes',
() async {
final sourcePath = await createAudioFixture('persistent-restart');
var extractionCalls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
extractionCalls++;
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes([extractionCalls, 20, 21]);
return jsonEncode({'success': true});
});
final first = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(first, isNotNull);
expect(extractionCalls, 1);
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(first),
isTrue,
);
final reused = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(reused, first);
expect(extractionCalls, 1);
await File(sourcePath).writeAsBytes(const [9, 8, 7, 6, 5]);
await File(
sourcePath,
).setLastModified(DateTime.now().add(const Duration(seconds: 2)));
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
final refreshed = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(extractionCalls, 2);
expect(refreshed, isNotNull);
expect(refreshed, isNot(first));
expect(await File(refreshed!).exists(), isTrue);
expect(await File(first!).exists(), isFalse);
},
);
test('invalidate removes persistent variants after memory reset', () async {
final sourcePath = await createAudioFixture('persistent-invalidate');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [22, 23, 24]);
return jsonEncode({'success': true});
});
final preview = await DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
expect(preview, isNotNull);
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
await DownloadedEmbeddedCoverResolver.invalidate(sourcePath);
expect(await File(preview!).exists(), isFalse);
final remainingPreviews = await persistentCacheDirectory
.list()
.where((entity) => entity is File && entity.path.endsWith('.jpg'))
.toList();
expect(remainingPreviews, isEmpty);
});
test('persistent maintenance enforces entry and byte caps', () async {
final paths = await Future.wait([
for (var index = 0; index < 3; index++)
createAudioFixture('persistent-cap-$index'),
]);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [25, 26, 27, 28]);
return jsonEncode({'success': true});
});
final previews = <String>[];
for (final path in paths) {
previews.add(
(await DownloadedEmbeddedCoverResolver.resolveOrExtract(path))!,
);
}
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
for (var index = 0; index < previews.length; index++) {
await File(previews[index]).setLastModified(DateTime(2024, 1, index + 1));
}
await DownloadedEmbeddedCoverResolver.runPersistentCacheMaintenanceForTesting(
maxEntries: 2,
maxBytes: 100,
targetBytes: 100,
);
expect(await File(previews[0]).exists(), isFalse);
expect(await File(previews[1]).exists(), isTrue);
expect(await File(previews[2]).exists(), isTrue);
expect(
DownloadedEmbeddedCoverResolver.isManagedPreviewPath(previews[1]),
isTrue,
);
await DownloadedEmbeddedCoverResolver.runPersistentCacheMaintenanceForTesting(
maxEntries: 10,
maxBytes: 5,
targetBytes: 4,
);
final survivors = await Future.wait([
for (final preview in previews) File(preview).exists(),
]);
expect(survivors.where((exists) => exists), hasLength(1));
});
test(
'persistent cache clear waits for cancelled native extraction cleanup',
() async {
final sourcePath = await createAudioFixture('clear-in-flight');
final started = Completer<void>();
final release = Completer<void>();
addTearDown(() {
if (!release.isCompleted) release.complete();
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
if (!started.isCompleted) started.complete();
await release.future;
final arguments = call.arguments as Map<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [29, 30, 31]);
return jsonEncode({'success': true});
});
final resolution = DownloadedEmbeddedCoverResolver.resolveOrExtract(
sourcePath,
);
await started.future.timeout(const Duration(seconds: 2));
var clearCompleted = false;
final clear = DownloadedEmbeddedCoverResolver.clearPersistentCache().then(
(_) => clearCompleted = true,
);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(clearCompleted, isFalse);
release.complete();
expect(await resolution, isNull);
await clear.timeout(const Duration(seconds: 2));
expect(clearCompleted, isTrue);
expect(await persistentCacheDirectory.list().toList(), isEmpty);
},
);
test('metadata cover pipeline enforces shared resolver ownership', () {
final coverSource = File(
'lib/screens/track_metadata_screen_cover.dart',
).readAsStringSync();
final cardsSource = File(
'lib/screens/track_metadata_cards.dart',
).readAsStringSync();
expect(
coverSource,
contains('DownloadedEmbeddedCoverResolver.isManagedPreviewPath(path)'),
);
expect(
coverSource,
contains('DownloadedEmbeddedCoverResolver.resolveOrExtract('),
);
expect(coverSource, isNot(contains('PlatformBridge.extractCoverToFile')));
expect(
cardsSource,
contains('Downloaded-item entry points must await resolveOrExtract'),
);
expect(
cardsSource,
contains(
'Widget coverImage() => artworkImage(cacheWidth: coverCacheWidth)',
),
);
expect(cardsSource, contains('cacheHeight: backdropCacheWidth'));
});
}
+129 -7
View File
@@ -77,13 +77,20 @@ void main() {
);
});
test('completion bridge prefers the finalized history path', () {
test('completion bridge prefers distinct finalized path candidates', () {
expect(
resolveCompletionBridgePlayablePath(
resolveCompletionBridgePlayableCandidates(
historyFilePath: ' /music/final.flac ',
completedItemFilePath: '/music/staging.flac',
completedItemFilePath: '/music/completed.flac',
),
'/music/final.flac',
['/music/final.flac', '/music/completed.flac'],
);
expect(
resolveCompletionBridgePlayableCandidates(
historyFilePath: ' /music/final.flac ',
completedItemFilePath: '/music/final.flac',
),
['/music/final.flac'],
);
expect(
resolveCompletionBridgePlayablePath(
@@ -101,7 +108,116 @@ void main() {
);
});
test('completion bridge cards retain Play actions during a batch', () {
test(
'completion probe falls back from stale history to completed path',
() async {
final checkedPaths = <String>[];
final probe = CompletionBridgePlayableProbeCache(
pathExists: (path) async {
checkedPaths.add(path);
return path == '/music/completed.flac';
},
retryDelays: const [],
);
addTearDown(probe.dispose);
final result = probe.listenable(
historyFilePath: '/music/stale.flac',
completedItemFilePath: '/music/completed.flac',
);
expect(result.value.status, CompletionBridgePlayableStatus.checking);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(checkedPaths, ['/music/stale.flac', '/music/completed.flac']);
expect(result.value.status, CompletionBridgePlayableStatus.playable);
expect(result.value.path, '/music/completed.flac');
},
);
test(
'completion probe retries publication before reporting missing',
() async {
var checks = 0;
final probe = CompletionBridgePlayableProbeCache(
pathExists: (_) async => ++checks >= 3,
retryDelays: const [Duration.zero, Duration.zero],
);
addTearDown(probe.dispose);
final result = probe.listenable(
completedItemFilePath: 'content://downloads/final.flac',
);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(checks, 3);
expect(result.value.status, CompletionBridgePlayableStatus.playable);
expect(result.value.path, 'content://downloads/final.flac');
},
);
test('completion probe can refresh a cached missing destination', () async {
var exists = false;
final probe = CompletionBridgePlayableProbeCache(
pathExists: (_) async => exists,
retryDelays: const [],
);
addTearDown(probe.dispose);
final result = probe.listenable(
completedItemFilePath: '/music/reused.flac',
);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(result.value.status, CompletionBridgePlayableStatus.missing);
exists = true;
probe.refreshForPath('/music/reused.flac');
expect(result.value.status, CompletionBridgePlayableStatus.checking);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(result.value.status, CompletionBridgePlayableStatus.playable);
expect(result.value.path, '/music/reused.flac');
});
test(
'completion probe does not evict an actively listened notifier',
() async {
final probe = CompletionBridgePlayableProbeCache(
pathExists: (_) async => true,
retryDelays: const [],
maxEntries: 2,
);
addTearDown(probe.dispose);
final first = probe.listenable(
completedItemFilePath: '/music/first.flac',
);
void listener() {}
first.addListener(listener);
final second = probe.listenable(
completedItemFilePath: '/music/second.flac',
);
probe.listenable(completedItemFilePath: '/music/third.flac');
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(
identical(
first,
probe.listenable(completedItemFilePath: '/music/first.flac'),
),
isTrue,
);
expect(
identical(
second,
probe.listenable(completedItemFilePath: '/music/second.flac'),
),
isFalse,
);
expect(() => first.removeListener(listener), returnsNormally);
},
);
test('completion bridge cards probe before showing Play or missing', () {
final source = File(
'lib/screens/queue_tab_collection_items.dart',
).readAsStringSync();
@@ -117,10 +233,16 @@ void main() {
final listSource = source.substring(listStart, badgeStart);
expect(gridSource, contains('resolveCompletionBridgePlayablePath('));
expect(gridSource, contains('_fileExistsListenable(playablePath)'));
expect(gridSource, contains('_completionBridgePlayableProbe.listenable('));
expect(gridSource, contains('CompletionBridgePlayableStatus.checking'));
expect(gridSource, contains('semanticsLabel:'));
expect(gridSource, contains('queueCheckingDownloadedFile'));
expect(gridSource, contains('TrackGridPlayButton('));
expect(listSource, contains('resolveCompletionBridgePlayablePath('));
expect(listSource, contains('_fileExistsListenable(playablePath)'));
expect(listSource, contains('_completionBridgePlayableProbe.listenable('));
expect(listSource, contains('CompletionBridgePlayableStatus.checking'));
expect(listSource, contains('semanticsLabel:'));
expect(listSource, contains('queueCheckingDownloadedFile'));
expect(listSource, contains('Icons.play_arrow'));
});
}