feat: add Storage Access Framework (SAF) support for Android 10+

- Add SAF tree picker and persistent URI storage in settings
- Implement SAF file operations: exists, delete, stat, copy, create
- Update download pipeline to support SAF content URIs
- Add fallback to app-private storage when SAF write fails
- Support SAF in library scan with DocumentFile traversal
- Add history item repair for missing SAF URIs
- Create file_access.dart utilities for abstracted file operations
- Update Tidal/Qobuz/Amazon/Extensions for SAF-aware output
- Add runPostProcessingV2 API for SAF content URIs
- Update screens (album, artist, queue, track) for SAF awareness

Resolves Android 10+ scoped storage permission issues
This commit is contained in:
zarzet committed 2026-02-06 07:09:57 +07:00
1 parent 7ade57e010
commit 278ebf3472
30 files changed
+2489 -389

No files matched your search

+25 -2
View File
@@ -34,7 +34,7 @@ class HistoryDatabase {
return await openDatabase(
path,
version: 1,
version: 3,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -52,6 +52,11 @@ class HistoryDatabase {
album_artist TEXT,
cover_url TEXT,
file_path TEXT NOT NULL,
storage_mode TEXT,
download_tree_uri TEXT,
saf_relative_dir TEXT,
saf_file_name TEXT,
saf_repaired INTEGER,
service TEXT NOT NULL,
downloaded_at TEXT NOT NULL,
isrc TEXT,
@@ -80,7 +85,15 @@ class HistoryDatabase {
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion');
// Future migrations go here
if (oldVersion < 2) {
await db.execute('ALTER TABLE history ADD COLUMN storage_mode TEXT');
await db.execute('ALTER TABLE history ADD COLUMN download_tree_uri TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_relative_dir TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_file_name TEXT');
}
if (oldVersion < 3) {
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
}
}
// ==================== iOS Path Normalization ====================
@@ -244,6 +257,11 @@ class HistoryDatabase {
'album_artist': json['albumArtist'],
'cover_url': json['coverUrl'],
'file_path': json['filePath'],
'storage_mode': json['storageMode'],
'download_tree_uri': json['downloadTreeUri'],
'saf_relative_dir': json['safRelativeDir'],
'saf_file_name': json['safFileName'],
'saf_repaired': json['safRepaired'] == true ? 1 : 0,
'service': json['service'],
'downloaded_at': json['downloadedAt'],
'isrc': json['isrc'],
@@ -272,6 +290,11 @@ class HistoryDatabase {
'albumArtist': row['album_artist'],
'coverUrl': row['cover_url'],
'filePath': _normalizeIosPath(row['file_path'] as String?),
'storageMode': row['storage_mode'],
'downloadTreeUri': row['download_tree_uri'],
'safRelativeDir': row['saf_relative_dir'],
'safFileName': row['saf_file_name'],
'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true,
'service': row['service'],
'downloadedAt': row['downloaded_at'],
'isrc': row['isrc'],
+2 -2
View File
@@ -1,8 +1,8 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/file_access.dart';
final _log = AppLogger('LibraryDatabase');
@@ -341,7 +341,7 @@ class LibraryDatabase {
int removed = 0;
for (final row in rows) {
final filePath = row['file_path'] as String;
if (!await File(filePath).exists()) {
if (!await fileExists(filePath)) {
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
removed++;
}
+130
View File
@@ -67,6 +67,11 @@ class PlatformBridge {
String? releaseDate,
String? itemId,
int durationMs = 0,
String storageMode = 'app',
String safTreeUri = '',
String safRelativeDir = '',
String safFileName = '',
String safOutputExt = '',
}) async {
_log.i('downloadTrack: "$trackName" by $artistName via $service');
final request = jsonEncode({
@@ -89,6 +94,11 @@ class PlatformBridge {
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
'duration_ms': durationMs,
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
});
final result = await _channel.invokeMethod('downloadTrack', request);
@@ -125,6 +135,11 @@ class PlatformBridge {
String? label,
String? copyright,
String lyricsMode = 'embed',
String storageMode = 'app',
String safTreeUri = '',
String safRelativeDir = '',
String safFileName = '',
String safOutputExt = '',
}) async {
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
final request = jsonEncode({
@@ -151,6 +166,11 @@ class PlatformBridge {
'label': label ?? '',
'copyright': copyright ?? '',
'lyrics_mode': lyricsMode,
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
});
final result = await _channel.invokeMethod('downloadWithFallback', request);
@@ -225,6 +245,80 @@ class PlatformBridge {
return result as String;
}
static Future<Map<String, dynamic>?> pickSafTree() async {
final result = await _channel.invokeMethod('pickSafTree');
if (result == null) return null;
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<bool> safExists(String uri) async {
final result = await _channel.invokeMethod('safExists', {'uri': uri});
return result as bool;
}
static Future<bool> safDelete(String uri) async {
final result = await _channel.invokeMethod('safDelete', {'uri': uri});
return result as bool;
}
static Future<Map<String, dynamic>> safStat(String uri) async {
final result = await _channel.invokeMethod('safStat', {'uri': uri});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> resolveSafFile({
required String treeUri,
required String fileName,
String relativeDir = '',
}) async {
final result = await _channel.invokeMethod('resolveSafFile', {
'tree_uri': treeUri,
'relative_dir': relativeDir,
'file_name': fileName,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<String?> copyContentUriToTemp(String uri) async {
final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri});
return result as String?;
}
static Future<bool> replaceContentUriFromPath(
String uri,
String srcPath,
) async {
final result = await _channel.invokeMethod('safReplaceFromPath', {
'uri': uri,
'src_path': srcPath,
});
return result as bool;
}
static Future<String?> createSafFileFromPath({
required String treeUri,
required String relativeDir,
required String fileName,
required String mimeType,
required String srcPath,
}) async {
final result = await _channel.invokeMethod('safCreateFromPath', {
'tree_uri': treeUri,
'relative_dir': relativeDir,
'file_name': fileName,
'mime_type': mimeType,
'src_path': srcPath,
});
return result as String?;
}
static Future<void> openContentUri(String uri, {String mimeType = ''}) async {
await _channel.invokeMethod('openContentUri', {
'uri': uri,
'mime_type': mimeType,
});
}
static Future<Map<String, dynamic>> fetchLyrics(
String spotifyId,
String trackName,
@@ -593,6 +687,11 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
String? label,
String lyricsMode = 'embed',
String? preferredService,
String storageMode = 'app',
String safTreeUri = '',
String safRelativeDir = '',
String safFileName = '',
String safOutputExt = '',
}) async {
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}');
final request = jsonEncode({
@@ -619,6 +718,11 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
'label': label ?? '',
'lyrics_mode': lyricsMode,
'service': preferredService ?? '',
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
});
final result = await _channel.invokeMethod('downloadWithExtensions', request);
@@ -852,6 +956,15 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
_log.i('scanSafTree: $treeUri');
final result = await _channel.invokeMethod('scanSafTree', {
'tree_uri': treeUri,
});
final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Get current library scan progress
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
final result = await _channel.invokeMethod('getLibraryScanProgress');
@@ -889,6 +1002,23 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> runPostProcessingV2(
String filePath, {
Map<String, dynamic>? metadata,
}) async {
final input = <String, dynamic>{};
if (filePath.startsWith('content://')) {
input['uri'] = filePath;
} else {
input['path'] = filePath;
}
final result = await _channel.invokeMethod('runPostProcessingV2', {
'input': jsonEncode(input),
'metadata': metadata != null ? jsonEncode(metadata) : '',
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
final result = await _channel.invokeMethod('getPostProcessingProviders');
final list = jsonDecode(result as String) as List<dynamic>;