mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
fix(library): distinguish unavailable storage from missing files
This commit is contained in:
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"))
|
||||
}
|
||||
}
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user