import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/features/logging/utils/redaction_utils.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; class FileService { FileService._(); static FileService? _singleton; late Directory _logDirectory; late Directory _temporaryDirectory; late Directory _documentsDirectory; late Directory _downloadsDirectory; late Directory _uploadDirectory; late Directory _temporaryScansDirectory; Directory get logDirectory => _logDirectory; Directory get temporaryDirectory => _temporaryDirectory; Directory get documentsDirectory => _documentsDirectory; Directory get downloadsDirectory => _downloadsDirectory; Directory get uploadDirectory => _uploadDirectory; Directory get temporaryScansDirectory => _temporaryScansDirectory; Future initialize() async { try { await _initTemporaryDirectory(); await _initTemporaryScansDirectory(); await _initUploadDirectory(); await _initLogDirectory(); await _initDownloadsDirectory(); await _initializeDocumentsDirectory(); } catch (error, stackTrace) { debugPrint("Could not initialize directories."); debugPrint(error.toString()); debugPrintStack(stackTrace: stackTrace); } } /// Make sure to call and await initialize before accessing any of the instance members. static FileService get instance { _singleton ??= FileService._(); return _singleton!; } Future saveToFile( Uint8List bytes, String filename, ) async { File file = File(p.join(_logDirectory.path, filename)); logger.fd( "Writing bytes to file $filename", methodName: 'saveToFile', className: runtimeType.toString(), ); return file..writeAsBytes(bytes); } Directory getDirectory(PaperlessDirectoryType type) { return switch (type) { PaperlessDirectoryType.documents => _documentsDirectory, PaperlessDirectoryType.temporary => _temporaryDirectory, PaperlessDirectoryType.scans => _temporaryScansDirectory, PaperlessDirectoryType.download => _downloadsDirectory, PaperlessDirectoryType.upload => _uploadDirectory, PaperlessDirectoryType.logs => _logDirectory, }; } /// /// Returns a [File] pointing to a temporary file in the directory specified by [type]. /// If [create] is true, the file will be created. /// If [fileName] is left blank, a random UUID will be generated. /// Future allocateTemporaryFile( PaperlessDirectoryType type, { required String extension, String? fileName, bool create = false, }) async { final dir = getDirectory(type); final filename = (fileName ?? const Uuid().v1()) + '.$extension'; final file = File(p.join(dir.path, filename)); if (create) { await file.create(recursive: true); } return file; } Future getConsumptionDirectory({required String userId}) async { return Directory(p.join(_uploadDirectory.path, userId)) .create(recursive: true); } Future clearUserData({required String userId}) async { final redactedId = redactUserId(userId); logger.fd( "Clearing data for user $redactedId...", className: runtimeType.toString(), methodName: "clearUserData", ); final scanDirSize = formatBytes(await getDirSizeInBytes(_temporaryScansDirectory)); final tempDirSize = formatBytes(await getDirSizeInBytes(_temporaryDirectory)); final consumptionDir = await getConsumptionDirectory(userId: userId); final consumptionDirSize = formatBytes(await getDirSizeInBytes(consumptionDir)); logger.ft( "Clearing scans directory...", className: runtimeType.toString(), methodName: "clearUserData", ); await _temporaryScansDirectory.clear(); logger.ft( "Removed $scanDirSize...", className: runtimeType.toString(), methodName: "clearUserData", ); logger.ft( "Removing temporary files and cache content...", className: runtimeType.toString(), methodName: "clearUserData", ); await _temporaryDirectory.delete(recursive: true); logger.ft( "Removed $tempDirSize...", className: runtimeType.toString(), methodName: "clearUserData", ); logger.ft( "Removing files waiting for consumption...", className: runtimeType.toString(), methodName: "clearUserData", ); await consumptionDir.delete(recursive: true); logger.ft( "Removed $consumptionDirSize...", className: runtimeType.toString(), methodName: "clearUserData", ); } Future clearDirectoryContent( PaperlessDirectoryType type, { bool filesOnly = false, }) async { final dir = getDirectory(type); final dirSize = await getDirSizeInBytes(dir); if (!await dir.exists()) { return 0; } final streamedEntities = filesOnly ? dir.list().whereType().cast() : dir.list(); final entities = await streamedEntities.toList(); await Future.wait([ for (var entity in entities) entity.delete(recursive: !filesOnly), ]); return dirSize; } Future> getAllFiles(Directory directory) { return directory.list().whereType().toList(); } Future> getAllSubdirectories(Directory directory) { return directory.list().whereType().toList(); } Future getDirSizeInBytes(Directory dir) async { return dir .list(recursive: true) .fold(0, (previous, element) => previous + element.statSync().size); } Future _initTemporaryDirectory() async { _temporaryDirectory = await getTemporaryDirectory().then((value) => value.create()); } Future _initializeDocumentsDirectory() async { if (Platform.isAndroid) { final dirs = await getExternalStorageDirectories(type: StorageDirectory.documents); _documentsDirectory = await dirs!.first.create(recursive: true); return; } else if (Platform.isIOS) { final dir = await getApplicationDocumentsDirectory(); _documentsDirectory = await Directory(p.join(dir.path, 'documents')) .create(recursive: true); return; } else { throw UnsupportedError("Platform not supported."); } } Future _initLogDirectory() async { if (Platform.isAndroid) { _logDirectory = await getExternalStorageDirectories(type: StorageDirectory.documents) .then((directory) async => directory?.firstOrNull ?? await getApplicationDocumentsDirectory()) .then((directory) => Directory(p.join(directory.path, 'logs')) .create(recursive: true)); return; } else if (Platform.isIOS) { _logDirectory = await getApplicationDocumentsDirectory().then((value) => Directory(p.join(value.path, 'logs')).create(recursive: true)); return; } throw UnsupportedError("Platform not supported."); } Future _initDownloadsDirectory() async { if (Platform.isAndroid) { var directory = Directory('/storage/emulated/0/Download'); if (!await directory.exists()) { final downloadsDir = await getExternalStorageDirectories( type: StorageDirectory.downloads, ); directory = await downloadsDir!.first.create(recursive: true); } _downloadsDirectory = directory; return; } else if (Platform.isIOS) { final appDir = await getApplicationDocumentsDirectory(); final dir = Directory('${appDir.path}/downloads'); _downloadsDirectory = await dir.create(recursive: true); return; } else { throw UnsupportedError("Platform not supported."); } } Future _initUploadDirectory() async { final dir = await getApplicationDocumentsDirectory() .then((dir) => Directory(p.join(dir.path, 'upload'))); _uploadDirectory = await dir.create(recursive: true); } Future _initTemporaryScansDirectory() async { _temporaryScansDirectory = await Directory(p.join(_temporaryDirectory.path, 'scans')) .create(recursive: true); } } enum PaperlessDirectoryType { documents, temporary, scans, download, upload, logs; } extension ClearDirectoryExtension on Directory { Future clear() async { final streamedEntities = list(); final entities = await streamedEntities.toList(); await Future.wait([ for (var entity in entities) entity.delete(recursive: true), ]); } }