254 lines
7.2 KiB
Dart
254 lines
7.2 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:crypto/crypto.dart' as crypto;
|
|
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,
|
|
sha256: crypto.sha256.convert(bytes).toString(),
|
|
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;
|
|
}
|
|
if (crypto.sha256.convert(bytes).toString() != file.sha256) {
|
|
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/*',
|
|
};
|
|
}
|