import 'dart:async'; import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/service_policy.dart'; import 'package:aves/services/services.dart'; import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:collection/collection.dart'; import 'package:country_code/country_code.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; class AvesEntry { String uri; String? _path, _directory, _filename, _extension; int? pageId, contentId; final String sourceMimeType; int width; int height; int sourceRotationDegrees; final int? sizeBytes; String? _sourceTitle; // `dateModifiedSecs` can be missing in viewer mode int? _dateModifiedSecs; final int? sourceDateTakenMillis; final int? durationMillis; int? _catalogDateMillis; CatalogMetadata? _catalogMetadata; AddressDetails? _addressDetails; final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); // TODO TLAD make it dynamic if it depends on OS/lib versions static const List undecodable = [ MimeTypes.art, MimeTypes.crw, MimeTypes.djvu, MimeTypes.psdVnd, MimeTypes.psdX, ]; AvesEntry({ required this.uri, required String? path, required this.contentId, required this.pageId, required this.sourceMimeType, required this.width, required this.height, required this.sourceRotationDegrees, required this.sizeBytes, required String? sourceTitle, required int? dateModifiedSecs, required this.sourceDateTakenMillis, required this.durationMillis, }) { this.path = path; this.sourceTitle = sourceTitle; this.dateModifiedSecs = dateModifiedSecs; } bool get canDecode => !undecodable.contains(mimeType); bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); AvesEntry copyWith({ String? uri, String? path, int? contentId, int? dateModifiedSecs, }) { final copyContentId = contentId ?? this.contentId; final copied = AvesEntry( uri: uri ?? this.uri, path: path ?? this.path, contentId: copyContentId, pageId: null, sourceMimeType: sourceMimeType, width: width, height: height, sourceRotationDegrees: sourceRotationDegrees, sizeBytes: sizeBytes, sourceTitle: sourceTitle, dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis, ) ..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) ..addressDetails = _addressDetails?.copyWith(contentId: copyContentId); return copied; } // from DB or platform source entry factory AvesEntry.fromMap(Map map) { return AvesEntry( uri: map['uri'] as String, path: map['path'] as String?, pageId: null, contentId: map['contentId'] as int?, sourceMimeType: map['sourceMimeType'] as String, width: map['width'] as int? ?? 0, height: map['height'] as int? ?? 0, sourceRotationDegrees: map['sourceRotationDegrees'] as int? ?? 0, sizeBytes: map['sizeBytes'] as int?, sourceTitle: map['title'] as String?, dateModifiedSecs: map['dateModifiedSecs'] as int?, sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?, durationMillis: map['durationMillis'] as int?, ); } // for DB only Map toMap() { return { 'uri': uri, 'path': path, 'contentId': contentId, 'sourceMimeType': sourceMimeType, 'width': width, 'height': height, 'sourceRotationDegrees': sourceRotationDegrees, 'sizeBytes': sizeBytes, 'title': sourceTitle, 'dateModifiedSecs': dateModifiedSecs, 'sourceDateTakenMillis': sourceDateTakenMillis, 'durationMillis': durationMillis, }; } void dispose() { imageChangeNotifier.dispose(); metadataChangeNotifier.dispose(); addressChangeNotifier.dispose(); } // do not implement [Object.==] and [Object.hashCode] using mutable attributes (e.g. `uri`) // so that we can reliably use instances in a `Set`, which requires consistent hash codes over time @override String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}'; set path(String? path) { _path = path; _directory = null; _filename = null; _extension = null; } String? get path => _path; String? get directory { _directory ??= path != null ? pContext.dirname(path!) : null; return _directory; } String? get filenameWithoutExtension { _filename ??= path != null ? pContext.basenameWithoutExtension(path!) : null; return _filename; } String? get extension { _extension ??= path != null ? pContext.extension(path!) : null; return _extension; } // the MIME type reported by the Media Store is unreliable // so we use the one found during cataloguing if possible String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType; String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*'); bool get isFavourite => favourites.isFavourite(this); bool get isSvg => mimeType == MimeTypes.svg; // guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels) bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg, MimeTypes.tiff].contains(mimeType) || isRaw; // Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported" // but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below, // and it actually fails to decode GIF, DNG and animated WEBP. Other formats were not tested. bool get _supportedByBitmapRegionDecoder => [ MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg, MimeTypes.webp, MimeTypes.arw, MimeTypes.cr2, MimeTypes.nef, MimeTypes.nrw, MimeTypes.orf, MimeTypes.pef, MimeTypes.raf, MimeTypes.rw2, MimeTypes.srw, ].contains(mimeType) && !isAnimated; bool get supportTiling => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff; bool get useTiles => supportTiling && (width > 4096 || height > 4096); bool get isRaw => MimeTypes.rawImages.contains(mimeType); bool get isImage => MimeTypes.isImage(mimeType); bool get isVideo => MimeTypes.isVideo(mimeType); bool get isCatalogued => _catalogMetadata != null; bool get isAnimated => _catalogMetadata?.isAnimated ?? false; bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false; bool get is360 => _catalogMetadata?.is360 ?? false; bool get isMultiPage => _catalogMetadata?.isMultiPage ?? false; bool get isMotionPhoto => isMultiPage && mimeType == MimeTypes.jpeg; bool get canEdit => path != null; bool get canRotateAndFlip => canEdit && canEditExif; // support for writing EXIF // as of androidx.exifinterface:exifinterface:1.3.0 bool get canEditExif { switch (mimeType.toLowerCase()) { case MimeTypes.jpeg: case MimeTypes.png: case MimeTypes.webp: return true; default: return false; } } // Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata, // so it should be registered as width=1920, height=1080, orientation=90, // but is incorrectly registered as width=1080, height=1920, orientation=0. // Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time. // Comparing width and height can help with the portrait FHD video example, // but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90 bool get isRotated => rotationDegrees % 180 == 90; static const ratioSeparator = '\u2236'; static const resolutionSeparator = ' \u00D7 '; bool get isSized => width > 0 && height > 0; String get resolutionText { final ws = width; final hs = height; return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs'; } String get aspectRatioText { if (width > 0 && height > 0) { final gcd = width.gcd(height); final w = width ~/ gcd; final h = height ~/ gcd; return isRotated ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h'; } else { return '?$ratioSeparator?'; } } double get displayAspectRatio { if (width == 0 || height == 0) return 1; return isRotated ? height / width : width / height; } Size get displaySize { final w = width.toDouble(); final h = height.toDouble(); return isRotated ? Size(h, w) : Size(w, h); } Size videoDisplaySize(double sar) { final size = displaySize; if (sar != 1) { final dar = displayAspectRatio * sar; final w = size.width; final h = size.height; if (w >= h) return Size(w, w / dar); if (h > w) return Size(h * dar, h); } return size; } int get megaPixels => (width * height / 1000000).round(); DateTime? _bestDate; DateTime? get bestDate { if (_bestDate == null) { if ((_catalogDateMillis ?? 0) > 0) { _bestDate = DateTime.fromMillisecondsSinceEpoch(_catalogDateMillis!); } else if ((sourceDateTakenMillis ?? 0) > 0) { _bestDate = DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis!); } else if ((dateModifiedSecs ?? 0) > 0) { _bestDate = DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs! * 1000); } } return _bestDate; } int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees; set rotationDegrees(int rotationDegrees) { sourceRotationDegrees = rotationDegrees; _catalogMetadata?.rotationDegrees = rotationDegrees; } bool get isFlipped => _catalogMetadata?.isFlipped ?? false; set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped; String? get sourceTitle => _sourceTitle; set sourceTitle(String? sourceTitle) { _sourceTitle = sourceTitle; _bestTitle = null; } int? get dateModifiedSecs => _dateModifiedSecs; set dateModifiedSecs(int? dateModifiedSecs) { _dateModifiedSecs = dateModifiedSecs; _bestDate = null; } DateTime? get monthTaken { final d = bestDate; return d == null ? null : DateTime(d.year, d.month); } DateTime? get dayTaken { final d = bestDate; return d == null ? null : DateTime(d.year, d.month, d.day); } String? _durationText; String get durationText { _durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0)); return _durationText!; } // returns whether this entry has GPS coordinates // (0, 0) coordinates are considered invalid, as it is likely a default value bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0; bool get hasAddress => _addressDetails != null; // has a place, or at least the full country name // derived from Google reverse geocoding addresses bool get hasFineAddress => _addressDetails?.place?.isNotEmpty == true || (_addressDetails?.countryName?.length ?? 0) > 3; LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null; String? get geoUri { if (!hasGps) return null; final latitude = roundToPrecision(_catalogMetadata!.latitude!, decimals: 6); final longitude = roundToPrecision(_catalogMetadata!.longitude!, decimals: 6); return 'geo:$latitude,$longitude?q=$latitude,$longitude'; } List? _xmpSubjects; List get xmpSubjects { _xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? []; return _xmpSubjects!; } String? _bestTitle; String? get bestTitle { _bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : sourceTitle; return _bestTitle; } CatalogMetadata? get catalogMetadata => _catalogMetadata; set catalogDateMillis(int? dateMillis) { _catalogDateMillis = dateMillis; _bestDate = null; } set catalogMetadata(CatalogMetadata? newMetadata) { final oldDateModifiedSecs = dateModifiedSecs; final oldRotationDegrees = rotationDegrees; final oldIsFlipped = isFlipped; catalogDateMillis = newMetadata?.dateMillis; _catalogMetadata = newMetadata; _bestTitle = null; _xmpSubjects = null; metadataChangeNotifier.notifyListeners(); _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); } void clearMetadata() { catalogMetadata = null; addressDetails = null; } Future catalog({bool background = false, bool persist = true}) async { if (isCatalogued) return; if (isSvg) { // vector image sizing is not essential, so we should not spend time for it during loading // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing final size = await SvgMetadataService.getSize(this); if (size != null) { await _applyNewFields({ 'width': size.width.round(), 'height': size.height.round(), }, persist: persist); } catalogMetadata = CatalogMetadata(contentId: contentId); } else { catalogMetadata = await metadataService.getCatalogMetadata(this, background: background); } } AddressDetails? get addressDetails => _addressDetails; set addressDetails(AddressDetails? newAddress) { _addressDetails = newAddress; addressChangeNotifier.notifyListeners(); } Future locate({required bool background}) async { if (!hasGps) return; await _locateCountry(); if (await availability.canLocatePlaces) { await locatePlace(background: background); } } // quick reverse geocoding to find the country, using an offline asset Future _locateCountry() async { if (!hasGps || hasAddress) return; final countryCode = await countryTopology.countryCode(latLng!); setCountry(countryCode); } void setCountry(CountryCode? countryCode) { if (hasFineAddress || countryCode == null) return; addressDetails = AddressDetails( contentId: contentId, countryCode: countryCode.alpha2, countryName: countryCode.alpha3, ); } String? _geocoderLocale; String get geocoderLocale { _geocoderLocale ??= (settings.locale ?? WidgetsBinding.instance!.window.locale).toString(); return _geocoderLocale!; } // full reverse geocoding, requiring Play Services and some connectivity Future locatePlace({required bool background}) async { if (!hasGps || hasFineAddress) return; try { Future> call() => GeocodingService.getAddress(latLng!, geocoderLocale); final addresses = await (background ? servicePolicy.call( call, priority: ServiceCallPriority.getLocation, ) : call()); if (addresses.isNotEmpty) { final address = addresses.first; final cc = address.countryCode; final cn = address.countryName; final aa = address.adminArea; addressDetails = AddressDetails( contentId: contentId, countryCode: cc, countryName: cn, adminArea: aa, // if country & admin fields are null, it is likely the ocean, // which is identified by `featureName` but we default to the address line anyway locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null), ); } } catch (error, stack) { debugPrint('$runtimeType locate failed with path=$path coordinates=$latLng error=$error\n$stack'); } } Future findAddressLine() async { if (!hasGps) return null; try { final addresses = await GeocodingService.getAddress(latLng!, geocoderLocale); if (addresses.isNotEmpty) { final address = addresses.first; return address.addressLine; } } catch (error, stack) { debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$latLng error=$error\n$stack'); } return null; } String get shortAddress { // `admin area` examples: Seoul, Geneva, null // `locality` examples: Mapo-gu, Geneva, Annecy return { _addressDetails?.countryName, _addressDetails?.adminArea, _addressDetails?.locality, }.where((part) => part != null && part.isNotEmpty).join(', '); } bool search(String query) => { bestTitle, _catalogMetadata?.xmpSubjects, _addressDetails?.countryName, _addressDetails?.adminArea, _addressDetails?.locality, }.any((s) => s != null && s.toUpperCase().contains(query)); Future _applyNewFields(Map newFields, {required bool persist}) async { final uri = newFields['uri']; if (uri is String) this.uri = uri; final path = newFields['path']; if (path is String) this.path = path; final contentId = newFields['contentId']; if (contentId is int) this.contentId = contentId; final sourceTitle = newFields['title']; if (sourceTitle is String) this.sourceTitle = sourceTitle; final width = newFields['width']; if (width is int) this.width = width; final height = newFields['height']; if (height is int) this.height = height; final dateModifiedSecs = newFields['dateModifiedSecs']; if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs; final rotationDegrees = newFields['rotationDegrees']; if (rotationDegrees is int) this.rotationDegrees = rotationDegrees; final isFlipped = newFields['isFlipped']; if (isFlipped is bool) this.isFlipped = isFlipped; if (persist) { await metadataDb.saveEntries({this}); if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); } metadataChangeNotifier.notifyListeners(); } Future rotate({required bool clockwise, required bool persist}) async { final newFields = await imageFileService.rotate(this, clockwise: clockwise); if (newFields.isEmpty) return false; final oldDateModifiedSecs = dateModifiedSecs; final oldRotationDegrees = rotationDegrees; final oldIsFlipped = isFlipped; await _applyNewFields(newFields, persist: persist); await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } Future flip({required bool persist}) async { final newFields = await imageFileService.flip(this); if (newFields.isEmpty) return false; final oldDateModifiedSecs = dateModifiedSecs; final oldRotationDegrees = rotationDegrees; final oldIsFlipped = isFlipped; await _applyNewFields(newFields, persist: persist); await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } Future delete() { final completer = Completer(); imageFileService.delete([this]).listen( (event) => completer.complete(event.success), onError: completer.completeError, onDone: () { if (!completer.isCompleted) { completer.complete(false); } }, ); return completer.future; } // when the entry image itself changed (e.g. after rotation) Future _onImageChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); imageChangeNotifier.notifyListeners(); } } // favourites Future toggleFavourite() async { if (isFavourite) { await removeFromFavourites(); } else { await addToFavourites(); } } Future addToFavourites() async { if (!isFavourite) { await favourites.add([this]); } } Future removeFromFavourites() async { if (isFavourite) { await favourites.remove([this]); } } // compare by: // 1) title ascending // 2) extension ascending static int compareByName(AvesEntry a, AvesEntry b) { final c = compareAsciiUpperCase(a.bestTitle ?? '', b.bestTitle ?? ''); return c != 0 ? c : compareAsciiUpperCase(a.extension ?? '', b.extension ?? ''); } // compare by: // 1) size descending // 2) name ascending static int compareBySize(AvesEntry a, AvesEntry b) { final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0); return c != 0 ? c : compareByName(a, b); } static final _epoch = DateTime.fromMillisecondsSinceEpoch(0); // compare by: // 1) date descending // 2) name descending static int compareByDate(AvesEntry a, AvesEntry b) { var c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch); if (c != 0) return c; return compareByName(b, a); } }