diff --git a/app/lib/api/entity_converter.dart b/app/lib/api/entity_converter.dart index 75f7b28f..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,23 +50,36 @@ class ApiFavoriteConverter { } class ApiFileConverter { + 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) { - final 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, - ), - )); return File( path: _hrefToPath(file.href), contentLength: file.contentLength, @@ -81,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/controller/account_controller.dart b/app/lib/controller/account_controller.dart index 5541b16b..695bb483 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 => @@ -107,6 +108,7 @@ class AccountController { KiwiContainer().resolve(), account: account, prefController: prefController, + serverController: serverController, ); PrefController prefController; 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/files_controller.dart b/app/lib/controller/files_controller.dart index f5ad6630..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, @@ -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/app/lib/controller/metadata_controller.dart b/app/lib/controller/metadata_controller.dart index 0f3ce7a8..68111347 100644 --- a/app/lib/controller/metadata_controller.dart +++ b/app/lib/controller/metadata_controller.dart @@ -3,10 +3,11 @@ 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; -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'; @@ -17,9 +18,10 @@ class MetadataController { this._c, { required this.account, required this.prefController, + required this.serverController, }) { - _subscriptions - .add(prefController.isEnableExifChange.listen(_onSetEnableExif)); + _subscriptions.add( + prefController.isEnableClientExifChange.listen(_onSetEnableClientExif)); } void dispose() { @@ -44,16 +46,21 @@ class MetadataController { void kickstart() { _log.info("[kickstart] Metadata controller enabled"); _isEnable = true; - if (prefController.isEnableExifValue && !_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(); } } - 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(); @@ -68,10 +75,11 @@ 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) { - unawaited(service.startService()); + unawaited(service.startService(prefController: prefController)); } } catch (e, stackTrace) { _log.shout( @@ -86,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/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/controller/server_controller.dart b/app/lib/controller/server_controller.dart index 7ff25853..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; @@ -13,12 +14,14 @@ part 'server_controller.g.dart'; enum ServerFeature { ncAlbum, + ncMetadata, } @npLog class ServerController { ServerController({ required this.account, + required this.accountPrefController, }); void dispose() { @@ -32,14 +35,6 @@ class ServerController { return _statusStreamContorller.stream; } - bool isSupported(ServerFeature feature) { - switch (feature) { - case ServerFeature.ncAlbum: - return !_statusStreamContorller.hasValue || - _statusStreamContorller.value.majorVersion >= 25; - } - } - Future _load() => _getStatus(); Future _getStatus() async { @@ -47,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/exif.dart b/app/lib/entity/exif.dart index ec3af558..f29368dc 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,54 @@ 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) { + _$ExifNpLog.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) { + _$ExifNpLog.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"); 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 e2b03858..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,6 +187,43 @@ class Metadata with EquatableMixin { ); } + 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: 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, + ); + } + @override String toString() => _$toString(); diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 8a67ab95..5465d2bd 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -55,6 +55,10 @@ class FileWebdavDataSource implements FileDataSource { trashbinFilename: 1, trashbinOriginalLocation: 1, trashbinDeletionTime: 1, + metadataPhotosIfd0: 1, + metadataPhotosExif: 1, + metadataPhotosGps: 1, + metadataPhotosSize: 1, customNamespaces: { "com.nkming.nc_photos": "app", }, @@ -275,6 +279,10 @@ class FileWebdavDataSource implements FileDataSource { trashbinFilename, trashbinOriginalLocation, trashbinDeletionTime, + metadataPhotosIfd0, + metadataPhotosExif, + metadataPhotosGps, + metadataPhotosSize, Map? customNamespaces, List? customProperties, }) async { @@ -302,6 +310,10 @@ class FileWebdavDataSource implements FileDataSource { trashbinFilename: trashbinFilename, trashbinOriginalLocation: trashbinOriginalLocation, trashbinDeletionTime: trashbinDeletionTime, + metadataPhotosIfd0: metadataPhotosIfd0, + metadataPhotosExif: metadataPhotosExif, + metadataPhotosGps: metadataPhotosGps, + metadataPhotosSize: metadataPhotosSize, customNamespaces: customNamespaces, customProperties: customProperties, ); @@ -623,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 @@ -645,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/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/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/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/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..b0fe3fd8 --- /dev/null +++ b/app/lib/service/config.dart @@ -0,0 +1,23 @@ +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 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/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..83813b21 --- /dev/null +++ b/app/lib/service/service.dart @@ -0,0 +1,197 @@ +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/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/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({ + required PrefController prefController, +}) 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( + prefController.shouldProcessExifWifiOnlyValue); + await ServiceConfig.setEnableClientExif( + prefController.isEnableClientExifValue); + 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 c = KiwiContainer().resolve(); + final prefController = PrefController(c.pref); + final account = prefController.currentAccountValue; + if (account == null) { + _log.shout("[_doWork] account == null"); + return; + } + final accountPrefController = AccountPrefController(account: account); + + 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, accountPrefController)) { + 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..e6e37347 --- /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 dbF in files) { + final f = DbFileConverter.fromDb( + account.userId.toCaseInsensitiveString(), + dbF, + ); + final result = await syncOne(f); + if (result != null) { + yield result; + } + if (!_shouldRun) { + return; + } + } + } + + Future syncOne(File file) async { + _log.fine("[syncOne] Syncing ${file.path}"); + 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 wifiEnsurer(); + await batteryEnsurer(); + if (!_shouldRun) { + return null; + } + _log.fine("[syncOne] Updating metadata for ${file.path}"); + final binary = await GetFileBinary(fileRepo)(account, file); + final metadata = + (await LoadMetadata().loadRemote(account, file, binary)).copyWith( + fileEtag: file.etag, + ); + metadataUpdate = OrNull(metadata); + } + + 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 ${file.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: ${file.path}", e, + stackTrace); + // if failed, we skip updating the location + } + + if (metadataUpdate != null || locationUpdate != null) { + await UpdateProperty(fileRepo: fileRepo2)( + account, + file, + metadata: metadataUpdate, + location: locationUpdate, + ); + return file; + } else { + return null; + } + } catch (e, stackTrace) { + _log.severe("[syncOne] Failed while updating metadata: ${file.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..12b27608 --- /dev/null +++ b/app/lib/use_case/sync_metadata/sync_by_server.dart @@ -0,0 +1,115 @@ +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, + required this.fallback, + }) { + interrupter?.listen((event) { + _shouldRun = false; + }); + } + + Future init() async { + await _geocoder.init(); + } + + 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 { + _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)) { + 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); + } + } + 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 { + _log.fine("[_syncOne] Syncing ${file.path}"); + 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 _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 new file mode 100644 index 00000000..fa1f53ec --- /dev/null +++ b/app/lib/use_case/sync_metadata/sync_metadata.dart @@ -0,0 +1,139 @@ +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'; +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/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'; +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, + AccountPrefController accountPrefController, + ) async* { + final bool isNcMetadataSupported; + try { + isNcMetadataSupported = + (await _isNcMetadataSupported(account, accountPrefController))!; + } catch (e) { + _log.severe("[syncAccount] Failed to get server version", e); + return; + } + final files = await db.getFilesByMissingMetadata( + account: account.toDb(), + mimes: file_util.supportedImageFormatMimes, + 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 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 fileIds = []; + final relativePaths = []; + for (final f in files.items) { + fileIds.add(f.fileId); + relativePaths.add(f.relativePath); + } + final stream = op.syncFiles( + fileIds: fileIds, + relativePaths: relativePaths, + ); + yield* stream; + } + + 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); + } + + 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 8f494ff3..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_extension.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 c4afe159..e438fb58 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: @@ -711,6 +712,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(), ); 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 { diff --git a/app/lib/widget/settings/metadata_settings.dart b/app/lib/widget/settings/metadata_settings.dart index 3916893d..c4ebdec7 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'; @@ -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( diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index 1aa4e5b1..a3e419e0 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; @@ -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/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/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/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; +} diff --git a/np_api/lib/src/entity/entity.dart b/np_api/lib/src/entity/entity.dart index 20eea6e0..6682ed37 100644 --- a/np_api/lib/src/entity/entity.dart +++ b/np_api/lib/src/entity/entity.dart @@ -84,6 +84,10 @@ class File with EquatableMixin { this.trashbinFilename, this.trashbinOriginalLocation, this.trashbinDeletionTime, + this.metadataPhotosIfd0, + this.metadataPhotosExif, + this.metadataPhotosGps, + this.metadataPhotosSize, this.customProperties, }); @@ -106,6 +110,10 @@ class File with EquatableMixin { trashbinFilename, trashbinOriginalLocation, trashbinDeletionTime, + metadataPhotosIfd0, + metadataPhotosExif, + metadataPhotosGps, + metadataPhotosSize, customProperties, ]; @@ -123,6 +131,10 @@ class File with EquatableMixin { final String? trashbinFilename; final String? trashbinOriginalLocation; final DateTime? trashbinDeletionTime; + 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 2365f0c9..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, "}${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 8dc18c66..4bb30c2a 100644 --- a/np_api/lib/src/entity/file_parser.dart +++ b/np_api/lib/src/entity/file_parser.dart @@ -33,6 +33,10 @@ class FileParser extends XmlResponseParser { String? trashbinFilename; String? trashbinOriginalLocation; DateTime? trashbinDeletionTime; + Map? metadataPhotosIfd0; + Map? metadataPhotosExif; + Map? metadataPhotosGps; + Map? metadataPhotosSize; Map? customProperties; for (final child in element.children.whereType()) { @@ -66,6 +70,10 @@ class FileParser extends XmlResponseParser { trashbinFilename = propParser.trashbinFilename; trashbinOriginalLocation = propParser.trashbinOriginalLocation; trashbinDeletionTime = propParser.trashbinDeletionTime; + metadataPhotosIfd0 = propParser.metadataPhotosIfd0; + metadataPhotosExif = propParser.metadataPhotosExif; + metadataPhotosGps = propParser.metadataPhotosGps; + metadataPhotosSize = propParser.metadataPhotosSize; customProperties = propParser.customProperties; } } @@ -85,6 +93,10 @@ class FileParser extends XmlResponseParser { trashbinFilename: trashbinFilename, trashbinOriginalLocation: trashbinOriginalLocation, trashbinDeletionTime: trashbinDeletionTime, + metadataPhotosIfd0: metadataPhotosIfd0, + metadataPhotosExif: metadataPhotosExif, + metadataPhotosGps: metadataPhotosGps, + metadataPhotosSize: metadataPhotosSize, customProperties: customProperties, ); } @@ -140,6 +152,27 @@ 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 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 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 @@ -162,6 +195,10 @@ class _PropParser { String? get trashbinFilename => _trashbinFilename; String? get trashbinOriginalLocation => _trashbinOriginalLocation; DateTime? get trashbinDeletionTime => _trashbinDeletionTime; + Map? get metadataPhotosIfd0 => _metadataPhotosIfd0; + Map? get metadataPhotosExif => _metadataPhotosExif; + Map? get metadataPhotosGps => _metadataPhotosGps; + Map? get metadataPhotosSize => _metadataPhotosSize; Map? get customProperties => _customProperties; final Map namespaces; @@ -179,6 +216,10 @@ class _PropParser { String? _trashbinFilename; String? _trashbinOriginalLocation; DateTime? _trashbinDeletionTime; + 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 656a143e..a647472f 100644 --- a/np_api/lib/src/files_api.dart +++ b/np_api/lib/src/files_api.dart @@ -72,6 +72,10 @@ class ApiFiles { trashbinFilename, trashbinOriginalLocation, trashbinDeletionTime, + metadataPhotosIfd0, + metadataPhotosExif, + metadataPhotosGps, + metadataPhotosSize, Map? customNamespaces, List? customProperties, }) async { @@ -96,7 +100,11 @@ class ApiFiles { richWorkspace != null || trashbinFilename != null || trashbinOriginalLocation != null || - trashbinDeletionTime != null); + trashbinDeletionTime != null || + metadataPhotosIfd0 != null || + metadataPhotosExif != null || + metadataPhotosGps != null || + metadataPhotosSize != null); if (!hasDavNs && !hasOcNs && !hasNcNs) { // no body return await _api.request("PROPFIND", path); @@ -175,6 +183,18 @@ class ApiFiles { if (trashbinDeletionTime != null) { builder.element("nc:trashbin-deletion-time"); } + 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"); + } + if (metadataPhotosSize != null) { + builder.element("nc:metadata-photos-size"); + } for (final p in customProperties ?? []) { builder.element(p); } diff --git a/np_api/test/entity/file_parser_test.dart b/np_api/test/entity/file_parser_test.dart index c45a19f0..2b41e7ec 100644 --- a/np_api/test/entity/file_parser_test.dart +++ b/np_api/test/entity/file_parser_test.dart @@ -13,6 +13,10 @@ 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); + test("file w/ metadata-photos-size", _filesNc28MetadataSize); }); }); } @@ -380,3 +384,255 @@ 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", + }, + ), + ]); +} + +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", + }, + ), + ]); +} 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 565b1469..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(); @@ -351,6 +364,14 @@ abstract class NpDb { Future countFilesByMissingMetadata({ required DbAccount account, required List mimes, + 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 @@ -411,6 +432,7 @@ abstract class NpDb { Future getFilesSummary({ required DbAccount account, List? includeRelativeRoots, + List? includeRelativeDirs, List? excludeRelativeRoots, List? mimes, }); 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 ecf4ef4c..3c7a2aec 100644 --- a/np_db_sqlite/lib/src/database/file_extension.dart +++ b/np_db_sqlite/lib/src/database/file_extension.dart @@ -340,9 +340,10 @@ extension SqliteDbFileExtension on SqliteDb { required ByAccount account, bool? isMissingMetadata, List? mimes, + String? ownerId, }) async { _log.info( - "[countFiles] isMissingMetadata: $isMissingMetadata, mimes: $mimes"); + "[countFiles] isMissingMetadata: $isMissingMetadata, mimes: $mimes, ownerId: $ownerId"); Expression? filter; if (isMissingMetadata != null) { if (isMissingMetadata) { @@ -380,9 +381,75 @@ 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(); } + 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, @@ -616,6 +683,7 @@ extension SqliteDbFileExtension on SqliteDb { Future countFileGroupsByDate({ required ByAccount account, List? includeRelativeRoots, + List? includeRelativeDirs, List? excludeRelativeRoots, List? mimes, bool? isArchived, @@ -623,10 +691,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()) @@ -643,6 +724,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 eeca157a..6acd63ad 100644 --- a/np_db_sqlite/lib/src/sqlite_api.dart +++ b/np_db_sqlite/lib/src/sqlite_api.dart @@ -383,16 +383,46 @@ 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, ); }); } + @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, @@ -472,6 +502,7 @@ class NpDbSqlite implements NpDb { Future getFilesSummary({ required DbAccount account, List? includeRelativeRoots, + List? includeRelativeDirs, List? excludeRelativeRoots, List? mimes, }) async { @@ -479,6 +510,7 @@ class NpDbSqlite implements NpDb { return await db.countFileGroupsByDate( account: ByAccount.db(account), includeRelativeRoots: includeRelativeRoots, + includeRelativeDirs: includeRelativeDirs, excludeRelativeRoots: excludeRelativeRoots, mimes: mimes, isArchived: false,