1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(mobile): iOS background sync notifications (#1811)

* adds notification handling logic

* notification on background updates for iOS

* fixed regression where i accidentally removed load translations from the background sync

* fixed ios translations

---------

Co-authored-by: Marty Fuhry <marty@fuhry.farm>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
martyfuhry 2023-02-21 07:28:52 -05:00 committed by GitHub
parent 2d2cfb0349
commit e9c9b7a3e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 396 additions and 63 deletions

View File

@ -214,5 +214,11 @@
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
"version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
}
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_dialog_cancel": "Cancel",
"notification_permission_dialog_settings": "Settings",
"notification_permission_list_tile_title": "Notification Permission",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications"
}

View File

@ -37,5 +37,66 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
# Permission macros for the permission_handler (https://pub.dev/packages/permission_handler)
# Start of the permission_handler configuration
# Remove the # character in front of the permission you do want to use.
target.build_configurations.each do |config|
# You can enable the permissions needed here. For example to enable camera
# permission, just remove the `#` character in front so it looks like this:
#
# ## dart: PermissionGroup.camera
# 'PERMISSION_CAMERA=1'
#
# Preprocessor definitions can be found in: https://github.com/Baseflow/flutter-permission-handler/blob/master/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
## dart: PermissionGroup.calendar
# 'PERMISSION_EVENTS=1',
## dart: PermissionGroup.reminders
# 'PERMISSION_REMINDERS=1',
## dart: PermissionGroup.contacts
# 'PERMISSION_CONTACTS=1',
## dart: PermissionGroup.camera
# 'PERMISSION_CAMERA=1',
## dart: PermissionGroup.microphone
# 'PERMISSION_MICROPHONE=1',
## dart: PermissionGroup.speech
# 'PERMISSION_SPEECH_RECOGNIZER=1',
## dart: PermissionGroup.photos
# 'PERMISSION_PHOTOS=1',
## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse]
# 'PERMISSION_LOCATION=1',
## dart: PermissionGroup.notification
'PERMISSION_NOTIFICATIONS=1',
## dart: PermissionGroup.mediaLibrary
# 'PERMISSION_MEDIA_LIBRARY=1',
## dart: PermissionGroup.sensors
# 'PERMISSION_SENSORS=1',
## dart: PermissionGroup.bluetooth
# 'PERMISSION_BLUETOOTH=1',
## dart: PermissionGroup.appTrackingTransparency
# 'PERMISSION_APP_TRACKING_TRANSPARENCY=1',
## dart: PermissionGroup.criticalAlerts
# 'PERMISSION_CRITICAL_ALERTS=1'
]
end
# End of the permission_handler configuration
end
end
end

View File

@ -26,6 +26,8 @@ PODS:
- FlutterMacOS
- path_provider_ios (0.0.1):
- Flutter
- permission_handler_apple (9.0.4):
- Flutter
- photo_manager (2.0.0):
- Flutter
- FlutterMacOS
@ -58,6 +60,7 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
@ -95,6 +98,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/ios"
path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/ios"
share_plus:
@ -123,6 +128,7 @@ SPEC CHECKSUMS:
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
@ -133,6 +139,6 @@ SPEC CHECKSUMS:
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
PODFILE CHECKSUM: c798208781ca5116c4a3d5927d689946791f0189
PODFILE CHECKSUM: 4a7e0475ea85ab7bf89955bc4c7ea9d18b54dfd8
COCOAPODS: 1.11.3

View File

@ -1,4 +1,5 @@
import UIKit
import shared_preferences_foundation
import Flutter
import BackgroundTasks
import path_provider_ios
@ -25,6 +26,10 @@ import photo_manager
if !registry.hasPlugin("org.cocoapods.photo-manager") {
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
}
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)

View File

