diff --git a/mobile/lib/models/file/file.dart b/mobile/lib/models/file/file.dart index 937bee0cb1..705d740e82 100644 --- a/mobile/lib/models/file/file.dart +++ b/mobile/lib/models/file/file.dart @@ -160,6 +160,7 @@ class EnteFile { Future> 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) { diff --git a/mobile/lib/models/metadata/file_magic.dart b/mobile/lib/models/metadata/file_magic.dart index 7599b7c82f..02f6188a9d 100644 --- a/mobile/lib/models/metadata/file_magic.dart +++ b/mobile/lib/models/metadata/file_magic.dart @@ -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], ); } diff --git a/mobile/lib/ui/viewer/file_details/creation_time_item_widget.dart b/mobile/lib/ui/viewer/file_details/creation_time_item_widget.dart index 90b6ab9d58..13c8800fa8 100644 --- a/mobile/lib/ui/viewer/file_details/creation_time_item_widget.dart +++ b/mobile/lib/ui/viewer/file_details/creation_time_item_widget.dart @@ -21,15 +21,15 @@ class CreationTimeItem extends StatefulWidget { class _CreationTimeItemState extends State { @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, diff --git a/mobile/lib/utils/exif_util.dart b/mobile/lib/utils/exif_util.dart index 652336ebee..a94e02bece 100644 --- a/mobile/lib/utils/exif_util.dart +++ b/mobile/lib/utils/exif_util.dart @@ -47,7 +47,7 @@ Future> getExif(EnteFile file) async { } } -Future?> getExifFromSourceFile(File originFile) async { +Future?> tryExifFromFile(File originFile) async { try { final exif = await readExifAsync(originFile); return exif; @@ -125,7 +125,25 @@ bool? checkPanoramaFromEXIF(File? file, Map? exifData) { return element?.printable == "6"; } -Future 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 tryParseExifDateTime( File? file, Map? exifData, ) async { @@ -137,46 +155,55 @@ Future 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 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 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 exif) { diff --git a/mobile/lib/utils/file_uploader.dart b/mobile/lib/utils/file_uploader.dart index 9290772910..e36779ead1 100644 --- a/mobile/lib/utils/file_uploader.dart +++ b/mobile/lib/utils/file_uploader.dart @@ -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 pubMetadata = {}; + final Map 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 _buildPublicMagicData( + MediaUploadData mediaUploadData, + ParsedExifDateTime? exifTime, + ) { + final Map 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") || diff --git a/mobile/lib/utils/file_uploader_util.dart b/mobile/lib/utils/file_uploader_util.dart index 6ad6622f5f..6f37236b8e 100644 --- a/mobile/lib/utils/file_uploader_util.dart +++ b/mobile/lib/utils/file_uploader_util.dart @@ -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? 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 getUploadDataFromEnteFile(EnteFile file) async { +Future 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 _getMediaUploadDataFromAssetFile(EnteFile file) async { +Future _getMediaUploadDataFromAssetFile( + EnteFile file, + bool parseExif, +) async { File? sourceFile; Uint8List? thumbnailData; bool isDeleted; String? zipHash; String fileHash; + Map? 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 _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 _getMediaUploadDataFromAssetFile(EnteFile file) async { height: h, width: w, motionPhotoStartIndex: motionPhotoStartingIndex, + exifData: exifData, ); } @@ -284,6 +299,7 @@ Future _decorateEnteFileData( EnteFile file, AssetEntity asset, File sourceFile, + Map? 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 _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 getPubMetadataRequest( ); } -Future _getMediaUploadDataFromAppCache(EnteFile file) async { +Future _getMediaUploadDataFromAppCache( + EnteFile file, + bool parseExif, +) async { File sourceFile; Uint8List? thumbnailData; + Map? exifData; const bool isDeleted = false; final localPath = getSharedMediaFilePath(file); sourceFile = File(localPath); @@ -350,6 +377,7 @@ Future _getMediaUploadDataFromAppCache(EnteFile file) async { Map? 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 _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); diff --git a/mobile/lib/utils/share_util.dart b/mobile/lib/utils/share_util.dart index c39d1733b9..3ec147798d 100644 --- a/mobile/lib/utils/share_util.dart +++ b/mobile/lib/utils/share_util.dart @@ -165,9 +165,9 @@ Future> 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;