import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; import '../../core/failure.dart'; import '../documents.dart'; import '../sftp_service.dart'; /// Size limits: text is edited in memory up to [maxEditableBytes]; viewers /// (images, PDF, archives, hex) read up to [maxViewBytes]. const maxEditableBytes = 20 * 1024 * 1024; const maxViewBytes = 100 * 1024 * 1024; /// Where an open file comes from and how it is saved back. abstract class FileSource { String get name; /// Shown to the user: server and path, or the device location. String get location; /// 'remote', 'device' or 'new'. String get kind; /// A stable key for recent files and favorites. String get key; /// False for files without a place to save to (new, read-only). bool get canSave; /// Why saving is not possible, as a localization key. String? get readOnlyReason; Future load({int maxBytes = maxEditableBytes}); /// Saves [bytes]. Throws 'remoteChanged' / 'documentChanged' when the /// file changed since it was loaded, unless [force]. Future save( Uint8List bytes, { bool force = false, bool inPlace = false, }); Map toJson(); } /// A file on the connected server, read and written over SFTP. class RemoteSource implements FileSource { /// The current SFTP service; throws when the connection is gone. final Future Function() service; final String profileId, profileName, path; SftpFileAttrs? baseline; List? baselineHash; bool writable = true; RemoteSource({ required this.service, required this.profileId, required this.profileName, required this.path, this.baseline, }); @override String get name => path.substring(path.lastIndexOf('/') + 1); @override String get location => '$profileName:$path'; @override String get kind => 'remote'; @override String get key => 'remote:$profileId:$path'; @override bool get canSave => writable; @override String? get readOnlyReason => writable ? null : 'readOnlyRemote'; /// Permission bits for display, e.g. "rw-r--r--". String? get permissions { final mode = baseline?.mode?.value; if (mode == null) return null; const chars = 'rwxrwxrwx'; return [ for (var i = 0; i < 9; i++) mode & (1 << (8 - i)) != 0 ? chars[i] : '-', ].join(); } @override Future load({int maxBytes = maxEditableBytes}) async { final s = await service(); final (bytes, attrs) = await s.readFile(path, maxBytes: maxBytes); baseline = attrs; baselineHash = await SftpService.sha256Of(bytes); // Whether writing is allowed is decided by the server on save; a // refusal is reported as missing permission. return bytes; } @override Future save( Uint8List bytes, { bool force = false, bool inPlace = false, }) async { final s = await service(); // Without a known state (a draft restored offline), an existing file is // never overwritten silently. if (baseline == null && !force && await s.statOrNull(path) != null) { throw const Failure('remoteChanged'); } final attrs = await s.saveFile( path, bytes, expected: baseline, expectedHash: baselineHash, force: force, inPlace: inPlace, ); baseline = attrs; baselineHash = await SftpService.sha256Of(bytes); } /// Whether the file on the server changed since it was loaded or saved. Future changedOnServer() async { final b = baseline; if (b == null) return false; try { await (await service()).checkUnchanged(path, b, baselineHash); return false; } on Failure catch (e) { if (e.code == 'remoteChanged' || e.code == 'remoteDeleted') return true; rethrow; } } @override Map toJson() => { 'kind': kind, 'profileId': profileId, 'profileName': profileName, 'path': path, if (baseline != null) 'baseline': {'size': baseline!.size, 'mtime': baseline!.modifyTime}, }; static RemoteSource fromJson( Map m, Future Function() service, ) { final b = m['baseline'] as Map?; return RemoteSource( service: service, profileId: m['profileId'] as String, profileName: m['profileName'] as String? ?? '', path: m['path'] as String, baseline: b == null ? null : SftpFileAttrs( size: b['size'] as int?, modifyTime: b['mtime'] as int?, ), ); } } /// A file on the device, opened through the system document picker. class DeviceSource implements FileSource { final DocumentPlatform documents; DeviceDocument doc; DateTime? _modified; int? _size; DeviceSource(this.documents, this.doc); /// A document that was just written with [size] bytes. DeviceSource.saved(this.documents, this.doc, int size) : _modified = doc.modified, _size = size; @override String get name => doc.name; @override String get location => doc.location ?? doc.name; @override String get kind => 'device'; @override String get key => 'device:${doc.id}'; @override bool get canSave => doc.writable; @override String? get readOnlyReason => doc.writable ? null : 'readOnlyDevice'; @override Future load({int maxBytes = maxEditableBytes}) async { doc = await documents.stat(doc); if ((doc.size ?? 0) > maxBytes) throw const Failure('fileTooLarge'); final bytes = await documents.read(doc); if (bytes.length > maxBytes) throw const Failure('fileTooLarge'); _modified = doc.modified; _size = bytes.length; return bytes; } @override Future save( Uint8List bytes, { bool force = false, bool inPlace = false, }) async { if (!force && _modified != null) { final now = await documents.stat(doc); if (now.modified != _modified || now.size != _size) { throw const Failure('documentChanged'); } } doc = await documents.write(doc, bytes); _modified = doc.modified; _size = bytes.length; } @override Map toJson() => {'kind': kind, 'doc': doc.toJson()}; static DeviceSource fromJson(Map m, DocumentPlatform d) => DeviceSource(d, DeviceDocument.fromMap(m['doc'] as Map)); } /// Rebuilds a source saved in a draft or the recent list. FileSource sourceFromJson( Map m, { required DocumentPlatform documents, required Future Function(String profileId) sftpFor, String fallbackName = 'untitled.txt', }) => switch (m['kind']) { 'remote' => RemoteSource.fromJson(m, () => sftpFor(m['profileId'] as String)), 'device' => DeviceSource.fromJson(m, documents), _ => NewSource(m['name'] as String? ?? fallbackName), }; /// A new file that has not been saved anywhere yet. class NewSource implements FileSource { @override final String name; NewSource(this.name); @override String get location => name; @override String get kind => 'new'; @override String get key => 'new:$name'; @override bool get canSave => false; @override String? get readOnlyReason => null; @override Future load({int maxBytes = maxEditableBytes}) async => Uint8List(0); @override Future save( Uint8List bytes, { bool force = false, bool inPlace = false, }) async => throw const Failure('saveAsRequired'); @override Map toJson() => {'kind': kind, 'name': name}; }