@ -25,6 +25,7 @@ class BackgroundSyncWorker {
name: "BackgroundImmich"
)
let notificationId = "com.alextran.immich/backgroundNotifications"
// The background message passing channel
var channel: FlutterMethodChannel?
@ -67,15 +68,15 @@ class BackgroundSyncWorker {
})
break
case "updateNotification":
// TODO: implement update notification
result(true)
let handled = self.handleNotification(call)
result(handled)
break
case "showError":
// TODO: implement show error
result(true)
let handled = self.handleError(call)
result(handled)
break
case "clearErrorNotifications":
// TODO: implement clear error notifications
self.handleClearErrorNotifications()
result(true)
break
case "hasContentChanged":
@ -184,5 +185,87 @@ class BackgroundSyncWorker {
channel = nil
completionHandler(fetchResult)
}
private func handleNotification(_ call: FlutterMethodCall) -> Bool {
// Parse the arguments as an array list
guard let args = call.arguments as? Array<Any> else {
print("Failed to parse \(call.arguments) as array")
return false;
}
// Requires 7 arguments passed or else fail
guard args.count == 7 else {
print("Needs 7 arguments, but was only passed \(args.count)")
return false
}
// Parse the arguments to send the notification update
let title = args[0] as? String
let content = args[1] as? String
let progress = args[2] as? Int
let maximum = args[3] as? Int
let indeterminate = args[4] as? Bool
let isDetail = args[5] as? Bool
let onlyIfForeground = args[6] as? Bool
// Build the notification
let notificationContent = UNMutableNotificationContent()
notificationContent.body = content ?? "Uploading..."
notificationContent.title = title ?? "Immich"
// Add it to the Notification center
let notification = UNNotificationRequest(
identifier: notificationId,
content: notificationContent,
trigger: nil
)
let center = UNUserNotificationCenter.current()
center.add(notification) { (error: Error?) in
if let theError = error {
print("Error showing notifications: \(theError)")
}
}
return true
}
private func handleError(_ call: FlutterMethodCall) -> Bool {
// Parse the arguments as an array list
guard let args = call.arguments as? Array<Any> else {
return false;
}
// Requires 7 arguments passed or else fail
guard args.count == 3 else {
return false
}
let title = args[0] as? String
let content = args[1] as? String
let individualTag = args[2] as? String
// Build the notification
let notificationContent = UNMutableNotificationContent()
notificationContent.body = content ?? "Error running the backup job."
notificationContent.title = title ?? "Immich"
// Add it to the Notification center
let notification = UNNotificationRequest(
identifier: notificationId,
content: notificationContent,
trigger: nil
)
let center = UNUserNotificationCenter.current()
center.add(notification)
return true
}
private func handleClearErrorNotifications() {
let center = UNUserNotificationCenter.current()
center.removeDeliveredNotifications(withIdentifiers: [notificationId])
center.removePendingNotificationRequests(withIdentifiers: [notificationId])
}
}

View File

@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/settings/providers/permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
@ -126,6 +127,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
ref.watch(notificationPermissionProvider.notifier)
.getNotificationPermission();
break;
case AppLifecycleState.inactive:

View File

@ -314,10 +314,9 @@ class BackgroundService {
return false;
}
// Notifications aren't enabled in iOS yet, and this line
// below crashes the iOS background service
if (Platform.isAndroid) {
await loadTranslations();
final translationsOk = await loadTranslations();
if (!translationsOk) {
debugPrint("[_callHandler] could not load translations");
}
final bool ok = await _onAssetsChanged();

View File

@ -0,0 +1,44 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
NotificationPermissionNotifier() :
super(Platform.isAndroid
? PermissionStatus.granted
: PermissionStatus.restricted,
) {
// Sets the initial state
getNotificationPermission().then((p) => state = p);
}
/// Requests the notification permission
/// Note: In Android, this is always granted
Future<PermissionStatus> requestNotificationPermission() async {
final permission = await Permission.notification.request();
state = permission;
return permission;
}
/// Whether the user has the permission or not
/// Note: In Android, this is always true
Future<bool> hasNotificationPermission() {
return Permission.notification.isGranted;
}
Future<PermissionStatus> getNotificationPermission() async {
final status = await Permission.notification.status;
state = status;
return status;
}
/// Either the permission was granted already or else ask for the permission
Future<bool> hasOrAskForNotificationPermission() {
return requestNotificationPermission().then((p) => p.isGranted);
}
}
final notificationPermissionProvider
= StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>
((ref) => NotificationPermissionNotifier());

