mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 15:28:45 +02:00
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:
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"))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user