aves/lib/model/entry.dart
2021-07-08 16:15:43 +09:00

681 lines
22 KiB
Dart

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/model/video/metadata.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;
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<String> 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 int? durationMillis,
}) {
this.path = path;
this.sourceTitle = sourceTitle;
this.dateModifiedSecs = dateModifiedSecs;
this.durationMillis = durationMillis;
}
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<String, dynamic> 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);
}
int? get durationMillis => _durationMillis;
set durationMillis(int? durationMillis) {
_durationMillis = durationMillis;
_durationText = null;
}
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<String>? _xmpSubjects;
List<String> 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<void> 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.ceil(),
'height': size.height.ceil(),
}, persist: persist);
}
catalogMetadata = CatalogMetadata(contentId: contentId);
} else {
if (isVideo && (!isSized || durationMillis == 0)) {
// exotic video that is not sized during loading
final fields = await VideoMetadataFormatter.getCatalogMetadata(this);
await _applyNewFields(fields, persist: persist);
}
catalogMetadata = await metadataService.getCatalogMetadata(this, background: background);
}
}
AddressDetails? get addressDetails => _addressDetails;
set addressDetails(AddressDetails? newAddress) {
_addressDetails = newAddress;
addressChangeNotifier.notifyListeners();
}
Future<void> 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<void> _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<void> locatePlace({required bool background}) async {
if (!hasGps || hasFineAddress) return;
try {
Future<List<Address>> 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<String?> 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,
}.whereNotNull().where((v) => v.isNotEmpty).join(', ');
}
bool search(String query) => {
bestTitle,
_catalogMetadata?.xmpSubjects,
_addressDetails?.countryName,
_addressDetails?.adminArea,
_addressDetails?.locality,
}.any((s) => s != null && s.toUpperCase().contains(query));
Future<void> _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 durationMillis = newFields['durationMillis'];
if (durationMillis is int) this.durationMillis = durationMillis;
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<bool> 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<bool> 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<bool> delete() {
final completer = Completer<bool>();
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<void> _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<void> toggleFavourite() async {
if (isFavourite) {
await removeFromFavourites();
} else {
await addToFavourites();
}
}
Future<void> addToFavourites() async {
if (!isFavourite) {
await favourites.add([this]);
}
}
Future<void> 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);
}
}