View File

@ -0,0 +1,21 @@
import 'package:permission_handler/permission_handler.dart';
/// This class is for requesting permissions in the app
class PermissionService {
/// Requests the notification permission
/// Note: In Android, this is always granted
Future<PermissionStatus> requestNotificationPermission() {
return Permission.notification.request();
}
/// Whether the user has the permission or not
/// Note: In Android, this is always true
Future<bool> hasNotificationPermission() {
return Permission.notification.isGranted;
}
/// Either the permission was granted already or else ask for the permission
Future<bool> hasOrAskForNotificationPermission() {
return requestNotificationPermission().then((p) => p.isGranted);
}
}

View File

@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
SwitchListTile buildSwitchListTile(
BuildContext context,
AppSettingsService appSettingService,
ValueNotifier<bool> valueNotifier,
AppSettingsEnum settingsEnum, {
required String title,
String? subtitle,
}) {
return SwitchListTile.adaptive(
key: Key(settingsEnum.name),
value: valueNotifier.value,
onChanged: (value) {
valueNotifier.value = value;
appSettingService.setSetting(settingsEnum, value);
},
activeColor: Theme.of(context).primaryColor,
dense: true,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: subtitle != null ? Text(subtitle) : null,
);
}

View File

@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/common.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
class ImageViewerQualitySetting extends HookConsumerWidget {
const ImageViewerQualitySetting({
@ -44,19 +44,17 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
title: const Text('setting_image_viewer_help').tr(),
dense: true,
),
buildSwitchListTile(
context,
settings,
isPreview,
AppSettingsEnum.loadPreview,
SettingsSwitchListTile(
appSettingService: settings,
valueNotifier: isPreview,
settingsEnum: AppSettingsEnum.loadPreview,
title: "setting_image_viewer_preview_title".tr(),
subtitle: "setting_image_viewer_preview_subtitle".tr(),
),
buildSwitchListTile(
context,
settings,
isOriginal,
AppSettingsEnum.loadOriginal,
SettingsSwitchListTile(
appSettingService: settings,
valueNotifier: isOriginal,
settingsEnum: AppSettingsEnum.loadOriginal,
title: "setting_image_viewer_original_title".tr(),
subtitle: "setting_image_viewer_original_subtitle".tr(),
),

View File

@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/providers/permission.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/common.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationSetting extends HookConsumerWidget {
const NotificationSetting({
@ -14,12 +16,14 @@ class NotificationSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final permissionService = ref.watch(notificationPermissionProvider);
final sliderValue = useState(0.0);
final totalProgressValue =
useState(AppSettingsEnum.backgroundBackupTotalProgress.defaultValue);
final singleProgressValue =
useState(AppSettingsEnum.backgroundBackupSingleProgress.defaultValue);
final hasPermission = permissionService == PermissionStatus.granted;
useEffect(
() {
@ -35,6 +39,30 @@ class NotificationSetting extends HookConsumerWidget {
[],
);
// When permissions are permanently denied, you need to go to settings to
// allow them
showPermissionsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
content: const Text('notification_permission_dialog_content').tr(),
actions: [
TextButton(
child: const Text('notification_permission_dialog_cancel').tr(),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: const Text('notification_permission_dialog_settings').tr(),
onPressed: () {
Navigator.of(context).pop();
openAppSettings();
},
),
],
),
);
}
final String formattedValue = _formatSliderValue(sliderValue.value);
return ExpansionTile(
textColor: Theme.of(context).primaryColor,
@ -51,23 +79,49 @@ class NotificationSetting extends HookConsumerWidget {
),
).tr(),
children: [
buildSwitchListTile(
context,
appSettingService,
totalProgressValue,
AppSettingsEnum.backgroundBackupTotalProgress,
if (!hasPermission)
ListTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('notification_permission_list_tile_title').tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('notification_permission_list_tile_content').tr(),
const SizedBox(height: 8),
ElevatedButton(
onPressed: ()
=> ref.watch(notificationPermissionProvider.notifier)
.requestNotificationPermission().then((permission) {
if (permission == PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
}),
child:
const Text('notification_permission_list_tile_enable_button')
.tr(),
),
],
),
isThreeLine: true,
),
SettingsSwitchListTile(
enabled: hasPermission,
appSettingService: appSettingService,
valueNotifier: totalProgressValue,
settingsEnum: AppSettingsEnum.backgroundBackupTotalProgress,
title: 'setting_notifications_total_progress_title'.tr(),
subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
),
buildSwitchListTile(
context,
appSettingService,
singleProgressValue,
AppSettingsEnum.backgroundBackupSingleProgress,
SettingsSwitchListTile(
enabled: hasPermission,
appSettingService: appSettingService,
valueNotifier: singleProgressValue,
settingsEnum: AppSettingsEnum.backgroundBackupSingleProgress,
title: 'setting_notifications_single_progress_title'.tr(),
subtitle: 'setting_notifications_single_progress_subtitle'.tr(),
),
ListTile(
enabled: hasPermission,
isThreeLine: false,
dense: true,
title: const Text(
@ -76,7 +130,7 @@ class NotificationSetting extends HookConsumerWidget {
).tr(args: [formattedValue]),
subtitle: Slider(
value: sliderValue.value,
onChanged: (double v) => sliderValue.value = v,
onChanged: !hasPermission ? null : (double v) => sliderValue.value = v,
onChangeEnd: (double v) => appSettingService.setSetting(
AppSettingsEnum.uploadErrorNotificationGracePeriod,
v.toInt(),

View File

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class SettingsSwitchListTile extends StatelessWidget {
final AppSettingsService appSettingService;
final ValueNotifier<bool> valueNotifier;
final AppSettingsEnum settingsEnum;
final String title;
final bool enabled;
final String? subtitle;
SettingsSwitchListTile({
required this.appSettingService,
required this.valueNotifier,
required this.settingsEnum,
required this.title,
this.subtitle,
this.enabled = true,
}) : super(key: Key(settingsEnum.name));
@override
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
value: valueNotifier.value,
onChanged: !enabled ? null : (value) {
valueNotifier.value = value;
appSettingService.setSetting(settingsEnum, value);
},
activeColor: Theme
.of(context)
.primaryColor,
dense: true,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: subtitle != null ? Text(subtitle!) : null,
);
}
}

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -41,7 +39,7 @@ class SettingsPage extends HookConsumerWidget {
const ImageViewerQualitySetting(),
const ThemeSetting(),
const AssetListSettings(),
if (Platform.isAndroid) const NotificationSetting(),
const NotificationSetting(),
//const ExperimentalSettings(),
],
).toList(),

View File

@ -891,6 +891,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.11.1"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "33c6a1253d1f95fd06fa74b65b7ba907ae9811f9d5c1d3150e51417d04b8d6a8"
url: "https://pub.dev"
source: hosted
version: "10.2.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "8028362b40c4a45298f1cbfccd227c8dd6caf0e27088a69f2ba2ab15464159e2"
url: "https://pub.dev"
source: hosted
version: "10.2.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: "9c370ef6a18b1c4b2f7f35944d644a56aa23576f23abee654cf73968de93f163"
url: "https://pub.dev"
source: hosted
version: "9.0.7"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: "68abbc472002b5e6dfce47fe9898c6b7d8328d58b5d2524f75e277c07a97eb84"
url: "https://pub.dev"
source: hosted
version: "3.9.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: f67cab14b4328574938ecea2db3475dad7af7ead6afab6338772c5f88963e38b
url: "https://pub.dev"
source: hosted
version: "0.1.2"
petitparser:
dependency: transitive
description:

View File

@ -45,6 +45,7 @@ dependencies:
easy_image_viewer: ^1.2.0
isar: *isar_version
isar_flutter_libs: *isar_version # contains Isar Core
permission_handler: ^10.2.0
openapi:
path: openapi