fix(library): distinguish unavailable storage from missing files

This commit is contained in:
zarzet committed 2026-10-06 13:08:12 +07:00
1 parent 450dc213a9
commit d98ab4852d
14 files changed
+438 -73

No files matched your search

@@ -1316,21 +1316,7 @@ class MainActivity: FlutterFragmentActivity() {
"safStat" -> {
val uriStr = call.argument<String>("uri") ?: ""
val response = withContext(Dispatchers.IO) {
val uri = Uri.parse(uriStr)
val doc = DocumentFile.fromSingleUri(this@MainActivity, uri)
val obj = JSONObject()
if (doc != null && doc.exists()) {
obj.put("exists", true)
obj.put("size", doc.length())
obj.put("modified", doc.lastModified())
obj.put("mime_type", doc.type ?: contentResolver.getType(uri) ?: "")
} else {
obj.put("exists", false)
obj.put("size", 0)
obj.put("modified", 0)
obj.put("mime_type", "")
}
obj.toString()
querySafDocumentStat(contentResolver, Uri.parse(uriStr)).toString()
}
result.success(response)
}
@@ -0,0 +1,34 @@
package com.zarz.spotiflac
import android.content.ContentResolver
import android.net.Uri
import android.provider.DocumentsContract
import org.json.JSONObject
internal fun querySafDocumentStat(resolver: ContentResolver, uri: Uri): JSONObject {
val projection = arrayOf(
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
DocumentsContract.Document.COLUMN_MIME_TYPE,
)
// DocumentFile.exists/length swallow provider and security exceptions.
// Query once, keep nullable metadata, and let failures reach the channel.
val cursor = resolver.query(uri, projection, null, null, null)
?: return safDocumentStatResult(hasRow = null)
return cursor.use {
val loading = it.extras.getBoolean(DocumentsContract.EXTRA_LOADING, false)
val hasRow = it.moveToFirst()
if (!hasRow || loading) return@use safDocumentStatResult(hasRow, loading)
fun nullableLong(column: String): Long? {
val index = it.getColumnIndex(column)
return if (index < 0 || it.isNull(index)) null else it.getLong(index)
}
val mimeIndex = it.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
safDocumentStatResult(
hasRow = true,
size = nullableLong(DocumentsContract.Document.COLUMN_SIZE),
modified = nullableLong(DocumentsContract.Document.COLUMN_LAST_MODIFIED),
mimeType = if (mimeIndex < 0 || it.isNull(mimeIndex)) null else it.getString(mimeIndex),
)
}
}
@@ -0,0 +1,20 @@
package com.zarz.spotiflac
import java.io.IOException
import org.json.JSONObject
/** Only a completed, empty document query proves absence. */
internal fun safDocumentStatResult(
hasRow: Boolean?,
loading: Boolean = false,
size: Long? = null,
modified: Long? = null,
mimeType: String? = null,
): JSONObject {
if (hasRow == null || loading) throw IOException("SAF document metadata is unavailable")
return JSONObject()
.put("exists", hasRow)
.put("size", if (hasRow) size ?: JSONObject.NULL else JSONObject.NULL)
.put("modified", if (hasRow) modified ?: JSONObject.NULL else JSONObject.NULL)
.put("mime_type", if (hasRow) mimeType ?: JSONObject.NULL else JSONObject.NULL)
}
@@ -0,0 +1,41 @@
package com.zarz.spotiflac
import java.io.IOException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class SafDocumentStatPolicyTest {
@Test fun nullCursorAndLoadingResultsNeverBecomeMissing() {
assertThrows(IOException::class.java) { safDocumentStatResult(hasRow = null) }
for (hasRow in listOf(false, true)) {
assertThrows(IOException::class.java) { safDocumentStatResult(hasRow, loading = true) }
}
}
@Test fun onlyCompletedEmptyQueryIsMissing() {
val result = safDocumentStatResult(hasRow = false)
assertFalse(result.getBoolean("exists"))
assertTrue(result.isNull("size"))
assertTrue(result.isNull("modified"))
assertTrue(result.isNull("mime_type"))
}
@Test fun presentDocumentRetainsUnknownMetadata() {
val result = safDocumentStatResult(hasRow = true)
assertTrue(result.getBoolean("exists"))
assertTrue(result.isNull("size"))
assertTrue(result.isNull("modified"))
assertTrue(result.isNull("mime_type"))
}
@Test fun knownMetadataIncludesZeroLengthDocuments() {
val result = safDocumentStatResult(true, size = 0L, modified = 123L, mimeType = "audio/flac")
assertTrue(result.getBoolean("exists"))
assertEquals(0L, result.getLong("size"))
assertEquals(123L, result.getLong("modified"))
assertEquals("audio/flac", result.getString("mime_type"))
}
}
+4
View File
@@ -1737,6 +1737,10 @@
"@trackFileNotFound": {
"description": "Error - file doesn't exist"
},
"trackFileUnavailable": "File is currently unavailable. Check storage access and try again.",
"@trackFileUnavailable": {
"description": "File access failed; this does not mean the file was deleted"
},
"trackOpenInDeezer": "Open in Deezer",
"@trackOpenInDeezer": {
"description": "Action - open track in Deezer app"
+1
View File
@@ -1,4 +1,5 @@
{
"trackFileUnavailable": "File saat ini tidak dapat diakses. Periksa akses penyimpanan dan coba lagi.",
"networkDownloadDestination": "Tujuan unduhan jaringan",
"networkUseForDownloads": "Unduh ke folder ini",
"networkDownloadSelected": "Tujuan unduhan terpilih",
+10 -10
View File
@@ -72,6 +72,15 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_fileAccessMessage(context) case final message?
when !context.isMornye)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
message,
style: TextStyle(color: colorScheme.error),
),
),
_buildMetadataCard(context, colorScheme, _fileSize),
const SizedBox(height: 16),
@@ -286,16 +295,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
const SizedBox(height: 12),
HeaderPalette(
scheme: _trackMetadataHeroScheme,
child: HeaderMetaRow(
items: [
..._headerMetadataItems(context),
if (_hasCheckedFile && !_fileExists)
HeaderMetaItem(
context.l10n.trackFileNotFound,
icon: Icons.warning_rounded,
),
],
),
child: HeaderMetaRow(items: _headerMetadataItems(context)),
),
],
),
+2 -2
View File
@@ -117,11 +117,11 @@ extension _TrackMetadataMornye on _TrackMetadataScreenState {
HeaderMetaRow(items: _headerMetadataItems(context)),
const SizedBox(height: 16),
_buildActionButtons(context, ref, scheme, _fileExists),
if (_hasCheckedFile && !_fileExists)
if (_fileAccessMessage(context) case final message?)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
context.l10n.trackFileNotFound,
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.error,
),
+29 -45
View File
@@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/conversion_library_service.dart';
import 'package:spotiflac_android/services/deleted_library_files.dart';
import 'package:spotiflac_android/services/file_access_check.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/re_enrich_result.dart';
@@ -152,10 +153,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
static final Map<String, _EmbeddedCoverPreviewCacheEntry>
_embeddedCoverPreviewCache = {};
bool _fileExists = false;
FileAccessCheck? _fileAccess;
bool get _fileExists => _fileAccess is FileAccessFound;
bool get _isNetworkItem => cleanFilePath.startsWith('network://');
bool _hasCheckedFile = false;
int? _fileSize;
int? get _fileSize => switch (_fileAccess) {
FileAccessFound(:final stat) => stat.size,
_ => null,
};
String? _fileAccessMessage(BuildContext context) => switch (_fileAccess) {
FileAccessMissing() => context.l10n.trackFileNotFound,
FileAccessUnavailable() => context.l10n.trackFileUnavailable,
_ => null,
};
String? _lyrics;
String? _rawLyrics;
bool _lyricsLoading = false;
@@ -212,49 +222,25 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
final generation = _metadataLoadGeneration;
final filePath = cleanFilePath;
bool exists = false;
int? size;
try {
final stat = await fileStat(filePath);
if (stat != null) {
exists = true;
size = stat.size;
}
} catch (_) {}
if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
(exists != _fileExists || size != _fileSize || !_hasCheckedFile)) {
setState(() {
_fileExists = exists;
_fileSize = size;
_hasCheckedFile = true;
});
final check = await checkFileAccess(filePath);
if (!mounted ||
generation != _metadataLoadGeneration ||
filePath != cleanFilePath) {
return;
}
if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
exists &&
_lyrics == null &&
!_lyricsLoading) {
_checkEmbeddedLyrics();
if (check case FileAccessUnavailable(:final error, :final stack)) {
_log.e('Unable to inspect track file: $filePath', error, stack);
}
if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
exists &&
!_isCueVirtualTrack &&
!_hasLoadedResolvedAudioMetadata) {
setState(() => _fileAccess = check);
if (!_fileExists) return;
if (_lyrics == null && !_lyricsLoading) {
unawaited(_checkEmbeddedLyrics());
}
if (!_isCueVirtualTrack && !_hasLoadedResolvedAudioMetadata) {
unawaited(_refreshResolvedAudioMetadataFromFile());
}
if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
exists &&
!_isNetworkItem &&
!_hasPath(_embeddedCoverPreviewPath)) {
if (!_isNetworkItem && !_hasPath(_embeddedCoverPreviewPath)) {
// The information card reports artwork embedded in the audio file, not
// a resized Library thumbnail or remote cover. The shared resolver owns
// extraction; this screen only caches validation data and dimensions.
@@ -654,9 +640,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
_currentDownloadItem = null;
_currentLocalLibraryItem = widget.localNavigationItems![targetIndex];
}
_fileExists = false;
_hasCheckedFile = false;
_fileSize = null;
_fileAccess = null;
_lyrics = null;
_rawLyrics = null;
_lyricsLoading = false;
+36
View File
@@ -0,0 +1,36 @@
import 'package:spotiflac_android/utils/file_access.dart';
sealed class FileAccessCheck {
const FileAccessCheck();
}
final class FileAccessFound extends FileAccessCheck {
const FileAccessFound(this.stat);
final FileAccessStat stat;
}
final class FileAccessMissing extends FileAccessCheck {
const FileAccessMissing();
}
final class FileAccessUnavailable extends FileAccessCheck {
const FileAccessUnavailable(this.error, this.stack);
final Object error;
final StackTrace stack;
}
/// Only a confirmed missing result may be presented as a deleted file.
/// Provider and permission failures retain their diagnostic cause.
Future<FileAccessCheck> checkFileAccess(
String path, {
Future<FileAccessStat?> Function(String?) readStat = fileStat,
}) async {
try {
final stat = await readStat(path);
return stat == null ? const FileAccessMissing() : FileAccessFound(stat);
} catch (error, stack) {
return FileAccessUnavailable(error, stack);
}
}
+11 -1
View File
@@ -404,7 +404,17 @@ Future<FileAccessStat?> fileStat(String? path) async {
}
final stat = await FileStat.stat(realPath);
if (stat.type == FileSystemEntityType.notFound) return null;
if (stat.type == FileSystemEntityType.notFound) {
// Dart stat hides every OS error behind notFound. A metadata-only lookup
// exposes the errno without opening or copying audio on the normal path.
try {
return FileAccessStat(size: await File(realPath).length());
} on FileSystemException catch (error) {
final code = error.osError?.errorCode;
if (code == 2 || code == (Platform.isWindows ? 3 : 20)) return null;
rethrow;
}
}
return FileAccessStat(size: stat.size, modified: stat.modified);
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/file_access_check.dart';
import 'package:spotiflac_android/utils/file_access.dart';
void main() {
test('found files retain their size and modification time', () async {
final stat = FileAccessStat(size: 123, modified: DateTime(2026));
final result = await checkFileAccess(
'/music/track.flac',
readStat: (_) async => stat,
);
expect(result, isA<FileAccessFound>());
expect((result as FileAccessFound).stat, same(stat));
});
test('only a confirmed absent file is missing', () async {
final result = await checkFileAccess(
'/music/removed.flac',
readStat: (_) async => null,
);
expect(result, isA<FileAccessMissing>());
});
for (final code in ['permission_denied', 'provider_unavailable']) {
test('$code is unavailable and retains its diagnostic', () async {
final error = PlatformException(code: code);
final result = await checkFileAccess(
'content://provider/music/track.flac',
readStat: (_) async => throw error,
);
expect(result, isA<FileAccessUnavailable>());
expect((result as FileAccessUnavailable).error, same(error));
expect(result.stack.toString(), isNotEmpty);
});
}
}
+146
View File
@@ -0,0 +1,146 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/file_access_check.dart';
import 'package:spotiflac_android/utils/file_access.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() => messenger.setMockMethodCallHandler(channel, null));
group('local stat OS boundary', () {
late Directory temporary;
setUp(() async {
temporary = await Directory.systemTemp.createTemp('file_stat_boundary_');
});
tearDown(() async {
await temporary.delete(recursive: true);
});
test('found and CUE backing files retain actual stat metadata', () async {
final file = await File(
'${temporary.path}/album.cue',
).writeAsString('CUE fixture');
final expected = await file.stat();
final result = await fileStat('${file.path}#track01');
expect(result?.size, expected.size);
expect(result?.modified, expected.modified);
});
test('ENOENT and ENOTDIR are confirmed missing', () async {
final file = await File(
'${temporary.path}/track.flac',
).writeAsString('audio');
for (final path in [
'${temporary.path}/removed.flac',
'${file.path}/child.flac',
]) {
expect(await checkFileAccess(path), isA<FileAccessMissing>());
}
});
test('symlink loop is unavailable instead of deleted', () async {
final path = '${temporary.path}/loop.flac';
await Link(path).create(path);
expect((await FileStat.stat(path)).type, FileSystemEntityType.notFound);
final result = await checkFileAccess(path);
expect(result, isA<FileAccessUnavailable>());
expect(
(result as FileAccessUnavailable).error,
isA<FileSystemException>(),
);
}, skip: Platform.isWindows);
test('permission denied is unavailable instead of deleted', () async {
final directory = await Directory(
'${temporary.path}/restricted',
).create();
final file = await File(
'${directory.path}/track.flac',
).writeAsString('audio');
final chmod = await Process.run('chmod', ['000', directory.path]);
expect(chmod.exitCode, 0);
try {
expect(
(await FileStat.stat(file.path)).type,
FileSystemEntityType.notFound,
reason: 'The OS fixture must actually deny directory traversal',
);
final result = await checkFileAccess(file.path);
expect(result, isA<FileAccessUnavailable>());
final error = (result as FileAccessUnavailable).error;
expect(error, isA<FileSystemException>());
expect((error as FileSystemException).osError?.errorCode, 13);
} finally {
await Process.run('chmod', ['700', directory.path]);
}
}, skip: Platform.isWindows);
});
test(
'network identity and empty paths avoid filesystem/provider calls',
() async {
messenger.setMockMethodCallHandler(channel, (_) async {
fail('Network identities must not query SAF');
});
expect(await fileStat(null), isNull);
expect(await fileStat(''), isNull);
final result = await fileStat('network://provider/track');
expect(result, isA<FileAccessStat>());
expect(result?.size, isNull);
expect(result?.modified, isNull);
},
);
test('SAF CUE stat queries once and preserves nullable metadata', () async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return '{"exists":true,"size":null,"modified":null,"mime_type":null}';
});
const uri = 'content://example.documents/tree/root/document/album%2Fcue';
final result = await checkFileAccess('$uri#track02');
expect(result, isA<FileAccessFound>());
expect((result as FileAccessFound).stat.size, isNull);
expect(result.stat.modified, isNull);
expect(calls.map((call) => call.method), ['safStat']);
expect(calls.single.arguments, {'uri': uri});
});
test('SAF confirmed absence is missing', () async {
messenger.setMockMethodCallHandler(channel, (_) async {
return '{"exists":false,"size":null,"modified":null}';
});
expect(
await checkFileAccess('content://example.documents/document/removed'),
isA<FileAccessMissing>(),
);
});
for (final code in ['permission_denied', 'provider_unavailable']) {
test(
'SAF $code reaches the unavailable state through the bridge',
() async {
final calls = <String>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
throw PlatformException(code: code, message: 'Native query failed');
});
final result = await checkFileAccess(
'content://example.documents/document/track.flac',
);
expect(result, isA<FileAccessUnavailable>());
final error = (result as FileAccessUnavailable).error;
expect(error, isA<PlatformException>());
expect((error as PlatformException).code, code);
expect(calls, ['safStat']);
},
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/providers/download_history_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
void main() {
const channel = MethodChannel('com.zarz.spotiflac/backend');
for (final mornye in [false, true]) {
for (final unavailable in [false, true]) {
testWidgets('file status distinguishes provider errors from absence '
'(Mornye=$mornye, unavailable=$unavailable)', (tester) async {
await tester.binding.setSurfaceSize(const Size(320, 780));
addTearDown(() => tester.binding.setSurfaceSize(null));
final messenger = tester.binding.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'safStat') {
if (unavailable) {
throw PlatformException(code: 'provider_unavailable');
}
return {'exists': false};
}
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final item = DownloadHistoryItem(
id: 'provider-track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: 'content://provider/music/track.flac',
service: 'provider-a',
downloadedAt: 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(item: item),
),
),
);
await tester.pumpAndSettle();
expect(
find.text('File not found'),
unavailable ? findsNothing : findsOneWidget,
);
expect(
find.text(
'File is currently unavailable. Check storage access and try again.',
),
unavailable ? findsOneWidget : findsNothing,
);
expect(tester.takeException(), isNull);
});
}
}
}