diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 90c866455..4672bed21 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -111,4 +111,5 @@ tools:ignore="ScopedStorage" /> + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/notification_icon.png b/android/app/src/main/res/drawable/notification_icon.png index 05f1fc8c3..dbaecfac8 100644 Binary files a/android/app/src/main/res/drawable/notification_icon.png and b/android/app/src/main/res/drawable/notification_icon.png differ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 78e65dbde..07cf964c9 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -10,6 +10,10 @@ import Flutter var flutter_native_splash = 1 UIApplication.shared.isStatusBarHidden = false + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate + } + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/lib/db/files_db.dart b/lib/db/files_db.dart index b15025c4b..ee7ec4cd7 100644 --- a/lib/db/files_db.dart +++ b/lib/db/files_db.dart @@ -50,6 +50,7 @@ class FilesDB { static const columnCreationTime = 'creation_time'; static const columnModificationTime = 'modification_time'; static const columnUpdationTime = 'updation_time'; + static const columnAddedTime = 'added_time'; static const columnEncryptedKey = 'encrypted_key'; static const columnKeyDecryptionNonce = 'key_decryption_nonce'; static const columnFileDecryptionHeader = 'file_decryption_header'; @@ -82,6 +83,7 @@ class FilesDB { ...addFileSizeColumn(), ...updateIndexes(), ...createEntityDataTable(), + ...addAddedTime(), ]; final dbConfig = MigrationConfig( @@ -367,6 +369,17 @@ class FilesDB { ]; } + static List addAddedTime() { + return [ + ''' + ALTER TABLE $filesTable ADD COLUMN $columnAddedTime INTEGER NOT NULL DEFAULT -1; + ''', + ''' + CREATE INDEX IF NOT EXISTS added_time_index ON $filesTable($columnAddedTime); + ''' + ]; + } + Future clearTable() async { final db = await instance.database; await db.delete(filesTable); @@ -627,6 +640,23 @@ class FilesDB { return files; } + Future> getNewFilesInCollection( + int collectionID, + int addedTime, + ) async { + final db = await instance.database; + const String whereClause = + '$columnCollectionID = ? AND $columnAddedTime > ?'; + final List whereArgs = [collectionID, addedTime]; + final results = await db.query( + filesTable, + where: whereClause, + whereArgs: whereArgs, + ); + final files = convertToFiles(results); + return files; + } + Future getFilesInCollections( List collectionIDs, int startTime, @@ -1507,6 +1537,8 @@ class FilesDB { row[columnCreationTime] = file.creationTime; row[columnModificationTime] = file.modificationTime; row[columnUpdationTime] = file.updationTime; + row[columnAddedTime] = + file.addedTime ?? DateTime.now().microsecondsSinceEpoch; row[columnEncryptedKey] = file.encryptedKey; row[columnKeyDecryptionNonce] = file.keyDecryptionNonce; row[columnFileDecryptionHeader] = file.fileDecryptionHeader; @@ -1552,6 +1584,8 @@ class FilesDB { row[columnCreationTime] = file.creationTime; row[columnModificationTime] = file.modificationTime; row[columnUpdationTime] = file.updationTime; + row[columnAddedTime] = + file.addedTime ?? DateTime.now().microsecondsSinceEpoch; row[columnFileDecryptionHeader] = file.fileDecryptionHeader; row[columnThumbnailDecryptionHeader] = file.thumbnailDecryptionHeader; row[columnMetadataDecryptionHeader] = file.metadataDecryptionHeader; @@ -1597,6 +1631,7 @@ class FilesDB { file.creationTime = row[columnCreationTime]; file.modificationTime = row[columnModificationTime]; file.updationTime = row[columnUpdationTime] ?? -1; + file.addedTime = row[columnAddedTime]; file.encryptedKey = row[columnEncryptedKey]; file.keyDecryptionNonce = row[columnKeyDecryptionNonce]; file.fileDecryptionHeader = row[columnFileDecryptionHeader]; diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 8f325d872..00fead06e 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -839,6 +839,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("No results found"), "nothingToSeeHere": MessageLookupByLibrary.simpleMessage("Nothing to see here! 👀"), + "notifications": MessageLookupByLibrary.simpleMessage("Notifications"), "ok": MessageLookupByLibrary.simpleMessage("Ok"), "onDevice": MessageLookupByLibrary.simpleMessage("On device"), "onEnte": MessageLookupByLibrary.simpleMessage( @@ -1093,6 +1094,10 @@ class MessageLookup extends MessageLookupByLibrary { "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( "Create shared and collaborative albums with other ente users, including users on free plans."), "sharedByMe": MessageLookupByLibrary.simpleMessage("Shared by me"), + "sharedPhotoNotifications": + MessageLookupByLibrary.simpleMessage("New shared photos"), + "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage( + "Receive notifications when someone adds a photo to a shared album that you\'re a part of"), "sharedWith": m47, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Shared with me"), "sharing": MessageLookupByLibrary.simpleMessage("Sharing..."), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index dbaff548c..95c756429 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3434,6 +3434,36 @@ class S { ); } + /// `Notifications` + String get notifications { + return Intl.message( + 'Notifications', + name: 'notifications', + desc: '', + args: [], + ); + } + + /// `New shared photos` + String get sharedPhotoNotifications { + return Intl.message( + 'New shared photos', + name: 'sharedPhotoNotifications', + desc: '', + args: [], + ); + } + + /// `Receive notifications when someone adds a photo to a shared album that you're a part of` + String get sharedPhotoNotificationsExplanation { + return Intl.message( + 'Receive notifications when someone adds a photo to a shared album that you\'re a part of', + name: 'sharedPhotoNotificationsExplanation', + desc: '', + args: [], + ); + } + /// `Advanced` String get advanced { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 3789babe9..45a402bc1 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -507,6 +507,9 @@ }, "familyPlans": "Family plans", "referrals": "Referrals", + "notifications": "Notifications", + "sharedPhotoNotifications": "New shared photos", + "sharedPhotoNotificationsExplanation": "Receive notifications when someone adds a photo to a shared album that you're a part of", "advanced": "Advanced", "general": "General", "security": "Security", @@ -659,7 +662,6 @@ "description": "Button text for raising a support tickets in case of unhandled errors during backup", "type": "text" }, - "backupFailed": "Backup failed", "couldNotBackUpTryLater": "We could not backup your data.\nWe will retry later.", "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "ente can encrypt and preserve files only if you grant access to them", @@ -771,7 +773,7 @@ }, "deleteAll": "Delete All", "renameAlbum": "Rename album", - "setCover" : "Set cover", + "setCover": "Set cover", "@setCover": { "description": "Text to set cover photo for an album" }, @@ -973,7 +975,7 @@ "save": "Save", "centerPoint": "Center point", "pickCenterPoint": "Pick center point", - "useSelectedPhoto": "Use selected photo", + "useSelectedPhoto": "Use selected photo", "edit": "Edit", "deleteLocation": "Delete location", "rotateLeft": "Rotate left", @@ -1000,13 +1002,13 @@ "@storageBreakupYou": { "description": "Label to indicate how much storage you are using when you are part of a family plan" }, - "storageUsageInfo" : "{usedAmount} {usedStorageUnit} of {totalAmount} {totalStorageUnit} used", - "@storageUsageInfo" :{ + "storageUsageInfo": "{usedAmount} {usedStorageUnit} of {totalAmount} {totalStorageUnit} used", + "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace" : "{freeAmount} {storageUnit} free", + "freeStorageSpace": "{freeAmount} {storageUnit} free", "appVersion": "Version: {versionValue}", - "verifyIDLabel" : "Verify", + "verifyIDLabel": "Verify", "fileInfoAddDescHint": "Add a description...", "editLocationTagTitle": "Edit location", "setLabel": "Set", @@ -1074,10 +1076,9 @@ "@map": { "description": "Label for the map view" }, - "maps" : "Maps", + "maps": "Maps", "enableMaps": "Enable Maps", - "enableMapsDesc" : "This will show your photos on a world map.\n\nThis map is hosted by Open Street Map, and the exact locations of your photos are never shared.\n\nYou can disable this feature anytime from Settings.", + "enableMapsDesc": "This will show your photos on a world map.\n\nThis map is hosted by Open Street Map, and the exact locations of your photos are never shared.\n\nYou can disable this feature anytime from Settings.", "unpinAlbum": "Unpin album", "pinAlbum": "Pin album" } - diff --git a/lib/main.dart b/lib/main.dart index 91c05c562..1d0d5153d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,7 +29,6 @@ import 'package:photos/services/local_file_update_service.dart'; import 'package:photos/services/local_sync_service.dart'; import "package:photos/services/location_service.dart"; import 'package:photos/services/memories_service.dart'; -import 'package:photos/services/notification_service.dart'; import "package:photos/services/object_detection/object_detection_service.dart"; import 'package:photos/services/push_service.dart'; import 'package:photos/services/remote_sync_service.dart'; @@ -149,6 +148,7 @@ Future _init(bool isBackground, {String via = ''}) async { final SharedPreferences preferences = await SharedPreferences.getInstance(); await _logFGHeartBeatInfo(); _scheduleHeartBeat(preferences, isBackground); + AppLifecycleService.instance.init(preferences); if (isBackground) { AppLifecycleService.instance.onAppInBackground('init via: $via'); } else { @@ -157,7 +157,6 @@ Future _init(bool isBackground, {String via = ''}) async { // Start workers asynchronously. No need to wait for them to start Computer.shared().turnOn(workersCount: 4, verbose: kDebugMode); CryptoUtil.init(); - await NotificationService.instance.init(); await NetworkClient.instance.init(); await Configuration.instance.init(); await UserService.instance.init(); diff --git a/lib/models/file.dart b/lib/models/file.dart index 96afb4aef..9ac2a5829 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -26,6 +26,7 @@ class File extends EnteFile { int? creationTime; int? modificationTime; int? updationTime; + int? addedTime; Location? location; late FileType fileType; int? fileSubType; diff --git a/lib/services/app_lifecycle_service.dart b/lib/services/app_lifecycle_service.dart index 127c704a7..e75174da7 100644 --- a/lib/services/app_lifecycle_service.dart +++ b/lib/services/app_lifecycle_service.dart @@ -1,18 +1,26 @@ import 'package:logging/logging.dart'; import 'package:media_extension/media_extension_action_types.dart'; +import "package:shared_preferences/shared_preferences.dart"; class AppLifecycleService { + static const String keyLastAppOpenTime = "last_app_open_time"; + final _logger = Logger("AppLifecycleService"); bool isForeground = false; MediaExtentionAction mediaExtensionAction = MediaExtentionAction(action: IntentAction.main); + late SharedPreferences _preferences; static final AppLifecycleService instance = AppLifecycleService._privateConstructor(); AppLifecycleService._privateConstructor(); + void init(SharedPreferences preferences) { + _preferences = preferences; + } + void setMediaExtensionAction(MediaExtentionAction mediaExtensionAction) { _logger.info("App invoked via ${mediaExtensionAction.action}"); this.mediaExtensionAction = mediaExtensionAction; @@ -25,6 +33,14 @@ class AppLifecycleService { void onAppInBackground(String reason) { _logger.info("App in background $reason"); + _preferences.setInt( + keyLastAppOpenTime, + DateTime.now().microsecondsSinceEpoch, + ); isForeground = false; } + + int getLastAppOpenTime() { + return _preferences.getInt(keyLastAppOpenTime) ?? 0; + } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 5ad7cd5bb..af1d67e3f 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,53 +1,123 @@ import 'dart:io'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import "package:photos/services/remote_sync_service.dart"; +import "package:shared_preferences/shared_preferences.dart"; class NotificationService { static final NotificationService instance = NotificationService._privateConstructor(); + static const String keyGrantedNotificationPermission = + "notification_permission_granted"; + static const String keyShouldShowNotificationsForSharedPhotos = + "notifications_enabled_shared_photos"; NotificationService._privateConstructor(); - final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = + + late SharedPreferences _preferences; + final FlutterLocalNotificationsPlugin _notificationsPlugin = FlutterLocalNotificationsPlugin(); - Future init() async { - if (!Platform.isAndroid) { - return; - } - const AndroidInitializationSettings initializationSettingsAndroid = - AndroidInitializationSettings('notification_icon'); + Future init( + void Function( + NotificationResponse notificationResponse, + ) + onNotificationTapped, + ) async { + _preferences = await SharedPreferences.getInstance(); + const androidSettings = AndroidInitializationSettings('notification_icon'); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: false, + requestSoundPermission: false, + requestBadgePermission: false, + requestCriticalPermission: false, + ); const InitializationSettings initializationSettings = InitializationSettings( - android: initializationSettingsAndroid, + android: androidSettings, + iOS: iosSettings, ); - await _flutterLocalNotificationsPlugin.initialize( + await _notificationsPlugin.initialize( initializationSettings, - onSelectNotification: selectNotification, + onDidReceiveNotificationResponse: onNotificationTapped, + ); + + final launchDetails = + await _notificationsPlugin.getNotificationAppLaunchDetails(); + if (launchDetails != null && + launchDetails.didNotificationLaunchApp && + launchDetails.notificationResponse != null) { + onNotificationTapped(launchDetails.notificationResponse!); + } + if (!hasGrantedPermissions() && + RemoteSyncService.instance.isFirstRemoteSyncDone()) { + await requestPermissions(); + } + } + + Future requestPermissions() async { + bool? result; + if (Platform.isIOS) { + result = await _notificationsPlugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions( + sound: true, + alert: true, + ); + } else { + result = await _notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestPermission(); + } + if (result != null) { + _preferences.setBool(keyGrantedNotificationPermission, result); + } + } + + bool hasGrantedPermissions() { + final result = _preferences.getBool(keyGrantedNotificationPermission); + return result ?? false; + } + + bool shouldShowNotificationsForSharedPhotos() { + final result = + _preferences.getBool(keyShouldShowNotificationsForSharedPhotos); + return result ?? true; + } + + Future setShouldShowNotificationsForSharedPhotos(bool value) { + return _preferences.setBool( + keyShouldShowNotificationsForSharedPhotos, + value, ); } - Future selectNotification(String? payload) async {} - - Future showNotification(String title, String message) async { - if (!Platform.isAndroid) { - return; - } - const AndroidNotificationDetails androidPlatformChannelSpecifics = - AndroidNotificationDetails( - 'io.ente.photos', - 'ente', + Future showNotification( + String title, + String message, { + String channelID = "io.ente.photos", + String channelName = "ente", + String payload = "ente://home", + }) async { + final androidSpecs = AndroidNotificationDetails( + channelID, + channelName, channelDescription: 'ente alerts', importance: Importance.max, priority: Priority.high, showWhen: false, ); - const NotificationDetails platformChannelSpecifics = - NotificationDetails(android: androidPlatformChannelSpecifics); - await _flutterLocalNotificationsPlugin.show( - 0, + final iosSpecs = DarwinNotificationDetails(threadIdentifier: channelID); + final platformChannelSpecs = + NotificationDetails(android: androidSpecs, iOS: iosSpecs); + await _notificationsPlugin.show( + channelName.hashCode, title, message, - platformChannelSpecifics, + platformChannelSpecs, + payload: payload, ); } } diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 3a3f7f606..72572b5fb 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -26,6 +26,7 @@ import 'package:photos/services/collections_service.dart'; import "package:photos/services/feature_flag_service.dart"; import 'package:photos/services/ignored_files_service.dart'; import 'package:photos/services/local_file_update_service.dart'; +import "package:photos/services/notification_service.dart"; import 'package:photos/services/sync_service.dart'; import 'package:photos/services/trash_sync_service.dart'; import 'package:photos/utils/diff_fetcher.dart'; @@ -170,7 +171,7 @@ class RemoteSyncService { _logger.info("Pulling remote diff"); final isFirstSync = !_collectionsService.hasSyncedCollections(); if (isFirstSync && !_isExistingSyncSilent) { - Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff)); + Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff)); } await _collectionsService.sync(); // check and reset user's collection syncTime in past for older clients @@ -183,13 +184,14 @@ class RemoteSyncService { await _markResetSyncTimeAsDone(); } - await _syncUpdatedCollections(); - unawaited(_localFileUpdateService.markUpdatedFilesForReUpload()); - } - - Future _syncUpdatedCollections() async { final idsToRemoteUpdationTimeMap = await _collectionsService.getCollectionIDsToBeSynced(); + await _syncUpdatedCollections(idsToRemoteUpdationTimeMap); + unawaited(_localFileUpdateService.markUpdatedFilesForReUpload()); + unawaited(_notifyNewFiles(idsToRemoteUpdationTimeMap.keys.toList())); + } + + Future _syncUpdatedCollections(final idsToRemoteUpdationTimeMap) async { for (final cid in idsToRemoteUpdationTimeMap.keys) { await _syncCollectionDiff( cid, @@ -621,7 +623,7 @@ class RemoteSyncService { ); } - /* _storeDiff maps each remoteDiff file to existing + /* _storeDiff maps each remoteFile to existing entries in files table. When match is found, it compares both file to perform relevant actions like [1] Clear local cache when required (Both Shared and Owned files) @@ -637,7 +639,7 @@ class RemoteSyncService { [Existing] ] */ - Future _storeDiff(List diff, int collectionID) async { + Future _storeDiff(List diff, int collectionID) async { int sharedFileNew = 0, sharedFileUpdated = 0, localUploadedFromDevice = 0, @@ -648,60 +650,60 @@ class RemoteSyncService { // this is required when same file is uploaded twice in the same // collection. Without this check, if both remote files are part of same // diff response, then we end up inserting one entry instead of two - // as we update the generatedID for remoteDiff to local file's genID + // as we update the generatedID for remoteFile to local file's genID final Set alreadyClaimedLocalFilesGenID = {}; final List toBeInserted = []; - for (File remoteDiff in diff) { + for (File remoteFile in diff) { // existingFile will be either set to existing collectionID+localID or // to the unclaimed aka not already linked to any uploaded file. File? existingFile; - if (remoteDiff.generatedID != null) { + if (remoteFile.generatedID != null) { // Case [1] Check and clear local cache when uploadedFile already exist // Note: Existing file can be null here if it's replaced by the time we // reach here - existingFile = await _db.getFile(remoteDiff.generatedID!); + existingFile = await _db.getFile(remoteFile.generatedID!); if (existingFile != null && - _shouldClearCache(remoteDiff, existingFile)) { + _shouldClearCache(remoteFile, existingFile)) { needsGalleryReload = true; - await clearCache(remoteDiff); + await clearCache(remoteFile); } } /* If file is not owned by the user, no further processing is required as Case [2,3,4] are only relevant to files owned by user */ - if (userID != remoteDiff.ownerID) { + if (userID != remoteFile.ownerID) { if (existingFile == null) { sharedFileNew++; - remoteDiff.localID = null; + remoteFile.localID = null; } else { sharedFileUpdated++; // if user has downloaded the file on the device, avoid removing the // localID reference. // [Todo-fix: Excluded shared file's localIDs during syncALL] - remoteDiff.localID = existingFile.localID; + remoteFile.localID = existingFile.localID; } - toBeInserted.add(remoteDiff); + toBeInserted.add(remoteFile); // end processing for file here, move to next file now continue; } - // If remoteDiff is not already synced (i.e. existingFile is null), check + // If remoteFile is not already synced (i.e. existingFile is null), check // if the remoteFile was uploaded from this device. // Note: DeviceFolder is ignored for iOS during matching - if (existingFile == null && remoteDiff.localID != null) { + if (existingFile == null && remoteFile.localID != null) { final localFileEntries = await _db.getUnlinkedLocalMatchesForRemoteFile( userID, - remoteDiff.localID!, - remoteDiff.fileType, - title: remoteDiff.title ?? '', - deviceFolder: remoteDiff.deviceFolder ?? '', + remoteFile.localID!, + remoteFile.fileType, + title: remoteFile.title ?? '', + deviceFolder: remoteFile.deviceFolder ?? '', ); if (localFileEntries.isEmpty) { // set remote file's localID as null because corresponding local file // does not exist [Case 2, do not retain localID of the remote file] - remoteDiff.localID = null; + remoteFile.localID = null; } else { // case 4: Check and schedule the file for update final int maxModificationTime = localFileEntries @@ -717,11 +719,11 @@ class RemoteSyncService { for the adjustments or just if the asset has been modified ever. https://stackoverflow.com/a/50093266/546896 */ - if (maxModificationTime > remoteDiff.modificationTime! && + if (maxModificationTime > remoteFile.modificationTime! && Platform.isAndroid) { localButUpdatedOnDevice++; await FileUpdationDB.instance.insertMultiple( - [remoteDiff.localID!], + [remoteFile.localID!], FileUpdationDB.modificationTimeUpdated, ); } @@ -738,17 +740,17 @@ class RemoteSyncService { existingFile = localFileEntries.first; localUploadedFromDevice++; alreadyClaimedLocalFilesGenID.add(existingFile.generatedID!); - remoteDiff.generatedID = existingFile.generatedID; + remoteFile.generatedID = existingFile.generatedID; } } } if (existingFile != null && - _shouldReloadHomeGallery(remoteDiff, existingFile)) { + _shouldReloadHomeGallery(remoteFile, existingFile)) { needsGalleryReload = true; } else { remoteNewFile++; } - toBeInserted.add(remoteDiff); + toBeInserted.add(remoteFile); } await _db.insertMultiple(toBeInserted); _logger.info( @@ -850,4 +852,38 @@ class RemoteSyncService { } }); } + + bool _shouldShowNotification(int collectionID) { + // TODO: Add option to opt out of notifications for a specific collection + // Screen: https://www.figma.com/file/SYtMyLBs5SAOkTbfMMzhqt/ente-Visual-Design?type=design&node-id=7689-52943&t=IyWOfh0Gsb0p7yVC-4 + return NotificationService.instance + .shouldShowNotificationsForSharedPhotos() && + isFirstRemoteSyncDone() && + !AppLifecycleService.instance.isForeground; + } + + Future _notifyNewFiles(List collectionIDs) async { + final userID = Configuration.instance.getUserID(); + final appOpenTime = AppLifecycleService.instance.getLastAppOpenTime(); + for (final collectionID in collectionIDs) { + final collection = _collectionsService.getCollectionByID(collectionID); + final files = + await _db.getNewFilesInCollection(collectionID, appOpenTime); + final sharedFileCount = + files.where((file) => file.ownerID != userID).length; + final collectedFileCount = files + .where((file) => file.pubMagicMetadata!.uploaderName != null) + .length; + final totalCount = sharedFileCount + collectedFileCount; + if (totalCount > 0 && _shouldShowNotification(collectionID)) { + NotificationService.instance.showNotification( + collection!.displayName, + totalCount.toString() + " new 📸", + channelID: "collection:" + collectionID.toString(), + channelName: collection.displayName, + payload: "ente://collection/?collectionID=" + collectionID.toString(), + ); + } + } + } } diff --git a/lib/ui/settings/general_section_widget.dart b/lib/ui/settings/general_section_widget.dart index c82ea1013..79bc755a5 100644 --- a/lib/ui/settings/general_section_widget.dart +++ b/lib/ui/settings/general_section_widget.dart @@ -12,6 +12,7 @@ import "package:photos/ui/growth/referral_screen.dart"; import 'package:photos/ui/settings/advanced_settings_screen.dart'; import 'package:photos/ui/settings/common_settings.dart'; import "package:photos/ui/settings/language_picker.dart"; +import "package:photos/ui/settings/notification_settings_screen.dart"; import 'package:photos/utils/navigation_util.dart'; class GeneralSectionWidget extends StatelessWidget { @@ -82,6 +83,18 @@ class GeneralSectionWidget extends StatelessWidget { }, ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).notifications, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + _onNotificationsTapped(context); + }, + ), + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).advanced, @@ -104,6 +117,13 @@ class GeneralSectionWidget extends StatelessWidget { BillingService.instance.launchFamilyPortal(context, userDetails); } + void _onNotificationsTapped(BuildContext context) { + routeToPage( + context, + const NotificationSettingsScreen(), + ); + } + void _onAdvancedTapped(BuildContext context) { routeToPage( context, diff --git a/lib/ui/settings/notification_settings_screen.dart b/lib/ui/settings/notification_settings_screen.dart new file mode 100644 index 000000000..e596bb2b3 --- /dev/null +++ b/lib/ui/settings/notification_settings_screen.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import "package:photos/generated/l10n.dart"; +import "package:photos/services/notification_service.dart"; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/buttons/icon_button_widget.dart'; +import 'package:photos/ui/components/captioned_text_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_section_description_widget.dart'; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import 'package:photos/ui/components/toggle_switch_widget.dart'; + +class NotificationSettingsScreen extends StatelessWidget { + const NotificationSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: S.of(context).notifications, + ), + actionIcons: [ + IconButtonWidget( + icon: Icons.close_outlined, + iconButtonType: IconButtonType.secondary, + onTap: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).sharedPhotoNotifications, + ), + menuItemColor: colorScheme.fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => + NotificationService.instance + .hasGrantedPermissions() && + NotificationService.instance + .shouldShowNotificationsForSharedPhotos(), + onChanged: () async { + await NotificationService.instance + .requestPermissions(); + await NotificationService.instance + .setShouldShowNotificationsForSharedPhotos( + !NotificationService.instance + .shouldShowNotificationsForSharedPhotos(), + ); + }, + ), + singleBorderRadius: 8, + alignCaptionedTextToLeft: true, + isGestureDetectorDisabled: true, + ), + MenuSectionDescriptionWidget( + content: S + .of(context) + .sharedPhotoNotificationsExplanation, + ) + ], + ) + ], + ), + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/tabs/home_widget.dart b/lib/ui/tabs/home_widget.dart index 5b1b2ba57..dcf21ddf5 100644 --- a/lib/ui/tabs/home_widget.dart +++ b/lib/ui/tabs/home_widget.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import "package:flutter_local_notifications/flutter_local_notifications.dart"; import 'package:logging/logging.dart'; import 'package:media_extension/media_extension_action_types.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; @@ -22,11 +23,13 @@ import 'package:photos/events/tab_changed_event.dart'; import 'package:photos/events/trigger_logout_event.dart'; import 'package:photos/events/user_logged_out_event.dart'; import "package:photos/generated/l10n.dart"; +import "package:photos/models/collection_items.dart"; import 'package:photos/models/selected_files.dart'; import 'package:photos/services/app_lifecycle_service.dart'; import 'package:photos/services/collections_service.dart'; import "package:photos/services/entity_service.dart"; import 'package:photos/services/local_sync_service.dart'; +import "package:photos/services/notification_service.dart"; import 'package:photos/services/update_service.dart'; import 'package:photos/services/user_service.dart'; import 'package:photos/states/user_details_state.dart'; @@ -48,7 +51,9 @@ import 'package:photos/ui/settings/app_update_dialog.dart'; import 'package:photos/ui/settings_page.dart'; import "package:photos/ui/tabs/shared_collections_tab.dart"; import "package:photos/ui/tabs/user_collections_tab.dart"; +import "package:photos/ui/viewer/gallery/collection_page.dart"; import 'package:photos/utils/dialog_util.dart'; +import "package:photos/utils/navigation_util.dart"; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:uni_links/uni_links.dart'; @@ -205,6 +210,8 @@ class _HomeWidgetState extends State { ), ); + NotificationService.instance.init(_onDidReceiveNotificationResponse); + super.initState(); } @@ -492,4 +499,29 @@ class _HomeWidgetState extends State { // Do not show change dialog again UpdateService.instance.hideChangeLog().ignore(); } + + void _onDidReceiveNotificationResponse( + NotificationResponse notificationResponse, + ) async { + final String? payload = notificationResponse.payload; + if (payload != null) { + debugPrint('notification payload: $payload'); + final collectionID = Uri.parse(payload).queryParameters["collectionID"]; + if (collectionID != null) { + final collection = CollectionsService.instance + .getCollectionByID(int.parse(collectionID))!; + final thumbnail = + await CollectionsService.instance.getCover(collection); + routeToPage( + context, + CollectionPage( + CollectionWithThumbnail( + collection, + thumbnail, + ), + ), + ); + } + } + } } diff --git a/lib/utils/diff_fetcher.dart b/lib/utils/diff_fetcher.dart index 312bee818..535faa525 100644 --- a/lib/utils/diff_fetcher.dart +++ b/lib/utils/diff_fetcher.dart @@ -53,6 +53,7 @@ class DiffFetcher { .getUploadedFile(file.uploadedFileID!, file.collectionID!); if (existingFile != null) { file.generatedID = existingFile.generatedID; + file.addedTime = existingFile.addedTime; } } file.ownerID = item["ownerID"]; diff --git a/pubspec.lock b/pubspec.lock index 98939b6da..d1a1e525c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -689,26 +689,26 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "57d0012730780fe137260dd180e072c18a73fbeeb924cdc029c18aaa0f338d64" + sha256: f222919a34545931e47b06000836b5101baeffb0e6eb5a4691d2d42851740dd9 url: "https://pub.dev" source: hosted - version: "9.9.1" + version: "12.0.4" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: b472bfc173791b59ede323661eae20f7fff0b6908fea33dd720a6ef5d576bae8 + sha256: "6af440e3962eeab8459602c309d7d4ab9e62f05d5cfe58195a28f846a0b5d523" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "21bceee103a66a53b30ea9daf677f990e5b9e89b62f222e60dd241cd08d63d3a" + sha256: "5ec1feac5f7f7d9266759488bc5f76416152baba9aa1b26fe572246caa00d1ab" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -1938,10 +1938,10 @@ packages: dependency: transitive description: name: timezone - sha256: "57b35f6e8ef731f18529695bffc62f92c6189fac2e52c12d478dec1931afb66e" + sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.9.2" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 08bed0d17..7c3c4e9ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,7 +63,7 @@ dependencies: flutter_image_compress: ^1.1.0 flutter_inappwebview: ^5.5.0+2 flutter_launcher_icons: ^0.9.3 - flutter_local_notifications: ^9.5.3+1 + flutter_local_notifications: ^12.0.4 flutter_localizations: sdk: flutter flutter_map: ^4.0.0