[mob] Fix exif time parsing (#5029)

## Description

## Tests
This commit is contained in:
Neeraj
2025-02-11 20:09:12 +05:30
committed by GitHub
7 changed files with 171 additions and 94 deletions

View File

@@ -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) {

View File

@@ -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],
);
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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") ||

View File

@@ -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);

View File

@@ -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;