import 'dart:async'; import 'package:flutter/services.dart'; import '../core/failure.dart'; /// A file or folder chosen by the user in the system file picker. /// /// [id] is opaque: on iOS a security-scoped bookmark (optionally followed by /// a path inside a chosen folder), on Android a `content://` document URI. /// Access only exists within what the user granted; nothing here bypasses /// the system sandbox. class DeviceDocument { final String id, name; final int? size; final bool writable, directory; final String? mime; final DateTime? modified; /// Where it lives, for display only (provider or folder name). final String? location; const DeviceDocument({ required this.id, required this.name, this.size, this.writable = false, this.directory = false, this.mime, this.modified, this.location, }); factory DeviceDocument.fromMap(Map m) => DeviceDocument( id: m['id'] as String, name: m['name'] as String? ?? '?', size: (m['size'] as num?)?.toInt(), writable: m['writable'] == true, directory: m['directory'] == true, mime: m['mime'] as String?, modified: m['modified'] == null ? null : DateTime.fromMillisecondsSinceEpoch((m['modified'] as num).toInt()), location: m['location'] as String?, ); Map toJson() => { 'id': id, 'name': name, 'size': ?size, 'writable': writable, 'directory': directory, 'mime': ?mime, 'modified': ?modified?.millisecondsSinceEpoch, 'location': ?location, }; } /// Access to files on the device through the system document picker /// (iOS/iPadOS Files, Android Storage Access Framework). abstract interface class DocumentPlatform { /// Lets the user choose one or more files. Empty when cancelled. Future> pick({bool multiple = false}); /// Lets the user choose a folder. Null when cancelled. Future pickFolder(); /// The entries of a folder chosen with [pickFolder]. Future> list(DeviceDocument folder); /// Current name, size, date and write permission. Future stat(DeviceDocument doc); Future read(DeviceDocument doc); /// Replaces the content of [doc] in place (coordinated with other apps on /// iOS). Throws 'readOnly' when the provider does not allow writing. Future write(DeviceDocument doc, Uint8List bytes); /// Lets the user choose where to save a new file. Null when cancelled. Future saveAs( String suggestedName, Uint8List bytes, { String? mime, }); /// Creates a new file in a chosen folder. Future create( DeviceDocument folder, String name, Uint8List bytes, { String? mime, }); /// Shows a local file (in the app's temporary directory) with the system /// viewer: Quick Look on iOS (in the app), an app chosen by the user on /// Android. Returns false when nothing on the device can show it. Future preview(String path, {String? mime}); /// Android only: renders PDF pages with the system PdfRenderer. Future pdfPageCount(String path); Future pdfPage(String path, int index, int width); /// Files handed to SM Mobile by other apps ("Open in", share). Stream> get incoming; /// Releases a persisted permission when a recent entry is removed. Future forget(DeviceDocument doc); } class MethodChannelDocuments implements DocumentPlatform { static const channel = MethodChannel('eu.chj.sm_mobile/documents'); final _incoming = StreamController>.broadcast(); bool _listening = false; MethodChannelDocuments(); Future _call(String method, [Object? args]) async { try { return await channel.invokeMethod(method, args); } on PlatformException catch (e) { throw Failure(switch (e.code) { 'readOnly' => 'readOnlyError', 'denied' => 'permissionDenied', 'notFound' => 'fileNotFound', 'noSpace' => 'noSpace', 'stale' => 'documentStale', 'busy' => 'operationFailed', _ => 'fileError', }); } on MissingPluginException { throw const Failure('unsupportedPlatform'); } } List _docs(List? raw) => [ for (final m in raw ?? const []) DeviceDocument.fromMap(m as Map), ]; @override Future> pick({bool multiple = false}) async => _docs(await _call('pick', {'multiple': multiple})); @override Future pickFolder() async { final m = await _call('pickFolder'); return m == null ? null : DeviceDocument.fromMap(m); } @override Future> list(DeviceDocument folder) async => _docs(await _call('list', {'id': folder.id})); @override Future stat(DeviceDocument doc) async => DeviceDocument.fromMap((await _call('stat', {'id': doc.id}))!); @override Future read(DeviceDocument doc) async => (await _call('read', {'id': doc.id}))!; @override Future write(DeviceDocument doc, Uint8List bytes) async => DeviceDocument.fromMap( (await _call('write', {'id': doc.id, 'bytes': bytes}))!, ); @override Future saveAs( String suggestedName, Uint8List bytes, { String? mime, }) async { final m = await _call('saveAs', { 'name': suggestedName, 'bytes': bytes, 'mime': mime ?? 'application/octet-stream', }); return m == null ? null : DeviceDocument.fromMap(m); } @override Future create( DeviceDocument folder, String name, Uint8List bytes, { String? mime, }) async => DeviceDocument.fromMap( (await _call('create', { 'folder': folder.id, 'name': name, 'bytes': bytes, 'mime': mime ?? 'application/octet-stream', }))!, ); @override Future preview(String path, {String? mime}) async => await _call('preview', {'path': path, 'mime': ?mime}) ?? false; @override Future pdfPageCount(String path) async => await _call('pdfPageCount', {'path': path}) ?? 0; @override Future pdfPage(String path, int index, int width) async => (await _call('pdfPage', { 'path': path, 'index': index, 'width': width, }))!; @override Stream> get incoming { if (!_listening) { _listening = true; channel.setMethodCallHandler((call) async { if (call.method == 'incoming') { _incoming.add(_docs(call.arguments as List?)); } }); // Files that arrived before Dart was listening (cold start). unawaited( _call('takeIncoming') .then((raw) { final docs = _docs(raw); if (docs.isNotEmpty) _incoming.add(docs); }) .catchError((Object _) {}), ); } return _incoming.stream; } @override Future forget(DeviceDocument doc) async { try { await _call('forget', {'id': doc.id}); } catch (_) { /* Nothing to release. */ } } }