From 774bcd97c517e363a073bbfd61e64d38182af710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corentin=20No=C3=ABl?= Date: Sun, 3 Nov 2024 08:25:39 +0000 Subject: [PATCH 01/24] Use the EXIF data provided by Nextcloud Photos --- app/lib/api/entity_converter.dart | 11 ++++++++++- app/lib/entity/file.dart | 10 ++++++++++ app/lib/entity/file/data_source.dart | 3 +++ np_api/lib/src/entity/entity.dart | 3 +++ np_api/lib/src/entity/file_parser.dart | 10 ++++++++++ np_api/lib/src/files_api.dart | 7 ++++++- 6 files changed, 42 insertions(+), 2 deletions(-) diff --git a/app/lib/api/entity_converter.dart b/app/lib/api/entity_converter.dart index 75f7b28f..bcd80aed 100644 --- a/app/lib/api/entity_converter.dart +++ b/app/lib/api/entity_converter.dart @@ -50,7 +50,7 @@ class ApiFavoriteConverter { class ApiFileConverter { static File fromApi(api.File file) { - final metadata = file.customProperties?["com.nkming.nc_photos:metadata"] + var metadata = file.customProperties?["com.nkming.nc_photos:metadata"] ?.run((obj) => Metadata.fromJson( jsonDecode(obj), upgraderV1: MetadataUpgraderV1( @@ -66,6 +66,15 @@ class ApiFileConverter { logFilePath: file.href, ), )); + if (file.metadataPhotosIfd0 != null) { + final ifd0_metadata = Metadata.fromPhotosIfd0(file.metadataPhotosIfd0!); + if (metadata == null) { + metadata = ifd0_metadata; + } else { + metadata = metadata.copyWith(exif: ifd0_metadata.exif); + } + } + return File( path: _hrefToPath(file.href), contentLength: file.contentLength, diff --git a/app/lib/entity/file.dart b/app/lib/entity/file.dart index e2b03858..f88834db 100644 --- a/app/lib/entity/file.dart +++ b/app/lib/entity/file.dart @@ -185,6 +185,16 @@ class Metadata with EquatableMixin { ); } + static Metadata fromPhotosIfd0(Map metadataPhotosIfd0) { + return Metadata( + lastUpdated: null, + fileEtag: null, + imageWidth: null, + imageHeight: null, + exif: new Exif(metadataPhotosIfd0), + ); + } + @override String toString() => _$toString(); diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 8a67ab95..6c87f137 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -55,6 +55,7 @@ class FileWebdavDataSource implements FileDataSource { trashbinFilename: 1, trashbinOriginalLocation: 1, trashbinDeletionTime: 1, + metadataPhotosIfd0: 1, customNamespaces: { "com.nkming.nc_photos": "app", }, @@ -275,6 +276,7 @@ class FileWebdavDataSource implements FileDataSource { trashbinFilename, trashbinOriginalLocation, trashbinDeletionTime, + metadataPhotosIfd0, Map? customNamespaces, List? customProperties, }) async { @@ -302,6 +304,7 @@ class FileWebdavDataSource implements FileDataSource { trashbinFilename: trashbinFilename, trashbinOriginalLocation: trashbinOriginalLocation, trashbinDeletionTime: trashbinDeletionTime, + metadataPhotosIfd0: metadataPhotosIfd0, customNamespaces: customNamespaces, customProperties: customProperties, ); diff --git a/np_api/lib/src/entity/entity.dart b/np_api/lib/src/entity/entity.dart index 20eea6e0..f73a35cb 100644 --- a/np_api/lib/src/entity/entity.dart +++ b/np_api/lib/src/entity/entity.dart @@ -84,6 +84,7 @@ class File with EquatableMixin { this.trashbinFilename, this.trashbinOriginalLocation, this.trashbinDeletionTime, + this.metadataPhotosIfd0, this.customProperties, }); @@ -106,6 +107,7 @@ class File with EquatableMixin { trashbinFilename, trashbinOriginalLocation, trashbinDeletionTime, + metadataPhotosIfd0, customProperties, ]; @@ -123,6 +125,7 @@ class File with EquatableMixin { final String? trashbinFilename; final String? trashbinOriginalLocation; final DateTime? trashbinDeletionTime; + final Map? metadataPhotosIfd0; final Map? customProperties; } diff --git a/np_api/lib/src/entity/file_parser.dart b/np_api/lib/src/entity/file_parser.dart index 8dc18c66..933af39f 100644 --- a/np_api/lib/src/entity/file_parser.dart +++ b/np_api/lib/src/entity/file_parser.dart @@ -33,6 +33,7 @@ class FileParser extends XmlResponseParser { String? trashbinFilename; String? trashbinOriginalLocation; DateTime? trashbinDeletionTime; + Map? metadataPhotosIfd0; Map? customProperties; for (final child in element.children.whereType()) { @@ -66,6 +67,7 @@ class FileParser extends XmlResponseParser { trashbinFilename = propParser.trashbinFilename; trashbinOriginalLocation = propParser.trashbinOriginalLocation; trashbinDeletionTime = propParser.trashbinDeletionTime; + metadataPhotosIfd0 = propParser.metadataPhotosIfd0; customProperties = propParser.customProperties; } } @@ -85,6 +87,7 @@ class FileParser extends XmlResponseParser { trashbinFilename: trashbinFilename, trashbinOriginalLocation: trashbinOriginalLocation, trashbinDeletionTime: trashbinDeletionTime, + metadataPhotosIfd0: metadataPhotosIfd0, customProperties: customProperties, ); } @@ -140,6 +143,11 @@ class _PropParser { prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { _trashbinDeletionTime = DateTime.fromMillisecondsSinceEpoch( int.parse(child.innerText) * 1000); + } else if (child.matchQualifiedName("metadata-photos-ifd0", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + for (final ifd0_child in child.children.whereType()) { + (_metadataPhotosIfd0 ??= {})[ifd0_child.name.toString()] = ifd0_child.innerText; + } } else { final key = child.name.prefix == null ? child.localName @@ -162,6 +170,7 @@ class _PropParser { String? get trashbinFilename => _trashbinFilename; String? get trashbinOriginalLocation => _trashbinOriginalLocation; DateTime? get trashbinDeletionTime => _trashbinDeletionTime; + Map? get metadataPhotosIfd0 => _metadataPhotosIfd0; Map? get customProperties => _customProperties; final Map namespaces; @@ -179,6 +188,7 @@ class _PropParser { String? _trashbinFilename; String? _trashbinOriginalLocation; DateTime? _trashbinDeletionTime; + Map? _metadataPhotosIfd0; Map? _customProperties; } diff --git a/np_api/lib/src/files_api.dart b/np_api/lib/src/files_api.dart index 656a143e..1f947970 100644 --- a/np_api/lib/src/files_api.dart +++ b/np_api/lib/src/files_api.dart @@ -72,6 +72,7 @@ class ApiFiles { trashbinFilename, trashbinOriginalLocation, trashbinDeletionTime, + metadataPhotosIfd0, Map? customNamespaces, List? customProperties, }) async { @@ -96,7 +97,8 @@ class ApiFiles { richWorkspace != null || trashbinFilename != null || trashbinOriginalLocation != null || - trashbinDeletionTime != null); + trashbinDeletionTime != null || + metadataPhotosIfd0 != null); if (!hasDavNs && !hasOcNs && !hasNcNs) { // no body return await _api.request("PROPFIND", path); @@ -175,6 +177,9 @@ class ApiFiles { if (trashbinDeletionTime != null) { builder.element("nc:trashbin-deletion-time"); } + if (metadataPhotosIfd0 != null) { + builder.element("nc:metadata-photos-ifd0"); + } for (final p in customProperties ?? []) { builder.element(p); } From 76cf08d6fb98c7f93f816cfb2699e1d86ebc2c79 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 3 Nov 2024 22:12:32 +0800 Subject: [PATCH 02/24] Add nc:metadata-photos-exif and nc:metadata-photos-gps to files api --- np_api/lib/src/entity/entity.dart | 6 ++++++ np_api/lib/src/entity/entity.g.dart | 2 +- np_api/lib/src/entity/file_parser.dart | 25 +++++++++++++++++++++++-- np_api/lib/src/files_api.dart | 12 +++++++++++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/np_api/lib/src/entity/entity.dart b/np_api/lib/src/entity/entity.dart index f73a35cb..e5a18195 100644 --- a/np_api/lib/src/entity/entity.dart +++ b/np_api/lib/src/entity/entity.dart @@ -85,6 +85,8 @@ class File with EquatableMixin { this.trashbinOriginalLocation, this.trashbinDeletionTime, this.metadataPhotosIfd0, + this.metadataPhotosExif, + this.metadataPhotosGps, this.customProperties, }); @@ -108,6 +110,8 @@ class File with EquatableMixin { trashbinOriginalLocation, trashbinDeletionTime, metadataPhotosIfd0, + metadataPhotosExif, + metadataPhotosGps, customProperties, ]; @@ -126,6 +130,8 @@ class File with EquatableMixin { final String? trashbinOriginalLocation; final DateTime? trashbinDeletionTime; final Map? metadataPhotosIfd0; + final Map? metadataPhotosExif; + final Map? metadataPhotosGps; final Map? customProperties; } diff --git a/np_api/lib/src/entity/entity.g.dart b/np_api/lib/src/entity/entity.g.dart index 2365f0c9..da142482 100644 --- a/np_api/lib/src/entity/entity.g.dart +++ b/np_api/lib/src/entity/entity.g.dart @@ -30,7 +30,7 @@ extension _$FavoriteToString on Favorite { extension _$FileToString on File { String _$toString() { // ignore: unnecessary_string_interpolations - return "File {href: $href, ${lastModified == null ? "" : "lastModified: $lastModified, "}${etag == null ? "" : "etag: $etag, "}${contentType == null ? "" : "contentType: $contentType, "}${isCollection == null ? "" : "isCollection: $isCollection, "}${contentLength == null ? "" : "contentLength: $contentLength, "}${fileId == null ? "" : "fileId: $fileId, "}${favorite == null ? "" : "favorite: $favorite, "}${ownerId == null ? "" : "ownerId: $ownerId, "}${ownerDisplayName == null ? "" : "ownerDisplayName: $ownerDisplayName, "}${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${trashbinFilename == null ? "" : "trashbinFilename: $trashbinFilename, "}${trashbinOriginalLocation == null ? "" : "trashbinOriginalLocation: $trashbinOriginalLocation, "}${trashbinDeletionTime == null ? "" : "trashbinDeletionTime: $trashbinDeletionTime, "}${customProperties == null ? "" : "customProperties: $customProperties"}}"; + return "File {href: $href, ${lastModified == null ? "" : "lastModified: $lastModified, "}${etag == null ? "" : "etag: $etag, "}${contentType == null ? "" : "contentType: $contentType, "}${isCollection == null ? "" : "isCollection: $isCollection, "}${contentLength == null ? "" : "contentLength: $contentLength, "}${fileId == null ? "" : "fileId: $fileId, "}${favorite == null ? "" : "favorite: $favorite, "}${ownerId == null ? "" : "ownerId: $ownerId, "}${ownerDisplayName == null ? "" : "ownerDisplayName: $ownerDisplayName, "}${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${trashbinFilename == null ? "" : "trashbinFilename: $trashbinFilename, "}${trashbinOriginalLocation == null ? "" : "trashbinOriginalLocation: $trashbinOriginalLocation, "}${trashbinDeletionTime == null ? "" : "trashbinDeletionTime: $trashbinDeletionTime, "}${metadataPhotosIfd0 == null ? "" : "metadataPhotosIfd0: $metadataPhotosIfd0, "}${metadataPhotosExif == null ? "" : "metadataPhotosExif: $metadataPhotosExif, "}${metadataPhotosGps == null ? "" : "metadataPhotosGps: $metadataPhotosGps, "}${customProperties == null ? "" : "customProperties: $customProperties"}}"; } } diff --git a/np_api/lib/src/entity/file_parser.dart b/np_api/lib/src/entity/file_parser.dart index 933af39f..13c902dc 100644 --- a/np_api/lib/src/entity/file_parser.dart +++ b/np_api/lib/src/entity/file_parser.dart @@ -34,6 +34,8 @@ class FileParser extends XmlResponseParser { String? trashbinOriginalLocation; DateTime? trashbinDeletionTime; Map? metadataPhotosIfd0; + Map? metadataPhotosExif; + Map? metadataPhotosGps; Map? customProperties; for (final child in element.children.whereType()) { @@ -68,6 +70,8 @@ class FileParser extends XmlResponseParser { trashbinOriginalLocation = propParser.trashbinOriginalLocation; trashbinDeletionTime = propParser.trashbinDeletionTime; metadataPhotosIfd0 = propParser.metadataPhotosIfd0; + metadataPhotosExif = propParser.metadataPhotosExif; + metadataPhotosGps = propParser.metadataPhotosGps; customProperties = propParser.customProperties; } } @@ -88,6 +92,8 @@ class FileParser extends XmlResponseParser { trashbinOriginalLocation: trashbinOriginalLocation, trashbinDeletionTime: trashbinDeletionTime, metadataPhotosIfd0: metadataPhotosIfd0, + metadataPhotosExif: metadataPhotosExif, + metadataPhotosGps: metadataPhotosGps, customProperties: customProperties, ); } @@ -145,8 +151,19 @@ class _PropParser { int.parse(child.innerText) * 1000); } else if (child.matchQualifiedName("metadata-photos-ifd0", prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { - for (final ifd0_child in child.children.whereType()) { - (_metadataPhotosIfd0 ??= {})[ifd0_child.name.toString()] = ifd0_child.innerText; + for (final ifd0Child in child.children.whereType()) { + (_metadataPhotosIfd0 ??= {})[ifd0Child.localName] = + ifd0Child.innerText; + } + } else if (child.matchQualifiedName("metadata-photos-exif", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + for (final c in child.children.whereType()) { + (_metadataPhotosExif ??= {})[c.localName] = c.innerText; + } + } else if (child.matchQualifiedName("metadata-photos-gps", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + for (final c in child.children.whereType()) { + (_metadataPhotosGps ??= {})[c.localName] = c.innerText; } } else { final key = child.name.prefix == null @@ -171,6 +188,8 @@ class _PropParser { String? get trashbinOriginalLocation => _trashbinOriginalLocation; DateTime? get trashbinDeletionTime => _trashbinDeletionTime; Map? get metadataPhotosIfd0 => _metadataPhotosIfd0; + Map? get metadataPhotosExif => _metadataPhotosExif; + Map? get metadataPhotosGps => _metadataPhotosGps; Map? get customProperties => _customProperties; final Map namespaces; @@ -189,6 +208,8 @@ class _PropParser { String? _trashbinOriginalLocation; DateTime? _trashbinDeletionTime; Map? _metadataPhotosIfd0; + Map? _metadataPhotosExif; + Map? _metadataPhotosGps; Map? _customProperties; } diff --git a/np_api/lib/src/files_api.dart b/np_api/lib/src/files_api.dart index 1f947970..1eb632ba 100644 --- a/np_api/lib/src/files_api.dart +++ b/np_api/lib/src/files_api.dart @@ -73,6 +73,8 @@ class ApiFiles { trashbinOriginalLocation, trashbinDeletionTime, metadataPhotosIfd0, + metadataPhotosExif, + metadataPhotosGps, Map? customNamespaces, List? customProperties, }) async { @@ -98,7 +100,9 @@ class ApiFiles { trashbinFilename != null || trashbinOriginalLocation != null || trashbinDeletionTime != null || - metadataPhotosIfd0 != null); + metadataPhotosIfd0 != null || + metadataPhotosExif != null || + metadataPhotosGps != null); if (!hasDavNs && !hasOcNs && !hasNcNs) { // no body return await _api.request("PROPFIND", path); @@ -180,6 +184,12 @@ class ApiFiles { if (metadataPhotosIfd0 != null) { builder.element("nc:metadata-photos-ifd0"); } + if (metadataPhotosExif != null) { + builder.element("nc:metadata-photos-exif"); + } + if (metadataPhotosGps != null) { + builder.element("nc:metadata-photos-gps"); + } for (final p in customProperties ?? []) { builder.element(p); } From b86e2ef33be80122adf64a21f6b9438c11fbd35d Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 3 Nov 2024 22:12:56 +0800 Subject: [PATCH 03/24] Add test case --- np_api/test/entity/file_parser_test.dart | 208 +++++++++++++++++++++++ 1 file changed, 208 insertions(+) diff --git a/np_api/test/entity/file_parser_test.dart b/np_api/test/entity/file_parser_test.dart index c45a19f0..32f63d60 100644 --- a/np_api/test/entity/file_parser_test.dart +++ b/np_api/test/entity/file_parser_test.dart @@ -13,6 +13,9 @@ void main() { test("multiple files", _filesMultiple); test("directory", _filesDir); test("nextcloud hosted in subdir", _filesServerHostedInSubdir); + test("file w/ metadata-photos-ifd0", _filesNc28MetadataIfd0); + test("file w/ metadata-photos-exif", _filesNc28MetadataExif); + test("file w/ metadata-photos-gps", _filesNc28MetadataGps); }); }); } @@ -380,3 +383,208 @@ Future _filesServerHostedInSubdir() async { ), ]); } + +Future _filesNc28MetadataIfd0() async { + const xml = """ + + + + /nextcloud/remote.php/dav/files/admin/1.jpg + + + Fri, 01 Jan 2021 02:03:04 GMT + "1324f58d4d5c8d81bed6e4ed9d5ea862" + image/jpeg + + 123 + 3963036 + false + + SUPER + Phone 1 + 1 + 72/1 + 72/1 + 2 + 1.0 + 2020:01:02 03:04:05 + 1 + + + HTTP/1.1 200 OK + + + +"""; + final results = await FileParser().parse(xml); + expect(results, [ + File( + href: "/nextcloud/remote.php/dav/files/admin/1.jpg", + contentLength: 3963036, + contentType: "image/jpeg", + etag: "1324f58d4d5c8d81bed6e4ed9d5ea862", + lastModified: DateTime.utc(2021, 1, 1, 2, 3, 4), + hasPreview: false, + fileId: 123, + isCollection: false, + metadataPhotosIfd0: { + "Make": "SUPER", + "Model": "Phone 1", + "Orientation": "1", + "XResolution": "72/1", + "YResolution": "72/1", + "ResolutionUnit": "2", + "Software": "1.0", + "DateTime": "2020:01:02 03:04:05", + "YCbCrPositioning": "1", + }, + ), + ]); +} + +Future _filesNc28MetadataExif() async { + const xml = """ + + + + /nextcloud/remote.php/dav/files/admin/1.jpg + + + Fri, 01 Jan 2021 02:03:04 GMT + "1324f58d4d5c8d81bed6e4ed9d5ea862" + image/jpeg + + 123 + 3963036 + false + + 1/381 + 9/5 + 2 + 20 + 0231 + 2020:01:02 03:04:05 + 2020:01:02 03:04:05 + +01:00 + + 126682/14777 + 54823/32325 + 69659/9080 + 0/1 + 5 + 16 + 4/1 + SUPER + 65535 + 4032 + 3024 + 2 + + 0 + 0 + 28 + 0 + + + HTTP/1.1 200 OK + + + +"""; + final results = await FileParser().parse(xml); + expect(results, [ + File( + href: "/nextcloud/remote.php/dav/files/admin/1.jpg", + contentLength: 3963036, + contentType: "image/jpeg", + etag: "1324f58d4d5c8d81bed6e4ed9d5ea862", + lastModified: DateTime.utc(2021, 1, 1, 2, 3, 4), + hasPreview: false, + fileId: 123, + isCollection: false, + metadataPhotosExif: { + "ExposureTime": "1/381", + "FNumber": "9/5", + "ExposureProgram": "2", + "ISOSpeedRatings": "20", + "ExifVersion": "0231", + "DateTimeOriginal": "2020:01:02 03:04:05", + "DateTimeDigitized": "2020:01:02 03:04:05", + "UndefinedTag__x____": "+01:00", + "ComponentsConfiguration": "", + "ShutterSpeedValue": "126682/14777", + "ApertureValue": "54823/32325", + "BrightnessValue": "69659/9080", + "ExposureBiasValue": "0/1", + "MeteringMode": "5", + "Flash": "16", + "FocalLength": "4/1", + "MakerNote": "SUPER", + "ColorSpace": "65535", + "ExifImageWidth": "4032", + "ExifImageLength": "3024", + "SensingMethod": "2", + "SceneType": "", + "ExposureMode": "0", + "WhiteBalance": "0", + "FocalLengthIn__mmFilm": "28", + "SceneCaptureType": "0", + }, + ), + ]); +} + +Future _filesNc28MetadataGps() async { + const xml = """ + + + + /nextcloud/remote.php/dav/files/admin/1.jpg + + + Fri, 01 Jan 2021 02:03:04 GMT + "1324f58d4d5c8d81bed6e4ed9d5ea862" + image/jpeg + + 123 + 3963036 + false + + 1.23456 + 2.34567 + 3.45678 + + + HTTP/1.1 200 OK + + + +"""; + final results = await FileParser().parse(xml); + expect(results, [ + File( + href: "/nextcloud/remote.php/dav/files/admin/1.jpg", + contentLength: 3963036, + contentType: "image/jpeg", + etag: "1324f58d4d5c8d81bed6e4ed9d5ea862", + lastModified: DateTime.utc(2021, 1, 1, 2, 3, 4), + hasPreview: false, + fileId: 123, + isCollection: false, + metadataPhotosGps: { + "latitude": "1.23456", + "longitude": "2.34567", + "altitude": "3.45678", + }, + ), + ]); +} From 987b236563ca2b3eb0f5a069d4a33b51e1f916cd Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 4 Nov 2024 00:42:47 +0800 Subject: [PATCH 04/24] Add nc:metadata-photos-size to files api --- np_api/lib/src/entity/entity.dart | 3 +++ np_api/lib/src/entity/entity.g.dart | 2 +- np_api/lib/src/entity/file_parser.dart | 10 ++++++++++ np_api/lib/src/files_api.dart | 7 ++++++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/np_api/lib/src/entity/entity.dart b/np_api/lib/src/entity/entity.dart index e5a18195..6682ed37 100644 --- a/np_api/lib/src/entity/entity.dart +++ b/np_api/lib/src/entity/entity.dart @@ -87,6 +87,7 @@ class File with EquatableMixin { this.metadataPhotosIfd0, this.metadataPhotosExif, this.metadataPhotosGps, + this.metadataPhotosSize, this.customProperties, }); @@ -112,6 +113,7 @@ class File with EquatableMixin { metadataPhotosIfd0, metadataPhotosExif, metadataPhotosGps, + metadataPhotosSize, customProperties, ]; @@ -132,6 +134,7 @@ class File with EquatableMixin { final Map? metadataPhotosIfd0; final Map? metadataPhotosExif; final Map? metadataPhotosGps; + final Map? metadataPhotosSize; final Map? customProperties; } diff --git a/np_api/lib/src/entity/entity.g.dart b/np_api/lib/src/entity/entity.g.dart index da142482..b7793d7a 100644 --- a/np_api/lib/src/entity/entity.g.dart +++ b/np_api/lib/src/entity/entity.g.dart @@ -30,7 +30,7 @@ extension _$FavoriteToString on Favorite { extension _$FileToString on File { String _$toString() { // ignore: unnecessary_string_interpolations - return "File {href: $href, ${lastModified == null ? "" : "lastModified: $lastModified, "}${etag == null ? "" : "etag: $etag, "}${contentType == null ? "" : "contentType: $contentType, "}${isCollection == null ? "" : "isCollection: $isCollection, "}${contentLength == null ? "" : "contentLength: $contentLength, "}${fileId == null ? "" : "fileId: $fileId, "}${favorite == null ? "" : "favorite: $favorite, "}${ownerId == null ? "" : "ownerId: $ownerId, "}${ownerDisplayName == null ? "" : "ownerDisplayName: $ownerDisplayName, "}${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${trashbinFilename == null ? "" : "trashbinFilename: $trashbinFilename, "}${trashbinOriginalLocation == null ? "" : "trashbinOriginalLocation: $trashbinOriginalLocation, "}${trashbinDeletionTime == null ? "" : "trashbinDeletionTime: $trashbinDeletionTime, "}${metadataPhotosIfd0 == null ? "" : "metadataPhotosIfd0: $metadataPhotosIfd0, "}${metadataPhotosExif == null ? "" : "metadataPhotosExif: $metadataPhotosExif, "}${metadataPhotosGps == null ? "" : "metadataPhotosGps: $metadataPhotosGps, "}${customProperties == null ? "" : "customProperties: $customProperties"}}"; + return "File {href: $href, ${lastModified == null ? "" : "lastModified: $lastModified, "}${etag == null ? "" : "etag: $etag, "}${contentType == null ? "" : "contentType: $contentType, "}${isCollection == null ? "" : "isCollection: $isCollection, "}${contentLength == null ? "" : "contentLength: $contentLength, "}${fileId == null ? "" : "fileId: $fileId, "}${favorite == null ? "" : "favorite: $favorite, "}${ownerId == null ? "" : "ownerId: $ownerId, "}${ownerDisplayName == null ? "" : "ownerDisplayName: $ownerDisplayName, "}${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${trashbinFilename == null ? "" : "trashbinFilename: $trashbinFilename, "}${trashbinOriginalLocation == null ? "" : "trashbinOriginalLocation: $trashbinOriginalLocation, "}${trashbinDeletionTime == null ? "" : "trashbinDeletionTime: $trashbinDeletionTime, "}${metadataPhotosIfd0 == null ? "" : "metadataPhotosIfd0: $metadataPhotosIfd0, "}${metadataPhotosExif == null ? "" : "metadataPhotosExif: $metadataPhotosExif, "}${metadataPhotosGps == null ? "" : "metadataPhotosGps: $metadataPhotosGps, "}${metadataPhotosSize == null ? "" : "metadataPhotosSize: $metadataPhotosSize, "}${customProperties == null ? "" : "customProperties: $customProperties"}}"; } } diff --git a/np_api/lib/src/entity/file_parser.dart b/np_api/lib/src/entity/file_parser.dart index 13c902dc..4bb30c2a 100644 --- a/np_api/lib/src/entity/file_parser.dart +++ b/np_api/lib/src/entity/file_parser.dart @@ -36,6 +36,7 @@ class FileParser extends XmlResponseParser { Map? metadataPhotosIfd0; Map? metadataPhotosExif; Map? metadataPhotosGps; + Map? metadataPhotosSize; Map? customProperties; for (final child in element.children.whereType()) { @@ -72,6 +73,7 @@ class FileParser extends XmlResponseParser { metadataPhotosIfd0 = propParser.metadataPhotosIfd0; metadataPhotosExif = propParser.metadataPhotosExif; metadataPhotosGps = propParser.metadataPhotosGps; + metadataPhotosSize = propParser.metadataPhotosSize; customProperties = propParser.customProperties; } } @@ -94,6 +96,7 @@ class FileParser extends XmlResponseParser { metadataPhotosIfd0: metadataPhotosIfd0, metadataPhotosExif: metadataPhotosExif, metadataPhotosGps: metadataPhotosGps, + metadataPhotosSize: metadataPhotosSize, customProperties: customProperties, ); } @@ -165,6 +168,11 @@ class _PropParser { for (final c in child.children.whereType()) { (_metadataPhotosGps ??= {})[c.localName] = c.innerText; } + } else if (child.matchQualifiedName("metadata-photos-size", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + for (final c in child.children.whereType()) { + (_metadataPhotosSize ??= {})[c.localName] = c.innerText; + } } else { final key = child.name.prefix == null ? child.localName @@ -190,6 +198,7 @@ class _PropParser { Map? get metadataPhotosIfd0 => _metadataPhotosIfd0; Map? get metadataPhotosExif => _metadataPhotosExif; Map? get metadataPhotosGps => _metadataPhotosGps; + Map? get metadataPhotosSize => _metadataPhotosSize; Map? get customProperties => _customProperties; final Map namespaces; @@ -210,6 +219,7 @@ class _PropParser { Map? _metadataPhotosIfd0; Map? _metadataPhotosExif; Map? _metadataPhotosGps; + Map? _metadataPhotosSize; Map? _customProperties; } diff --git a/np_api/lib/src/files_api.dart b/np_api/lib/src/files_api.dart index 1eb632ba..a647472f 100644 --- a/np_api/lib/src/files_api.dart +++ b/np_api/lib/src/files_api.dart @@ -75,6 +75,7 @@ class ApiFiles { metadataPhotosIfd0, metadataPhotosExif, metadataPhotosGps, + metadataPhotosSize, Map? customNamespaces, List? customProperties, }) async { @@ -102,7 +103,8 @@ class ApiFiles { trashbinDeletionTime != null || metadataPhotosIfd0 != null || metadataPhotosExif != null || - metadataPhotosGps != null); + metadataPhotosGps != null || + metadataPhotosSize != null); if (!hasDavNs && !hasOcNs && !hasNcNs) { // no body return await _api.request("PROPFIND", path); @@ -190,6 +192,9 @@ class ApiFiles { if (metadataPhotosGps != null) { builder.element("nc:metadata-photos-gps"); } + if (metadataPhotosSize != null) { + builder.element("nc:metadata-photos-size"); + } for (final p in customProperties ?? []) { builder.element(p); } From eaa8b2d90760cb0748d9e5c91a340798be6484fd Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 4 Nov 2024 00:47:04 +0800 Subject: [PATCH 05/24] Add test case --- np_api/test/entity/file_parser_test.dart | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/np_api/test/entity/file_parser_test.dart b/np_api/test/entity/file_parser_test.dart index 32f63d60..2b41e7ec 100644 --- a/np_api/test/entity/file_parser_test.dart +++ b/np_api/test/entity/file_parser_test.dart @@ -16,6 +16,7 @@ void main() { test("file w/ metadata-photos-ifd0", _filesNc28MetadataIfd0); test("file w/ metadata-photos-exif", _filesNc28MetadataExif); test("file w/ metadata-photos-gps", _filesNc28MetadataGps); + test("file w/ metadata-photos-size", _filesNc28MetadataSize); }); }); } @@ -588,3 +589,50 @@ Future _filesNc28MetadataGps() async { ), ]); } + +Future _filesNc28MetadataSize() async { + const xml = """ + + + + /nextcloud/remote.php/dav/files/admin/1.jpg + + + Fri, 01 Jan 2021 02:03:04 GMT + "1324f58d4d5c8d81bed6e4ed9d5ea862" + image/jpeg + + 123 + 3963036 + false + + 4032 + 3024 + + + HTTP/1.1 200 OK + + + +"""; + final results = await FileParser().parse(xml); + expect(results, [ + File( + href: "/nextcloud/remote.php/dav/files/admin/1.jpg", + contentLength: 3963036, + contentType: "image/jpeg", + etag: "1324f58d4d5c8d81bed6e4ed9d5ea862", + lastModified: DateTime.utc(2021, 1, 1, 2, 3, 4), + hasPreview: false, + fileId: 123, + isCollection: false, + metadataPhotosSize: { + "width": "4032", + "height": "3024", + }, + ), + ]); +} From bc9bbe9455e804c6e6bfb720a9407ea1f90fec9b Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 10 Nov 2024 18:50:38 +0800 Subject: [PATCH 06/24] Adapt to server side metadata --- app/lib/api/entity_converter.dart | 55 ++++---- .../{exif_extension.dart => exif_util.dart} | 29 ++++- app/lib/entity/file.dart | 41 +++++- app/lib/entity/file/data_source.dart | 9 ++ app/lib/use_case/update_missing_metadata.dart | 2 +- app/lib/widget/viewer_detail_pane.dart | 2 +- app/test/entity/exif_util_test.dart | 110 ++++++++++++++++ app/test/entity/file_test.dart | 123 ++++++++++++++++++ 8 files changed, 335 insertions(+), 36 deletions(-) rename app/lib/entity/{exif_extension.dart => exif_util.dart} (55%) create mode 100644 app/test/entity/exif_util_test.dart diff --git a/app/lib/api/entity_converter.dart b/app/lib/api/entity_converter.dart index bcd80aed..944da29c 100644 --- a/app/lib/api/entity_converter.dart +++ b/app/lib/api/entity_converter.dart @@ -17,6 +17,7 @@ import 'package:nc_photos/entity/tagged_file.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:np_api/np_api.dart' as api; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/object_util.dart'; import 'package:np_string/np_string.dart'; part 'entity_converter.g.dart'; @@ -49,32 +50,36 @@ class ApiFavoriteConverter { } class ApiFileConverter { - static File fromApi(api.File file) { - var metadata = file.customProperties?["com.nkming.nc_photos:metadata"] - ?.run((obj) => Metadata.fromJson( - jsonDecode(obj), - upgraderV1: MetadataUpgraderV1( - fileContentType: file.contentType, - logFilePath: file.href, - ), - upgraderV2: MetadataUpgraderV2( - fileContentType: file.contentType, - logFilePath: file.href, - ), - upgraderV3: MetadataUpgraderV3( - fileContentType: file.contentType, - logFilePath: file.href, - ), - )); - if (file.metadataPhotosIfd0 != null) { - final ifd0_metadata = Metadata.fromPhotosIfd0(file.metadataPhotosIfd0!); - if (metadata == null) { - metadata = ifd0_metadata; - } else { - metadata = metadata.copyWith(exif: ifd0_metadata.exif); - } + static Metadata? _metadataFromApi(api.File file) { + if (file.metadataPhotosSize != null) { + return Metadata.fromApi( + etag: file.etag, + ifd0: file.metadataPhotosIfd0, + exif: file.metadataPhotosExif, + gps: file.metadataPhotosGps, + size: file.metadataPhotosSize!, + ); + } else { + return file.customProperties?["com.nkming.nc_photos:metadata"] + ?.let((obj) => Metadata.fromJson( + jsonDecode(obj), + upgraderV1: MetadataUpgraderV1( + fileContentType: file.contentType, + logFilePath: file.href, + ), + upgraderV2: MetadataUpgraderV2( + fileContentType: file.contentType, + logFilePath: file.href, + ), + upgraderV3: MetadataUpgraderV3( + fileContentType: file.contentType, + logFilePath: file.href, + ), + )); } + } + static File fromApi(api.File file) { return File( path: _hrefToPath(file.href), contentLength: file.contentLength, @@ -90,7 +95,7 @@ class ApiFileConverter { trashbinFilename: file.trashbinFilename, trashbinOriginalLocation: file.trashbinOriginalLocation, trashbinDeletionTime: file.trashbinDeletionTime, - metadata: metadata, + metadata: _metadataFromApi(file), isArchived: file.customProperties?["com.nkming.nc_photos:is-archived"] ?.run((obj) => obj == "true"), overrideDateTime: file diff --git a/app/lib/entity/exif_extension.dart b/app/lib/entity/exif_util.dart similarity index 55% rename from app/lib/entity/exif_extension.dart rename to app/lib/entity/exif_util.dart index f92bf7ac..9f1b0b4e 100644 --- a/app/lib/entity/exif_extension.dart +++ b/app/lib/entity/exif_util.dart @@ -1,4 +1,5 @@ import 'package:exifdart/exifdart.dart'; +import 'package:flutter/foundation.dart'; import 'package:nc_photos/entity/exif.dart'; extension ExifExtension on Exif { @@ -12,7 +13,7 @@ extension ExifExtension on Exif { // invalid value return null; } else { - return _gpsDmsToDouble(gpsLatitude!) * (gpsLatitudeRef == "S" ? -1 : 1); + return gpsDmsToDouble(gpsLatitude!) * (gpsLatitudeRef == "S" ? -1 : 1); } } @@ -26,12 +27,23 @@ extension ExifExtension on Exif { // invalid value return null; } else { - return _gpsDmsToDouble(gpsLongitude!) * (gpsLongitudeRef == "W" ? -1 : 1); + return gpsDmsToDouble(gpsLongitude!) * (gpsLongitudeRef == "W" ? -1 : 1); } } } -double _gpsDmsToDouble(List dms) { +List gpsDoubleToDms(double src) { + var tmp = src.abs(); + final d = tmp.floor(); + tmp -= d; + final ss = (tmp * 3600 * 100).floor(); + final s = ss % (60 * 100); + final m = (ss / (60 * 100)).floor(); + return [Rational(d, 1), Rational(m, 1), Rational(s, 100)]; +} + +@visibleForTesting +double gpsDmsToDouble(List dms) { double product = dms[0].toDouble(); if (dms.length > 1) { product += dms[1].toDouble() / 60; @@ -41,3 +53,14 @@ double _gpsDmsToDouble(List dms) { } return product; } + +Rational doubleToRational(double src) { + final s = src.abs(); + if (s < 1000) { + return Rational((s * 100000).truncate(), 100000); + } else if (s < 100000) { + return Rational((s * 1000).truncate(), 1000); + } else { + return Rational(s.truncate(), 1); + } +} diff --git a/app/lib/entity/file.dart b/app/lib/entity/file.dart index f88834db..ded0b3b9 100644 --- a/app/lib/entity/file.dart +++ b/app/lib/entity/file.dart @@ -5,10 +5,12 @@ import 'package:equatable/equatable.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/exif.dart'; +import 'package:nc_photos/entity/exif_util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/json_util.dart' as json_util; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/object_util.dart'; import 'package:np_common/or_null.dart'; import 'package:np_common/type.dart'; import 'package:np_string/np_string.dart'; @@ -185,13 +187,40 @@ class Metadata with EquatableMixin { ); } - static Metadata fromPhotosIfd0(Map metadataPhotosIfd0) { + static Metadata? fromApi({ + required String? etag, + Map? ifd0, + Map? exif, + Map? gps, + required Map size, + }) { + final lat = gps?["latitude"]?.let(double.tryParse); + final lng = gps?["longitude"]?.let(double.tryParse); + final alt = gps?["altitude"]?.let(double.tryParse); return Metadata( - lastUpdated: null, - fileEtag: null, - imageWidth: null, - imageHeight: null, - exif: new Exif(metadataPhotosIfd0), + lastUpdated: clock.now().toUtc(), + fileEtag: etag, + imageWidth: int.parse(size["width"]!), + imageHeight: int.parse(size["height"]!), + exif: ifd0 != null || exif != null || gps != null + ? Exif({ + if (ifd0 != null) ...ifd0, + if (exif != null) ...exif, + if (lat != null && lng != null) ...{ + "GPSLatitude": gpsDoubleToDms(lat), + "GPSLatitudeRef": lat.isNegative ? "S" : "N", + "GPSLongitude": gpsDoubleToDms(lng), + "GPSLongitudeRef": lng.isNegative ? "W" : "E", + if (alt != null) ...{ + "GPSAltitude": doubleToRational(alt), + "GPSAltitudeRef": alt.isNegative ? 1 : 0, + } + } + }..removeWhere((key, value) => + key == "MakerNote" || + key == "UserComment" || + key == "ImageDescription")) + : null, ); } diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 6c87f137..08df13e6 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -56,6 +56,9 @@ class FileWebdavDataSource implements FileDataSource { trashbinOriginalLocation: 1, trashbinDeletionTime: 1, metadataPhotosIfd0: 1, + metadataPhotosExif: 1, + metadataPhotosGps: 1, + metadataPhotosSize: 1, customNamespaces: { "com.nkming.nc_photos": "app", }, @@ -277,6 +280,9 @@ class FileWebdavDataSource implements FileDataSource { trashbinOriginalLocation, trashbinDeletionTime, metadataPhotosIfd0, + metadataPhotosExif, + metadataPhotosGps, + metadataPhotosSize, Map? customNamespaces, List? customProperties, }) async { @@ -305,6 +311,9 @@ class FileWebdavDataSource implements FileDataSource { trashbinOriginalLocation: trashbinOriginalLocation, trashbinDeletionTime: trashbinDeletionTime, metadataPhotosIfd0: metadataPhotosIfd0, + metadataPhotosExif: metadataPhotosExif, + metadataPhotosGps: metadataPhotosGps, + metadataPhotosSize: metadataPhotosSize, customNamespaces: customNamespaces, customProperties: customProperties, ); diff --git a/app/lib/use_case/update_missing_metadata.dart b/app/lib/use_case/update_missing_metadata.dart index 8f494ff3..89a52470 100644 --- a/app/lib/use_case/update_missing_metadata.dart +++ b/app/lib/use_case/update_missing_metadata.dart @@ -5,7 +5,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/connectivity_util.dart' as connectivity_util; import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/exif_extension.dart'; +import 'package:nc_photos/entity/exif_util.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/exception.dart'; diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index 1aa4e5b1..e0e16f52 100644 --- a/app/lib/widget/viewer_detail_pane.dart +++ b/app/lib/widget/viewer_detail_pane.dart @@ -14,7 +14,7 @@ import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; import 'package:nc_photos/entity/collection_item.dart'; -import 'package:nc_photos/entity/exif_extension.dart'; +import 'package:nc_photos/entity/exif_util.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; diff --git a/app/test/entity/exif_util_test.dart b/app/test/entity/exif_util_test.dart new file mode 100644 index 00000000..bb970479 --- /dev/null +++ b/app/test/entity/exif_util_test.dart @@ -0,0 +1,110 @@ +import 'package:exifdart/exifdart.dart'; +import 'package:nc_photos/entity/exif_util.dart'; +import 'package:test/test.dart'; + +void main() { + group("exif_util", () { + group("gpsDmsToDouble", () { + test("United Nations HQ", () { + // 40° 44′ 58″ N, 73° 58′ 5″ W + final lat = gpsDmsToDouble([ + Rational(40, 1), + Rational(44, 1), + Rational(58, 1), + ]); + final lng = gpsDmsToDouble([ + Rational(73, 1), + Rational(58, 1), + Rational(5, 1), + ]); + expect(lat, closeTo(40.749444, .00001)); + expect(lng, closeTo(73.968056, .00001)); + }); + + test("East Cape Lighthouse", () { + // 37° 41′ 20.2″ S, 178° 32′ 53.3″ E + final lat = gpsDmsToDouble([ + Rational(37, 1), + Rational(41, 1), + Rational(202, 10), + ]); + final lng = gpsDmsToDouble([ + Rational(178, 1), + Rational(32, 1), + Rational(533, 10), + ]); + expect(lat, closeTo(37.688944, .00001)); + expect(lng, closeTo(178.548139, .00001)); + }); + }); + + group("gpsDoubleToDms", () { + test("United Nations HQ", () { + // 40.749444, -73.968056 + final lat = gpsDoubleToDms(40.749444); + final lng = gpsDoubleToDms(-73.968056); + expect( + lat.map((e) => e.toString()), + [ + Rational(40, 1).toString(), + Rational(44, 1).toString(), + Rational(5799, 100).toString(), + ], + ); + expect( + lng.map((e) => e.toString()), + [ + Rational(73, 1).toString(), + Rational(58, 1).toString(), + Rational(500, 100).toString(), + ], + ); + }); + + test("East Cape Lighthouse", () { + // -37.688944, 178.548139 + final lat = gpsDoubleToDms(-37.688944); + final lng = gpsDoubleToDms(178.548139); + expect( + lat.map((e) => e.toString()), + [ + Rational(37, 1).toString(), + Rational(41, 1).toString(), + Rational(2019, 100).toString(), + ], + ); + expect( + lng.map((e) => e.toString()), + [ + Rational(178, 1).toString(), + Rational(32, 1).toString(), + Rational(5330, 100).toString(), + ], + ); + }); + }); + + group("doubleToRational", () { + test("<1000", () { + expect( + doubleToRational(123.456789123).toString(), + Rational(12345678, 100000).toString(), + ); + }); + + test(">1000 <100000", () { + expect( + doubleToRational(12345.6789123).toString(), + Rational(12345678, 1000).toString(), + ); + }); + + test(">100000", () { + expect( + doubleToRational(12345678.9123).toString(), + Rational(12345678, 1).toString(), + ); + }); + }); + }); +} diff --git a/app/test/entity/file_test.dart b/app/test/entity/file_test.dart index a91311c8..a60bb2d0 100644 --- a/app/test/entity/file_test.dart +++ b/app/test/entity/file_test.dart @@ -1,4 +1,6 @@ import 'package:clock/clock.dart'; +import 'package:exifdart/exifdart.dart' hide Metadata; +import 'package:flutter/foundation.dart'; import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; @@ -245,6 +247,14 @@ void main() { }); }); }); + + group("fromApi", () { + test("size", _fromApiSize); + group("gps", () { + test("place1", _fromApiGpsPlace1); + test("place2", _fromApiGpsPlace2); + }); + }); }); group("MetadataUpgraderV1", () { @@ -1217,3 +1227,116 @@ void main() { }); }); } + +void _fromApiSize() { + withClock( + Clock(() => DateTime(2020, 1, 2, 3, 4, 5)), + () { + final actual = Metadata.fromApi( + etag: null, + size: { + "width": "1234", + "height": "5678", + }, + ); + expect( + actual, + Metadata( + imageWidth: 1234, + imageHeight: 5678, + ), + ); + }, + ); +} + +void _fromApiGpsPlace1() { + final actual = Metadata.fromApi( + etag: null, + size: { + "width": "1234", + "height": "5678", + }, + gps: { + "latitude": "40.749444", + "longitude": "-73.968056", + "altitude": "12.345678", + }, + ); + expect( + actual?.exif, + _MetadataGpsMatcher(Exif({ + "GPSLatitude": [Rational(40, 1), Rational(44, 1), Rational(5799, 100)], + "GPSLatitudeRef": "N", + "GPSLongitude": [Rational(73, 1), Rational(58, 1), Rational(500, 100)], + "GPSLongitudeRef": "W", + "GPSAltitude": Rational(1234567, 100000), + "GPSAltitudeRef": 0, + })), + ); +} + +void _fromApiGpsPlace2() { + final actual = Metadata.fromApi( + etag: null, + size: { + "width": "1234", + "height": "5678", + }, + gps: { + "latitude": "-37.688944", + "longitude": "178.5481396", + "altitude": "-12.345678", + }, + ); + expect( + actual?.exif, + _MetadataGpsMatcher(Exif({ + "GPSLatitude": [Rational(37, 1), Rational(41, 1), Rational(2019, 100)], + "GPSLatitudeRef": "S", + "GPSLongitude": [Rational(178, 1), Rational(32, 1), Rational(5330, 100)], + "GPSLongitudeRef": "E", + "GPSAltitude": Rational(1234567, 100000), + "GPSAltitudeRef": 1, + })), + ); +} + +class _MetadataGpsMatcher extends Matcher { + const _MetadataGpsMatcher(this.expected); + + @override + bool matches(Object? item, Map matchState) { + final actual = item as Exif; + final gpsLatitude = listEquals( + actual["GPSLatitude"]?.map((e) => e.toString()).toList(), + expected["GPSLatitude"]?.map((e) => e.toString()).toList(), + ); + final gpsLatitudeRef = + actual["GPSLatitudeRef"] == expected["GPSLatitudeRef"]; + final gpsLongitude = listEquals( + actual["GPSLongitude"]?.map((e) => e.toString()).toList(), + expected["GPSLongitude"]?.map((e) => e.toString()).toList(), + ); + final gpsLongitudeRef = + actual["GPSLongitudeRef"] == expected["GPSLongitudeRef"]; + final gpsAltitude = actual["GPSAltitude"]?.toString() == + expected["GPSAltitude"]?.toString(); + final gpsAltitudeRef = + actual["GPSAltitudeRef"] == expected["GPSAltitudeRef"]; + + return gpsLatitude && + gpsLatitudeRef && + gpsLongitude && + gpsLongitudeRef && + gpsAltitude && + gpsAltitudeRef; + } + + @override + Description describe(Description description) { + return description.add(expected.toString()); + } + + final Exif expected; +} From d0b156852403daf647188d7469c8c33598ebd1da Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 10 Nov 2024 22:18:54 +0800 Subject: [PATCH 07/24] Disable metadata service on nextcloud 28+ --- app/lib/controller/server_controller.dart | 4 ++++ app/lib/widget/home_photos/bloc.dart | 8 +++++++- app/lib/widget/home_photos2.dart | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/lib/controller/server_controller.dart b/app/lib/controller/server_controller.dart index 7ff25853..6e0164fc 100644 --- a/app/lib/controller/server_controller.dart +++ b/app/lib/controller/server_controller.dart @@ -13,6 +13,7 @@ part 'server_controller.g.dart'; enum ServerFeature { ncAlbum, + ncMetadata, } @npLog @@ -37,6 +38,9 @@ class ServerController { case ServerFeature.ncAlbum: return !_statusStreamContorller.hasValue || _statusStreamContorller.value.majorVersion >= 25; + case ServerFeature.ncMetadata: + return !_statusStreamContorller.hasValue || + _statusStreamContorller.value.majorVersion >= 28; } } diff --git a/app/lib/widget/home_photos/bloc.dart b/app/lib/widget/home_photos/bloc.dart index c4afe159..6e0da340 100644 --- a/app/lib/widget/home_photos/bloc.dart +++ b/app/lib/widget/home_photos/bloc.dart @@ -13,6 +13,7 @@ class _Bloc extends Bloc<_Event, _State> required this.syncController, required this.personsController, required this.metadataController, + required this.serverController, }) : super(_State.init( zoom: prefController.homePhotosZoomLevelValue, isEnableMemoryCollection: @@ -535,7 +536,11 @@ class _Bloc extends Bloc<_Event, _State> personsController: personsController, personProvider: accountPrefController.personProviderValue, ); - metadataController.kickstart(); + if (!serverController.isSupported(ServerFeature.ncMetadata)) { + metadataController.kickstart(); + } else { + _log.info("[_syncRemote] Skipping metadata service"); + } _log.info( "[_syncRemote] Elapsed time: ${stopwatch.elapsedMilliseconds}ms"); }); @@ -711,6 +716,7 @@ class _Bloc extends Bloc<_Event, _State> final SyncController syncController; final PersonsController personsController; final MetadataController metadataController; + final ServerController serverController; final _itemTransformerQueue = ComputeQueue<_ItemTransformerArgument, _ItemTransformerResult>(); diff --git a/app/lib/widget/home_photos2.dart b/app/lib/widget/home_photos2.dart index 07e3a139..8b569da2 100644 --- a/app/lib/widget/home_photos2.dart +++ b/app/lib/widget/home_photos2.dart @@ -21,6 +21,7 @@ import 'package:nc_photos/controller/files_controller.dart'; import 'package:nc_photos/controller/metadata_controller.dart'; import 'package:nc_photos/controller/persons_controller.dart'; import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/controller/server_controller.dart'; import 'package:nc_photos/controller/sync_controller.dart'; import 'package:nc_photos/db/entity_converter.dart'; import 'package:nc_photos/di_container.dart'; @@ -97,6 +98,7 @@ class HomePhotos2 extends StatelessWidget { syncController: accountController.syncController, personsController: accountController.personsController, metadataController: accountController.metadataController, + serverController: accountController.serverController, ), child: const _WrappedHomePhotos(), ); From 5c9f51a1106e7829b983531dd2dd13d0e1a120ff Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 10 Nov 2024 22:55:50 +0800 Subject: [PATCH 08/24] Hide EXIF settings on nextcloud 28+ --- app/lib/widget/settings.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/lib/widget/settings.dart b/app/lib/widget/settings.dart index f721080a..0232eba1 100644 --- a/app/lib/widget/settings.dart +++ b/app/lib/widget/settings.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/account_controller.dart'; import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/controller/server_controller.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/language_util.dart' as language_util; @@ -79,11 +81,15 @@ class _SettingsState extends State { description: L10n.global().settingsThemeDescription, pageBuilder: () => const ThemeSettings(), ), - _SubPageItem( - leading: const Icon(Icons.local_offer_outlined), - label: L10n.global().settingsMetadataTitle, - pageBuilder: () => const MetadataSettings(), - ), + if (!context + .read() + .serverController + .isSupported(ServerFeature.ncMetadata)) + _SubPageItem( + leading: const Icon(Icons.local_offer_outlined), + label: L10n.global().settingsMetadataTitle, + pageBuilder: () => const MetadataSettings(), + ), _SubPageItem( leading: const Icon(Icons.image_outlined), label: L10n.global().photosTabLabel, From 7ea37dc4911fe41d32e949e9ad642611fd906f74 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 10 Nov 2024 22:56:07 +0800 Subject: [PATCH 09/24] Remove setup page for EXIF --- app/lib/widget/setup.dart | 62 +-------------------------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/app/lib/widget/setup.dart b/app/lib/widget/setup.dart index 6cc8c52d..b8f5e0a1 100644 --- a/app/lib/widget/setup.dart +++ b/app/lib/widget/setup.dart @@ -43,7 +43,6 @@ class _SetupState extends State { Widget _buildContent(BuildContext context) { final page = _pageController.hasClients ? _pageController.page!.round() : 0; final pages = [ - if (_initialProgress & _PageId.exif == 0) _Exif(), if (_initialProgress & _PageId.hiddenPrefDirNotice == 0) _HiddenPrefDirNotice(), ]; @@ -125,73 +124,14 @@ class _SetupState extends State { } class _PageId { - static const exif = 0x01; static const hiddenPrefDirNotice = 0x02; - static const all = exif | hiddenPrefDirNotice; + static const all = hiddenPrefDirNotice; } abstract class _Page { int getPageId(); } -class _Exif extends StatefulWidget implements _Page { - @override - createState() => _ExifState(); - - @override - getPageId() => _PageId.exif; -} - -class _ExifState extends State<_Exif> { - @override - build(BuildContext context) { - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SwitchListTile( - title: Text(L10n.global().settingsExifSupportTitle), - value: _isEnableExif, - onChanged: _onValueChanged, - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text(L10n.global().exifSupportDetails), - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - L10n.global().setupSettingsModifyLaterHint, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(fontStyle: FontStyle.italic), - ), - ), - const SizedBox(height: 8), - ], - ), - ); - } - - @override - dispose() { - super.dispose(); - // persist user's choice - Pref().setEnableExif(_isEnableExif); - } - - void _onValueChanged(bool value) { - setState(() { - _isEnableExif = value; - }); - } - - bool _isEnableExif = Pref().isEnableExifOr(); -} - class _HiddenPrefDirNotice extends StatefulWidget implements _Page { @override createState() => _HiddenPrefDirNoticeState(); From 0aab97f2f5db20f92a5cbcfa4bc1d76b14b60d16 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 13 Nov 2024 01:29:02 +0800 Subject: [PATCH 10/24] Support untyped exif values saved as string, which is the case when the value came from the server --- app/lib/entity/exif.dart | 54 +++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/app/lib/entity/exif.dart b/app/lib/entity/exif.dart index ec3af558..fda96a9b 100644 --- a/app/lib/entity/exif.dart +++ b/app/lib/entity/exif.dart @@ -87,7 +87,7 @@ class Exif with EquatableMixin { } @override - toString() { + String toString() { final dataStr = data.entries.map((e) { return "${e.key}: '${e.value}'"; }).join(", "); @@ -117,16 +117,16 @@ class Exif with EquatableMixin { } /// 0x829a ExposureTime - Rational? get exposureTime => data["ExposureTime"]; + Rational? get exposureTime => _readRationalValue("ExposureTime"); /// 0x829d FNumber - Rational? get fNumber => data["FNumber"]; + Rational? get fNumber => _readRationalValue("FNumber"); /// 0x8827 ISO/ISOSpeedRatings/PhotographicSensitivity - int? get isoSpeedRatings => data["ISOSpeedRatings"]; + int? get isoSpeedRatings => _readIntValue("ISOSpeedRatings"); /// 0x920a FocalLength - Rational? get focalLength => data["FocalLength"]; + Rational? get focalLength => _readRationalValue("FocalLength"); /// 0x8825 GPS tags String? get gpsLatitudeRef => data["GPSLatitudeRef"]; @@ -135,10 +135,52 @@ class Exif with EquatableMixin { List? get gpsLongitude => data["GPSLongitude"]?.cast(); @override - get props => [ + List get props => [ data, ]; + Rational? _readRationalValue(String key) { + // values may be saved as typed (extracted by app) or untyped string + // (extracted by server) + return data[key] is String ? _tryParseRationalString(data[key]) : data[key]; + } + + int? _readIntValue(String key) { + return data[key] is String ? _tryParseIntString(data[key]) : data[key]; + } + + static Rational? _tryParseRationalString(String str) { + if (str.isEmpty) { + return null; + } + try { + final pos = str.indexOf("/"); + return Rational( + int.parse(str.substring(0, pos)), + int.parse(str.substring(pos + 1)), + ); + } catch (e, stackTrace) { + log.shout( + "[_tryParseRationalString] Failed to parse rational string: $str", + e, + stackTrace); + return null; + } + } + + static int? _tryParseIntString(String str) { + if (str.isEmpty) { + return null; + } + try { + return int.parse(str); + } catch (e, stackTrace) { + log.shout("[_tryParseIntString] Failed to parse int string: $str", e, + stackTrace); + return null; + } + } + final Map data; static final dateTimeFormat = DateFormat("yyyy:MM:dd HH:mm:ss"); From ce0b6d6c053c020e47bdd0b63dcad3b3c263040a Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 18 Nov 2024 01:26:45 +0800 Subject: [PATCH 11/24] Fix build error --- app/lib/entity/exif.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/lib/entity/exif.dart b/app/lib/entity/exif.dart index fda96a9b..f29368dc 100644 --- a/app/lib/entity/exif.dart +++ b/app/lib/entity/exif.dart @@ -160,7 +160,7 @@ class Exif with EquatableMixin { int.parse(str.substring(pos + 1)), ); } catch (e, stackTrace) { - log.shout( + _$ExifNpLog.log.shout( "[_tryParseRationalString] Failed to parse rational string: $str", e, stackTrace); @@ -175,7 +175,9 @@ class Exif with EquatableMixin { try { return int.parse(str); } catch (e, stackTrace) { - log.shout("[_tryParseIntString] Failed to parse int string: $str", e, + _$ExifNpLog.log.shout( + "[_tryParseIntString] Failed to parse int string: $str", + e, stackTrace); return null; } From 4eab80752487798271d538f7fb016381cdddc09b Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 18 Nov 2024 01:30:48 +0800 Subject: [PATCH 12/24] Fix not considering owner id when counting files missing metadata --- app/lib/controller/metadata_controller.dart | 1 + np_db/lib/src/api.dart | 1 + np_db_sqlite/lib/src/database/file_extension.dart | 4 ++++ np_db_sqlite/lib/src/sqlite_api.dart | 2 ++ 4 files changed, 8 insertions(+) diff --git a/app/lib/controller/metadata_controller.dart b/app/lib/controller/metadata_controller.dart index 0f3ce7a8..2d280801 100644 --- a/app/lib/controller/metadata_controller.dart +++ b/app/lib/controller/metadata_controller.dart @@ -68,6 +68,7 @@ class MetadataController { final missingCount = await _c.npDb.countFilesByMissingMetadata( account: account.toDb(), mimes: file_util.supportedImageFormatMimes, + ownerId: account.userId.toCaseInsensitiveString(), ); _log.info("[_startMetadataTask] Missing count: $missingCount"); if (missingCount > 0) { diff --git a/np_db/lib/src/api.dart b/np_db/lib/src/api.dart index 565b1469..d7fed616 100644 --- a/np_db/lib/src/api.dart +++ b/np_db/lib/src/api.dart @@ -351,6 +351,7 @@ abstract class NpDb { Future countFilesByMissingMetadata({ required DbAccount account, required List mimes, + required String ownerId, }); /// Delete a file or dir from db diff --git a/np_db_sqlite/lib/src/database/file_extension.dart b/np_db_sqlite/lib/src/database/file_extension.dart index ecf4ef4c..89ec5a68 100644 --- a/np_db_sqlite/lib/src/database/file_extension.dart +++ b/np_db_sqlite/lib/src/database/file_extension.dart @@ -340,6 +340,7 @@ extension SqliteDbFileExtension on SqliteDb { required ByAccount account, bool? isMissingMetadata, List? mimes, + String? ownerId, }) async { _log.info( "[countFiles] isMissingMetadata: $isMissingMetadata, mimes: $mimes"); @@ -380,6 +381,9 @@ extension SqliteDbFileExtension on SqliteDb { if (mimes != null) { query.where(files.contentType.isIn(mimes)); } + if (ownerId != null) { + query.where(files.ownerId.equals(ownerId)); + } return await query.map((r) => r.read(count)!).getSingle(); } diff --git a/np_db_sqlite/lib/src/sqlite_api.dart b/np_db_sqlite/lib/src/sqlite_api.dart index eeca157a..c0cb90c2 100644 --- a/np_db_sqlite/lib/src/sqlite_api.dart +++ b/np_db_sqlite/lib/src/sqlite_api.dart @@ -383,12 +383,14 @@ class NpDbSqlite implements NpDb { Future countFilesByMissingMetadata({ required DbAccount account, required List mimes, + required String ownerId, }) async { return _db.use((db) async { return await db.countFiles( account: ByAccount.db(account), isMissingMetadata: true, mimes: mimes, + ownerId: ownerId, ); }); } From 97f1b25ae9788d1d45f224f269fccebd0da9e188 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 18 Nov 2024 01:32:54 +0800 Subject: [PATCH 13/24] Fix wrong mime types used when counting file w/o metadata --- app/lib/controller/metadata_controller.dart | 2 +- app/lib/entity/file_util.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/lib/controller/metadata_controller.dart b/app/lib/controller/metadata_controller.dart index 2d280801..03d452b4 100644 --- a/app/lib/controller/metadata_controller.dart +++ b/app/lib/controller/metadata_controller.dart @@ -67,7 +67,7 @@ class MetadataController { try { final missingCount = await _c.npDb.countFilesByMissingMetadata( account: account.toDb(), - mimes: file_util.supportedImageFormatMimes, + mimes: file_util.metadataSupportedFormatMimes, ownerId: account.userId.toCaseInsensitiveString(), ); _log.info("[_startMetadataTask] Missing count: $missingCount"); diff --git a/app/lib/entity/file_util.dart b/app/lib/entity/file_util.dart index 2eb5453c..940477a6 100644 --- a/app/lib/entity/file_util.dart +++ b/app/lib/entity/file_util.dart @@ -27,7 +27,7 @@ bool isSupportedVideoFormat(FileDescriptor file) => isSupportedVideoMime(file.fdMime ?? ""); bool isMetadataSupportedMime(String mime) => - _metadataSupportedFormatMimes.contains(mime); + metadataSupportedFormatMimes.contains(mime); bool isMetadataSupportedFormat(FileDescriptor file) => isMetadataSupportedMime(file.fdMime ?? ""); @@ -138,7 +138,7 @@ final supportedImageFormatMimes = final supportedVideoFormatMimes = supportedFormatMimes.where((f) => f.startsWith("video/")).toList(); -const _metadataSupportedFormatMimes = [ +const metadataSupportedFormatMimes = [ "image/jpeg", "image/heic", ]; From 5ec09d5c4cdacb2e9b265794f9e13262452cf941 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 20 Nov 2024 00:52:52 +0800 Subject: [PATCH 14/24] Fix shared dir not considered when counting files for the photos timeline --- app/lib/controller/files_controller.dart | 1 + np_db/lib/src/api.dart | 1 + .../lib/src/database/file_extension.dart | 25 +++++++++++++++++++ np_db_sqlite/lib/src/sqlite_api.dart | 2 ++ 4 files changed, 29 insertions(+) diff --git a/app/lib/controller/files_controller.dart b/app/lib/controller/files_controller.dart index f5ad6630..56b46aaf 100644 --- a/app/lib/controller/files_controller.dart +++ b/app/lib/controller/files_controller.dart @@ -574,6 +574,7 @@ class FilesController { .map((e) => File(path: file_util.unstripPath(account, e)) .strippedPathWithEmpty) .toList(), + includeRelativeDirs: [accountPrefController.shareFolderValue], excludeRelativeRoots: [remote_storage_util.remoteStorageDirRelativePath], mimes: file_util.supportedFormatMimes, ); diff --git a/np_db/lib/src/api.dart b/np_db/lib/src/api.dart index d7fed616..3544f637 100644 --- a/np_db/lib/src/api.dart +++ b/np_db/lib/src/api.dart @@ -412,6 +412,7 @@ abstract class NpDb { Future getFilesSummary({ required DbAccount account, List? includeRelativeRoots, + List? includeRelativeDirs, List? excludeRelativeRoots, List? mimes, }); diff --git a/np_db_sqlite/lib/src/database/file_extension.dart b/np_db_sqlite/lib/src/database/file_extension.dart index 89ec5a68..ffa54db5 100644 --- a/np_db_sqlite/lib/src/database/file_extension.dart +++ b/np_db_sqlite/lib/src/database/file_extension.dart @@ -620,6 +620,7 @@ extension SqliteDbFileExtension on SqliteDb { Future countFileGroupsByDate({ required ByAccount account, List? includeRelativeRoots, + List? includeRelativeDirs, List? excludeRelativeRoots, List? mimes, bool? isArchived, @@ -627,10 +628,23 @@ extension SqliteDbFileExtension on SqliteDb { _log.info( "[countFileGroupsByDate] " "includeRelativeRoots: $includeRelativeRoots, " + "includeRelativeDirs: $includeRelativeDirs, " "excludeRelativeRoots: $excludeRelativeRoots, " "mimes: $mimes", ); + List? dirIds; + if (includeRelativeDirs?.isNotEmpty == true) { + final sqlAccount = await accountOf(account); + final result = await _accountFileRowIdsOf(ByAccount.sql(sqlAccount), + includeRelativeDirs!.map((e) => DbFileKey.byPath(e)).toList()) + .notNull(); + dirIds = result.values.map((e) => e.fileRowId).toList(); + if (dirIds.length != includeRelativeDirs.length) { + _log.warning("Some dirs not found: $includeRelativeDirs"); + } + } + final count = countAll(); final localDate = accountFiles.bestDateTime .modify(const DateTimeModifier.localTime()) @@ -647,6 +661,17 @@ extension SqliteDbFileExtension on SqliteDb { for (final r in includeRelativeRoots) { q.byOrRelativePathPattern("$r/%"); } + if (dirIds != null) { + for (final i in dirIds) { + q.byOrDirRowId(i); + } + } + } + } else { + if (dirIds != null) { + for (final i in dirIds) { + q.byOrDirRowId(i); + } } } return q.build(); diff --git a/np_db_sqlite/lib/src/sqlite_api.dart b/np_db_sqlite/lib/src/sqlite_api.dart index c0cb90c2..7c7a1b00 100644 --- a/np_db_sqlite/lib/src/sqlite_api.dart +++ b/np_db_sqlite/lib/src/sqlite_api.dart @@ -474,6 +474,7 @@ class NpDbSqlite implements NpDb { Future getFilesSummary({ required DbAccount account, List? includeRelativeRoots, + List? includeRelativeDirs, List? excludeRelativeRoots, List? mimes, }) async { @@ -481,6 +482,7 @@ class NpDbSqlite implements NpDb { return await db.countFileGroupsByDate( account: ByAccount.db(account), includeRelativeRoots: includeRelativeRoots, + includeRelativeDirs: includeRelativeDirs, excludeRelativeRoots: excludeRelativeRoots, mimes: mimes, isArchived: false, From 0e4ecf4f2b02de77a23424c596841ece1d968610 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 21 Nov 2024 01:34:24 +0800 Subject: [PATCH 15/24] Rewrite metadata service, add support for server side exif --- app/lib/controller/files_controller.dart | 2 +- app/lib/controller/metadata_controller.dart | 2 +- app/lib/entity/file/data_source.dart | 4 +- app/lib/entity/file/file_cache_manager.dart | 9 +- app/lib/event/event.dart | 21 +- app/lib/metadata_task_manager.dart | 134 ------- app/lib/metadata_task_manager.g.dart | 32 -- app/lib/service.dart | 358 ------------------ app/lib/service/config.dart | 15 + app/lib/service/l10n.dart | 43 +++ app/lib/service/service.dart | 192 ++++++++++ app/lib/{ => service}/service.g.dart | 18 +- app/lib/use_case/battery_ensurer.dart | 35 ++ app/lib/use_case/scan_missing_metadata.dart | 50 --- .../use_case/sync_metadata/sync_by_app.dart | 120 ++++++ .../sync_metadata/sync_by_server.dart | 91 +++++ .../use_case/sync_metadata/sync_metadata.dart | 109 ++++++ .../sync_metadata/sync_metadata.g.dart | 30 ++ app/lib/use_case/update_missing_metadata.dart | 173 --------- .../use_case/update_missing_metadata.g.dart | 15 - app/lib/use_case/update_property.dart | 57 +-- app/lib/use_case/wifi_ensurer.dart | 41 ++ app/lib/widget/home_photos/bloc.dart | 6 +- .../widget/settings/metadata_settings.dart | 2 +- app/lib/widget/viewer_detail_pane.dart | 2 +- .../entity/file/file_cache_manager_test.dart | 28 +- np_collection/lib/np_collection.dart | 2 + np_db/lib/src/api.dart | 20 + np_db/lib/src/api.g.dart | 32 ++ .../lib/src/database/file_extension.dart | 65 +++- np_db_sqlite/lib/src/sqlite_api.dart | 28 ++ 31 files changed, 865 insertions(+), 871 deletions(-) delete mode 100644 app/lib/metadata_task_manager.dart delete mode 100644 app/lib/metadata_task_manager.g.dart delete mode 100644 app/lib/service.dart create mode 100644 app/lib/service/config.dart create mode 100644 app/lib/service/l10n.dart create mode 100644 app/lib/service/service.dart rename app/lib/{ => service}/service.g.dart (71%) create mode 100644 app/lib/use_case/battery_ensurer.dart delete mode 100644 app/lib/use_case/scan_missing_metadata.dart create mode 100644 app/lib/use_case/sync_metadata/sync_by_app.dart create mode 100644 app/lib/use_case/sync_metadata/sync_by_server.dart create mode 100644 app/lib/use_case/sync_metadata/sync_metadata.dart create mode 100644 app/lib/use_case/sync_metadata/sync_metadata.g.dart delete mode 100644 app/lib/use_case/update_missing_metadata.dart delete mode 100644 app/lib/use_case/update_missing_metadata.g.dart create mode 100644 app/lib/use_case/wifi_ensurer.dart diff --git a/app/lib/controller/files_controller.dart b/app/lib/controller/files_controller.dart index 56b46aaf..c29867c6 100644 --- a/app/lib/controller/files_controller.dart +++ b/app/lib/controller/files_controller.dart @@ -253,7 +253,7 @@ class FilesController { final failures = []; for (final f in files) { try { - await UpdateProperty(_c)( + await UpdateProperty(fileRepo: _c.fileRepo2)( account, f, metadata: metadata, diff --git a/app/lib/controller/metadata_controller.dart b/app/lib/controller/metadata_controller.dart index 03d452b4..097c64ea 100644 --- a/app/lib/controller/metadata_controller.dart +++ b/app/lib/controller/metadata_controller.dart @@ -6,7 +6,7 @@ import 'package:nc_photos/controller/pref_controller.dart'; import 'package:nc_photos/db/entity_converter.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/service.dart' as service; +import 'package:nc_photos/service/service.dart' as service; import 'package:np_codegen/np_codegen.dart'; part 'metadata_controller.g.dart'; diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 08df13e6..5465d2bd 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -635,7 +635,7 @@ class FileCachedDataSource implements FileDataSource { return state.files; } - await FileSqliteCacheUpdater(_c)(state.account, state.dir, + await FileSqliteCacheUpdater(_c.npDb)(state.account, state.dir, remote: state.files); if (shouldCheckCache) { // update our local touch token to match the remote one @@ -657,7 +657,7 @@ class FileCachedDataSource implements FileDataSource { if (remote.isCollection != true) { // only update regular files _log.info("[listSingle] Cache single file: ${logFilename(f.path)}"); - await FileSqliteCacheUpdater(_c).updateSingle(account, remote); + await FileSqliteCacheUpdater(_c.npDb).updateSingle(account, remote); } return remote; } diff --git a/app/lib/entity/file/file_cache_manager.dart b/app/lib/entity/file/file_cache_manager.dart index 60475043..3e464faf 100644 --- a/app/lib/entity/file/file_cache_manager.dart +++ b/app/lib/entity/file/file_cache_manager.dart @@ -8,6 +8,7 @@ import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/exception.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_db/np_db.dart'; part 'file_cache_manager.g.dart'; @@ -81,7 +82,7 @@ class FileCacheLoader { @npLog class FileSqliteCacheUpdater { - const FileSqliteCacheUpdater(this._c); + const FileSqliteCacheUpdater(this.db); Future call( Account account, @@ -90,7 +91,7 @@ class FileSqliteCacheUpdater { }) async { final s = Stopwatch()..start(); try { - await _c.npDb.syncDirFiles( + await db.syncDirFiles( account: account.toDb(), dirFile: dir.toDbKey(), files: remote.map((e) => e.toDb()).toList(), @@ -101,13 +102,13 @@ class FileSqliteCacheUpdater { } Future updateSingle(Account account, File remoteFile) async { - await _c.npDb.syncFile( + await db.syncFile( account: account.toDb(), file: remoteFile.toDb(), ); } - final DiContainer _c; + final NpDb db; } class FileSqliteCacheEmptier { diff --git a/app/lib/event/event.dart b/app/lib/event/event.dart index 126bb1c7..be29eea8 100644 --- a/app/lib/event/event.dart +++ b/app/lib/event/event.dart @@ -46,6 +46,7 @@ class AccountPrefUpdatedEvent { final dynamic value; } +@Deprecated("not fired anymore, to be removed") class FilePropertyUpdatedEvent { FilePropertyUpdatedEvent(this.account, this.file, this.properties); @@ -97,26 +98,6 @@ class FavoriteResyncedEvent { final Account account; } -enum MetadataTaskState { - /// No work is being done - idle, - - /// Processing images - prcoessing, - - /// Paused on data network - waitingForWifi, - - /// Paused on low battery - lowBattery, -} - -class MetadataTaskStateChangedEvent { - const MetadataTaskStateChangedEvent(this.state); - - final MetadataTaskState state; -} - @Deprecated("not fired anymore, to be removed") class PrefUpdatedEvent { PrefUpdatedEvent(this.key, this.value); diff --git a/app/lib/metadata_task_manager.dart b/app/lib/metadata_task_manager.dart deleted file mode 100644 index ed87305f..00000000 --- a/app/lib/metadata_task_manager.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'dart:async'; - -import 'package:event_bus/event_bus.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/entity/pref.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/use_case/update_missing_metadata.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:np_geocoder/np_geocoder.dart'; -import 'package:to_string/to_string.dart'; - -part 'metadata_task_manager.g.dart'; - -/// Task to update metadata for missing files -@npLog -@ToString(ignorePrivate: true) -class MetadataTask { - MetadataTask(this._c, this.account, this.pref) : assert(require(_c)); - - static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo); - - @override - String toString() => _$toString(); - - Future call() async { - try { - final shareFolder = - File(path: file_util.unstripPath(account, pref.getShareFolderOr())); - bool hasScanShareFolder = false; - final geocoder = ReverseGeocoder(); - await geocoder.init(); - for (final r in account.roots) { - final dir = File(path: file_util.unstripPath(account, r)); - hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir); - final op = UpdateMissingMetadata( - _c, const _UpdateMissingMetadataConfigProvider(), geocoder); - await for (final _ in op(account, dir)) { - if (!Pref().isEnableExifOr()) { - _log.info("[call] EXIF disabled, task ending immaturely"); - op.stop(); - return; - } - } - } - if (!hasScanShareFolder) { - final op = UpdateMissingMetadata( - _c, const _UpdateMissingMetadataConfigProvider(), geocoder); - await for (final _ in op( - account, - shareFolder, - isRecursive: false, - filter: (f) => f.ownerId != account.userId, - )) { - if (!Pref().isEnableExifOr()) { - _log.info("[call] EXIF disabled, task ending immaturely"); - op.stop(); - return; - } - } - } - } finally { - KiwiContainer() - .resolve() - .fire(const MetadataTaskStateChangedEvent(MetadataTaskState.idle)); - } - } - - final DiContainer _c; - - final Account account; - @ignore - final AccountPref pref; -} - -/// Manage metadata tasks to run concurrently -@npLog -class MetadataTaskManager { - factory MetadataTaskManager() { - _inst ??= MetadataTaskManager._(); - return _inst!; - } - - MetadataTaskManager._() { - _stateChangedListener.begin(); - _handleStream(); - } - - /// Add a task to the queue - void addTask(MetadataTask task) { - _log.info("[addTask] New task added: $task"); - _streamController.add(task); - } - - MetadataTaskState get state => _currentState; - - void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) { - if (ev.state != _currentState) { - _currentState = ev.state; - } - } - - Future _handleStream() async { - await for (final task in _streamController.stream) { - if (Pref().isEnableExifOr()) { - _log.info("[_doTask] Executing task: $task"); - await task(); - } else { - _log.info("[_doTask] Ignoring task: $task"); - } - } - } - - final _streamController = StreamController.broadcast(); - - var _currentState = MetadataTaskState.idle; - late final _stateChangedListener = - AppEventListener( - _onMetadataTaskStateChanged); - - static MetadataTaskManager? _inst; -} - -class _UpdateMissingMetadataConfigProvider - implements UpdateMissingMetadataConfigProvider { - const _UpdateMissingMetadataConfigProvider(); - - @override - isWifiOnly() async => Pref().shouldProcessExifWifiOnlyOr(); -} diff --git a/app/lib/metadata_task_manager.g.dart b/app/lib/metadata_task_manager.g.dart deleted file mode 100644 index 65fc62ed..00000000 --- a/app/lib/metadata_task_manager.g.dart +++ /dev/null @@ -1,32 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'metadata_task_manager.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$MetadataTaskNpLog on MetadataTask { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("metadata_task_manager.MetadataTask"); -} - -extension _$MetadataTaskManagerNpLog on MetadataTaskManager { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("metadata_task_manager.MetadataTaskManager"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$MetadataTaskToString on MetadataTask { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "MetadataTask {account: $account}"; - } -} diff --git a/app/lib/service.dart b/app/lib/service.dart deleted file mode 100644 index d19c03a1..00000000 --- a/app/lib/service.dart +++ /dev/null @@ -1,358 +0,0 @@ -import 'dart:async'; - -import 'package:devicelocale/devicelocale.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_background_service/flutter_background_service.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations_en.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_init.dart' as app_init; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/controller/pref_controller.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; -import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/entity/pref.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/event/native_event.dart'; -import 'package:nc_photos/language_util.dart' as language_util; -import 'package:nc_photos/use_case/update_missing_metadata.dart'; -import 'package:nc_photos_plugin/nc_photos_plugin.dart'; -import 'package:np_async/np_async.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:np_geocoder/np_geocoder.dart'; -import 'package:np_platform_message_relay/np_platform_message_relay.dart'; - -part 'service.g.dart'; - -/// Start the background service -Future startService() async { - _C.log.info("[startService] Starting service"); - final service = FlutterBackgroundService(); - await service.configure( - androidConfiguration: AndroidConfiguration( - onStart: serviceMain, - autoStart: false, - isForegroundMode: true, - foregroundServiceNotificationTitle: - L10n.global().metadataTaskProcessingNotification, - ), - iosConfiguration: IosConfiguration( - onForeground: () => throw UnimplementedError(), - onBackground: () => throw UnimplementedError(), - ), - ); - // sync settings - await ServiceConfig.setProcessExifWifiOnly( - Pref().shouldProcessExifWifiOnlyOr()); - await service.start(); -} - -/// Ask the background service to stop ASAP -void stopService() { - _C.log.info("[stopService] Stopping service"); - FlutterBackgroundService().sendData({ - _dataKeyEvent: _eventStop, - }); -} - -@visibleForTesting -@pragma("vm:entry-point") -Future serviceMain() async { - _Service._shouldRun.value = true; - WidgetsFlutterBinding.ensureInitialized(); - - await _Service()(); -} - -class ServiceConfig { - static Future setProcessExifWifiOnly(bool flag) async { - await Preference.setBool(_servicePref, _servicePrefProcessWifiOnly, flag); - } -} - -@npLog -class _Service { - Future call() async { - final service = FlutterBackgroundService(); - service.setForegroundMode(true); - - await app_init.init(app_init.InitIsolateType.flutterIsolate); - await _L10n().init(); - - _log.info("[call] Service started"); - final onCancelSubscription = service.onCancel.listen((_) { - _log.info("[call] User canceled"); - _stopSelf(); - }); - final onDataSubscription = - service.onDataReceived.listen((event) => _onReceiveData(event ?? {})); - - try { - await _doWork(); - } catch (e, stackTrace) { - _log.shout("[call] Uncaught exception", e, stackTrace); - } - await onCancelSubscription.cancel(); - await onDataSubscription.cancel(); - await KiwiContainer().resolve().npDb.dispose(); - service.stopBackgroundService(); - _log.info("[call] Service stopped"); - } - - Future _doWork() async { - final prefController = PrefController(Pref()); - final account = prefController.currentAccountValue; - if (account == null) { - _log.shout("[_doWork] account == null"); - return; - } - final accountPref = AccountPref.of(account); - - final service = FlutterBackgroundService(); - final metadataTask = _MetadataTask(service, account, accountPref); - _metadataTaskStateChangedListener.begin(); - try { - await metadataTask(); - } finally { - _metadataTaskStateChangedListener.end(); - } - } - - void _onReceiveData(Map data) { - try { - final event = data[_dataKeyEvent]; - switch (event) { - case _eventStop: - _stopSelf(); - break; - - default: - _log.severe("[_onReceiveData] Unknown event: $event"); - break; - } - } catch (e, stackTrace) { - _log.shout("[_onReceiveData] Uncaught exception", e, stackTrace); - } - } - - void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) { - if (ev.state == _metadataTaskState) { - return; - } - _metadataTaskState = ev.state; - if (_isPaused != true) { - if (ev.state == MetadataTaskState.waitingForWifi) { - FlutterBackgroundService() - ..setNotificationInfo( - title: _L10n.global().metadataTaskPauseNoWiFiNotification, - ) - ..pauseWakeLock(); - _isPaused = true; - } else if (ev.state == MetadataTaskState.lowBattery) { - FlutterBackgroundService() - ..setNotificationInfo( - title: _L10n.global().metadataTaskPauseLowBatteryNotification, - ) - ..pauseWakeLock(); - _isPaused = true; - } - } else { - if (ev.state == MetadataTaskState.prcoessing) { - FlutterBackgroundService().resumeWakeLock(); - _isPaused = false; - } - } - } - - void _stopSelf() { - _log.info("[_stopSelf] Stopping service"); - FlutterBackgroundService().setNotificationInfo( - title: _L10n.global().backgroundServiceStopping, - ); - _shouldRun.value = false; - } - - var _metadataTaskState = MetadataTaskState.idle; - late final _metadataTaskStateChangedListener = - AppEventListener( - _onMetadataTaskStateChanged); - - bool? _isPaused; - - static final _shouldRun = ValueNotifier(true); -} - -/// Access localized string out of the main isolate -@npLog -class _L10n { - factory _L10n() => _inst; - - _L10n._(); - - Future init() async { - try { - final locale = language_util.getSelectedLocale(); - if (locale == null) { - _l10n = await _queryL10n(); - } else { - _l10n = lookupAppLocalizations(locale); - } - } catch (e, stackTrace) { - _log.shout("[init] Uncaught exception", e, stackTrace); - _l10n = AppLocalizationsEn(); - } - } - - static AppLocalizations global() => _L10n()._l10n; - - Future _queryL10n() async { - try { - final locale = await Devicelocale.currentAsLocale; - return lookupAppLocalizations(locale!); - } on FlutterError catch (_) { - // unsupported locale, use default (en) - return AppLocalizationsEn(); - } catch (e, stackTrace) { - _log.shout( - "[_queryL10n] Failed while lookupAppLocalizations", e, stackTrace); - return AppLocalizationsEn(); - } - } - - static final _inst = _L10n._(); - late AppLocalizations _l10n; -} - -@npLog -class _MetadataTask { - _MetadataTask(this.service, this.account, this.accountPref); - - Future call() async { - try { - await _updateMetadata(); - } catch (e, stackTrace) { - _log.shout("[call] Uncaught exception", e, stackTrace); - } - if (_processedIds.isNotEmpty) { - unawaited( - MessageRelay.broadcast(FileExifUpdatedEvent(_processedIds).toEvent()), - ); - _processedIds = []; - } - - final c = KiwiContainer().resolve(); - if (c.fileRepo.dataSrc is FileCachedDataSource) { - await (c.fileRepo.dataSrc as FileCachedDataSource).flushRemoteTouch(); - } - } - - Future _updateMetadata() async { - final shareFolder = File( - path: file_util.unstripPath(account, accountPref.getShareFolderOr())); - bool hasScanShareFolder = false; - final c = KiwiContainer().resolve(); - final geocoder = ReverseGeocoder(); - await geocoder.init(); - for (final r in account.roots) { - final dir = File(path: file_util.unstripPath(account, r)); - hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir); - final updater = UpdateMissingMetadata( - c, const _UpdateMissingMetadataConfigProvider(), geocoder); - void onServiceStop() { - _log.info("[_updateMetadata] Stopping task: user canceled"); - updater.stop(); - _shouldRun = false; - } - - _Service._shouldRun.addListener(onServiceStop); - try { - await for (final ev in updater(account, dir)) { - if (ev is File) { - _onFileProcessed(ev); - } - } - } finally { - _Service._shouldRun.removeListener(onServiceStop); - } - if (!_shouldRun) { - return; - } - } - if (!hasScanShareFolder) { - final shareUpdater = UpdateMissingMetadata( - c, const _UpdateMissingMetadataConfigProvider(), geocoder); - void onServiceStop() { - _log.info("[_updateMetadata] Stopping task: user canceled"); - shareUpdater.stop(); - _shouldRun = false; - } - - _Service._shouldRun.addListener(onServiceStop); - try { - await for (final ev in shareUpdater( - account, - shareFolder, - isRecursive: false, - filter: (f) => f.ownerId != account.userId, - )) { - if (ev is File) { - _onFileProcessed(ev); - } - } - } finally { - _Service._shouldRun.removeListener(onServiceStop); - } - if (!_shouldRun) { - return; - } - } - } - - void _onFileProcessed(File file) { - ++_count; - service.setNotificationInfo( - title: _L10n.global().metadataTaskProcessingNotification, - content: file.strippedPath, - ); - - _processedIds.add(file.fileId!); - if (_processedIds.length >= 10) { - MessageRelay.broadcast(FileExifUpdatedEvent(_processedIds).toEvent()); - _processedIds = []; - } - } - - final FlutterBackgroundService service; - final Account account; - final AccountPref accountPref; - - var _shouldRun = true; - var _count = 0; - var _processedIds = []; -} - -class _UpdateMissingMetadataConfigProvider - implements UpdateMissingMetadataConfigProvider { - const _UpdateMissingMetadataConfigProvider(); - - @override - isWifiOnly() => - Preference.getBool(_servicePref, _servicePrefProcessWifiOnly, true) - .notNull(); -} - -class _C { - // needed to work with generator logger - static final log = Logger("service"); -} - -const _dataKeyEvent = "event"; -const _eventStop = "stop"; - -const _servicePref = "service"; -const _servicePrefProcessWifiOnly = "shouldProcessWifiOnly"; diff --git a/app/lib/service/config.dart b/app/lib/service/config.dart new file mode 100644 index 00000000..67690b77 --- /dev/null +++ b/app/lib/service/config.dart @@ -0,0 +1,15 @@ +part of 'service.dart'; + +class ServiceConfig { + static Future isProcessExifWifiOnly() async { + return Preference.getBool(_pref, _prefProcessWifiOnly, true) + .notNull(); + } + + static Future setProcessExifWifiOnly(bool flag) async { + await Preference.setBool(_pref, _prefProcessWifiOnly, flag); + } + + static const _pref = "service"; + static const _prefProcessWifiOnly = "shouldProcessWifiOnly"; +} diff --git a/app/lib/service/l10n.dart b/app/lib/service/l10n.dart new file mode 100644 index 00000000..343ef704 --- /dev/null +++ b/app/lib/service/l10n.dart @@ -0,0 +1,43 @@ +part of 'service.dart'; + +/// Access localized string out of the main isolate +@npLog +class _L10n { + _L10n._(); + + factory _L10n() => _inst; + + Future init() async { + try { + final locale = language_util.getSelectedLocale(); + if (locale == null) { + _l10n = await _queryL10n(); + } else { + _l10n = lookupAppLocalizations(locale); + } + } catch (e, stackTrace) { + _log.shout("[init] Uncaught exception", e, stackTrace); + _l10n = AppLocalizationsEn(); + } + } + + static AppLocalizations global() => _L10n()._l10n; + + Future _queryL10n() async { + try { + final locale = await Devicelocale.currentAsLocale; + return lookupAppLocalizations(locale!); + } on FlutterError catch (_) { + // unsupported locale, use default (en) + return AppLocalizationsEn(); + } catch (e, stackTrace) { + _log.shout( + "[_queryL10n] Failed while lookupAppLocalizations", e, stackTrace); + return AppLocalizationsEn(); + } + } + + late AppLocalizations _l10n; + + static final _inst = _L10n._(); +} diff --git a/app/lib/service/service.dart b/app/lib/service/service.dart new file mode 100644 index 00000000..cf689d87 --- /dev/null +++ b/app/lib/service/service.dart @@ -0,0 +1,192 @@ +import 'dart:async'; + +import 'package:devicelocale/devicelocale.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations_en.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_init.dart' as app_init; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/pref.dart'; +import 'package:nc_photos/event/native_event.dart'; +import 'package:nc_photos/language_util.dart' as language_util; +import 'package:nc_photos/use_case/battery_ensurer.dart'; +import 'package:nc_photos/use_case/sync_metadata/sync_metadata.dart'; +import 'package:nc_photos/use_case/wifi_ensurer.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; +import 'package:np_async/np_async.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_platform_message_relay/np_platform_message_relay.dart'; + +part 'config.dart'; +part 'l10n.dart'; +part 'service.g.dart'; + +/// Start the background service +Future startService() async { + _$__NpLog.log.info("[startService] Starting service"); + final service = FlutterBackgroundService(); + await service.configure( + androidConfiguration: AndroidConfiguration( + onStart: serviceMain, + autoStart: false, + isForegroundMode: true, + foregroundServiceNotificationTitle: + L10n.global().metadataTaskProcessingNotification, + ), + iosConfiguration: IosConfiguration( + onForeground: () => throw UnimplementedError(), + onBackground: () => throw UnimplementedError(), + ), + ); + // sync settings + await ServiceConfig.setProcessExifWifiOnly( + Pref().shouldProcessExifWifiOnlyOr()); + await service.start(); +} + +/// Ask the background service to stop ASAP +void stopService() { + _$__NpLog.log.info("[stopService] Stopping service"); + FlutterBackgroundService().sendData({ + _dataKeyEvent: _eventStop, + }); +} + +@visibleForTesting +@pragma("vm:entry-point") +Future serviceMain() async { + WidgetsFlutterBinding.ensureInitialized(); + await _Service()(); +} + +@npLog +class _Service { + Future call() async { + final service = FlutterBackgroundService(); + service.setForegroundMode(true); + + await app_init.init(app_init.InitIsolateType.flutterIsolate); + await _L10n().init(); + + _log.info("[call] Service started"); + final onCancelSubscription = service.onCancel.listen((_) { + _log.info("[call] User canceled"); + _stopSelf(); + }); + final onDataSubscription = + service.onDataReceived.listen((event) => _onNotifAction(event ?? {})); + + try { + await _doWork(); + } catch (e, stackTrace) { + _log.shout("[call] Uncaught exception", e, stackTrace); + } + await onCancelSubscription.cancel(); + await onDataSubscription.cancel(); + await KiwiContainer().resolve().npDb.dispose(); + service.stopBackgroundService(); + _log.info("[call] Service stopped"); + } + + Future _doWork() async { + final prefController = PrefController(Pref()); + final account = prefController.currentAccountValue; + if (account == null) { + _log.shout("[_doWork] account == null"); + return; + } + final c = KiwiContainer().resolve(); + + final wifiEnsurer = WifiEnsurer( + interrupter: _shouldRun.stream, + ); + wifiEnsurer.isWaiting.listen((event) { + if (event) { + FlutterBackgroundService() + ..setNotificationInfo( + title: _L10n.global().metadataTaskPauseNoWiFiNotification, + ) + ..pauseWakeLock(); + } else { + FlutterBackgroundService().resumeWakeLock(); + } + }); + final batteryEnsurer = BatteryEnsurer( + interrupter: _shouldRun.stream, + ); + batteryEnsurer.isWaiting.listen((event) { + if (event) { + FlutterBackgroundService() + ..setNotificationInfo( + title: _L10n.global().metadataTaskPauseLowBatteryNotification, + ) + ..pauseWakeLock(); + } else { + FlutterBackgroundService().resumeWakeLock(); + } + }); + + final service = FlutterBackgroundService(); + final syncOp = SyncMetadata( + fileRepo: c.fileRepo, + fileRepo2: c.fileRepo2, + fileRepoRemote: c.fileRepoRemote, + db: c.npDb, + interrupter: _shouldRun.stream, + wifiEnsurer: wifiEnsurer, + batteryEnsurer: batteryEnsurer, + ); + final processedIds = []; + await for (final f in syncOp.syncAccount(account)) { + processedIds.add(f.fdId); + service.setNotificationInfo( + title: _L10n.global().metadataTaskProcessingNotification, + content: f.strippedPath, + ); + } + if (processedIds.isNotEmpty) { + await MessageRelay.broadcast( + FileExifUpdatedEvent(processedIds).toEvent()); + } + } + + void _onNotifAction(Map data) { + try { + final event = data[_dataKeyEvent]; + switch (event) { + case _eventStop: + _stopSelf(); + break; + + default: + _log.severe("[_onNotifAction] Unknown event: $event"); + break; + } + } catch (e, stackTrace) { + _log.shout("[_onNotifAction] Uncaught exception", e, stackTrace); + } + } + + void _stopSelf() { + _log.info("[_stopSelf] Stopping service"); + FlutterBackgroundService().setNotificationInfo( + title: _L10n.global().backgroundServiceStopping, + ); + _shouldRun.add(null); + } + + final _shouldRun = StreamController.broadcast(); +} + +@npLog +// ignore: camel_case_types +class __ {} + +const _dataKeyEvent = "event"; +const _eventStop = "stop"; diff --git a/app/lib/service.g.dart b/app/lib/service/service.g.dart similarity index 71% rename from app/lib/service.g.dart rename to app/lib/service/service.g.dart index 03542f4b..bdcfc699 100644 --- a/app/lib/service.g.dart +++ b/app/lib/service/service.g.dart @@ -10,19 +10,19 @@ extension _$_ServiceNpLog on _Service { // ignore: unused_element Logger get _log => log; - static final log = Logger("service._Service"); + static final log = Logger("service.service._Service"); +} + +extension _$__NpLog on __ { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("service.service.__"); } extension _$_L10nNpLog on _L10n { // ignore: unused_element Logger get _log => log; - static final log = Logger("service._L10n"); -} - -extension _$_MetadataTaskNpLog on _MetadataTask { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("service._MetadataTask"); + static final log = Logger("service.service._L10n"); } diff --git a/app/lib/use_case/battery_ensurer.dart b/app/lib/use_case/battery_ensurer.dart new file mode 100644 index 00000000..332f5b84 --- /dev/null +++ b/app/lib/use_case/battery_ensurer.dart @@ -0,0 +1,35 @@ +import 'package:battery_plus/battery_plus.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:rxdart/rxdart.dart'; + +class BatteryEnsurer { + BatteryEnsurer({ + this.interrupter, + }) { + interrupter?.listen((event) { + _shouldRun = false; + }); + } + + Future call() async { + while (await Battery().batteryLevel <= 15) { + if (!_shouldRun) { + throw const InterruptedException(); + } + if (!_isWaiting.value) { + _isWaiting.add(true); + } + await Future.delayed(const Duration(seconds: 5)); + } + if (_isWaiting.value) { + _isWaiting.add(false); + } + } + + ValueStream get isWaiting => _isWaiting.stream; + + final Stream? interrupter; + + var _shouldRun = true; + final _isWaiting = BehaviorSubject.seeded(false); +} diff --git a/app/lib/use_case/scan_missing_metadata.dart b/app/lib/use_case/scan_missing_metadata.dart deleted file mode 100644 index 8d1ef6aa..00000000 --- a/app/lib/use_case/scan_missing_metadata.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/exception_event.dart'; -import 'package:nc_photos/use_case/ls.dart'; -import 'package:nc_photos/use_case/scan_dir.dart'; - -class ScanMissingMetadata { - ScanMissingMetadata(this.fileRepo); - - /// List all files that support metadata but yet having one under a dir - /// - /// The returned stream would emit either File data or ExceptionEvent - /// - /// If [isRecursive] is true, [root] and its sub dirs will be listed, - /// otherwise only [root] will be listed. Default to true - Stream call( - Account account, - File root, { - bool isRecursive = true, - }) async* { - if (isRecursive) { - yield* _doRecursive(account, root); - } else { - yield* _doSingle(account, root); - } - } - - Stream _doRecursive(Account account, File root) async* { - final dataStream = ScanDir(fileRepo)(account, root); - await for (final d in dataStream) { - if (d is ExceptionEvent) { - yield d; - continue; - } - for (final f in (d as List).where(file_util.isMissingMetadata)) { - yield f; - } - } - } - - Stream _doSingle(Account account, File root) async* { - final files = await Ls(fileRepo)(account, root); - for (final f in files.where(file_util.isMissingMetadata)) { - yield f; - } - } - - final FileRepo fileRepo; -} diff --git a/app/lib/use_case/sync_metadata/sync_by_app.dart b/app/lib/use_case/sync_metadata/sync_by_app.dart new file mode 100644 index 00000000..d7106905 --- /dev/null +++ b/app/lib/use_case/sync_metadata/sync_by_app.dart @@ -0,0 +1,120 @@ +part of 'sync_metadata.dart'; + +/// Sync metadata using the client side logic +@npLog +class _SyncByApp { + _SyncByApp({ + required this.account, + required this.fileRepo, + required this.fileRepo2, + required this.db, + this.interrupter, + required this.wifiEnsurer, + required this.batteryEnsurer, + }) { + interrupter?.listen((event) { + _shouldRun = false; + }); + } + + Future init() async { + await _geocoder.init(); + } + + Stream syncFiles({ + required List fileIds, + }) async* { + for (final ids in partition(fileIds, 100)) { + yield* _syncGroup(ids); + } + } + + Stream _syncGroup(List fileIds) async* { + final files = await db.getFilesByFileIds( + account: account.toDb(), + fileIds: fileIds, + ); + for (final f in files) { + final result = await _syncOne(f); + if (result != null) { + yield result; + } + if (!_shouldRun) { + return; + } + } + } + + Future _syncOne(DbFile file) async { + final f = DbFileConverter.fromDb( + account.userId.toCaseInsensitiveString(), + file, + ); + _log.fine("[_syncOne] Syncing ${file.relativePath}"); + try { + OrNull? metadataUpdate; + OrNull? locationUpdate; + if (f.metadata == null) { + // since we need to download multiple images in their original size, + // we only do it with WiFi + await wifiEnsurer(); + await batteryEnsurer(); + if (!_shouldRun) { + return null; + } + _log.fine("[_syncOne] Updating metadata for ${f.path}"); + final binary = await GetFileBinary(fileRepo)(account, f); + final metadata = + (await LoadMetadata().loadRemote(account, f, binary)).copyWith( + fileEtag: f.etag, + ); + metadataUpdate = OrNull(metadata); + } + + final lat = (metadataUpdate?.obj ?? f.metadata)?.exif?.gpsLatitudeDeg; + final lng = (metadataUpdate?.obj ?? f.metadata)?.exif?.gpsLongitudeDeg; + try { + ImageLocation? location; + if (lat != null && lng != null) { + _log.fine("[_syncOne] Reverse geocoding for ${f.path}"); + final l = await _geocoder(lat, lng); + if (l != null) { + location = l.toImageLocation(); + } + } + locationUpdate = OrNull(location ?? ImageLocation.empty()); + } catch (e, stackTrace) { + _log.severe("[_syncOne] Failed while reverse geocoding: ${f.path}", e, + stackTrace); + // if failed, we skip updating the location + } + + if (metadataUpdate != null || locationUpdate != null) { + await UpdateProperty(fileRepo: fileRepo2)( + account, + f, + metadata: metadataUpdate, + location: locationUpdate, + ); + return f; + } else { + return null; + } + } catch (e, stackTrace) { + _log.severe("[_syncOne] Failed while updating metadata: ${f.path}", e, + stackTrace); + return null; + } + } + + final Account account; + final FileRepo fileRepo; + final FileRepo2 fileRepo2; + final NpDb db; + final Stream? interrupter; + final WifiEnsurer wifiEnsurer; + final BatteryEnsurer batteryEnsurer; + + final _geocoder = ReverseGeocoder(); + var _shouldRun = true; +} diff --git a/app/lib/use_case/sync_metadata/sync_by_server.dart b/app/lib/use_case/sync_metadata/sync_by_server.dart new file mode 100644 index 00000000..cb4956f6 --- /dev/null +++ b/app/lib/use_case/sync_metadata/sync_by_server.dart @@ -0,0 +1,91 @@ +part of 'sync_metadata.dart'; + +/// Sync metadata using the client side logic +@npLog +class _SyncByServer { + _SyncByServer({ + required this.account, + required this.fileRepoRemote, + required this.fileRepo2, + required this.db, + this.interrupter, + }) { + interrupter?.listen((event) { + _shouldRun = false; + }); + } + + Future init() async { + await _geocoder.init(); + } + + Stream syncFiles({ + required List relativePaths, + }) async* { + final dirs = relativePaths.map(dirname).toSet(); + for (final dir in dirs) { + yield* _syncDir( + dir: File(path: file_util.unstripPath(account, dir)), + ); + } + } + + Stream _syncDir({ + required File dir, + }) async* { + try { + _log.fine("[_syncDir] Syncing dir $dir"); + final files = await fileRepoRemote.list(account, dir); + await FileSqliteCacheUpdater(db)(account, dir, remote: files); + for (final f in files) { + if (f.metadata != null && f.location == null) { + final result = await _syncOne(f); + if (result != null) { + yield result; + } + if (!_shouldRun) { + return; + } + } + } + } catch (e, stackTrace) { + _log.severe("[_syncDir] Failed to sync dir: $dir", e, stackTrace); + } + } + + Future _syncOne(File file) async { + try { + final lat = file.metadata!.exif?.gpsLatitudeDeg; + final lng = file.metadata!.exif?.gpsLongitudeDeg; + ImageLocation? location; + if (lat != null && lng != null) { + _log.fine("[_syncOne] Reverse geocoding for ${file.path}"); + final l = await _geocoder(lat, lng); + if (l != null) { + location = l.toImageLocation(); + } + } + final locationUpdate = OrNull(location ?? ImageLocation.empty()); + await UpdateProperty(fileRepo: fileRepo2)( + account, + file, + metadata: OrNull(file.metadata), + location: locationUpdate, + ); + return file; + } catch (e, stackTrace) { + _log.severe("[_syncOne] Failed while updating location: ${file.path}", e, + stackTrace); + return null; + } + } + + final Account account; + final FileRepo fileRepoRemote; + final FileRepo2 fileRepo2; + final NpDb db; + final Stream? interrupter; + + final _geocoder = ReverseGeocoder(); + var _shouldRun = true; +} diff --git a/app/lib/use_case/sync_metadata/sync_metadata.dart b/app/lib/use_case/sync_metadata/sync_metadata.dart new file mode 100644 index 00000000..cacc76d9 --- /dev/null +++ b/app/lib/use_case/sync_metadata/sync_metadata.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/controller/server_controller.dart'; +import 'package:nc_photos/db/entity_converter.dart'; +import 'package:nc_photos/entity/exif_util.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/file_cache_manager.dart'; +import 'package:nc_photos/entity/file/repo.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/geocoder_util.dart'; +import 'package:nc_photos/use_case/battery_ensurer.dart'; +import 'package:nc_photos/use_case/get_file_binary.dart'; +import 'package:nc_photos/use_case/load_metadata.dart'; +import 'package:nc_photos/use_case/update_property.dart'; +import 'package:nc_photos/use_case/wifi_ensurer.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_collection/np_collection.dart'; +import 'package:np_common/or_null.dart'; +import 'package:np_db/np_db.dart'; +import 'package:np_geocoder/np_geocoder.dart'; +import 'package:path/path.dart'; + +part 'sync_by_app.dart'; +part 'sync_by_server.dart'; +part 'sync_metadata.g.dart'; + +@npLog +class SyncMetadata { + const SyncMetadata({ + required this.fileRepo, + required this.fileRepo2, + required this.fileRepoRemote, + required this.db, + this.interrupter, + required this.wifiEnsurer, + required this.batteryEnsurer, + }); + + Stream syncAccount(Account account) async* { + final bool isNcMetadataSupported; + try { + isNcMetadataSupported = await _isNcMetadataSupported(account); + } catch (e) { + _log.severe("[syncAccount] Failed to get server version", e); + return; + } + final files = await db.getFilesByMissingMetadata( + account: account.toDb(), + mimes: file_util.metadataSupportedFormatMimes, + ownerId: account.userId.toCaseInsensitiveString(), + ); + _log.info("[syncAccount] Missing count: ${files.items.length}"); + if (isNcMetadataSupported) { + yield* _doWithServer(account, files); + } else { + yield* _doWithApp(account, files); + } + } + + Stream _doWithApp( + Account account, DbFileMissingMetadataResult files) async* { + final op = _SyncByApp( + account: account, + fileRepo: fileRepo, + fileRepo2: fileRepo2, + db: db, + interrupter: interrupter, + wifiEnsurer: wifiEnsurer, + batteryEnsurer: batteryEnsurer, + ); + await op.init(); + final stream = op.syncFiles( + fileIds: files.items.map((e) => e.fileId).toList(), + ); + yield* stream; + } + + Stream _doWithServer( + Account account, DbFileMissingMetadataResult files) async* { + final op = _SyncByServer( + account: account, + fileRepoRemote: fileRepoRemote, + fileRepo2: fileRepo2, + db: db, + interrupter: interrupter, + ); + await op.init(); + final stream = op.syncFiles( + relativePaths: files.items.map((e) => e.relativePath).toList(), + ); + yield* stream; + } + + Future _isNcMetadataSupported(Account account) async { + final serverController = ServerController(account: account); + await serverController.status.first.timeout(const Duration(seconds: 15)); + return serverController.isSupported(ServerFeature.ncMetadata); + } + + final FileRepo fileRepo; + final FileRepo2 fileRepo2; + final FileRepo fileRepoRemote; + final NpDb db; + final Stream? interrupter; + final WifiEnsurer wifiEnsurer; + final BatteryEnsurer batteryEnsurer; +} diff --git a/app/lib/use_case/sync_metadata/sync_metadata.g.dart b/app/lib/use_case/sync_metadata/sync_metadata.g.dart new file mode 100644 index 00000000..d27370e4 --- /dev/null +++ b/app/lib/use_case/sync_metadata/sync_metadata.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_metadata.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$SyncMetadataNpLog on SyncMetadata { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("use_case.sync_metadata.sync_metadata.SyncMetadata"); +} + +extension _$_SyncByAppNpLog on _SyncByApp { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("use_case.sync_metadata.sync_metadata._SyncByApp"); +} + +extension _$_SyncByServerNpLog on _SyncByServer { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("use_case.sync_metadata.sync_metadata._SyncByServer"); +} diff --git a/app/lib/use_case/update_missing_metadata.dart b/app/lib/use_case/update_missing_metadata.dart deleted file mode 100644 index 89a52470..00000000 --- a/app/lib/use_case/update_missing_metadata.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:battery_plus/battery_plus.dart'; -import 'package:event_bus/event_bus.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/connectivity_util.dart' as connectivity_util; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/exif_util.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/exception.dart'; -import 'package:nc_photos/exception_event.dart'; -import 'package:nc_photos/geocoder_util.dart'; -import 'package:nc_photos/use_case/get_file_binary.dart'; -import 'package:nc_photos/use_case/load_metadata.dart'; -import 'package:nc_photos/use_case/scan_missing_metadata.dart'; -import 'package:nc_photos/use_case/update_property.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:np_common/or_null.dart'; -import 'package:np_geocoder/np_geocoder.dart'; - -part 'update_missing_metadata.g.dart'; - -abstract class UpdateMissingMetadataConfigProvider { - Future isWifiOnly(); -} - -@npLog -class UpdateMissingMetadata { - UpdateMissingMetadata(this._c, this.configProvider, this.geocoder); - - /// Update metadata for all files that support one under a dir - /// - /// The returned stream would emit either File data (for each updated files) - /// or ExceptionEvent - /// - /// If [isRecursive] is true, [root] and its sub dirs will be scanned, - /// otherwise only [root] will be scanned. Default to true - /// - /// [filter] can be used to filter files -- return true if a file should be - /// included. If [filter] is null, all files will be included. - Stream call( - Account account, - File root, { - bool isRecursive = true, - bool Function(File file)? filter, - }) async* { - final dataStream = ScanMissingMetadata(_c.fileRepo)( - account, - root, - isRecursive: isRecursive, - ); - await for (final d in dataStream) { - if (!_shouldRun) { - return; - } - if (d is ExceptionEvent) { - yield d; - continue; - } - final File file = d; - // check if this is a federation share. Nextcloud doesn't support - // properties for such files - if (file.ownerId?.contains("/") == true || filter?.call(d) == false) { - continue; - } - try { - OrNull? metadataUpdate; - OrNull? locationUpdate; - if (file.metadata == null) { - // since we need to download multiple images in their original size, - // we only do it with WiFi - await _ensureWifi(); - await _ensureBattery(); - KiwiContainer().resolve().fire( - const MetadataTaskStateChangedEvent( - MetadataTaskState.prcoessing)); - if (!_shouldRun) { - return; - } - _log.fine("[call] Updating metadata for ${file.path}"); - final binary = await GetFileBinary(_c.fileRepo)(account, file); - final metadata = - (await LoadMetadata().loadRemote(account, file, binary)).copyWith( - fileEtag: file.etag, - ); - metadataUpdate = OrNull(metadata); - } else { - _log.finer("[call] Skip updating metadata for ${file.path}"); - KiwiContainer().resolve().fire( - const MetadataTaskStateChangedEvent( - MetadataTaskState.prcoessing)); - } - - final lat = - (metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLatitudeDeg; - final lng = - (metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLongitudeDeg; - try { - ImageLocation? location; - if (lat != null && lng != null) { - _log.fine("[call] Reverse geocoding for ${file.path}"); - final l = await geocoder(lat, lng); - if (l != null) { - location = l.toImageLocation(); - } - } - locationUpdate = OrNull(location ?? ImageLocation.empty()); - } catch (e, stackTrace) { - _log.severe("[call] Failed while reverse geocoding: ${file.path}", e, - stackTrace); - } - - if (metadataUpdate != null || locationUpdate != null) { - await UpdateProperty(_c)( - account, - file, - metadata: metadataUpdate, - location: locationUpdate, - ); - yield file; - } - - // slow down a bit to give some space for the main isolate - await Future.delayed(const Duration(milliseconds: 10)); - } on InterruptedException catch (_) { - return; - } catch (e, stackTrace) { - _log.severe("[call] Failed while updating metadata: ${file.path}", e, - stackTrace); - yield ExceptionEvent(e, stackTrace); - } - } - } - - void stop() { - _shouldRun = false; - } - - Future _ensureWifi() async { - var count = 0; - while (await configProvider.isWifiOnly() && - !await connectivity_util.isWifi()) { - if (!_shouldRun) { - throw const InterruptedException(); - } - // give a chance to reconnect with the WiFi network - if (++count >= 6) { - KiwiContainer().resolve().fire( - const MetadataTaskStateChangedEvent( - MetadataTaskState.waitingForWifi)); - } - await Future.delayed(const Duration(seconds: 5)); - } - } - - Future _ensureBattery() async { - while (await Battery().batteryLevel <= 15) { - if (!_shouldRun) { - throw const InterruptedException(); - } - KiwiContainer().resolve().fire( - const MetadataTaskStateChangedEvent(MetadataTaskState.lowBattery)); - await Future.delayed(const Duration(seconds: 5)); - } - } - - final DiContainer _c; - final UpdateMissingMetadataConfigProvider configProvider; - final ReverseGeocoder geocoder; - - bool _shouldRun = true; -} diff --git a/app/lib/use_case/update_missing_metadata.g.dart b/app/lib/use_case/update_missing_metadata.g.dart deleted file mode 100644 index 1c045135..00000000 --- a/app/lib/use_case/update_missing_metadata.g.dart +++ /dev/null @@ -1,15 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'update_missing_metadata.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$UpdateMissingMetadataNpLog on UpdateMissingMetadata { - // ignore: unused_element - Logger get _log => log; - - static final log = - Logger("use_case.update_missing_metadata.UpdateMissingMetadata"); -} diff --git a/app/lib/use_case/update_property.dart b/app/lib/use_case/update_property.dart index 65f5e970..8889b733 100644 --- a/app/lib/use_case/update_property.dart +++ b/app/lib/use_case/update_property.dart @@ -1,11 +1,8 @@ -import 'package:event_bus/event_bus.dart'; -import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/repo.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/event/event.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/or_null.dart'; @@ -13,7 +10,11 @@ part 'update_property.g.dart'; @npLog class UpdateProperty { - const UpdateProperty(this._c); + const UpdateProperty({ + required this.fileRepo, + }); + + final FileRepo2 fileRepo; Future call( Account account, @@ -34,17 +35,7 @@ class UpdateProperty { return; } - await _c.fileRepo2.updateProperty( - account, - file, - metadata: metadata, - isArchived: isArchived, - overrideDateTime: overrideDateTime, - favorite: favorite, - location: location, - ); - - _notify( + await fileRepo.updateProperty( account, file, metadata: metadata, @@ -54,40 +45,6 @@ class UpdateProperty { location: location, ); } - - @Deprecated("legacy") - void _notify( - Account account, - FileDescriptor file, { - OrNull? metadata, - OrNull? isArchived, - OrNull? overrideDateTime, - bool? favorite, - OrNull? location, - }) { - int properties = 0; - if (metadata != null) { - properties |= FilePropertyUpdatedEvent.propMetadata; - } - if (isArchived != null) { - properties |= FilePropertyUpdatedEvent.propIsArchived; - } - if (overrideDateTime != null) { - properties |= FilePropertyUpdatedEvent.propOverrideDateTime; - } - if (favorite != null) { - properties |= FilePropertyUpdatedEvent.propFavorite; - } - if (location != null) { - properties |= FilePropertyUpdatedEvent.propImageLocation; - } - assert(properties != 0); - KiwiContainer() - .resolve() - .fire(FilePropertyUpdatedEvent(account, file, properties)); - } - - final DiContainer _c; } extension UpdatePropertyExtension on UpdateProperty { diff --git a/app/lib/use_case/wifi_ensurer.dart b/app/lib/use_case/wifi_ensurer.dart new file mode 100644 index 00000000..ba239fb1 --- /dev/null +++ b/app/lib/use_case/wifi_ensurer.dart @@ -0,0 +1,41 @@ +import 'package:nc_photos/connectivity_util.dart' as connectivity_util; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/service/service.dart'; +import 'package:rxdart/rxdart.dart'; + +class WifiEnsurer { + WifiEnsurer({ + this.interrupter, + }) { + interrupter?.listen((event) { + _shouldRun = false; + }); + } + + Future call() async { + var count = 0; + while (await ServiceConfig.isProcessExifWifiOnly() && + !await connectivity_util.isWifi()) { + if (!_shouldRun) { + throw const InterruptedException(); + } + // give a chance to reconnect with the WiFi network + if (++count >= 6) { + if (!_isWaiting.value) { + _isWaiting.add(true); + } + } + await Future.delayed(const Duration(seconds: 5)); + } + if (_isWaiting.value) { + _isWaiting.add(false); + } + } + + ValueStream get isWaiting => _isWaiting.stream; + + final Stream? interrupter; + + var _shouldRun = true; + final _isWaiting = BehaviorSubject.seeded(false); +} diff --git a/app/lib/widget/home_photos/bloc.dart b/app/lib/widget/home_photos/bloc.dart index 6e0da340..e438fb58 100644 --- a/app/lib/widget/home_photos/bloc.dart +++ b/app/lib/widget/home_photos/bloc.dart @@ -536,11 +536,7 @@ class _Bloc extends Bloc<_Event, _State> personsController: personsController, personProvider: accountPrefController.personProviderValue, ); - if (!serverController.isSupported(ServerFeature.ncMetadata)) { - metadataController.kickstart(); - } else { - _log.info("[_syncRemote] Skipping metadata service"); - } + metadataController.kickstart(); _log.info( "[_syncRemote] Elapsed time: ${stopwatch.elapsedMilliseconds}ms"); }); diff --git a/app/lib/widget/settings/metadata_settings.dart b/app/lib/widget/settings/metadata_settings.dart index 3916893d..98982b57 100644 --- a/app/lib/widget/settings/metadata_settings.dart +++ b/app/lib/widget/settings/metadata_settings.dart @@ -6,7 +6,7 @@ import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc_util.dart'; import 'package:nc_photos/controller/pref_controller.dart'; import 'package:nc_photos/exception_event.dart'; -import 'package:nc_photos/service.dart'; +import 'package:nc_photos/service/service.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/widget/page_visibility_mixin.dart'; import 'package:np_codegen/np_codegen.dart'; diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index e0e16f52..a3e419e0 100644 --- a/app/lib/widget/viewer_detail_pane.dart +++ b/app/lib/widget/viewer_detail_pane.dart @@ -505,7 +505,7 @@ class _ViewerDetailPaneState extends State { return; } try { - await UpdateProperty(_c) + await UpdateProperty(fileRepo: _c.fileRepo2) .updateOverrideDateTime(widget.account, _file!, value); if (mounted) { setState(() { diff --git a/app/test/entity/file/file_cache_manager_test.dart b/app/test/entity/file/file_cache_manager_test.dart index d8ca587a..d58cc797 100644 --- a/app/test/entity/file/file_cache_manager_test.dart +++ b/app/test/entity/file/file_cache_manager_test.dart @@ -205,7 +205,7 @@ Future _updaterIdentical() async { await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(account, files[0], remote: files.slice(0, 3)); expect( await util.listSqliteDbFiles(c.sqliteDb), @@ -240,7 +240,7 @@ Future _updaterNewFile() async { await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(account, files[0], remote: [...files.slice(0, 3), newFile]); expect( await util.listSqliteDbFiles(c.sqliteDb), @@ -271,7 +271,7 @@ Future _updaterDeleteFile() async { await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(account, files[0], remote: [files[0], files[2]]); expect( await util.listSqliteDbFiles(c.sqliteDb), @@ -305,7 +305,7 @@ Future _updaterDeleteDir() async { await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(account, files[0], remote: files.slice(0, 2)); expect( await util.listSqliteDbFiles(c.sqliteDb), @@ -343,7 +343,7 @@ Future _updaterUpdateFile() async { await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(account, files[0], remote: [files[0], newFile, ...files.slice(2)]); expect( @@ -382,7 +382,7 @@ Future _updaterNewSharedFile() async { await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(user1Account, user1Files[0], remote: user1Files); expect( await util.listSqliteDbFiles(c.sqliteDb), @@ -419,7 +419,7 @@ Future _updaterNewSharedDir() async { await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(user1Account, user1Files[0], remote: user1Files); expect( await util.listSqliteDbFiles(c.sqliteDb), @@ -461,7 +461,7 @@ Future _updaterDeleteSharedFile() async { c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(user1Account, user1Files[0], remote: [user1Files[0]]); expect( await util.listSqliteDbFiles(c.sqliteDb), @@ -504,7 +504,7 @@ Future _updaterDeleteSharedDir() async { c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(user1Account, user1Files[0], remote: [user1Files[0]]); expect( await util.listSqliteDbFiles(c.sqliteDb), @@ -541,7 +541,7 @@ Future _updaterTooManyFiles() async { await util.insertDirRelation(c.sqliteDb, account, files[2], files.slice(3)); }); - final updater = FileSqliteCacheUpdater(c); + final updater = FileSqliteCacheUpdater(c.npDb); await updater(account, files[2], remote: [...files.slice(2), ...newFiles]); // we are testing to make sure the above function won't throw, so nothing to // expect here @@ -574,12 +574,12 @@ Future _updaterMovedFileToFront() async { final movedFile = files[3].copyWith( path: "remote.php/dav/files/admin/test1/test1.jpg", ); - await FileSqliteCacheUpdater(c)( + await FileSqliteCacheUpdater(c.npDb)( account, files[1], remote: [files[1], movedFile], ); - await FileSqliteCacheUpdater(c)( + await FileSqliteCacheUpdater(c.npDb)( account, files[2], remote: [files[2]], @@ -621,12 +621,12 @@ Future _updaterMovedFileToBehind() async { final movedFile = files[3].copyWith( path: "remote.php/dav/files/admin/test2/test1.jpg", ); - await FileSqliteCacheUpdater(c)( + await FileSqliteCacheUpdater(c.npDb)( account, files[1], remote: [files[1]], ); - await FileSqliteCacheUpdater(c)( + await FileSqliteCacheUpdater(c.npDb)( account, files[2], remote: [files[2], movedFile], diff --git a/np_collection/lib/np_collection.dart b/np_collection/lib/np_collection.dart index 5373cb4e..bae883df 100644 --- a/np_collection/lib/np_collection.dart +++ b/np_collection/lib/np_collection.dart @@ -1,5 +1,7 @@ library np_collection; +export 'package:quiver/iterables.dart' show partition; + export 'src/iterable_extension.dart'; export 'src/iterator_extension.dart'; export 'src/list_extension.dart'; diff --git a/np_db/lib/src/api.dart b/np_db/lib/src/api.dart index 3544f637..44184b9a 100644 --- a/np_db/lib/src/api.dart +++ b/np_db/lib/src/api.dart @@ -201,6 +201,19 @@ class DbFilesMemory { final Map> memories; } +@genCopyWith +@toString +class DbFileMissingMetadataResult { + const DbFileMissingMetadataResult({ + required this.items, + }); + + @override + String toString() => _$toString(); + + final List<({int fileId, String relativePath})> items; +} + @npLog abstract class NpDb { factory NpDb() => NpDbSqlite(); @@ -354,6 +367,13 @@ abstract class NpDb { required String ownerId, }); + /// Return files without metadata + Future getFilesByMissingMetadata({ + required DbAccount account, + required List mimes, + required String ownerId, + }); + /// Delete a file or dir from db Future deleteFile({ required DbAccount account, diff --git a/np_db/lib/src/api.g.dart b/np_db/lib/src/api.g.dart index ea32abb0..70a73028 100644 --- a/np_db/lib/src/api.g.dart +++ b/np_db/lib/src/api.g.dart @@ -81,6 +81,31 @@ extension $DbFilesMemoryCopyWith on DbFilesMemory { _$DbFilesMemoryCopyWithWorkerImpl(this); } +abstract class $DbFileMissingMetadataResultCopyWithWorker { + DbFileMissingMetadataResult call( + {List<({int fileId, String relativePath})>? items}); +} + +class _$DbFileMissingMetadataResultCopyWithWorkerImpl + implements $DbFileMissingMetadataResultCopyWithWorker { + _$DbFileMissingMetadataResultCopyWithWorkerImpl(this.that); + + @override + DbFileMissingMetadataResult call({dynamic items}) { + return DbFileMissingMetadataResult( + items: + items as List<({int fileId, String relativePath})>? ?? that.items); + } + + final DbFileMissingMetadataResult that; +} + +extension $DbFileMissingMetadataResultCopyWith on DbFileMissingMetadataResult { + $DbFileMissingMetadataResultCopyWithWorker get copyWith => _$copyWith; + $DbFileMissingMetadataResultCopyWithWorker get _$copyWith => + _$DbFileMissingMetadataResultCopyWithWorkerImpl(this); +} + // ************************************************************************** // NpLogGenerator // ************************************************************************** @@ -151,3 +176,10 @@ extension _$DbFilesMemoryToString on DbFilesMemory { return "DbFilesMemory {memories: $memories}"; } } + +extension _$DbFileMissingMetadataResultToString on DbFileMissingMetadataResult { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "DbFileMissingMetadataResult {items: [length: ${items.length}]}"; + } +} diff --git a/np_db_sqlite/lib/src/database/file_extension.dart b/np_db_sqlite/lib/src/database/file_extension.dart index ffa54db5..3c7a2aec 100644 --- a/np_db_sqlite/lib/src/database/file_extension.dart +++ b/np_db_sqlite/lib/src/database/file_extension.dart @@ -343,7 +343,7 @@ extension SqliteDbFileExtension on SqliteDb { String? ownerId, }) async { _log.info( - "[countFiles] isMissingMetadata: $isMissingMetadata, mimes: $mimes"); + "[countFiles] isMissingMetadata: $isMissingMetadata, mimes: $mimes, ownerId: $ownerId"); Expression? filter; if (isMissingMetadata != null) { if (isMissingMetadata) { @@ -387,6 +387,69 @@ extension SqliteDbFileExtension on SqliteDb { return await query.map((r) => r.read(count)!).getSingle(); } + Future> + queryFileIdPathsByMissingMetadata({ + required ByAccount account, + bool? isMissingMetadata, + List? mimes, + String? ownerId, + int? offset, + int? limit, + }) async { + _log.info( + "[queryFileIdPathsByMissingMetadata] isMissingMetadata: $isMissingMetadata, mimes: $mimes, ownerId: $ownerId"); + final query = selectOnly(files).join([ + innerJoin(accountFiles, accountFiles.file.equalsExp(files.rowId), + useColumns: false), + if (account.dbAccount != null) ...[ + innerJoin(accounts, accounts.rowId.equalsExp(accountFiles.account), + useColumns: false), + innerJoin(servers, servers.rowId.equalsExp(accounts.server), + useColumns: false), + ], + leftOuterJoin(images, images.accountFile.equalsExp(accountFiles.rowId), + useColumns: false), + leftOuterJoin(imageLocations, + imageLocations.accountFile.equalsExp(accountFiles.rowId), + useColumns: false), + ]); + query.addColumns([files.fileId, accountFiles.relativePath]); + if (account.sqlAccount != null) { + query.where(accountFiles.account.equals(account.sqlAccount!.rowId)); + } else if (account.dbAccount != null) { + query + ..where(servers.address.equals(account.dbAccount!.serverAddress)) + ..where(accounts.userId + .equals(account.dbAccount!.userId.toCaseInsensitiveString())); + } + if (mimes != null) { + query.where(files.contentType.isIn(mimes)); + } + if (ownerId != null) { + query.where(files.ownerId.equals(ownerId)); + } + if (isMissingMetadata != null) { + if (isMissingMetadata) { + query.where( + images.lastUpdated.isNull() | imageLocations.version.isNull()); + } else { + query.where(images.lastUpdated.isNotNull() & + imageLocations.version.isNotNull()); + } + } + + query.orderBy([OrderingTerm.desc(files.fileId)]); + if (limit != null) { + query.limit(limit, offset: offset); + } + return await query + .map((r) => ( + fileId: r.read(files.fileId)!, + relativePath: r.read(accountFiles.relativePath)!, + )) + .get(); + } + Future> queryFileDescriptors({ required ByAccount account, List? fileIds, diff --git a/np_db_sqlite/lib/src/sqlite_api.dart b/np_db_sqlite/lib/src/sqlite_api.dart index 7c7a1b00..6acd63ad 100644 --- a/np_db_sqlite/lib/src/sqlite_api.dart +++ b/np_db_sqlite/lib/src/sqlite_api.dart @@ -395,6 +395,34 @@ class NpDbSqlite implements NpDb { }); } + @override + Future getFilesByMissingMetadata({ + required DbAccount account, + required List mimes, + required String ownerId, + }) async { + return _db.use((db) async { + final results = <({int fileId, String relativePath})>[]; + var i = 0; + while (true) { + final sqlObjs = await db.queryFileIdPathsByMissingMetadata( + account: ByAccount.db(account), + isMissingMetadata: true, + mimes: mimes, + ownerId: ownerId, + limit: 10000, + offset: i, + ); + if (sqlObjs.isEmpty) { + break; + } + results.addAll(sqlObjs); + i += 10000; + } + return DbFileMissingMetadataResult(items: results); + }); + } + @override Future deleteFile({ required DbAccount account, From 114b52d0e96c801a5aa777870a8e1b98bc8d2374 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Fri, 22 Nov 2024 00:26:33 +0800 Subject: [PATCH 16/24] Revert "Fix wrong mime types used when counting file w/o metadata" This reverts commit 97f1b25ae9788d1d45f224f269fccebd0da9e188. --- app/lib/controller/metadata_controller.dart | 2 +- app/lib/entity/file_util.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/lib/controller/metadata_controller.dart b/app/lib/controller/metadata_controller.dart index 097c64ea..581461a8 100644 --- a/app/lib/controller/metadata_controller.dart +++ b/app/lib/controller/metadata_controller.dart @@ -67,7 +67,7 @@ class MetadataController { try { final missingCount = await _c.npDb.countFilesByMissingMetadata( account: account.toDb(), - mimes: file_util.metadataSupportedFormatMimes, + mimes: file_util.supportedImageFormatMimes, ownerId: account.userId.toCaseInsensitiveString(), ); _log.info("[_startMetadataTask] Missing count: $missingCount"); diff --git a/app/lib/entity/file_util.dart b/app/lib/entity/file_util.dart index 940477a6..2eb5453c 100644 --- a/app/lib/entity/file_util.dart +++ b/app/lib/entity/file_util.dart @@ -27,7 +27,7 @@ bool isSupportedVideoFormat(FileDescriptor file) => isSupportedVideoMime(file.fdMime ?? ""); bool isMetadataSupportedMime(String mime) => - metadataSupportedFormatMimes.contains(mime); + _metadataSupportedFormatMimes.contains(mime); bool isMetadataSupportedFormat(FileDescriptor file) => isMetadataSupportedMime(file.fdMime ?? ""); @@ -138,7 +138,7 @@ final supportedImageFormatMimes = final supportedVideoFormatMimes = supportedFormatMimes.where((f) => f.startsWith("video/")).toList(); -const metadataSupportedFormatMimes = [ +const _metadataSupportedFormatMimes = [ "image/jpeg", "image/heic", ]; From c1915b393a53a5f82c9e3e8672bcee4ffa09d392 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 23 Nov 2024 00:43:36 +0800 Subject: [PATCH 17/24] Fallback to our metadata extractor if file not supported by nextcloud server --- .../use_case/sync_metadata/sync_by_app.dart | 40 +++++++++---------- .../sync_metadata/sync_by_server.dart | 32 +++++++++++---- .../use_case/sync_metadata/sync_metadata.dart | 13 +++++- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/app/lib/use_case/sync_metadata/sync_by_app.dart b/app/lib/use_case/sync_metadata/sync_by_app.dart index d7106905..e6e37347 100644 --- a/app/lib/use_case/sync_metadata/sync_by_app.dart +++ b/app/lib/use_case/sync_metadata/sync_by_app.dart @@ -34,8 +34,12 @@ class _SyncByApp { account: account.toDb(), fileIds: fileIds, ); - for (final f in files) { - final result = await _syncOne(f); + for (final dbF in files) { + final f = DbFileConverter.fromDb( + account.userId.toCaseInsensitiveString(), + dbF, + ); + final result = await syncOne(f); if (result != null) { yield result; } @@ -45,16 +49,12 @@ class _SyncByApp { } } - Future _syncOne(DbFile file) async { - final f = DbFileConverter.fromDb( - account.userId.toCaseInsensitiveString(), - file, - ); - _log.fine("[_syncOne] Syncing ${file.relativePath}"); + Future syncOne(File file) async { + _log.fine("[syncOne] Syncing ${file.path}"); try { OrNull? metadataUpdate; OrNull? locationUpdate; - if (f.metadata == null) { + if (file.metadata == null) { // since we need to download multiple images in their original size, // we only do it with WiFi await wifiEnsurer(); @@ -62,21 +62,21 @@ class _SyncByApp { if (!_shouldRun) { return null; } - _log.fine("[_syncOne] Updating metadata for ${f.path}"); - final binary = await GetFileBinary(fileRepo)(account, f); + _log.fine("[syncOne] Updating metadata for ${file.path}"); + final binary = await GetFileBinary(fileRepo)(account, file); final metadata = - (await LoadMetadata().loadRemote(account, f, binary)).copyWith( - fileEtag: f.etag, + (await LoadMetadata().loadRemote(account, file, binary)).copyWith( + fileEtag: file.etag, ); metadataUpdate = OrNull(metadata); } - final lat = (metadataUpdate?.obj ?? f.metadata)?.exif?.gpsLatitudeDeg; - final lng = (metadataUpdate?.obj ?? f.metadata)?.exif?.gpsLongitudeDeg; + final lat = (metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLatitudeDeg; + final lng = (metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLongitudeDeg; try { ImageLocation? location; if (lat != null && lng != null) { - _log.fine("[_syncOne] Reverse geocoding for ${f.path}"); + _log.fine("[syncOne] Reverse geocoding for ${file.path}"); final l = await _geocoder(lat, lng); if (l != null) { location = l.toImageLocation(); @@ -84,7 +84,7 @@ class _SyncByApp { } locationUpdate = OrNull(location ?? ImageLocation.empty()); } catch (e, stackTrace) { - _log.severe("[_syncOne] Failed while reverse geocoding: ${f.path}", e, + _log.severe("[syncOne] Failed while reverse geocoding: ${file.path}", e, stackTrace); // if failed, we skip updating the location } @@ -92,16 +92,16 @@ class _SyncByApp { if (metadataUpdate != null || locationUpdate != null) { await UpdateProperty(fileRepo: fileRepo2)( account, - f, + file, metadata: metadataUpdate, location: locationUpdate, ); - return f; + return file; } else { return null; } } catch (e, stackTrace) { - _log.severe("[_syncOne] Failed while updating metadata: ${f.path}", e, + _log.severe("[syncOne] Failed while updating metadata: ${file.path}", e, stackTrace); return null; } diff --git a/app/lib/use_case/sync_metadata/sync_by_server.dart b/app/lib/use_case/sync_metadata/sync_by_server.dart index cb4956f6..92debc87 100644 --- a/app/lib/use_case/sync_metadata/sync_by_server.dart +++ b/app/lib/use_case/sync_metadata/sync_by_server.dart @@ -9,6 +9,7 @@ class _SyncByServer { required this.fileRepo2, required this.db, this.interrupter, + required this.fallback, }) { interrupter?.listen((event) { _shouldRun = false; @@ -20,17 +21,20 @@ class _SyncByServer { } Stream syncFiles({ + required List fileIds, required List relativePaths, }) async* { final dirs = relativePaths.map(dirname).toSet(); for (final dir in dirs) { yield* _syncDir( + fileIds: fileIds, dir: File(path: file_util.unstripPath(account, dir)), ); } } Stream _syncDir({ + required List fileIds, required File dir, }) async* { try { @@ -38,15 +42,22 @@ class _SyncByServer { final files = await fileRepoRemote.list(account, dir); await FileSqliteCacheUpdater(db)(account, dir, remote: files); for (final f in files) { - if (f.metadata != null && f.location == null) { - final result = await _syncOne(f); - if (result != null) { - yield result; - } - if (!_shouldRun) { - return; + File? result; + if (!_supportedMimes.contains(f.fdMime)) { + _log.info( + "[_syncDir] File ${f.path} (mime: ${f.fdMime}) not supported by server, fallback to client"); + result = await fallback.syncOne(f); + } else { + if (f.metadata != null && f.location == null) { + result = await _syncOne(f); } } + if (result != null) { + yield result; + } + if (!_shouldRun) { + return; + } } } catch (e, stackTrace) { _log.severe("[_syncDir] Failed to sync dir: $dir", e, stackTrace); @@ -54,6 +65,7 @@ class _SyncByServer { } Future _syncOne(File file) async { + _log.fine("[_syncOne] Syncing ${file.path}"); try { final lat = file.metadata!.exif?.gpsLatitudeDeg; final lng = file.metadata!.exif?.gpsLongitudeDeg; @@ -85,7 +97,13 @@ class _SyncByServer { final FileRepo2 fileRepo2; final NpDb db; final Stream? interrupter; + final _SyncByApp fallback; final _geocoder = ReverseGeocoder(); var _shouldRun = true; + + static const _supportedMimes = [ + "image/jpeg", + "image/webp", + ]; } diff --git a/app/lib/use_case/sync_metadata/sync_metadata.dart b/app/lib/use_case/sync_metadata/sync_metadata.dart index cacc76d9..305b8ed2 100644 --- a/app/lib/use_case/sync_metadata/sync_metadata.dart +++ b/app/lib/use_case/sync_metadata/sync_metadata.dart @@ -48,7 +48,7 @@ class SyncMetadata { } final files = await db.getFilesByMissingMetadata( account: account.toDb(), - mimes: file_util.metadataSupportedFormatMimes, + mimes: file_util.supportedImageFormatMimes, ownerId: account.userId.toCaseInsensitiveString(), ); _log.info("[syncAccount] Missing count: ${files.items.length}"); @@ -79,12 +79,23 @@ class SyncMetadata { Stream _doWithServer( Account account, DbFileMissingMetadataResult files) async* { + final fallback = _SyncByApp( + account: account, + fileRepo: fileRepo, + fileRepo2: fileRepo2, + db: db, + interrupter: interrupter, + wifiEnsurer: wifiEnsurer, + batteryEnsurer: batteryEnsurer, + ); + await fallback.init(); final op = _SyncByServer( account: account, fileRepoRemote: fileRepoRemote, fileRepo2: fileRepo2, db: db, interrupter: interrupter, + fallback: fallback, ); await op.init(); final stream = op.syncFiles( From 15eb565eac197969a9ba7fbbc6712bbee30ee1b6 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 23 Nov 2024 00:44:58 +0800 Subject: [PATCH 18/24] Fix server side metadata service not filtering file type --- app/lib/use_case/sync_metadata/sync_by_server.dart | 2 +- app/lib/use_case/sync_metadata/sync_metadata.dart | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/lib/use_case/sync_metadata/sync_by_server.dart b/app/lib/use_case/sync_metadata/sync_by_server.dart index 92debc87..66ed17a6 100644 --- a/app/lib/use_case/sync_metadata/sync_by_server.dart +++ b/app/lib/use_case/sync_metadata/sync_by_server.dart @@ -41,7 +41,7 @@ class _SyncByServer { _log.fine("[_syncDir] Syncing dir $dir"); final files = await fileRepoRemote.list(account, dir); await FileSqliteCacheUpdater(db)(account, dir, remote: files); - for (final f in files) { + for (final f in files.where((e) => fileIds.contains(e.fdId))) { File? result; if (!_supportedMimes.contains(f.fdMime)) { _log.info( diff --git a/app/lib/use_case/sync_metadata/sync_metadata.dart b/app/lib/use_case/sync_metadata/sync_metadata.dart index 305b8ed2..6b5eccc7 100644 --- a/app/lib/use_case/sync_metadata/sync_metadata.dart +++ b/app/lib/use_case/sync_metadata/sync_metadata.dart @@ -98,8 +98,15 @@ class SyncMetadata { fallback: fallback, ); await op.init(); + final fileIds = []; + final relativePaths = []; + for (final f in files.items) { + fileIds.add(f.fileId); + relativePaths.add(f.relativePath); + } final stream = op.syncFiles( - relativePaths: files.items.map((e) => e.relativePath).toList(), + fileIds: fileIds, + relativePaths: relativePaths, ); yield* stream; } From c905be8b01c01ab418b6648aaa551689ecb4dd03 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 23 Nov 2024 01:30:48 +0800 Subject: [PATCH 19/24] Revert "Hide EXIF settings on nextcloud 28+" This reverts commit 5c9f51a1106e7829b983531dd2dd13d0e1a120ff. --- app/lib/widget/settings.dart | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/app/lib/widget/settings.dart b/app/lib/widget/settings.dart index 0232eba1..f721080a 100644 --- a/app/lib/widget/settings.dart +++ b/app/lib/widget/settings.dart @@ -4,9 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/controller/account_controller.dart'; import 'package:nc_photos/controller/pref_controller.dart'; -import 'package:nc_photos/controller/server_controller.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/language_util.dart' as language_util; @@ -81,15 +79,11 @@ class _SettingsState extends State { description: L10n.global().settingsThemeDescription, pageBuilder: () => const ThemeSettings(), ), - if (!context - .read() - .serverController - .isSupported(ServerFeature.ncMetadata)) - _SubPageItem( - leading: const Icon(Icons.local_offer_outlined), - label: L10n.global().settingsMetadataTitle, - pageBuilder: () => const MetadataSettings(), - ), + _SubPageItem( + leading: const Icon(Icons.local_offer_outlined), + label: L10n.global().settingsMetadataTitle, + pageBuilder: () => const MetadataSettings(), + ), _SubPageItem( leading: const Icon(Icons.image_outlined), label: L10n.global().photosTabLabel, From 00ec53e09758ddaf6eb8c7bbb62b11ac92fdd821 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 23 Nov 2024 13:43:50 +0800 Subject: [PATCH 20/24] Revert "Remove setup page for EXIF" This reverts commit 7ea37dc4911fe41d32e949e9ad642611fd906f74. --- app/lib/widget/setup.dart | 62 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/app/lib/widget/setup.dart b/app/lib/widget/setup.dart index b8f5e0a1..6cc8c52d 100644 --- a/app/lib/widget/setup.dart +++ b/app/lib/widget/setup.dart @@ -43,6 +43,7 @@ class _SetupState extends State { Widget _buildContent(BuildContext context) { final page = _pageController.hasClients ? _pageController.page!.round() : 0; final pages = [ + if (_initialProgress & _PageId.exif == 0) _Exif(), if (_initialProgress & _PageId.hiddenPrefDirNotice == 0) _HiddenPrefDirNotice(), ]; @@ -124,14 +125,73 @@ class _SetupState extends State { } class _PageId { + static const exif = 0x01; static const hiddenPrefDirNotice = 0x02; - static const all = hiddenPrefDirNotice; + static const all = exif | hiddenPrefDirNotice; } abstract class _Page { int getPageId(); } +class _Exif extends StatefulWidget implements _Page { + @override + createState() => _ExifState(); + + @override + getPageId() => _PageId.exif; +} + +class _ExifState extends State<_Exif> { + @override + build(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: Text(L10n.global().settingsExifSupportTitle), + value: _isEnableExif, + onChanged: _onValueChanged, + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(L10n.global().exifSupportDetails), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + L10n.global().setupSettingsModifyLaterHint, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontStyle: FontStyle.italic), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + @override + dispose() { + super.dispose(); + // persist user's choice + Pref().setEnableExif(_isEnableExif); + } + + void _onValueChanged(bool value) { + setState(() { + _isEnableExif = value; + }); + } + + bool _isEnableExif = Pref().isEnableExifOr(); +} + class _HiddenPrefDirNotice extends StatefulWidget implements _Page { @override createState() => _HiddenPrefDirNoticeState(); From d363d544d6d754c75994f9cdff7add78ba574745 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 23 Nov 2024 13:54:22 +0800 Subject: [PATCH 21/24] Update messaging for server side metadata support --- app/lib/l10n/app_en.arb | 12 +++-- app/lib/l10n/untranslated-messages.txt | 48 +++++++++++++++++-- .../widget/settings/metadata_settings.dart | 13 +++-- app/lib/widget/setup.dart | 7 ++- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 0f703d68..b73ad4ee 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -274,8 +274,8 @@ "@settingsMetadataTitle": { "description": "Metadata (e.g., date, resolution, GPS, etc)" }, - "settingsExifSupportTitle": "EXIF support", - "@settingsExifSupportTitle": { + "settingsExifSupportTitle2": "Client side EXIF support", + "@settingsExifSupportTitle2": { "description": "Title of the EXIF support setting" }, "settingsExifSupportTrueSubtitle": "Require extra network usage", @@ -501,8 +501,12 @@ "@exifSupportDetails": { "description": "Detailed description of the exif support feature" }, - "exifSupportConfirmationDialogTitle": "Enable EXIF support?", - "@exifSupportConfirmationDialogTitle": { + "exifSupportNextcloud28Notes": "Client side support complements your server. The app will process files and attributes not supported by Nextcloud", + "@exifSupportNextcloud28Notes": { + "description": "Extra notes for Nextcloud 28+" + }, + "exifSupportConfirmationDialogTitle2": "Enable client side EXIF support?", + "@exifSupportConfirmationDialogTitle2": { "description": "Title of the dialog to confirm enabling exif support" }, "captureLogDetails": "To take logs for a bug report:\n\n1. Enable this setting\n2. Reproduce the issue\n3. Disable this setting\n4. Look for nc-photos.log in the download folder\n\n*If the issue causes the app to crash, no logs could be captured. In such case, please contact the developer for further instructions", diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 262fcee0..09465826 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -1,6 +1,7 @@ { "ca": [ "settingsMetadataTitle", + "settingsExifSupportTitle2", "settingsShareFolderPickerDescription", "settingsPersonProviderTitle", "settingsServerAppSectionTitle", @@ -55,7 +56,8 @@ "writePreferenceFailureNotification", "enableButtonLabel", "exifSupportDetails", - "exifSupportConfirmationDialogTitle", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "captureLogDetails", "captureLogSuccessNotification", "doneButtonLabel", @@ -281,9 +283,12 @@ ], "cs": [ + "settingsExifSupportTitle2", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", "settingsCollectionsCustomizeNavigationBarTitle", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", @@ -294,9 +299,12 @@ ], "de": [ + "settingsExifSupportTitle2", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", "settingsCollectionsCustomizeNavigationBarTitle", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", @@ -313,6 +321,7 @@ "signInHeaderText2", "settingsLanguageOptionSystemDefaultLabel", "settingsMetadataTitle", + "settingsExifSupportTitle2", "settingsExifWifiOnlyTitle", "settingsExifWifiOnlyFalseSubtitle", "settingsAccountLabelTitle", @@ -359,6 +368,8 @@ "settingsUseNewHttpEngineDescription", "settingsServerVersionTitle", "settingsRestartNeededDialog", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "slideshowSetupDialogReverseTitle", "shareMethodPreviewTitle", "shareMethodPreviewDescription", @@ -465,9 +476,12 @@ ], "es": [ + "settingsExifSupportTitle2", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", "settingsCollectionsCustomizeNavigationBarTitle", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", @@ -478,6 +492,7 @@ ], "fi": [ + "settingsExifSupportTitle2", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", "settingsCollectionsCustomizeNavigationBarTitle", @@ -498,6 +513,8 @@ "settingsUseNewHttpEngine", "settingsUseNewHttpEngineDescription", "settingsRestartNeededDialog", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "appLockUnlockHint", "appLockUnlockWrongPassword", "enabledText", @@ -527,6 +544,7 @@ ], "fr": [ + "settingsExifSupportTitle2", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", "settingsCollectionsCustomizeNavigationBarTitle", @@ -547,6 +565,8 @@ "settingsUseNewHttpEngine", "settingsUseNewHttpEngineDescription", "settingsRestartNeededDialog", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "appLockUnlockHint", "appLockUnlockWrongPassword", "enabledText", @@ -576,6 +596,7 @@ ], "it": [ + "settingsExifSupportTitle2", "settingsPersonProviderTitle", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", @@ -598,6 +619,8 @@ "settingsUseNewHttpEngine", "settingsUseNewHttpEngineDescription", "settingsRestartNeededDialog", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "unmuteTooltip", "slideshowTooltip", "enhanceColorPopTitle", @@ -657,7 +680,7 @@ "settingsLanguageTitle", "settingsLanguageOptionSystemDefaultLabel", "settingsMetadataTitle", - "settingsExifSupportTitle", + "settingsExifSupportTitle2", "settingsExifSupportTrueSubtitle", "settingsExifWifiOnlyTitle", "settingsExifWifiOnlyFalseSubtitle", @@ -744,7 +767,8 @@ "writePreferenceFailureNotification", "enableButtonLabel", "exifSupportDetails", - "exifSupportConfirmationDialogTitle", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "captureLogDetails", "captureLogSuccessNotification", "doneButtonLabel", @@ -1028,6 +1052,7 @@ ], "pl": [ + "settingsExifSupportTitle2", "settingsMemoriesRangeValueText", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", @@ -1049,6 +1074,8 @@ "settingsUseNewHttpEngine", "settingsUseNewHttpEngineDescription", "settingsRestartNeededDialog", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "enhanceColorPopTitle", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", @@ -1083,6 +1110,7 @@ "pt": [ "nameInputInvalidEmpty", "settingsMetadataTitle", + "settingsExifSupportTitle2", "settingsPersonProviderTitle", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", @@ -1107,6 +1135,8 @@ "settingsUseNewHttpEngineDescription", "settingsServerVersionTitle", "settingsRestartNeededDialog", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "searchLandingPeopleListEmptyText2", "createCollectionFailureNotification", "addItemToCollectionTooltip", @@ -1150,6 +1180,7 @@ ], "ru": [ + "settingsExifSupportTitle2", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", "settingsCollectionsCustomizeNavigationBarTitle", @@ -1170,6 +1201,8 @@ "settingsUseNewHttpEngine", "settingsUseNewHttpEngineDescription", "settingsRestartNeededDialog", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "appLockUnlockHint", "appLockUnlockWrongPassword", "enabledText", @@ -1199,9 +1232,12 @@ ], "tr": [ + "settingsExifSupportTitle2", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", "settingsCollectionsCustomizeNavigationBarTitle", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", @@ -1213,6 +1249,7 @@ "zh": [ "settingsMetadataTitle", + "settingsExifSupportTitle2", "settingsPersonProviderTitle", "settingsMemoriesRangeValueText", "settingsViewerCustomizeAppBarTitle", @@ -1239,6 +1276,8 @@ "settingsUseNewHttpEngine", "settingsUseNewHttpEngineDescription", "settingsRestartNeededDialog", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "slideshowSetupDialogReverseTitle", "enhanceColorPopTitle", "enhanceRetouchTitle", @@ -1298,6 +1337,7 @@ "signInHeaderText2", "settingsLanguageOptionSystemDefaultLabel", "settingsMetadataTitle", + "settingsExifSupportTitle2", "settingsExifWifiOnlyTitle", "settingsExifWifiOnlyFalseSubtitle", "settingsAccountLabelTitle", @@ -1347,6 +1387,8 @@ "settingsUseNewHttpEngineDescription", "settingsServerVersionTitle", "settingsRestartNeededDialog", + "exifSupportNextcloud28Notes", + "exifSupportConfirmationDialogTitle2", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", "slideshowSetupDialogReverseTitle", diff --git a/app/lib/widget/settings/metadata_settings.dart b/app/lib/widget/settings/metadata_settings.dart index 98982b57..c4ebdec7 100644 --- a/app/lib/widget/settings/metadata_settings.dart +++ b/app/lib/widget/settings/metadata_settings.dart @@ -77,7 +77,7 @@ class _WrappedMetadataSettingsState extends State<_WrappedMetadataSettings> selector: (state) => state.isEnable, builder: (context, state) { return SwitchListTile( - title: Text(L10n.global().settingsExifSupportTitle), + title: Text(L10n.global().settingsExifSupportTitle2), subtitle: state ? Text( L10n.global().settingsExifSupportTrueSubtitle) @@ -122,8 +122,15 @@ class _WrappedMetadataSettingsState extends State<_WrappedMetadataSettings> final result = await showDialog( context: context, builder: (context) => AlertDialog( - title: Text(L10n.global().exifSupportConfirmationDialogTitle), - content: Text(L10n.global().exifSupportDetails), + title: Text(L10n.global().exifSupportConfirmationDialogTitle2), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(L10n.global().exifSupportDetails), + const SizedBox(height: 16), + Text(L10n.global().exifSupportNextcloud28Notes), + ], + ), actions: [ TextButton( onPressed: () { diff --git a/app/lib/widget/setup.dart b/app/lib/widget/setup.dart index 6cc8c52d..26e0b137 100644 --- a/app/lib/widget/setup.dart +++ b/app/lib/widget/setup.dart @@ -150,7 +150,7 @@ class _ExifState extends State<_Exif> { crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( - title: Text(L10n.global().settingsExifSupportTitle), + title: Text(L10n.global().settingsExifSupportTitle2), value: _isEnableExif, onChanged: _onValueChanged, ), @@ -160,6 +160,11 @@ class _ExifState extends State<_Exif> { child: Text(L10n.global().exifSupportDetails), ), const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(L10n.global().exifSupportNextcloud28Notes), + ), + const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( From 2374fe83dfacf4e965e6f22a6f04ba959afefd0f Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 23 Nov 2024 23:01:14 +0800 Subject: [PATCH 22/24] Cache server version for use when server is down --- app/lib/controller/account_controller.dart | 1 + .../controller/account_pref_controller.dart | 15 +++++++ .../controller/account_pref_controller.g.dart | 6 +++ .../account_pref_controller/util.dart | 9 ++++ app/lib/controller/server_controller.dart | 43 ++++++++++++++----- app/lib/entity/pref.dart | 3 ++ app/lib/entity/server_status.dart | 23 +++++++++- app/lib/service/service.dart | 9 ++-- .../use_case/sync_metadata/sync_metadata.dart | 20 +++++++-- 9 files changed, 109 insertions(+), 20 deletions(-) diff --git a/app/lib/controller/account_controller.dart b/app/lib/controller/account_controller.dart index 5541b16b..91436c43 100644 --- a/app/lib/controller/account_controller.dart +++ b/app/lib/controller/account_controller.dart @@ -63,6 +63,7 @@ class AccountController { ServerController get serverController => _serverController ??= ServerController( account: _account!, + accountPrefController: accountPrefController, ); AccountPrefController get accountPrefController => diff --git a/app/lib/controller/account_pref_controller.dart b/app/lib/controller/account_pref_controller.dart index ffde9669..a49ff13b 100644 --- a/app/lib/controller/account_pref_controller.dart +++ b/app/lib/controller/account_pref_controller.dart @@ -1,8 +1,12 @@ +import 'dart:convert'; + import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/pref.dart'; +import 'package:nc_photos/entity/server_status.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/object_util.dart'; import 'package:rxdart/rxdart.dart'; part 'account_pref_controller.g.dart'; @@ -52,6 +56,13 @@ class AccountPrefController { value: value, ); + Future setServerStatus(ServerStatus value) => _set( + controller: _serverStatusController, + setter: (pref, value) => + pref.setServerStatus(jsonEncode(value!.toJson())), + value: value, + ); + Future _set({ required BehaviorSubject controller, required Future Function(AccountPref pref, T value) setter, @@ -89,4 +100,8 @@ class AccountPrefController { @npSubjectAccessor late final _hasNewSharedAlbumController = BehaviorSubject.seeded(_accountPref.hasNewSharedAlbum() ?? false); + @npSubjectAccessor + late final _serverStatusController = BehaviorSubject.seeded(_accountPref + .getServerStatus() + ?.let((e) => ServerStatus.fromJson(jsonDecode(e)))); } diff --git a/app/lib/controller/account_pref_controller.g.dart b/app/lib/controller/account_pref_controller.g.dart index fff374da..d2497a26 100644 --- a/app/lib/controller/account_pref_controller.g.dart +++ b/app/lib/controller/account_pref_controller.g.dart @@ -50,4 +50,10 @@ extension $AccountPrefControllerNpSubjectAccessor on AccountPrefController { Stream get hasNewSharedAlbumChange => hasNewSharedAlbum.distinct().skip(1); bool get hasNewSharedAlbumValue => _hasNewSharedAlbumController.value; +// _serverStatusController + ValueStream get serverStatus => _serverStatusController.stream; + Stream get serverStatusNew => serverStatus.skip(1); + Stream get serverStatusChange => + serverStatus.distinct().skip(1); + ServerStatus? get serverStatusValue => _serverStatusController.value; } diff --git a/app/lib/controller/account_pref_controller/util.dart b/app/lib/controller/account_pref_controller/util.dart index 48b368a8..b6a936b0 100644 --- a/app/lib/controller/account_pref_controller/util.dart +++ b/app/lib/controller/account_pref_controller/util.dart @@ -5,4 +5,13 @@ extension on AccountPref { provider.getBool(AccountPrefKey.hasNewSharedAlbum); // Future setNewSharedAlbum(bool value) => // provider.setBool(AccountPrefKey.hasNewSharedAlbum, value); + + String? getServerStatus() => provider.getString(AccountPrefKey.serverStatus); + Future setServerStatus(String? value) { + if (value == null) { + return provider.remove(AccountPrefKey.serverStatus); + } else { + return provider.setString(AccountPrefKey.serverStatus, value); + } + } } diff --git a/app/lib/controller/server_controller.dart b/app/lib/controller/server_controller.dart index 6e0164fc..7c3e49fa 100644 --- a/app/lib/controller/server_controller.dart +++ b/app/lib/controller/server_controller.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/entity_converter.dart'; +import 'package:nc_photos/controller/account_pref_controller.dart'; import 'package:nc_photos/entity/server_status.dart'; import 'package:nc_photos/np_api_util.dart'; import 'package:np_api/np_api.dart' as api; @@ -20,6 +21,7 @@ enum ServerFeature { class ServerController { ServerController({ required this.account, + required this.accountPrefController, }); void dispose() { @@ -33,17 +35,6 @@ class ServerController { return _statusStreamContorller.stream; } - bool isSupported(ServerFeature feature) { - switch (feature) { - case ServerFeature.ncAlbum: - return !_statusStreamContorller.hasValue || - _statusStreamContorller.value.majorVersion >= 25; - case ServerFeature.ncMetadata: - return !_statusStreamContorller.hasValue || - _statusStreamContorller.value.majorVersion >= 28; - } - } - Future _load() => _getStatus(); Future _getStatus() async { @@ -51,19 +42,49 @@ class ServerController { final response = await ApiUtil.fromAccount(account).status().get(); if (!response.isGood) { _log.severe("[_getStatus] Failed requesting server: $response"); + _loadStatus(); return; } final apiStatus = await api.StatusParser().parse(response.body); final status = ApiStatusConverter.fromApi(apiStatus); _log.info("[_getStatus] Server status: $status"); _statusStreamContorller.add(status); + _saveStatus(status); } catch (e, stackTrace) { _log.severe("[_getStatus] Failed while get", e, stackTrace); + _loadStatus(); return; } } + void _loadStatus() { + final cache = accountPrefController.serverStatusValue; + if (cache != null) { + _statusStreamContorller.add(cache); + } + } + + void _saveStatus(ServerStatus status) { + final cache = accountPrefController.serverStatusValue; + if (cache != status) { + accountPrefController.setServerStatus(status); + } + } + final Account account; + final AccountPrefController accountPrefController; final _statusStreamContorller = BehaviorSubject(); } + +extension ServerControllerExtension on ServerController { + bool isSupported(ServerFeature feature) { + final status = _statusStreamContorller.valueOrNull; + switch (feature) { + case ServerFeature.ncAlbum: + return status == null || status.majorVersion >= 25; + case ServerFeature.ncMetadata: + return status != null && status.majorVersion >= 28; + } + } +} diff --git a/app/lib/entity/pref.dart b/app/lib/entity/pref.dart index 5cfb6a8b..4cac6ea6 100644 --- a/app/lib/entity/pref.dart +++ b/app/lib/entity/pref.dart @@ -230,6 +230,7 @@ enum AccountPrefKey implements PrefKeyInterface { accountLabel, lastNewCollectionType, personProvider, + serverStatus, ; @override @@ -249,6 +250,8 @@ enum AccountPrefKey implements PrefKeyInterface { return "lastNewCollectionType"; case AccountPrefKey.personProvider: return "personProvider"; + case AccountPrefKey.serverStatus: + return "serverStatus"; } } } diff --git a/app/lib/entity/server_status.dart b/app/lib/entity/server_status.dart index 8e27ed81..94ac5b17 100644 --- a/app/lib/entity/server_status.dart +++ b/app/lib/entity/server_status.dart @@ -1,9 +1,11 @@ +import 'package:equatable/equatable.dart'; +import 'package:np_common/type.dart'; import 'package:to_string/to_string.dart'; part 'server_status.g.dart'; @toString -class ServerStatus { +class ServerStatus with EquatableMixin { const ServerStatus({ required this.versionRaw, required this.versionName, @@ -13,6 +15,25 @@ class ServerStatus { @override String toString() => _$toString(); + factory ServerStatus.fromJson(JsonObj json) { + return ServerStatus( + versionRaw: json["versionRaw"], + versionName: json["versionName"], + productName: json["productName"], + ); + } + + JsonObj toJson() { + return { + "versionRaw": versionRaw, + "versionName": versionName, + "productName": productName, + }; + } + + @override + List get props => [versionRaw, versionName, productName]; + final String versionRaw; final String versionName; final String productName; diff --git a/app/lib/service/service.dart b/app/lib/service/service.dart index cf689d87..b06da03f 100644 --- a/app/lib/service/service.dart +++ b/app/lib/service/service.dart @@ -9,10 +9,10 @@ import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/app_init.dart' as app_init; import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/account_pref_controller.dart'; import 'package:nc_photos/controller/pref_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/entity/pref.dart'; import 'package:nc_photos/event/native_event.dart'; import 'package:nc_photos/language_util.dart' as language_util; import 'package:nc_photos/use_case/battery_ensurer.dart'; @@ -95,13 +95,14 @@ class _Service { } Future _doWork() async { - final prefController = PrefController(Pref()); + final c = KiwiContainer().resolve(); + final prefController = PrefController(c.pref); final account = prefController.currentAccountValue; if (account == null) { _log.shout("[_doWork] account == null"); return; } - final c = KiwiContainer().resolve(); + final accountPrefController = AccountPrefController(account: account); final wifiEnsurer = WifiEnsurer( interrupter: _shouldRun.stream, @@ -143,7 +144,7 @@ class _Service { batteryEnsurer: batteryEnsurer, ); final processedIds = []; - await for (final f in syncOp.syncAccount(account)) { + await for (final f in syncOp.syncAccount(account, accountPrefController)) { processedIds.add(f.fdId); service.setNotificationInfo( title: _L10n.global().metadataTaskProcessingNotification, diff --git a/app/lib/use_case/sync_metadata/sync_metadata.dart b/app/lib/use_case/sync_metadata/sync_metadata.dart index 6b5eccc7..fa1f53ec 100644 --- a/app/lib/use_case/sync_metadata/sync_metadata.dart +++ b/app/lib/use_case/sync_metadata/sync_metadata.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; +import 'package:nc_photos/controller/account_pref_controller.dart'; import 'package:nc_photos/controller/server_controller.dart'; import 'package:nc_photos/db/entity_converter.dart'; import 'package:nc_photos/entity/exif_util.dart'; @@ -10,6 +11,7 @@ import 'package:nc_photos/entity/file/file_cache_manager.dart'; import 'package:nc_photos/entity/file/repo.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/geocoder_util.dart'; +import 'package:nc_photos/service/service.dart'; import 'package:nc_photos/use_case/battery_ensurer.dart'; import 'package:nc_photos/use_case/get_file_binary.dart'; import 'package:nc_photos/use_case/load_metadata.dart'; @@ -38,10 +40,14 @@ class SyncMetadata { required this.batteryEnsurer, }); - Stream syncAccount(Account account) async* { + Stream syncAccount( + Account account, + AccountPrefController accountPrefController, + ) async* { final bool isNcMetadataSupported; try { - isNcMetadataSupported = await _isNcMetadataSupported(account); + isNcMetadataSupported = + (await _isNcMetadataSupported(account, accountPrefController))!; } catch (e) { _log.severe("[syncAccount] Failed to get server version", e); return; @@ -111,8 +117,14 @@ class SyncMetadata { yield* stream; } - Future _isNcMetadataSupported(Account account) async { - final serverController = ServerController(account: account); + Future _isNcMetadataSupported( + Account account, + AccountPrefController accountPrefController, + ) async { + final serverController = ServerController( + account: account, + accountPrefController: accountPrefController, + ); await serverController.status.first.timeout(const Duration(seconds: 15)); return serverController.isSupported(ServerFeature.ncMetadata); } From ef67d414fdfc7a3521109892955b308d99da2378 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 23 Nov 2024 23:04:13 +0800 Subject: [PATCH 23/24] Rename exif pref --- app/lib/controller/metadata_controller.dart | 12 ++++++------ app/lib/controller/pref_controller.dart | 10 +++++----- app/lib/controller/pref_controller.g.dart | 12 +++++++----- app/lib/controller/pref_controller/util.dart | 3 +++ app/lib/widget/settings/metadata/bloc.dart | 6 +++--- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/app/lib/controller/metadata_controller.dart b/app/lib/controller/metadata_controller.dart index 581461a8..24e55336 100644 --- a/app/lib/controller/metadata_controller.dart +++ b/app/lib/controller/metadata_controller.dart @@ -18,8 +18,8 @@ class MetadataController { required this.account, required this.prefController, }) { - _subscriptions - .add(prefController.isEnableExifChange.listen(_onSetEnableExif)); + _subscriptions.add( + prefController.isEnableClientExifChange.listen(_onSetEnableClientExif)); } void dispose() { @@ -44,16 +44,16 @@ class MetadataController { void kickstart() { _log.info("[kickstart] Metadata controller enabled"); _isEnable = true; - if (prefController.isEnableExifValue && !_hasStarted) { + if (prefController.isEnableClientExifValue && !_hasStarted) { _startMetadataTask(); } } - void _onSetEnableExif(bool value) { - _log.info("[_onSetEnableExif]"); + void _onSetEnableClientExif(bool value) { + _log.info("[_onSetEnableClientExif]"); if (value) { if (!_isEnable) { - _log.info("[_onSetEnableExif] Ignored as not enabled"); + _log.info("[_onSetEnableClientExif] Ignored as not enabled"); return; } _startMetadataTask(); diff --git a/app/lib/controller/pref_controller.dart b/app/lib/controller/pref_controller.dart index a807f949..2f7536d9 100644 --- a/app/lib/controller/pref_controller.dart +++ b/app/lib/controller/pref_controller.dart @@ -69,9 +69,9 @@ class PrefController { value: value, ); - Future setEnableExif(bool value) => _set( - controller: _isEnableExifController, - setter: (pref, value) => pref.setEnableExif(value), + Future setEnableClientExif(bool value) => _set( + controller: _isEnableClientExifController, + setter: (pref, value) => pref.setEnableClientExif(value), value: value, ); @@ -331,8 +331,8 @@ class PrefController { late final _homeAlbumsSortController = BehaviorSubject.seeded( CollectionSort.values[pref.getHomeAlbumsSortOr(0)]); @npSubjectAccessor - late final _isEnableExifController = - BehaviorSubject.seeded(pref.isEnableExifOr(true)); + late final _isEnableClientExifController = + BehaviorSubject.seeded(pref.isEnableClientExif() ?? true); @npSubjectAccessor late final _shouldProcessExifWifiOnlyController = BehaviorSubject.seeded(pref.shouldProcessExifWifiOnlyOr(true)); diff --git a/app/lib/controller/pref_controller.g.dart b/app/lib/controller/pref_controller.g.dart index f2e92c47..5da6d873 100644 --- a/app/lib/controller/pref_controller.g.dart +++ b/app/lib/controller/pref_controller.g.dart @@ -56,11 +56,13 @@ extension $PrefControllerNpSubjectAccessor on PrefController { Stream get homeAlbumsSortChange => homeAlbumsSort.distinct().skip(1); CollectionSort get homeAlbumsSortValue => _homeAlbumsSortController.value; -// _isEnableExifController - ValueStream get isEnableExif => _isEnableExifController.stream; - Stream get isEnableExifNew => isEnableExif.skip(1); - Stream get isEnableExifChange => isEnableExif.distinct().skip(1); - bool get isEnableExifValue => _isEnableExifController.value; +// _isEnableClientExifController + ValueStream get isEnableClientExif => + _isEnableClientExifController.stream; + Stream get isEnableClientExifNew => isEnableClientExif.skip(1); + Stream get isEnableClientExifChange => + isEnableClientExif.distinct().skip(1); + bool get isEnableClientExifValue => _isEnableClientExifController.value; // _shouldProcessExifWifiOnlyController ValueStream get shouldProcessExifWifiOnly => _shouldProcessExifWifiOnlyController.stream; diff --git a/app/lib/controller/pref_controller/util.dart b/app/lib/controller/pref_controller/util.dart index ad3c6f2e..f59d2c85 100644 --- a/app/lib/controller/pref_controller/util.dart +++ b/app/lib/controller/pref_controller/util.dart @@ -15,6 +15,9 @@ extension on Pref { Future setHomeAlbumsSort(int value) => provider.setInt(PrefKey.homeAlbumsSort, value); + bool? isEnableClientExif() => isEnableExif(); + Future setEnableClientExif(bool value) => setEnableExif(value); + bool? isDarkTheme() => provider.getBool(PrefKey.darkTheme); bool isDarkThemeOr(bool def) => isDarkTheme() ?? def; Future setDarkTheme(bool value) => diff --git a/app/lib/widget/settings/metadata/bloc.dart b/app/lib/widget/settings/metadata/bloc.dart index bed6ea50..c07ec967 100644 --- a/app/lib/widget/settings/metadata/bloc.dart +++ b/app/lib/widget/settings/metadata/bloc.dart @@ -6,7 +6,7 @@ class _Bloc extends Bloc<_Event, _State> _Bloc({ required this.prefController, }) : super(_State( - isEnable: prefController.isEnableExifValue, + isEnable: prefController.isEnableClientExifValue, isWifiOnly: prefController.shouldProcessExifWifiOnlyValue, )) { on<_Init>(_onInit); @@ -22,7 +22,7 @@ class _Bloc extends Bloc<_Event, _State> await Future.wait([ forEach( emit, - prefController.isEnableExifChange, + prefController.isEnableClientExifChange, onData: (data) => state.copyWith(isEnable: data), onError: (e, stackTrace) { _log.severe("[_onInit] Uncaught exception", e, stackTrace); @@ -43,7 +43,7 @@ class _Bloc extends Bloc<_Event, _State> void _onSetEnable(_SetEnable ev, Emitter<_State> emit) { _log.info(ev); - prefController.setEnableExif(ev.value); + prefController.setEnableClientExif(ev.value); } Future _onSetWifiOnly(_SetWifiOnly ev, Emitter<_State> emit) async { From cfae5d6b900a47719eec012f190be9ca59394dc8 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 23 Nov 2024 23:18:23 +0800 Subject: [PATCH 24/24] Don't do the client side fallback if it's disabled in pref --- app/lib/controller/account_controller.dart | 1 + app/lib/controller/metadata_controller.dart | 12 ++++++++++-- app/lib/service/config.dart | 12 ++++++++++-- app/lib/service/service.dart | 8 ++++++-- app/lib/use_case/sync_metadata/sync_by_server.dart | 12 +++++++++--- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/app/lib/controller/account_controller.dart b/app/lib/controller/account_controller.dart index 91436c43..695bb483 100644 --- a/app/lib/controller/account_controller.dart +++ b/app/lib/controller/account_controller.dart @@ -108,6 +108,7 @@ class AccountController { KiwiContainer().resolve(), account: account, prefController: prefController, + serverController: serverController, ); PrefController prefController; diff --git a/app/lib/controller/metadata_controller.dart b/app/lib/controller/metadata_controller.dart index 24e55336..68111347 100644 --- a/app/lib/controller/metadata_controller.dart +++ b/app/lib/controller/metadata_controller.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/controller/server_controller.dart'; import 'package:nc_photos/db/entity_converter.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; @@ -17,6 +18,7 @@ class MetadataController { this._c, { required this.account, required this.prefController, + required this.serverController, }) { _subscriptions.add( prefController.isEnableClientExifChange.listen(_onSetEnableClientExif)); @@ -44,7 +46,12 @@ class MetadataController { void kickstart() { _log.info("[kickstart] Metadata controller enabled"); _isEnable = true; - if (prefController.isEnableClientExifValue && !_hasStarted) { + // on NC28+, the service is needed to get metadata for files that are not + // yet available the moment we queried them, and files not supported by the + // server (if client side exif enabled). + if ((serverController.isSupported(ServerFeature.ncMetadata) || + prefController.isEnableClientExifValue) && + !_hasStarted) { _startMetadataTask(); } } @@ -72,7 +79,7 @@ class MetadataController { ); _log.info("[_startMetadataTask] Missing count: $missingCount"); if (missingCount > 0) { - unawaited(service.startService()); + unawaited(service.startService(prefController: prefController)); } } catch (e, stackTrace) { _log.shout( @@ -87,6 +94,7 @@ class MetadataController { final DiContainer _c; final Account account; final PrefController prefController; + final ServerController serverController; final _subscriptions = []; var _isEnable = false; diff --git a/app/lib/service/config.dart b/app/lib/service/config.dart index 67690b77..b0fe3fd8 100644 --- a/app/lib/service/config.dart +++ b/app/lib/service/config.dart @@ -2,14 +2,22 @@ part of 'service.dart'; class ServiceConfig { static Future isProcessExifWifiOnly() async { - return Preference.getBool(_pref, _prefProcessWifiOnly, true) - .notNull(); + return Preference.getBool(_pref, _prefProcessWifiOnly, true).notNull(); } static Future setProcessExifWifiOnly(bool flag) async { await Preference.setBool(_pref, _prefProcessWifiOnly, flag); } + static Future isEnableClientExif() async { + return Preference.getBool(_pref, _prefIsEnableClientExif, false).notNull(); + } + + static Future setEnableClientExif(bool flag) async { + await Preference.setBool(_pref, _prefIsEnableClientExif, flag); + } + static const _pref = "service"; static const _prefProcessWifiOnly = "shouldProcessWifiOnly"; + static const _prefIsEnableClientExif = "isEnableClientExif"; } diff --git a/app/lib/service/service.dart b/app/lib/service/service.dart index b06da03f..83813b21 100644 --- a/app/lib/service/service.dart +++ b/app/lib/service/service.dart @@ -28,7 +28,9 @@ part 'l10n.dart'; part 'service.g.dart'; /// Start the background service -Future startService() async { +Future startService({ + required PrefController prefController, +}) async { _$__NpLog.log.info("[startService] Starting service"); final service = FlutterBackgroundService(); await service.configure( @@ -46,7 +48,9 @@ Future startService() async { ); // sync settings await ServiceConfig.setProcessExifWifiOnly( - Pref().shouldProcessExifWifiOnlyOr()); + prefController.shouldProcessExifWifiOnlyValue); + await ServiceConfig.setEnableClientExif( + prefController.isEnableClientExifValue); await service.start(); } diff --git a/app/lib/use_case/sync_metadata/sync_by_server.dart b/app/lib/use_case/sync_metadata/sync_by_server.dart index 66ed17a6..12b27608 100644 --- a/app/lib/use_case/sync_metadata/sync_by_server.dart +++ b/app/lib/use_case/sync_metadata/sync_by_server.dart @@ -41,12 +41,18 @@ class _SyncByServer { _log.fine("[_syncDir] Syncing dir $dir"); final files = await fileRepoRemote.list(account, dir); await FileSqliteCacheUpdater(db)(account, dir, remote: files); + final isEnableClientExif = await ServiceConfig.isEnableClientExif(); for (final f in files.where((e) => fileIds.contains(e.fdId))) { File? result; if (!_supportedMimes.contains(f.fdMime)) { - _log.info( - "[_syncDir] File ${f.path} (mime: ${f.fdMime}) not supported by server, fallback to client"); - result = await fallback.syncOne(f); + if (isEnableClientExif) { + _log.info( + "[_syncDir] File ${f.path} (mime: ${f.fdMime}) not supported by server, fallback to client"); + result = await fallback.syncOne(f); + } else { + _log.info( + "[_syncDir] File ${f.path} (mime: ${f.fdMime}) not supported by server"); + } } else { if (f.metadata != null && f.location == null) { result = await _syncOne(f);