Files
GameTime/lib/infrastructure/local/local_media_storage.dart
2026-07-22 13:23:30 +02:00

249 lines
7.0 KiB
Dart

import 'dart:convert';
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, LocalBackupMediaStore {
const PathProviderLocalMediaStorage();
static const _mediaDirectoryName = 'gametime_media';
@override
Future<StoredMediaFile> 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<Set<String>> 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<File>()
.toList();
return files.map((file) => file.uri.toString()).toSet();
}
@override
Future<void> 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();
}
}
@override
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
List<LocalBackupResource> mediaAssets,
) async {
final files = <EmbeddedBackupMediaFile>[];
for (final resource in mediaAssets) {
final localUri = resource.payload['localUri'];
if (localUri is! String || localUri.isEmpty) {
continue;
}
final file = _fileFromLocalUri(localUri);
if (!await file.exists()) {
continue;
}
final bytes = await file.readAsBytes();
files.add(
EmbeddedBackupMediaFile(
mediaAssetId: resource.id,
role: 'original',
fileName: p.basename(file.path),
mimeType: resource.payload['mimeType'] as String?,
sizeBytes: bytes.length,
base64: base64Encode(bytes),
),
);
}
return files;
}
@override
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
List<EmbeddedBackupMediaFile> files,
) async {
final restored = <String, RestoredMediaFile>{};
final directory = await _restoredBackupDirectory();
await directory.create(recursive: true);
for (final file in files) {
final bytes = switch (_decodeBase64OrNull(file.base64)) {
final decoded? => decoded,
null => null,
};
if (bytes == null) {
continue;
}
final extension = p.extension(file.fileName).toLowerCase();
final target = File(
p.join(directory.path, '${file.mediaAssetId}$extension'),
);
await target.writeAsBytes(bytes, flush: true);
restored[file.mediaAssetId] = RestoredMediaFile(
mediaAssetId: file.mediaAssetId,
localUri: target.uri.toString(),
sizeBytes: bytes.length,
);
}
return restored;
}
Future<Directory> _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<Directory> _rootDirectory() async {
final documents = await getApplicationDocumentsDirectory();
return Directory(p.join(documents.path, _mediaDirectoryName));
}
Future<Directory> _restoredBackupDirectory() async {
final root = await _rootDirectory();
return Directory(p.join(root.path, 'restored'));
}
}
File _fileFromLocalUri(String localUri) {
final uri = Uri.tryParse(localUri);
if (uri != null && uri.scheme == 'file') {
return File.fromUri(uri);
}
return File(localUri);
}
Uint8List? _decodeBase64OrNull(String value) {
try {
return base64Decode(value);
} on FormatException {
return null;
}
}
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>(
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/*',
};
}