diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index dfba4e3a..c9b5ed12 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1316,21 +1316,7 @@ class MainActivity: FlutterFragmentActivity() { "safStat" -> { val uriStr = call.argument("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) } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDocumentStat.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDocumentStat.kt new file mode 100644 index 00000000..7d371a7b --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDocumentStat.kt @@ -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), + ) + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDocumentStatPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDocumentStatPolicy.kt new file mode 100644 index 00000000..639a1cf9 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDocumentStatPolicy.kt @@ -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) +} diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/SafDocumentStatPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/SafDocumentStatPolicyTest.kt new file mode 100644 index 00000000..bb5f2b96 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/SafDocumentStatPolicyTest.kt @@ -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")) + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 85db7d24..3fe9f028 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index a1eaad49..9527d9bc 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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", diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index f74a9a71..e879ba09 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -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)), ), ], ), diff --git a/lib/screens/track_metadata_mornye.dart b/lib/screens/track_metadata_mornye.dart index 7379bd4e..1cebced0 100644 --- a/lib/screens/track_metadata_mornye.dart +++ b/lib/screens/track_metadata_mornye.dart @@ -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, ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 2e2f50d3..5a7fafec 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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 static final Map _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 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 _currentDownloadItem = null; _currentLocalLibraryItem = widget.localNavigationItems![targetIndex]; } - _fileExists = false; - _hasCheckedFile = false; - _fileSize = null; + _fileAccess = null; _lyrics = null; _rawLyrics = null; _lyricsLoading = false; diff --git a/lib/services/file_access_check.dart b/lib/services/file_access_check.dart new file mode 100644 index 00000000..24de7c78 --- /dev/null +++ b/lib/services/file_access_check.dart @@ -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 checkFileAccess( + String path, { + Future 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); + } +} diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index ca496be7..16bfb4ba 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -404,7 +404,17 @@ Future 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); } diff --git a/test/file_access_check_test.dart b/test/file_access_check_test.dart new file mode 100644 index 00000000..1a08cd78 --- /dev/null +++ b/test/file_access_check_test.dart @@ -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()); + 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()); + }); + + 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()); + expect((result as FileAccessUnavailable).error, same(error)); + expect(result.stack.toString(), isNotEmpty); + }); + } +} diff --git a/test/file_stat_boundary_test.dart b/test/file_stat_boundary_test.dart new file mode 100644 index 00000000..93f9505d --- /dev/null +++ b/test/file_stat_boundary_test.dart @@ -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()); + } + }); + + 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()); + expect( + (result as FileAccessUnavailable).error, + isA(), + ); + }, 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()); + final error = (result as FileAccessUnavailable).error; + expect(error, isA()); + 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()); + expect(result?.size, isNull); + expect(result?.modified, isNull); + }, + ); + + test('SAF CUE stat queries once and preserves nullable metadata', () async { + final calls = []; + 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()); + 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(), + ); + }); + + for (final code in ['permission_denied', 'provider_unavailable']) { + test( + 'SAF $code reaches the unavailable state through the bridge', + () async { + final calls = []; + 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()); + final error = (result as FileAccessUnavailable).error; + expect(error, isA()); + expect((error as PlatformException).code, code); + expect(calls, ['safStat']); + }, + ); + } +} diff --git a/test/track_metadata_file_access_test.dart b/test/track_metadata_file_access_test.dart new file mode 100644 index 00000000..d961e5ba --- /dev/null +++ b/test/track_metadata_file_access_test.dart @@ -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); + }); + } + } +}