@@ -160,6 +160,7 @@ class EnteFile {
|
||||
|
||||
Future<Map<String, dynamic>> getMetadataForUpload(
|
||||
MediaUploadData mediaUploadData,
|
||||
ParsedExifDateTime? exifTime,
|
||||
) async {
|
||||
final asset = await getAsset;
|
||||
// asset can be null for files shared to app
|
||||
@@ -170,36 +171,24 @@ class EnteFile {
|
||||
}
|
||||
}
|
||||
bool hasExifTime = false;
|
||||
if ((fileType == FileType.image || fileType == FileType.video) &&
|
||||
mediaUploadData.sourceFile != null) {
|
||||
final exifData = await getExifFromSourceFile(mediaUploadData.sourceFile!);
|
||||
if (exifData != null) {
|
||||
if (fileType == FileType.image) {
|
||||
final exifTime = await getCreationTimeFromEXIF(null, exifData);
|
||||
if (exifTime != null) {
|
||||
hasExifTime = true;
|
||||
creationTime = exifTime.microsecondsSinceEpoch;
|
||||
}
|
||||
mediaUploadData.isPanorama = checkPanoramaFromEXIF(null, exifData);
|
||||
|
||||
if (mediaUploadData.isPanorama != true) {
|
||||
try {
|
||||
final xmpData = await getXmp(mediaUploadData.sourceFile!);
|
||||
mediaUploadData.isPanorama = checkPanoramaFromXMP(xmpData);
|
||||
} catch (_) {}
|
||||
|
||||
mediaUploadData.isPanorama ??= false;
|
||||
}
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
//Fix for missing location data in lower android versions.
|
||||
final Location? exifLocation = locationFromExif(exifData);
|
||||
if (Location.isValidLocation(exifLocation)) {
|
||||
location = exifLocation;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (exifTime != null && exifTime.time != null) {
|
||||
hasExifTime = true;
|
||||
creationTime = exifTime.time!.microsecondsSinceEpoch;
|
||||
}
|
||||
if (mediaUploadData.exifData != null) {
|
||||
mediaUploadData.isPanorama =
|
||||
checkPanoramaFromEXIF(null, mediaUploadData.exifData);
|
||||
}
|
||||
if (mediaUploadData.isPanorama != true &&
|
||||
fileType == FileType.image &&
|
||||
mediaUploadData.sourceFile != null) {
|
||||
try {
|
||||
final xmpData = await getXmp(mediaUploadData.sourceFile!);
|
||||
mediaUploadData.isPanorama = checkPanoramaFromXMP(xmpData);
|
||||
} catch (_) {}
|
||||
mediaUploadData.isPanorama ??= false;
|
||||
}
|
||||
|
||||
// Try to get the timestamp from fileName. In case of iOS, file names are
|
||||
// generic IMG_XXXX, so only parse it on Android devices
|
||||
if (!hasExifTime && Platform.isAndroid && title != null) {
|
||||
|
||||
@@ -14,6 +14,8 @@ const latKey = "lat";
|
||||
const longKey = "long";
|
||||
const motionVideoIndexKey = "mvi";
|
||||
const noThumbKey = "noThumb";
|
||||
const dateTimeKey = 'dateTime';
|
||||
const offsetTimeKey = 'offsetTime';
|
||||
|
||||
class MagicMetadata {
|
||||
// 0 -> visible
|
||||
@@ -46,6 +48,11 @@ class PubMagicMetadata {
|
||||
double? lat;
|
||||
double? long;
|
||||
|
||||
// ISO 8601 datetime without timezone. This contains the date and time of the photo in the original tz
|
||||
// where the photo was taken.
|
||||
String? dateTime;
|
||||
String? offsetTime;
|
||||
|
||||
// Motion Video Index. Positive value (>0) indicates that the file is a motion
|
||||
// photo
|
||||
int? mvi;
|
||||
@@ -74,6 +81,8 @@ class PubMagicMetadata {
|
||||
this.mvi,
|
||||
this.noThumb,
|
||||
this.mediaType,
|
||||
this.dateTime,
|
||||
this.offsetTime,
|
||||
});
|
||||
|
||||
factory PubMagicMetadata.fromEncodedJson(String encodedJson) =>
|
||||
@@ -96,6 +105,8 @@ class PubMagicMetadata {
|
||||
mvi: map[motionVideoIndexKey],
|
||||
noThumb: map[noThumbKey],
|
||||
mediaType: map[mediaTypeKey],
|
||||
dateTime: map[dateTimeKey],
|
||||
offsetTime: map[offsetTimeKey],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,15 +21,15 @@ class CreationTimeItem extends StatefulWidget {
|
||||
class _CreationTimeItemState extends State<CreationTimeItem> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateTime =
|
||||
DateTime.fromMicrosecondsSinceEpoch(widget.file.creationTime!);
|
||||
final dateTime = DateTime.fromMicrosecondsSinceEpoch(
|
||||
widget.file.creationTime!,
|
||||
isUtc: true,
|
||||
).toLocal();
|
||||
return InfoItemWidget(
|
||||
key: const ValueKey("Creation time"),
|
||||
leadingIcon: Icons.calendar_today_outlined,
|
||||
title: DateFormat.yMMMEd(Localizations.localeOf(context).languageCode)
|
||||
.format(
|
||||
DateTime.fromMicrosecondsSinceEpoch(widget.file.creationTime!),
|
||||
),
|
||||
.format(dateTime),
|
||||
subtitleSection: Future.value([
|
||||
Text(
|
||||
getTimeIn12hrFormat(dateTime) + " " + dateTime.timeZoneName,
|
||||
|
||||
@@ -47,7 +47,7 @@ Future<Map<String, IfdTag>> getExif(EnteFile file) async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, IfdTag>?> getExifFromSourceFile(File originFile) async {
|
||||
Future<Map<String, IfdTag>?> tryExifFromFile(File originFile) async {
|
||||
try {
|
||||
final exif = await readExifAsync(originFile);
|
||||
return exif;
|
||||
@@ -125,7 +125,25 @@ bool? checkPanoramaFromEXIF(File? file, Map<String, IfdTag>? exifData) {
|
||||
return element?.printable == "6";
|
||||
}
|
||||
|
||||
Future<DateTime?> getCreationTimeFromEXIF(
|
||||
class ParsedExifDateTime {
|
||||
late final DateTime? time;
|
||||
late final String? dateTime;
|
||||
late final String? offsetTime;
|
||||
ParsedExifDateTime(DateTime this.time, String? dateTime, this.offsetTime) {
|
||||
if (dateTime != null && dateTime.endsWith('Z')) {
|
||||
this.dateTime = dateTime.substring(0, dateTime.length - 1);
|
||||
} else {
|
||||
this.dateTime = dateTime;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "ParsedExifDateTime{time: $time, dateTime: $dateTime, offsetTime: $offsetTime}";
|
||||
}
|
||||
}
|
||||
|
||||
Future<ParsedExifDateTime?> tryParseExifDateTime(
|
||||
File? file,
|
||||
Map<String, IfdTag>? exifData,
|
||||
) async {
|
||||
@@ -137,46 +155,55 @@ Future<DateTime?> getCreationTimeFromEXIF(
|
||||
: exif.containsKey(kImageDateTime)
|
||||
? exif[kImageDateTime]!.printable
|
||||
: null;
|
||||
if (exifTime != null && exifTime != kEmptyExifDateTime) {
|
||||
String? exifOffsetTime;
|
||||
for (final key in kExifOffSetKeys) {
|
||||
if (exif.containsKey(key)) {
|
||||
exifOffsetTime = exif[key]!.printable;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return getDateTimeInDeviceTimezone(exifTime, exifOffsetTime);
|
||||
if (exifTime == null || exifTime == kEmptyExifDateTime) {
|
||||
return null;
|
||||
}
|
||||
String? exifOffsetTime;
|
||||
for (final key in kExifOffSetKeys) {
|
||||
if (exif.containsKey(key)) {
|
||||
exifOffsetTime = exif[key]!.printable;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return getDateTimeInDeviceTimezone(exifTime, exifOffsetTime);
|
||||
} catch (e) {
|
||||
_logger.severe("failed to getCreationTimeFromEXIF", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime getDateTimeInDeviceTimezone(String exifTime, String? offsetString) {
|
||||
final DateTime result = DateFormat(kExifDateTimePattern).parse(exifTime);
|
||||
if (offsetString == null) {
|
||||
return result;
|
||||
ParsedExifDateTime getDateTimeInDeviceTimezone(
|
||||
String exifTime,
|
||||
String? offsetString,
|
||||
) {
|
||||
final hasOffset = (offsetString ?? '') != '';
|
||||
final DateTime result =
|
||||
DateFormat(kExifDateTimePattern).parse(exifTime, hasOffset);
|
||||
if (hasOffset && offsetString!.toUpperCase() != "Z") {
|
||||
try {
|
||||
final List<String> splitHHMM = offsetString.split(":");
|
||||
final int offsetHours = int.parse(splitHHMM[0]);
|
||||
final int offsetMinutes =
|
||||
int.parse(splitHHMM[1]) * (offsetHours.isNegative ? -1 : 1);
|
||||
// Adjust the date for the offset to get the photo's correct UTC time
|
||||
final photoUtcDate =
|
||||
result.add(Duration(hours: -offsetHours, minutes: -offsetMinutes));
|
||||
// Convert the UTC time to the device's local time
|
||||
final deviceLocalTime = photoUtcDate.toLocal();
|
||||
return ParsedExifDateTime(
|
||||
deviceLocalTime,
|
||||
result.toIso8601String(),
|
||||
offsetString,
|
||||
);
|
||||
} catch (e, s) {
|
||||
_logger.severe("offset parsing failed $exifTime && $offsetString", e, s);
|
||||
}
|
||||
}
|
||||
try {
|
||||
final List<String> splitHHMM = offsetString.split(":");
|
||||
// Parse the offset from the photo's time zone
|
||||
final int offsetHours = int.parse(splitHHMM[0]);
|
||||
final int offsetMinutes =
|
||||
int.parse(splitHHMM[1]) * (offsetHours.isNegative ? -1 : 1);
|
||||
// Adjust the date for the offset to get the photo's correct UTC time
|
||||
final photoUtcDate =
|
||||
result.add(Duration(hours: -offsetHours, minutes: -offsetMinutes));
|
||||
// Getting the current device's time zone offset from UTC
|
||||
final now = DateTime.now();
|
||||
final localOffset = now.timeZoneOffset;
|
||||
// Adjusting the photo's UTC time to the device's local time
|
||||
final deviceLocalTime = photoUtcDate.add(localOffset);
|
||||
return deviceLocalTime;
|
||||
} catch (e, s) {
|
||||
_logger.severe("tz offset adjust failed $offsetString", e, s);
|
||||
}
|
||||
return result;
|
||||
return ParsedExifDateTime(
|
||||
result,
|
||||
result.toIso8601String(),
|
||||
(offsetString ?? '').toUpperCase() == 'Z' ? 'Z' : null,
|
||||
);
|
||||
}
|
||||
|
||||
Location? locationFromExif(Map<String, IfdTag> exif) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import 'package:photos/services/sync_service.dart';
|
||||
import "package:photos/services/user_service.dart";
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import 'package:photos/utils/data_util.dart';
|
||||
import "package:photos/utils/exif_util.dart";
|
||||
import "package:photos/utils/file_key.dart";
|
||||
import 'package:photos/utils/file_uploader_util.dart';
|
||||
import "package:photos/utils/file_util.dart";
|
||||
@@ -536,7 +537,7 @@ class FileUploader {
|
||||
|
||||
MediaUploadData? mediaUploadData;
|
||||
try {
|
||||
mediaUploadData = await getUploadDataFromEnteFile(file);
|
||||
mediaUploadData = await getUploadDataFromEnteFile(file, parseExif: true);
|
||||
} catch (e) {
|
||||
// This additional try catch block is added because for resumable upload,
|
||||
// we need to compute the hash before the next step. Previously, this
|
||||
@@ -728,8 +729,13 @@ class FileUploader {
|
||||
encThumbSize,
|
||||
);
|
||||
}
|
||||
final ParsedExifDateTime? exifTime = await tryParseExifDateTime(
|
||||
null,
|
||||
mediaUploadData.exifData,
|
||||
);
|
||||
final metadata =
|
||||
await file.getMetadataForUpload(mediaUploadData, exifTime);
|
||||
|
||||
final metadata = await file.getMetadataForUpload(mediaUploadData);
|
||||
final encryptedMetadataResult = await CryptoUtil.encryptChaCha(
|
||||
utf8.encode(jsonEncode(metadata)),
|
||||
fileAttributes.key!,
|
||||
@@ -771,22 +777,9 @@ class FileUploader {
|
||||
CryptoUtil.bin2base64(encryptedFileKeyData.encryptedData!);
|
||||
final keyDecryptionNonce =
|
||||
CryptoUtil.bin2base64(encryptedFileKeyData.nonce!);
|
||||
final Map<String, dynamic> pubMetadata = {};
|
||||
final Map<String, dynamic> pubMetadata =
|
||||
_buildPublicMagicData(mediaUploadData, exifTime);
|
||||
MetadataRequest? pubMetadataRequest;
|
||||
if ((mediaUploadData.height ?? 0) != 0 &&
|
||||
(mediaUploadData.width ?? 0) != 0) {
|
||||
pubMetadata[heightKey] = mediaUploadData.height;
|
||||
pubMetadata[widthKey] = mediaUploadData.width;
|
||||
pubMetadata[mediaTypeKey] =
|
||||
mediaUploadData.isPanorama == true ? 1 : 0;
|
||||
}
|
||||
if (mediaUploadData.motionPhotoStartIndex != null) {
|
||||
pubMetadata[motionVideoIndexKey] =
|
||||
mediaUploadData.motionPhotoStartIndex;
|
||||
}
|
||||
if (mediaUploadData.thumbnail == null) {
|
||||
pubMetadata[noThumbKey] = true;
|
||||
}
|
||||
if (pubMetadata.isNotEmpty) {
|
||||
pubMetadataRequest = await getPubMetadataRequest(
|
||||
file,
|
||||
@@ -868,6 +861,34 @@ class FileUploader {
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPublicMagicData(
|
||||
MediaUploadData mediaUploadData,
|
||||
ParsedExifDateTime? exifTime,
|
||||
) {
|
||||
final Map<String, dynamic> pubMetadata = {};
|
||||
if ((mediaUploadData.height ?? 0) != 0 &&
|
||||
(mediaUploadData.width ?? 0) != 0) {
|
||||
pubMetadata[heightKey] = mediaUploadData.height;
|
||||
pubMetadata[widthKey] = mediaUploadData.width;
|
||||
pubMetadata[mediaTypeKey] = mediaUploadData.isPanorama == true ? 1 : 0;
|
||||
}
|
||||
if (mediaUploadData.motionPhotoStartIndex != null) {
|
||||
pubMetadata[motionVideoIndexKey] = mediaUploadData.motionPhotoStartIndex;
|
||||
}
|
||||
if (mediaUploadData.thumbnail == null) {
|
||||
pubMetadata[noThumbKey] = true;
|
||||
}
|
||||
if (exifTime != null) {
|
||||
if (exifTime.dateTime != null) {
|
||||
pubMetadata[dateTimeKey] = exifTime.dateTime;
|
||||
}
|
||||
if (exifTime.offsetTime != null) {
|
||||
pubMetadata[offsetTimeKey] = exifTime.offsetTime;
|
||||
}
|
||||
}
|
||||
return pubMetadata;
|
||||
}
|
||||
|
||||
bool isPutOrUpdateFileError(Object e) {
|
||||
if (e is DioException) {
|
||||
return e.requestOptions.path.contains("/files") ||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'dart:ui' as ui;
|
||||
|
||||
import "package:archive/archive_io.dart";
|
||||
import "package:computer/computer.dart";
|
||||
import "package:exif/exif.dart";
|
||||
import 'package:logging/logging.dart';
|
||||
import "package:motion_photos/motion_photos.dart";
|
||||
import 'package:motionphoto/motionphoto.dart';
|
||||
@@ -44,6 +45,8 @@ class MediaUploadData {
|
||||
// For iOS, this value will be always null.
|
||||
final int? motionPhotoStartIndex;
|
||||
|
||||
final Map<String, IfdTag>? exifData;
|
||||
|
||||
bool? isPanorama;
|
||||
|
||||
MediaUploadData(
|
||||
@@ -55,6 +58,7 @@ class MediaUploadData {
|
||||
this.width,
|
||||
this.motionPhotoStartIndex,
|
||||
this.isPanorama,
|
||||
this.exifData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,20 +73,27 @@ class FileHashData {
|
||||
FileHashData(this.fileHash, {this.zipHash});
|
||||
}
|
||||
|
||||
Future<MediaUploadData> getUploadDataFromEnteFile(EnteFile file) async {
|
||||
Future<MediaUploadData> getUploadDataFromEnteFile(
|
||||
EnteFile file, {
|
||||
bool parseExif = false,
|
||||
}) async {
|
||||
if (file.isSharedMediaToAppSandbox) {
|
||||
return await _getMediaUploadDataFromAppCache(file);
|
||||
return await _getMediaUploadDataFromAppCache(file, parseExif);
|
||||
} else {
|
||||
return await _getMediaUploadDataFromAssetFile(file);
|
||||
return await _getMediaUploadDataFromAssetFile(file, parseExif);
|
||||
}
|
||||
}
|
||||
|
||||
Future<MediaUploadData> _getMediaUploadDataFromAssetFile(EnteFile file) async {
|
||||
Future<MediaUploadData> _getMediaUploadDataFromAssetFile(
|
||||
EnteFile file,
|
||||
bool parseExif,
|
||||
) async {
|
||||
File? sourceFile;
|
||||
Uint8List? thumbnailData;
|
||||
bool isDeleted;
|
||||
String? zipHash;
|
||||
String fileHash;
|
||||
Map<String, IfdTag>? exifData;
|
||||
|
||||
// The timeouts are to safeguard against https://github.com/CaiJingLong/flutter_photo_manager/issues/467
|
||||
final asset = await file.getAsset
|
||||
@@ -115,8 +126,11 @@ Future<MediaUploadData> _getMediaUploadDataFromAssetFile(EnteFile file) async {
|
||||
InvalidReason.sourceFileMissing,
|
||||
);
|
||||
}
|
||||
if (parseExif) {
|
||||
exifData = await tryExifFromFile(sourceFile);
|
||||
}
|
||||
// h4ck to fetch location data if missing (thank you Android Q+) lazily only during uploads
|
||||
await _decorateEnteFileData(file, asset, sourceFile);
|
||||
await _decorateEnteFileData(file, asset, sourceFile, exifData);
|
||||
fileHash = CryptoUtil.bin2base64(await CryptoUtil.getHash(sourceFile));
|
||||
|
||||
if (file.fileType == FileType.livePhoto && Platform.isIOS) {
|
||||
@@ -177,6 +191,7 @@ Future<MediaUploadData> _getMediaUploadDataFromAssetFile(EnteFile file) async {
|
||||
height: h,
|
||||
width: w,
|
||||
motionPhotoStartIndex: motionPhotoStartingIndex,
|
||||
exifData: exifData,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,6 +299,7 @@ Future<void> _decorateEnteFileData(
|
||||
EnteFile file,
|
||||
AssetEntity asset,
|
||||
File sourceFile,
|
||||
Map<String, IfdTag>? exifData,
|
||||
) async {
|
||||
// h4ck to fetch location data if missing (thank you Android Q+) lazily only during uploads
|
||||
if (file.location == null ||
|
||||
@@ -298,6 +314,13 @@ Future<void> _decorateEnteFileData(
|
||||
file.location = props.location;
|
||||
}
|
||||
}
|
||||
if (Platform.isAndroid && exifData != null) {
|
||||
//Fix for missing location data in lower android versions.
|
||||
final Location? exifLocation = locationFromExif(exifData);
|
||||
if (Location.isValidLocation(exifLocation)) {
|
||||
file.location = exifLocation;
|
||||
}
|
||||
}
|
||||
if (file.title == null || file.title!.isEmpty) {
|
||||
_logger.warning("Title was missing ${file.tag}");
|
||||
file.title = await asset.titleAsync;
|
||||
@@ -330,9 +353,13 @@ Future<MetadataRequest> getPubMetadataRequest(
|
||||
);
|
||||
}
|
||||
|
||||
Future<MediaUploadData> _getMediaUploadDataFromAppCache(EnteFile file) async {
|
||||
Future<MediaUploadData> _getMediaUploadDataFromAppCache(
|
||||
EnteFile file,
|
||||
bool parseExif,
|
||||
) async {
|
||||
File sourceFile;
|
||||
Uint8List? thumbnailData;
|
||||
Map<String, IfdTag>? exifData;
|
||||
const bool isDeleted = false;
|
||||
final localPath = getSharedMediaFilePath(file);
|
||||
sourceFile = File(localPath);
|
||||
@@ -350,6 +377,7 @@ Future<MediaUploadData> _getMediaUploadDataFromAppCache(EnteFile file) async {
|
||||
Map<String, int>? dimensions;
|
||||
if (file.fileType == FileType.image) {
|
||||
dimensions = await getImageHeightAndWith(imagePath: localPath);
|
||||
exifData = await tryExifFromFile(sourceFile);
|
||||
} else if (thumbnailData != null) {
|
||||
// the thumbnail null check is to ensure that we are able to generate thum
|
||||
// for video, we need to use the thumbnail data with any max width/height
|
||||
@@ -368,6 +396,7 @@ Future<MediaUploadData> _getMediaUploadDataFromAppCache(EnteFile file) async {
|
||||
FileHashData(fileHash),
|
||||
height: dimensions?['height'],
|
||||
width: dimensions?['width'],
|
||||
exifData: exifData,
|
||||
);
|
||||
} catch (e, s) {
|
||||
_logger.warning("failed to generate thumbnail", e, s);
|
||||
|
||||
@@ -165,9 +165,9 @@ Future<List<EnteFile>> convertIncomingSharedMediaToFile(
|
||||
enteFile.fileType =
|
||||
media.type == SharedMediaType.image ? FileType.image : FileType.video;
|
||||
if (enteFile.fileType == FileType.image) {
|
||||
final exifTime = await getCreationTimeFromEXIF(ioFile, null);
|
||||
if (exifTime != null) {
|
||||
enteFile.creationTime = exifTime.microsecondsSinceEpoch;
|
||||
final dateResult = await tryParseExifDateTime(ioFile, null);
|
||||
if (dateResult != null && dateResult.time != null) {
|
||||
enteFile.creationTime = dateResult.time!.microsecondsSinceEpoch;
|
||||
}
|
||||
} else if (enteFile.fileType == FileType.video) {
|
||||
enteFile.duration = (media.duration ?? 0) ~/ 1000;
|
||||
|
||||
Reference in New Issue
Block a user