Files
plezy/lib/services/saf_storage_service.dart
T
2025-12-15 02:29:03 +01:00

203 lines
6.4 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:saf_util/saf_util.dart';
import 'package:saf_util/saf_util_platform_interface.dart';
import 'package:saf_stream/saf_stream.dart';
/// Handles Storage Access Framework (SAF) operations for Android
class SafStorageService {
static SafStorageService? _instance;
static SafStorageService get instance => _instance ??= SafStorageService._();
SafStorageService._();
final SafUtil _safUtil = SafUtil();
final SafStream _safStream = SafStream();
/// Check if SAF is available (Android only)
bool get isAvailable => Platform.isAndroid;
/// Pick a directory using SAF
/// Returns the content:// URI or null if cancelled
Future<String?> pickDirectory() async {
if (!isAvailable) return null;
try {
// Pick directory with persistent write permission
final doc = await _safUtil.pickDirectory(writePermission: true, persistablePermission: true);
return doc?.uri;
} catch (e) {
debugPrint('SAF pickDirectory error: $e');
return null;
}
}
/// Check if we have persisted access to a URI
Future<bool> hasPersistedPermission(String contentUri) async {
if (!isAvailable) return false;
try {
return await _safUtil.hasPersistedPermission(contentUri, checkRead: true, checkWrite: true);
} catch (e) {
debugPrint('SAF hasPersistedPermission error: $e');
return false;
}
}
/// Get document file info for a URI
Future<SafDocumentFile?> getDocumentFile(String contentUri, {bool isDir = true}) async {
if (!isAvailable) return null;
try {
return await _safUtil.documentFileFromUri(contentUri, isDir);
} catch (e) {
debugPrint('SAF getDocumentFile error: $e');
return null;
}
}
/// Create a subdirectory in a SAF directory
/// Returns the URI of the created directory
Future<String?> createDirectory(String parentUri, String name) async {
if (!isAvailable) return null;
try {
final result = await _safUtil.mkdirp(parentUri, [name]);
return result.uri;
} catch (e) {
debugPrint('SAF createDirectory error: $e');
return null;
}
}
/// List files in a SAF directory
Future<List<SafDocumentFile>> listDirectory(String contentUri) async {
if (!isAvailable) return [];
try {
return await _safUtil.list(contentUri);
} catch (e) {
debugPrint('SAF listDirectory error: $e');
return [];
}
}
/// Get a child file/directory in a SAF directory
Future<SafDocumentFile?> getChild(String parentUri, String name) async {
if (!isAvailable) return null;
try {
return await _safUtil.child(parentUri, [name]);
} catch (e) {
debugPrint('SAF getChild error: $e');
return null;
}
}
/// Delete a file or directory in SAF
Future<bool> delete(String contentUri, {bool isDir = false}) async {
if (!isAvailable) return false;
try {
await _safUtil.delete(contentUri, isDir);
return true;
} catch (e) {
debugPrint('SAF delete error: $e');
return false;
}
}
/// Get a display name for a SAF URI (for UI purposes)
Future<String?> getDisplayName(String contentUri) async {
if (!isAvailable) return null;
try {
final doc = await _safUtil.documentFileFromUri(contentUri, true);
return doc?.name;
} catch (e) {
debugPrint('SAF getDisplayName error: $e');
return null;
}
}
/// Create nested directories in a SAF directory
/// Returns the URI of the deepest directory
Future<String?> createNestedDirectories(String parentUri, List<String> pathComponents) async {
if (!isAvailable) return null;
try {
final result = await _safUtil.mkdirp(parentUri, pathComponents);
return result.uri;
} catch (e) {
debugPrint('SAF createNestedDirectories error: $e');
return null;
}
}
/// Copy a file from local storage to SAF directory using native copy
/// Returns the SAF URI of the copied file, or null on failure
Future<String?> copyFileToSaf(
String sourceFilePath,
String targetDirectoryUri,
String fileName,
String mimeType,
) async {
if (!isAvailable) return null;
try {
final sourceFile = File(sourceFilePath);
if (!await sourceFile.exists()) {
debugPrint('SAF copyFileToSaf: source file does not exist');
return null;
}
// Use pasteLocalFile for native-side copy (no method channel streaming)
// This is much more efficient for large files and avoids hangs
final result = await _safStream
.pasteLocalFile(sourceFilePath, targetDirectoryUri, fileName, mimeType, overwrite: true)
.timeout(const Duration(minutes: 30), onTimeout: () => throw TimeoutException('SAF copy timed out'));
debugPrint('SAF copyFileToSaf: successfully copied to ${result.uri}');
return result.uri.toString();
} on TimeoutException catch (e) {
debugPrint('SAF copyFileToSaf timeout: $e');
return null;
} catch (e) {
debugPrint('SAF copyFileToSaf error: $e');
return null;
}
}
/// Write bytes directly to a SAF file
/// Returns the SAF URI of the created file, or null on failure
Future<String?> writeFileBytes(String directoryUri, String fileName, String mimeType, Uint8List bytes) async {
if (!isAvailable) return null;
try {
final result = await _safStream.writeFileBytes(directoryUri, fileName, mimeType, bytes);
return result.uri.toString();
} catch (e) {
debugPrint('SAF writeFileBytes error: $e');
return null;
}
}
/// Read bytes from a SAF file
Future<Uint8List?> readFileBytes(String fileUri) async {
if (!isAvailable) return null;
try {
return await _safStream.readFileBytes(fileUri);
} catch (e) {
debugPrint('SAF readFileBytes error: $e');
return null;
}
}
/// Check if a file exists in a SAF directory
Future<bool> fileExists(String parentUri, String fileName) async {
if (!isAvailable) return false;
try {
final child = await _safUtil.child(parentUri, [fileName]);
return child != null;
} catch (e) {
debugPrint('SAF fileExists error: $e');
return false;
}
}
/// Get the content URI for a file that should be readable by MediaStore/media players
/// For SAF files, this returns the same URI as input (content:// URIs are already readable)
String getReadableUri(String safUri) => safUri;
}