fix(library): recover interrupted scans and stale notifications

Serialize scan notifications and surface failures even when database error persistence fails. Recover interrupted scans independently of auto-scan, retain their checkpoints, and collect matching Android process-exit details on restart.
This commit is contained in:
zarzet committed 2026-10-19 15:43:00 +07:00
1 parent 18fd556869
commit 378c8f3749
14 files changed
+481 -31

No files matched your search

@@ -0,0 +1,46 @@
package com.zarz.spotiflac
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Build
/** OS-owned diagnostics survive fatal native crashes that bypass Dart cleanup. */
internal object LibraryScanExitInfo {
fun matches(processName: String, timestamp: Long, packageName: String, startedAfter: Long): Boolean =
startedAfter > 0L && processName == packageName && timestamp >= startedAfter
fun reasonLabel(reason: Int): String = when (reason) {
ApplicationExitInfo.REASON_CRASH -> "App crash"
ApplicationExitInfo.REASON_CRASH_NATIVE -> "Native crash"
ApplicationExitInfo.REASON_ANR -> "App stopped responding"
ApplicationExitInfo.REASON_LOW_MEMORY -> "Android stopped the app because memory was low"
ApplicationExitInfo.REASON_SIGNALED -> "App process terminated by a signal"
ApplicationExitInfo.REASON_USER_REQUESTED,
ApplicationExitInfo.REASON_USER_STOPPED -> "App stopped by the user or system"
ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE -> "Android stopped the app for resource usage"
else -> "App process stopped (Android reason $reason)"
}
fun read(context: Context, startedAfter: Long): Map<String, Any>? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || startedAfter <= 0L) return null
val manager = context.getSystemService(ActivityManager::class.java)
// Android returns newest first. Ignore unrelated package processes and
// exits older than this scan, rather than blaming an earlier crash.
val exit = manager.getHistoricalProcessExitReasons(context.packageName, 0, 16)
.firstOrNull { matches(it.processName, it.timestamp, context.packageName, startedAfter) }
?: return null
return mapOf(
"reason" to reasonLabel(exit.reason),
"reason_code" to exit.reason,
"description" to exit.description.orEmpty().take(2000),
"timestamp" to exit.timestamp,
"status" to exit.status,
"pss_kb" to exit.pss,
"rss_kb" to exit.rss,
"device" to "${Build.MANUFACTURER} ${Build.MODEL}",
"android" to Build.VERSION.RELEASE,
"sdk" to Build.VERSION.SDK_INT,
)
}
}
@@ -76,6 +76,7 @@ class MainActivity: FlutterFragmentActivity() {
"scanSafTreeToNDJSONFile",
"scanSafTreeIncremental",
"getLibraryScanProgress",
"getLibraryScanExitInfo",
"cancelLibraryScan",
"pauseLibraryScan",
"resumeLibraryScan",
@@ -553,6 +554,7 @@ class MainActivity: FlutterFragmentActivity() {
"scanSafTreeIncremental" -> handleScanSafTreeIncremental(call, result)
"getSafFileModTimes" -> handleGetSafFileModTimes(call, result)
"getLibraryScanProgress" -> handleGetLibraryScanProgress(call, result)
"getLibraryScanExitInfo" -> handleGetLibraryScanExitInfo(call, result)
"cancelLibraryScan" -> handleCancelLibraryScan(call, result)
"pauseLibraryScan" -> handlePauseLibraryScan(call, result)
"resumeLibraryScan" -> handleResumeLibraryScan(call, result)
@@ -79,6 +79,14 @@ internal suspend fun MainActivity.handleGetLibraryScanProgress(call: MethodCall,
result.success(parseJsonPayload(response))
}
internal suspend fun MainActivity.handleGetLibraryScanExitInfo(call: MethodCall, result: MethodChannel.Result) {
val startedAfter = call.argument<Number>("started_after")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
LibraryScanExitInfo.read(applicationContext, startedAfter)
}
result.success(response)
}
internal suspend fun MainActivity.handleCancelLibraryScan(call: MethodCall, result: MethodChannel.Result) {
withContext(Dispatchers.IO) {
safScanCancel = true
@@ -0,0 +1,26 @@
package com.zarz.spotiflac
import android.app.ApplicationExitInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LibraryScanExitInfoTest {
@Test
fun excludesOldDeathsAndUnrelatedProcesses() {
assertTrue(LibraryScanExitInfo.matches("example.app", 200L, "example.app", 100L))
assertFalse(LibraryScanExitInfo.matches("example.app", 99L, "example.app", 100L))
assertFalse(LibraryScanExitInfo.matches("example.app", 200L, "example.app", 0L))
assertFalse(LibraryScanExitInfo.matches("example.app:other", 200L, "example.app", 100L))
}
@Test
fun distinguishesNativeCrashesFromLowMemoryAndAnr() {
assertEquals("Native crash", LibraryScanExitInfo.reasonLabel(ApplicationExitInfo.REASON_CRASH_NATIVE))
assertEquals("App crash", LibraryScanExitInfo.reasonLabel(ApplicationExitInfo.REASON_CRASH))
assertEquals("App stopped responding", LibraryScanExitInfo.reasonLabel(ApplicationExitInfo.REASON_ANR))
assertTrue(LibraryScanExitInfo.reasonLabel(ApplicationExitInfo.REASON_LOW_MEMORY).contains("memory"))
assertTrue(LibraryScanExitInfo.reasonLabel(999).contains("999"))
}
}
+4
View File
@@ -5475,6 +5475,10 @@
"@notifLibraryScanFailed": {
"description": "Notification title when library scan fails"
},
"notifLibraryScanInterruptedBody": "The previous library scan was interrupted. Scan the folder again to continue. Your indexed tracks are still available.",
"@notifLibraryScanInterruptedBody": {
"description": "Recovery notice after the app stops during a library scan"
},
"notifLibraryScanCancelled": "Library scan cancelled",
"@notifLibraryScanCancelled": {
"description": "Notification title when library scan is cancelled by the user"
+4
View File
@@ -5475,6 +5475,10 @@
"@notifLibraryScanFailed": {
"description": "Notification title when library scan fails"
},
"notifLibraryScanInterruptedBody": "Pemindaian koleksi sebelumnya terputus. Pindai ulang folder untuk melanjutkan. Lagu yang sudah terindeks tetap tersedia.",
"@notifLibraryScanInterruptedBody": {
"description": "Recovery notice after the app stops during a library scan"
},
"notifLibraryScanCancelled": "Pindai koleksi dibatalkan",
"@notifLibraryScanCancelled": {
"description": "Notification title when library scan is cancelled by the user"
@@ -170,6 +170,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
bool _isLoaded = false;
bool _hasLoadedFromDatabase = false;
Future<void>? _loadFuture;
Future<void>? _scanRecovery;
Future<void> _scanNotificationTail = Future<void>.value();
bool _scanCancelRequested = false;
bool _scanPauseRequested = false;
bool _scanInProgress = false;
@@ -255,6 +257,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(isLoading: true, loadFailed: false);
try {
await _recoverInterruptedScan();
if (!ref.mounted) return;
// Folder configuration is independent of the potentially expensive
// track index and storage probes. Publish it before either can fail.
final configuredSources = await _db.getSources();
@@ -459,6 +463,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
String? iosBookmark,
String? sourceId,
}) async {
if (!ref.mounted) return;
await _recoverInterruptedScan();
if (!ref.mounted) return;
if (_scanInProgress || state.isScanning) {
_log.w('Scan already in progress');
+111 -24
View File
@@ -3,6 +3,51 @@ part of 'local_library_provider.dart';
/// Scan execution, progress polling, pause handling and scan notifications.
extension _LocalLibraryScan on LocalLibraryNotifier {
Future<void> _recoverInterruptedScan() =>
_scanRecovery ??= _recoverPreviousScan();
Future<void> _recoverPreviousScan() async {
try {
final prefs = await _prefs;
final sourceId = prefs.getString(localLibraryActiveScanSourceKey);
if (!ref.mounted || sourceId == null || sourceId.trim().isEmpty) return;
final startedAfter =
prefs.getInt(localLibraryActiveScanStartedAtKey) ?? 0;
final recoveryKey = '$sourceId:$startedAfter';
if (prefs.getString(localLibraryRecoveredScanKey) == recoveryKey) return;
var message = _notificationService.libraryScanInterruptedMessage;
try {
final exit = await PlatformBridge.getLibraryScanExitInfo(
startedAfter: startedAfter,
);
if (exit != null) {
_log.e('Android process exit during library scan: $exit');
final reason = exit['reason'] as String?;
final description = exit['description'] as String?;
message = [
message,
if (reason != null && reason.isNotEmpty) reason,
if (description != null && description.isNotEmpty) description,
].join('\n');
}
} catch (e) {
_log.w('Android did not provide a scan exit report: $e');
}
_log.e('Interrupted library scan for $sourceId: $message');
await _notificationService.endBackgroundWork(
NotificationService.libraryScanWorkKind,
);
await _cancelScanPausedNotification();
await _showScanFailedNotification(message);
// Keep the checkpoint marker so enabled auto-scan can resume it. This
// recovery also runs when auto-scan is off and never deletes tracks.
await _db.updateSourceState(sourceId, lastScanError: message);
await prefs.setString(localLibraryRecoveredScanKey, recoveryKey);
} catch (e, stack) {
_log.e('Could not fully recover interrupted library scan: $e', e, stack);
}
}
Future<({int inserted, int skipped})?> _replaceFromFullScanStream({
required String sourceId,
required String folderPath,
@@ -133,7 +178,12 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
await _releaseNativeScanPause();
try {
final prefs = await _prefs;
await prefs.setInt(
localLibraryActiveScanStartedAtKey,
DateTime.now().millisecondsSinceEpoch,
);
await prefs.setString(localLibraryActiveScanSourceKey, activeSourceId);
await prefs.remove(localLibraryRecoveredScanKey);
} catch (e) {
_log.w('Failed to persist active library scan marker: $e');
}
@@ -436,21 +486,37 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
return;
}
_log.e('Library scan failed: $e', e, stack);
if (activeSourceId != null) {
await _db.updateSourceState(
activeSourceId,
lastScanError: e.toString(),
);
await _refreshSummaryFromStorage();
}
if (!ref.mounted) return;
state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: false,
sources: [
for (final source in state.sources)
source.id == activeSourceId
? source.copyWith(lastScanError: e.toString())
: source,
],
);
_stopProgressPolling();
await _showScanFailedNotification(e.toString());
if (activeSourceId != null) {
try {
await _db.updateSourceState(
activeSourceId,
lastScanError: e.toString(),
);
await _refreshSummaryFromStorage();
} catch (saveError, saveStack) {
_log.e(
'Could not save library scan failure: $saveError',
saveError,
saveStack,
);
}
}
} finally {
_stopProgressPolling();
await _scanNotificationTail;
await _notificationService.endBackgroundWork(
NotificationService.libraryScanWorkKind,
);
@@ -458,7 +524,6 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
await PlatformBridge.stopAccessingIosBookmark(securityAccess);
_log.i('Stopped iOS security-scoped access');
}
_stopProgressPolling();
if (_scanPauseRequested) {
_scanPauseRequested = false;
await _releaseNativeScanPause();
@@ -468,6 +533,8 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
if (prefs.getString(localLibraryActiveScanSourceKey) ==
activeSourceId) {
await prefs.remove(localLibraryActiveScanSourceKey);
await prefs.remove(localLibraryActiveScanStartedAtKey);
await prefs.remove(localLibraryRecoveredScanKey);
}
} catch (e) {
_log.w('Failed to clear active library scan marker: $e');
@@ -660,8 +727,10 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
Future<void> _showScanPausedNotification(int scannedFiles) async {
try {
await _notificationService.showLibraryScanPaused(
scannedFiles: scannedFiles,
await _queueScanNotification(
() => _notificationService.showLibraryScanPaused(
scannedFiles: scannedFiles,
),
);
} catch (e) {
_log.w('Failed to show scan paused notification: $e');
@@ -670,7 +739,9 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
Future<void> _cancelScanPausedNotification() async {
try {
await _notificationService.cancelLibraryScanNotification();
await _queueScanNotification(
_notificationService.cancelLibraryScanNotification,
);
} catch (e) {
_log.w('Failed to clear scan paused notification: $e');
}
@@ -683,12 +754,15 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
required String? currentFile,
}) async {
try {
await _notificationService.showLibraryScanProgress(
progress: progress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: _shortenFileForNotification(currentFile),
);
await _queueScanNotification(() async {
if (!ref.mounted || !state.isScanning || state.scanIsPaused) return;
await _notificationService.showLibraryScanProgress(
progress: progress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: _shortenFileForNotification(currentFile),
);
});
} catch (e) {
_log.w('Failed to show scan progress notification: $e');
}
@@ -700,10 +774,12 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
required int errorCount,
}) async {
try {
await _notificationService.showLibraryScanComplete(
totalTracks: totalTracks,
excludedDownloadedCount: excludedDownloadedCount,
errorCount: errorCount,
await _queueScanNotification(
() => _notificationService.showLibraryScanComplete(
totalTracks: totalTracks,
excludedDownloadedCount: excludedDownloadedCount,
errorCount: errorCount,
),
);
} catch (e) {
_log.w('Failed to show scan complete notification: $e');
@@ -712,7 +788,9 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
Future<void> _showScanFailedNotification(String message) async {
try {
await _notificationService.showLibraryScanFailed(message);
await _queueScanNotification(
() => _notificationService.showLibraryScanFailed(message),
);
} catch (e) {
_log.w('Failed to show scan failure notification: $e');
}
@@ -720,12 +798,21 @@ extension _LocalLibraryScan on LocalLibraryNotifier {
Future<void> _showScanCancelledNotification() async {
try {
await _notificationService.showLibraryScanCancelled();
await _queueScanNotification(
_notificationService.showLibraryScanCancelled,
);
} catch (e) {
_log.w('Failed to show scan cancelled notification: $e');
}
}
Future<void> _queueScanNotification(Future<void> Function() notify) {
final next = _scanNotificationTail.then((_) => notify());
// A failed notification must not prevent the terminal notice or cleanup.
_scanNotificationTail = next.catchError((Object _) {});
return next;
}
String? _shortenFileForNotification(String? path) {
final raw = path?.trim() ?? '';
if (raw.isEmpty) return null;
@@ -142,7 +142,12 @@ class _LibrarySourceSettingsItem extends StatelessWidget {
child: const AppIcon(Icons.sd_storage_outlined, size: 16),
)
: null,
subtitle: pathLine == null ? details : '$pathLine\n$details',
subtitle: [
if (pathLine != null) pathLine,
details,
if (!isScanning && source.lastScanError?.trim().isNotEmpty == true)
context.friendlyError(source.lastScanError!),
].join('\n'),
onTap: () => _showActions(context),
trailing: Row(
mainAxisSize: MainAxisSize.min,
+4
View File
@@ -32,6 +32,10 @@ class NotificationService {
String get embeddingMetadataLabel =>
_l10n?.notifEmbeddingMetadata ?? 'Embedding metadata...';
String get libraryScanInterruptedMessage =>
_l10n?.notifLibraryScanInterruptedBody ??
'The previous library scan was interrupted. Scan the folder again to continue. Your indexed tracks are still available.';
static const String libraryScanWorkKind = 'library_scan';
static const String updateDownloadWorkKind = 'app_update';
+10
View File
@@ -2238,6 +2238,16 @@ class PlatformBridge {
// ---- Library scan ----
static Future<Map<String, dynamic>?> getLibraryScanExitInfo({
required int startedAfter,
}) async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return null;
final result = await _channel.invokeMethod('getLibraryScanExitInfo', {
'started_after': startedAfter,
});
return _decodeNullableMapResult(result, 'getLibraryScanExitInfo');
}
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
await _channel.invokeMethod('setLibraryCoverCacheDir', {
'cache_dir': cacheDir,
+3
View File
@@ -2,6 +2,9 @@ import 'package:shared_preferences/shared_preferences.dart';
const localLibraryLastScannedAtKey = 'local_library_last_scanned_at';
const localLibraryActiveScanSourceKey = 'local_library_active_scan_source';
const localLibraryActiveScanStartedAtKey =
'local_library_active_scan_started_at';
const localLibraryRecoveredScanKey = 'local_library_recovered_scan';
DateTime? readLocalLibraryLastScannedAt(SharedPreferences prefs) {
final lastScannedAtStr = prefs.getString(localLibraryLastScannedAtKey);
+219 -6
View File
@@ -2,7 +2,9 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -11,6 +13,8 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
class _Settings extends SettingsNotifier {
@override
@@ -26,6 +30,7 @@ class _Database implements LibraryDatabase {
List<LocalLibrarySource> sources;
Future<List<LocalLibrarySource>> Function()? sourceRead;
bool failLookup = false;
bool failScanErrorWrite = false;
int? countOverride;
Future<({int inserted, int skipped})> Function()? fullReplacement;
@@ -79,10 +84,18 @@ class _Database implements LibraryDatabase {
String? lastScanError,
bool clearLastScanError = false,
}) async {
if (lastScanError != null && failScanErrorWrite) {
throw StateError('Could not save scan failure');
}
sources = [
for (final source in sources)
source.id == sourceId
? source.copyWith(enabled: enabled, available: available)
? source.copyWith(
enabled: enabled,
available: available,
lastScanError: lastScanError,
clearLastScanError: clearLastScanError,
)
: source,
];
}
@@ -100,6 +113,8 @@ Future<void> _waitFor(bool Function() condition) async {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
AndroidFlutterLocalNotificationsPlugin.registerWith();
SharedPreferences.setMockInitialValues({});
const backend = MethodChannel('com.zarz.spotiflac/backend');
const paths = MethodChannel('plugins.flutter.io/path_provider');
const notifications = MethodChannel(
@@ -115,10 +130,12 @@ void main() {
late LocalLibraryAvailabilityProbe probe;
late List<(String, Completer<String>)> scans;
late List<String> calls;
late List<MethodCall> notificationCalls;
var completeOnCancel = false;
setUp(() async {
SharedPreferences.setMockInitialValues({});
debugDefaultTargetPlatformOverride = TargetPlatform.android;
await (await SharedPreferences.getInstance()).clear();
directory = await Directory.systemTemp.createTemp('library-scheduling-');
database = _Database([
for (final id in ['active', 'reconnect'])
@@ -136,12 +153,13 @@ void main() {
probe = (path, {bookmark}) async => available[path];
scans = [];
calls = [];
notificationCalls = [];
completeOnCancel = false;
messenger.setMockMethodCallHandler(paths, (_) async => directory.path);
messenger.setMockMethodCallHandler(
notifications,
(call) async => call.method == 'initialize' ? true : null,
);
messenger.setMockMethodCallHandler(notifications, (call) async {
notificationCalls.add(call);
return call.method == 'initialize' ? true : null;
});
messenger.setMockMethodCallHandler(backend, (call) async {
calls.add(call.method);
if (call.method == 'scanLibraryFolderIncremental') {
@@ -182,6 +200,7 @@ void main() {
messenger.setMockMethodCallHandler(paths, null);
messenger.setMockMethodCallHandler(notifications, null);
await directory.delete(recursive: true);
debugDefaultTargetPlatformOverride = null;
});
Future<void> reconnect() async {
@@ -189,6 +208,200 @@ void main() {
await library.refreshSourceAvailability(scanReconnected: true);
}
Future<void> restartLibrary() async {
container.dispose();
container = ProviderContainer(
overrides: [
settingsProvider.overrideWith(_Settings.new),
localLibraryProvider.overrideWith(
() => LocalLibraryNotifier(
database: database,
availabilityProbe: probe,
),
),
],
);
library = container.read(localLibraryProvider.notifier);
await _waitFor(() => !container.read(localLibraryProvider).isLoading);
}
test(
'Android crash details are recovered and retained without manual logs',
() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(localLibraryActiveScanStartedAtKey, 100);
await prefs.setString(localLibraryActiveScanSourceKey, 'active');
var exitReads = 0;
messenger.setMockMethodCallHandler(backend, (call) async {
if (call.method == 'getLibraryScanExitInfo') {
exitReads++;
expect((call.arguments as Map)['started_after'], 100);
return {
'reason': 'Native crash',
'description': 'SIGSEGV in example codec',
'timestamp': 200,
'status': 11,
};
}
return null;
});
await restartLibrary();
final state = container.read(localLibraryProvider);
expect(state.sources.first.lastScanError, contains('Native crash'));
expect(
state.sources.first.lastScanError,
contains('SIGSEGV in example codec'),
);
expect(state.totalCount, 1);
expect(exitReads, 1);
final shows = notificationCalls
.where((call) => call.method == 'show')
.length;
await restartLibrary();
expect(exitReads, 1);
expect(
notificationCalls.where((call) => call.method == 'show').length,
shows,
);
expect(
container.read(localLibraryProvider).sources.first.lastScanError,
contains('Native crash'),
);
},
);
for (final failErrorWrite in [false, true]) {
test(
'restart reports an interrupted scan with auto-scan off (save failure: $failErrorWrite)',
() async {
final previousCount = container.read(localLibraryProvider).totalCount;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(localLibraryActiveScanSourceKey, 'active');
database.failScanErrorWrite = failErrorWrite;
container.dispose();
container = ProviderContainer(
overrides: [
settingsProvider.overrideWith(_Settings.new),
localLibraryProvider.overrideWith(
() => LocalLibraryNotifier(
database: database,
availabilityProbe: probe,
),
),
],
);
library = container.read(localLibraryProvider.notifier);
await _waitFor(() => !container.read(localLibraryProvider).isLoading);
final state = container.read(localLibraryProvider);
expect(state.isScanning, isFalse);
expect(state.loadFailed, isFalse);
expect(state.totalCount, previousCount);
expect(scans, isEmpty);
expect(prefs.getString(localLibraryActiveScanSourceKey), 'active');
if (!failErrorWrite) {
expect(state.sources.first.lastScanError, contains('interrupted'));
}
final shows = notificationCalls.where((call) => call.method == 'show');
final failure = shows.last.arguments as Map;
expect(failure['title'], 'Library scan failed');
expect(failure['body'], contains('interrupted'));
expect(
notificationCalls.any((call) => call.method == 'cancel'),
isTrue,
);
final notificationCount = shows.length;
await library.reloadFromStorage();
expect(
notificationCalls.where((call) => call.method == 'show').length,
notificationCount,
);
},
);
}
test(
'a scan failure remains visible when saving the error also fails',
() async {
final previousCount = container.read(localLibraryProvider).totalCount;
database.failScanErrorWrite = true;
final active = library.startSourceScan('active');
await _waitFor(() => scans.length == 1);
scans.single.$2.completeError(
PlatformException(code: 'scan_failed', message: 'Invalid audio header'),
);
await active;
final state = container.read(localLibraryProvider);
expect(state.isScanning, isFalse);
expect(state.totalCount, previousCount);
expect(
state.sources.first.lastScanError,
contains('Invalid audio header'),
);
final failure =
notificationCalls.lastWhere((call) => call.method == 'show').arguments
as Map;
expect(failure['title'], 'Library scan failed');
expect(failure['body'], contains('Invalid audio header'));
expect(
(await SharedPreferences.getInstance()).getString(
localLibraryActiveScanSourceKey,
),
isNull,
);
},
);
test(
'a delayed progress notification cannot overwrite a scan failure',
() async {
final entered = Completer<void>();
final release = Completer<void>();
messenger.setMockMethodCallHandler(notifications, (call) async {
if (call.method == 'show' &&
(call.arguments as Map)['body'].toString().contains('track.flac')) {
if (!entered.isCompleted) entered.complete();
await release.future;
}
notificationCalls.add(call);
return call.method == 'initialize' ? true : null;
});
messenger.setMockMethodCallHandler(backend, (call) async {
if (call.method == 'scanLibraryFolderIncremental') {
final response = Completer<String>();
scans.add((
(call.arguments as Map)['folder_path'] as String,
response,
));
return response.future;
}
if (call.method == 'getLibraryScanProgress') {
return {
'scanned_files': 785,
'total_files': 1877,
'progress_pct': 42,
'current_file': 'track.flac',
};
}
return null;
});
final active = library.startSourceScan('active');
await entered.future.timeout(const Duration(seconds: 5));
scans.single.$2.completeError(StateError('Scan stopped'));
await _waitFor(() => !container.read(localLibraryProvider).isScanning);
release.complete();
await active;
final shows = notificationCalls
.where((call) => call.method == 'show')
.toList();
expect((shows.last.arguments as Map)['title'], 'Library scan failed');
expect((shows.last.arguments as Map)['body'], contains('Scan stopped'));
expect(
(shows.last.arguments as Map)['id'],
NotificationService.libraryScanId,
);
},
);
test('concurrent starts own one preflight and one native scan', () async {
final pending = Completer<bool?>();
var probes = 0;
+32
View File
@@ -112,6 +112,38 @@ Future<void> _mount(
void main() {
for (final mornye in [false, true]) {
testWidgets('source shows the scan failure reason (Mornye: $mornye)', (
tester,
) async {
const error =
'The previous library scan was interrupted. Scan again to continue.';
final library = _Library(
LocalLibraryState(
totalCount: 1877,
sources: const [
LocalLibrarySource(
id: 'music',
path: '/music',
displayName: 'Saved music',
trackCount: 1877,
lastScanError: error,
),
],
),
() async => 0,
);
await _mount(
tester,
mornye: mornye,
library: library,
history: _History(0, () async => 0),
);
await tester.scrollUntilVisible(find.text('Saved music'), 200);
await tester.pumpAndSettle();
expect(find.textContaining(error), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('failed load shows saved folder and offers retry ($mornye)', (
tester,
) async {