import 'dart:io'; import 'dart:typed_data'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../application/application.dart'; import '../../domain/domain.dart'; final class PathProviderLocalMediaStorage implements LocalMediaStorage { const PathProviderLocalMediaStorage(); static const _mediaDirectoryName = 'gametime_media'; @override Future importFile({ required String sourcePath, required MediaKind kind, required String stableFileName, }) async { final source = File(sourcePath); if (!await source.exists()) { throw DomainException('Media source file not found: $sourcePath'); } final extension = p.extension(source.path).toLowerCase(); final targetDirectory = await _kindDirectory(kind); await targetDirectory.create(recursive: true); final target = File( p.join(targetDirectory.path, '$stableFileName$extension'), ); await source.copy(target.path); final bytes = await target.length(); final dimensions = kind == MediaKind.image ? await _tryReadImageDimensions(target) : null; return StoredMediaFile( localUri: target.uri.toString(), mimeType: _mimeTypeForExtension(extension, kind), sizeBytes: bytes, width: dimensions?.width, height: dimensions?.height, durationMs: null, ); } @override Future> listManagedLocalUris() async { final root = await _rootDirectory(); if (!await root.exists()) { return const {}; } final files = await root .list(recursive: true) .where((entity) => entity is File) .cast() .toList(); return files.map((file) => file.uri.toString()).toSet(); } @override Future deleteByLocalUri(String localUri) async { final file = File.fromUri(Uri.parse(localUri)); final root = await _rootDirectory(); final filePath = p.normalize(file.absolute.path); final rootPath = p.normalize(root.absolute.path); if (!p.isWithin(rootPath, filePath) && filePath != rootPath) { throw DomainException( 'Refusing to delete unmanaged media file: $localUri', ); } if (await file.exists()) { await file.delete(); } } Future _kindDirectory(MediaKind kind) async { final root = await _rootDirectory(); final child = switch (kind) { MediaKind.image => 'images', MediaKind.video => 'videos', }; return Directory(p.join(root.path, child)); } Future _rootDirectory() async { final documents = await getApplicationDocumentsDirectory(); return Directory(p.join(documents.path, _mediaDirectoryName)); } } final class _ImageDimensions { const _ImageDimensions(this.width, this.height); final int width; final int height; } Future<_ImageDimensions?> _tryReadImageDimensions(File file) async { final bytes = await file.openRead(0, 32).fold( BytesBuilder(copy: false), (builder, chunk) { builder.add(chunk); return builder; }, ); final data = bytes.toBytes(); return _readPngDimensions(data) ?? await _readJpegDimensions(file); } _ImageDimensions? _readPngDimensions(Uint8List data) { const signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; if (data.length < 24) { return null; } for (var index = 0; index < signature.length; index++) { if (data[index] != signature[index]) { return null; } } final view = ByteData.sublistView(data); return _ImageDimensions(view.getUint32(16), view.getUint32(20)); } Future<_ImageDimensions?> _readJpegDimensions(File file) async { final data = await file.readAsBytes(); if (data.length < 4 || data[0] != 0xFF || data[1] != 0xD8) { return null; } var offset = 2; while (offset + 9 < data.length) { if (data[offset] != 0xFF) { return null; } final marker = data[offset + 1]; final length = (data[offset + 2] << 8) + data[offset + 3]; if (length < 2 || offset + 2 + length > data.length) { return null; } if ((marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) || (marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF)) { final height = (data[offset + 5] << 8) + data[offset + 6]; final width = (data[offset + 7] << 8) + data[offset + 8]; return _ImageDimensions(width, height); } offset += 2 + length; } return null; } String? _mimeTypeForExtension(String extension, MediaKind kind) { return switch (extension) { '.jpg' || '.jpeg' => 'image/jpeg', '.png' => 'image/png', '.gif' => 'image/gif', '.webp' => 'image/webp', '.mp4' => 'video/mp4', '.mov' => 'video/quicktime', '.webm' => 'video/webm', _ => kind == MediaKind.image ? 'image/*' : 'video/*', }; }