From 9850d36b76dbe17a752e7e5390d9df0515da4665 Mon Sep 17 00:00:00 2001 From: Alessio Caputo <84250128+alessioC42@users.noreply.github.com> Date: Sun, 30 Jun 2024 13:08:56 +0200 Subject: [PATCH 1/7] WIP: Implement iOS background fetch without UI or settings --- app/lib/background_service.dart | 161 +++++++++++++++++++++----------- app/lib/main.dart | 45 +-------- 2 files changed, 109 insertions(+), 97 deletions(-) diff --git a/app/lib/background_service.dart b/app/lib/background_service.dart index fd18d92f..90ca3d07 100644 --- a/app/lib/background_service.dart +++ b/app/lib/background_service.dart @@ -1,21 +1,119 @@ import 'dart:convert'; +import 'dart:io'; import 'package:crypto/crypto.dart' as crypto; +import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:intl/intl.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:sph_plan/client/client_submodules/substitutions.dart'; -import 'package:sph_plan/shared/exceptions/client_status_exceptions.dart'; import 'package:workmanager/workmanager.dart'; import 'client/client.dart'; import 'client/logger.dart'; import 'client/storage.dart'; +Future setupBackgroundService() async { + if (!Platform.isAndroid && !Platform.isIOS) return; + bool enableNotifications = + await globalStorage.read(key: StorageKey.settingsPushService) == + "true"; + + PermissionStatus? notificationsPermissionStatus; + + await Permission.notification.isDenied.then((value) async { + if (value) { + notificationsPermissionStatus = + await Permission.notification.request(); + } + }); + if ((notificationsPermissionStatus ?? PermissionStatus.granted).isGranted && enableNotifications) return; + + await Workmanager().cancelAll(); + + await Workmanager().initialize(callbackDispatcher, + isInDebugMode: kDebugMode); + const uniqueName = "sphplanfetchservice-alessioc42-github-io"; + final constraints = Constraints( + networkType: NetworkType.connected, + requiresBatteryNotLow: true, + requiresCharging: false, + requiresDeviceIdle: false, + requiresStorageNotLow: false + ); + + if (Platform.isAndroid) { + int notificationInterval = int.parse(await globalStorage.read( + key: StorageKey.settingsPushServiceIntervall)); + + await Workmanager().registerPeriodicTask(uniqueName, uniqueName, + frequency: Duration(minutes: notificationInterval), + constraints: constraints, + initialDelay: const Duration(minutes: 3) + ); + + } + if (Platform.isIOS) { + logger.i("iOS detected, using one-off task"); + await Workmanager().registerOneOffTask(uniqueName, uniqueName, + constraints: constraints, + initialDelay: const Duration(minutes: 5), + ); + } +} + + @pragma('vm:entry-point') void callbackDispatcher() { Workmanager().executeTask((task, inputData) async { try { logger.i("Background fetch triggered"); - await performBackgroundFetch(); + sendMessage("DEBUG TEST", "Substitutions fetcher called!"); + /* todo: uncomment before PR + var client = SPHclient(); + await client.prepareDio(); + await client.loadFromStorage(); + if (client.username == "" || client.password == "") { + logger.w("No credentials found, aborting background fetch"); + return Future.value(true); + } + await client.login(backgroundFetch: true); + final vPlan = + await client.substitutions.getAllSubstitutions(skipLoginCheck: true, filtered: true); + await globalStorage.write( + key: StorageKey.lastSubstitutionData, value: jsonEncode(vPlan)); + List allSubstitutions = vPlan.allSubstitutions; + String messageBody = ""; + + for (final entry in allSubstitutions) { + final time = + "${weekDayGer(entry.tag)} ${entry.stunde.replaceAll(" - ", "/")}"; + final type = entry.art ?? ""; + final subject = entry.fach ?? ""; + final teacher = entry.lehrer ?? ""; + final classInfo = entry.klasse ?? ""; + + // Concatenate non-null values with separator "-" + final entryText = [time, type, subject, teacher, classInfo] + .where((e) => e.isNotEmpty) + .join(" - "); + + messageBody += "$entryText\n"; + } + + if (messageBody != "") { + final messageUUID = generateUUID(messageBody); + + messageBody += + "Zuletzt editiert: ${DateFormat.Hm().format(vPlan.lastUpdated)}"; + + if (!(await isMessageAlreadySent(messageUUID))) { + await sendMessage( + "${allSubstitutions.length} Einträge im Vertretungsplan", + messageBody); + await markMessageAsSent(messageUUID); + } + } + */ } catch (e) { logger.f(e.toString()); } @@ -23,63 +121,13 @@ void callbackDispatcher() { }); } -Future performBackgroundFetch() async { - try { - var client = SPHclient(); - await client.prepareDio(); - await client.loadFromStorage(); - if (client.username == "" || client.password == "") { - logger.w("No credentials found, aborting background fetch"); - return; - } - await client.login(backgroundFetch: true); - final vPlan = - await client.substitutions.getAllSubstitutions(skipLoginCheck: true, filtered: true); - await globalStorage.write( - key: StorageKey.lastSubstitutionData, value: jsonEncode(vPlan)); - List allSubstitutions = vPlan.allSubstitutions; - String messageBody = ""; - - for (final entry in allSubstitutions) { - final time = - "${weekDayGer(entry.tag)} ${entry.stunde.replaceAll(" - ", "/")}"; - final type = entry.art ?? ""; - final subject = entry.fach ?? ""; - final teacher = entry.lehrer ?? ""; - final classInfo = entry.klasse ?? ""; - - // Concatenate non-null values with separator "-" - final entryText = [time, type, subject, teacher, classInfo] - .where((e) => e.isNotEmpty) - .join(" - "); - - messageBody += "$entryText\n"; - } - - if (messageBody != "") { - final messageUUID = generateUUID(messageBody); - - messageBody += - "Zuletzt editiert: ${DateFormat.Hm().format(vPlan.lastUpdated)}"; - - if (!(await isMessageAlreadySent(messageUUID))) { - await sendMessage( - "${allSubstitutions.length} Einträge im Vertretungsplan", - messageBody); - await markMessageAsSent(messageUUID); - } - } - } on LanisException { - logger.w("Error occurred in background service"); - } -} Future sendMessage(String title, String message, {int id = 0}) async { bool ongoingMessage = (await globalStorage.read(key: StorageKey.settingsPushServiceOngoing)) == "true"; - var androidDetails = AndroidNotificationDetails( + final androidDetails = AndroidNotificationDetails( 'io.github.alessioc42.sphplan', 'lanis-mobile', channelDescription: "Benachrichtigungen über den Vertretungsplan", importance: Importance.high, @@ -87,7 +135,10 @@ Future sendMessage(String title, String message, {int id = 0}) async { styleInformation: BigTextStyleInformation(message), ongoing: ongoingMessage, icon: "@drawable/ic_launcher"); - var platformDetails = NotificationDetails(android: androidDetails); + const iOSDetails = DarwinNotificationDetails( + presentAlert: false, presentBadge: true + ); + var platformDetails = NotificationDetails(android: androidDetails, iOS: iOSDetails); await FlutterLocalNotificationsPlugin() .show(id, title, message, platformDetails); } @@ -105,7 +156,7 @@ Future markMessageAsSent(String uuid) async { Future isMessageAlreadySent(String uuid) async { // Read the existing JSON from secure storage String storageValue = - await globalStorage.read(key: StorageKey.lastPushMessageHash); + await globalStorage.read(key: StorageKey.lastPushMessageHash); return storageValue == uuid; } diff --git a/app/lib/main.dart b/app/lib/main.dart index e2d6782e..15c4e583 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,25 +1,21 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; - import 'package:countly_flutter_np/countly_flutter.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:sph_plan/themes.dart'; import 'package:stack_trace/stack_trace.dart'; import 'package:sph_plan/startup.dart'; - import 'package:intl/date_symbol_data_local.dart'; import 'package:flutter/material.dart'; import 'package:sph_plan/client/storage.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:workmanager/workmanager.dart'; -import 'background_service.dart' as background_service; +import 'background_service.dart'; import 'package:http_proxy/http_proxy.dart'; + void main() async { ErrorWidget.builder = (FlutterErrorDetails details) { return errorWidget(details); @@ -28,42 +24,7 @@ void main() async { runZonedGuarded>(() async { WidgetsFlutterBinding.ensureInitialized(); - /* periodic background fetching is not supported on IOS due to battery saving restrictions - * a workaround would be to use an external push service, but that would require the users to - * transfer their passwords to a third party service, which is not acceptable. - * Maybe someone will find a better solution in the future. It would be possible to provide a - * self-hosted solution per school, but that's some unlikely idea for the future. - * - * edit: it should be possible to run an event on a specified time, but that would require the user to open the app at least once a day - */ - if (Platform.isAndroid) { - PermissionStatus? notificationsPermissionStatus; - - await Permission.notification.isDenied.then((value) async { - if (value) { - notificationsPermissionStatus = - await Permission.notification.request(); - } - }); - bool enableNotifications = - await globalStorage.read(key: StorageKey.settingsPushService) == - "true"; - int notificationInterval = int.parse(await globalStorage.read( - key: StorageKey.settingsPushServiceIntervall)); - - await Workmanager().cancelAll(); - if ((notificationsPermissionStatus ?? PermissionStatus.granted) - .isGranted && - enableNotifications) { - await Workmanager().initialize(background_service.callbackDispatcher, - isInDebugMode: kDebugMode); - - await Workmanager().registerPeriodicTask( - "sphplanfetchservice-alessioc42-github-io", - "sphVertretungsplanUpdateService", - frequency: Duration(minutes: notificationInterval)); - } - } + await setupBackgroundService(); await initializeDateFormatting(); if (!kDebugMode && From d346875fdbae2be3e1e630b59075fc649b9d2c7f Mon Sep 17 00:00:00 2001 From: Alessio Caputo <84250128+alessioC42@users.noreply.github.com> Date: Sun, 30 Jun 2024 17:27:49 +0200 Subject: [PATCH 2/7] add iOS support for notifications --- app/ios/Podfile.lock | 88 ++++++++++++-- app/ios/Runner.xcodeproj/project.pbxproj | 31 ++++- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- app/ios/Runner/AppDelegate.swift | 15 ++- app/ios/Runner/Info.plist | 114 ++++++++++-------- app/lib/background_service.dart | 43 +++++-- app/lib/l10n/app_en.arb | 2 +- app/lib/main.dart | 1 + 8 files changed, 214 insertions(+), 82 deletions(-) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 52c0420a..9bc813dd 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -1,5 +1,39 @@ PODS: - - countly_flutter_np (23.12.0): + - countly_flutter_np (24.4.1): + - Flutter + - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.4) + - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.4) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery - Flutter - Flutter (1.0.0) - flutter_keyboard_visibility (0.0.1): @@ -10,6 +44,8 @@ PODS: - Flutter - flutter_secure_storage (6.0.0): - Flutter + - http_proxy (0.0.1): + - Flutter - open_file (0.0.1): - Flutter - package_info_plus (0.4.5): @@ -17,11 +53,18 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - permission_handler_apple (9.1.1): + - permission_handler_apple (9.3.0): - Flutter + - SDWebImage (5.19.0): + - SDWebImage/Core (= 5.19.0) + - SDWebImage/Core (5.19.0) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sqflite (0.0.3): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.4) - url_launcher_ios (0.0.1): - Flutter - workmanager (0.0.1): @@ -29,22 +72,34 @@ PODS: DEPENDENCIES: - countly_flutter_np (from `.symlinks/plugins/countly_flutter_np/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - http_proxy (from `.symlinks/plugins/http_proxy/ios`) - open_file (from `.symlinks/plugins/open_file/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite (from `.symlinks/plugins/sqflite/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - workmanager (from `.symlinks/plugins/workmanager/ios`) +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + EXTERNAL SOURCES: countly_flutter_np: :path: ".symlinks/plugins/countly_flutter_np/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_keyboard_visibility: @@ -55,6 +110,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + http_proxy: + :path: ".symlinks/plugins/http_proxy/ios" open_file: :path: ".symlinks/plugins/open_file/ios" package_info_plus: @@ -65,24 +122,33 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite: + :path: ".symlinks/plugins/sqflite/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" workmanager: :path: ".symlinks/plugins/workmanager/ios" SPEC CHECKSUMS: - countly_flutter_np: 5faaf9bd3e5d5fe41736ca4dd5ccd9f98558d566 + countly_flutter_np: bdeb538a9cb4635a4375b75c6cbc97a930e11c2f + DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 - flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef - flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be + flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 + flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + http_proxy: 20f129d3e1c70cfdef92530b374c619832027729 open_file: 02eb5cb6b21264bd3a696876f5afbfb7ca4f4b7d - package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 - permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - url_launcher_ios: bf5ce03e0e2088bad9cc378ea97fa0ed5b49673b + package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + SDWebImage: 981fd7e860af070920f249fd092420006014c3eb + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 PODFILE CHECKSUM: 7be2f5f74864d463a8ad433546ed1de7e0f29aef diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj index 9c0fb118..b9357869 100644 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -1,5 +1,10 @@ // !$*UTF8*$! { + SystemCapabilities = { + com.apple.BackgroundModes = { + enabled = 1; + }; + }; archiveVersion = 1; classes = { }; @@ -198,6 +203,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 52F0749CF5E5775CDAC412E2 /* [CP] Embed Pods Frameworks */, + 363FF3C8308604CBA222411E /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -215,7 +221,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C8080294A63A400263BE5 = { @@ -269,6 +275,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 363FF3C8308604CBA222411E /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -470,7 +493,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = DBR4MR8BAV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -655,7 +678,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = DBR4MR8BAV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -681,7 +704,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = DBR4MR8BAV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 87131a09..8e3ca5df 100644 --- a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ Bool { GeneratedPluginRegistrant.register(with: self) + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + } + + FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in + GeneratedPluginRegistrant.register(with: registry) + } + + WorkmanagerPlugin.registerTask(withIdentifier: "notificationservice") // Set the minimum background fetch interval. - UIApplication.shared.setMinimumBackgroundFetchInterval(TimeInterval(60*15)) + UIApplication.shared.setMinimumBackgroundFetchInterval(TimeInterval(60*40)) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } -} \ No newline at end of file +} diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 0d5abbd9..a5afe27f 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -1,57 +1,65 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Lanis - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Lanis - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSApplicationQueriesSchemes - - sms - tel - mailto - - LSRequiresIPhoneOS - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIStatusBarHidden - - + + BGTaskSchedulerPermittedIdentifiers + + notificationservice + io.github.alessioc42.notificationservice + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Lanis + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Lanis + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSApplicationQueriesSchemes + + sms + tel + mailto + + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + fetch + processing + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + diff --git a/app/lib/background_service.dart b/app/lib/background_service.dart index 90ca3d07..50dcf509 100644 --- a/app/lib/background_service.dart +++ b/app/lib/background_service.dart @@ -32,7 +32,7 @@ Future setupBackgroundService() async { await Workmanager().initialize(callbackDispatcher, isInDebugMode: kDebugMode); - const uniqueName = "sphplanfetchservice-alessioc42-github-io"; + const uniqueName = "notificationservice"; final constraints = Constraints( networkType: NetworkType.connected, requiresBatteryNotLow: true, @@ -54,21 +54,41 @@ Future setupBackgroundService() async { } if (Platform.isIOS) { logger.i("iOS detected, using one-off task"); - await Workmanager().registerOneOffTask(uniqueName, uniqueName, + try { + await Workmanager().registerOneOffTask(uniqueName, uniqueName, constraints: constraints, - initialDelay: const Duration(minutes: 5), + initialDelay: const Duration(hours: 5), ); + } catch (e, s) { + logger.e(e, stackTrace: s); + } } } +Future initializeNotifications() async { + FlutterLocalNotificationsPlugin().initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@drawable/ic_launcher'), + iOS: DarwinInitializationSettings( + onDidReceiveLocalNotification: onDidReceiveLocalNotification, + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true + ) + ), + ); +} +void onDidReceiveLocalNotification( + int id, String? title, String? body, String? payload) { + logger.i("Received local notification with id $id, title $title, body $body, payload $payload"); +} @pragma('vm:entry-point') void callbackDispatcher() { Workmanager().executeTask((task, inputData) async { try { logger.i("Background fetch triggered"); - sendMessage("DEBUG TEST", "Substitutions fetcher called!"); - /* todo: uncomment before PR + initializeNotifications(); var client = SPHclient(); await client.prepareDio(); await client.loadFromStorage(); @@ -113,7 +133,6 @@ void callbackDispatcher() { await markMessageAsSent(messageUUID); } } - */ } catch (e) { logger.f(e.toString()); } @@ -121,9 +140,9 @@ void callbackDispatcher() { }); } - Future sendMessage(String title, String message, {int id = 0}) async { - bool ongoingMessage = + try { + bool ongoingMessage = (await globalStorage.read(key: StorageKey.settingsPushServiceOngoing)) == "true"; @@ -134,13 +153,17 @@ Future sendMessage(String title, String message, {int id = 0}) async { priority: Priority.high, styleInformation: BigTextStyleInformation(message), ongoing: ongoingMessage, - icon: "@drawable/ic_launcher"); + ); const iOSDetails = DarwinNotificationDetails( - presentAlert: false, presentBadge: true + presentAlert: false, presentBadge: true, + ); var platformDetails = NotificationDetails(android: androidDetails, iOS: iOSDetails); await FlutterLocalNotificationsPlugin() .show(id, title, message, platformDetails); + } catch (e,s) { + logger.e(e, stackTrace: s); + } } String generateUUID(String input) { diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index a1b963f6..f3eb3609 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -35,7 +35,7 @@ "dynamicColor": "Dynamic", "schoolColor": "School color", "schoolColorOriginExplanation": "Your school has an accent color in the SPH.", - "noAppleMessageSupport": "Notifications are currently not supported on your end device (IOS / IpadOS).", + "noAppleMessageSupport": "Notifications are currently not supported on your device (IOS / IpadOS).", "sadlyNoSupport": "Unfortunately no support", "systemPermissionForNotifications": "Authorization for notifications", "systemPermissionForNotificationsExplained": "You must change your permissions for notifications in the app's system settings!", diff --git a/app/lib/main.dart b/app/lib/main.dart index 15c4e583..bc2ec908 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -25,6 +25,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await setupBackgroundService(); + await initializeNotifications(); await initializeDateFormatting(); if (!kDebugMode && From 81ecf35c98571a7dcf12c7bf47064c342b7134e5 Mon Sep 17 00:00:00 2001 From: Alessio Caputo <84250128+alessioC42@users.noreply.github.com> Date: Sun, 30 Jun 2024 22:43:28 +0200 Subject: [PATCH 3/7] support for background service on iOS with UI --- app/lib/background_service.dart | 117 +++++++++++------- app/lib/client/storage.dart | 5 + app/lib/home_page.dart | 18 ++- app/lib/view/data_storage/node_view.dart | 2 +- app/lib/view/data_storage/root_view.dart | 2 +- .../settings/subsettings/notifications.dart | 89 +++++++------ 6 files changed, 142 insertions(+), 91 deletions(-) diff --git a/app/lib/background_service.dart b/app/lib/background_service.dart index 50dcf509..fdec6855 100644 --- a/app/lib/background_service.dart +++ b/app/lib/background_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart' as crypto; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:intl/intl.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -55,10 +56,25 @@ Future setupBackgroundService() async { if (Platform.isIOS) { logger.i("iOS detected, using one-off task"); try { + String executionTime = await globalStorage.read( + key: StorageKey.settingsPushServiceIOSTime); + final timeList = executionTime.split(":"); + TimeOfDay time = TimeOfDay(hour: int.parse(timeList[0]), minute: int.parse(timeList[1])); + DateTime now = DateTime.now(); + DateTime scheduledTime = DateTime(now.year, now.month, now.day, time.hour, time.minute); + + if (scheduledTime.isBefore(now)) { + scheduledTime = scheduledTime.add(const Duration(days: 1)); + } + + while (scheduledTime.weekday == DateTime.saturday || scheduledTime.weekday == DateTime.sunday) { + scheduledTime = scheduledTime.add(const Duration(days: 1)); + } + await Workmanager().registerOneOffTask(uniqueName, uniqueName, - constraints: constraints, - initialDelay: const Duration(hours: 5), - ); + constraints: constraints, + initialDelay: scheduledTime.difference(now), + ); } catch (e, s) { logger.e(e, stackTrace: s); } @@ -83,56 +99,14 @@ void onDidReceiveLocalNotification( int id, String? title, String? body, String? payload) { logger.i("Received local notification with id $id, title $title, body $body, payload $payload"); } + @pragma('vm:entry-point') void callbackDispatcher() { Workmanager().executeTask((task, inputData) async { try { logger.i("Background fetch triggered"); - initializeNotifications(); - var client = SPHclient(); - await client.prepareDio(); - await client.loadFromStorage(); - if (client.username == "" || client.password == "") { - logger.w("No credentials found, aborting background fetch"); - return Future.value(true); - } - await client.login(backgroundFetch: true); - final vPlan = - await client.substitutions.getAllSubstitutions(skipLoginCheck: true, filtered: true); - await globalStorage.write( - key: StorageKey.lastSubstitutionData, value: jsonEncode(vPlan)); - List allSubstitutions = vPlan.allSubstitutions; - String messageBody = ""; - - for (final entry in allSubstitutions) { - final time = - "${weekDayGer(entry.tag)} ${entry.stunde.replaceAll(" - ", "/")}"; - final type = entry.art ?? ""; - final subject = entry.fach ?? ""; - final teacher = entry.lehrer ?? ""; - final classInfo = entry.klasse ?? ""; - - // Concatenate non-null values with separator "-" - final entryText = [time, type, subject, teacher, classInfo] - .where((e) => e.isNotEmpty) - .join(" - "); - - messageBody += "$entryText\n"; - } - - if (messageBody != "") { - final messageUUID = generateUUID(messageBody); - - messageBody += - "Zuletzt editiert: ${DateFormat.Hm().format(vPlan.lastUpdated)}"; - - if (!(await isMessageAlreadySent(messageUUID))) { - await sendMessage( - "${allSubstitutions.length} Einträge im Vertretungsplan", - messageBody); - await markMessageAsSent(messageUUID); - } - } + await updateNotifications(); + return Future.value(true); } catch (e) { logger.f(e.toString()); } @@ -140,6 +114,53 @@ void callbackDispatcher() { }); } +Future updateNotifications() async { + initializeNotifications(); + var client = SPHclient(); + await client.prepareDio(); + await client.loadFromStorage(); + if (client.username == "" || client.password == "") { + logger.w("No credentials found, aborting background fetch"); + } + await client.login(backgroundFetch: true); + final vPlan = + await client.substitutions.getAllSubstitutions(skipLoginCheck: true, filtered: true); + await globalStorage.write( + key: StorageKey.lastSubstitutionData, value: jsonEncode(vPlan)); + List allSubstitutions = vPlan.allSubstitutions; + String messageBody = ""; + + for (final entry in allSubstitutions) { + final time = + "${weekDayGer(entry.tag)} ${entry.stunde.replaceAll(" - ", "/")}"; + final type = entry.art ?? ""; + final subject = entry.fach ?? ""; + final teacher = entry.lehrer ?? ""; + final classInfo = entry.klasse ?? ""; + + // Concatenate non-null values with separator "-" + final entryText = [time, type, subject, teacher, classInfo] + .where((e) => e.isNotEmpty) + .join(" - "); + + messageBody += "$entryText\n"; + } + + if (messageBody != "") { + final messageUUID = generateUUID(messageBody); + + messageBody += + "Zuletzt editiert: ${DateFormat.Hm().format(vPlan.lastUpdated)}"; + + if (!(await isMessageAlreadySent(messageUUID))) { + await sendMessage( + "${allSubstitutions.length} Einträge im Vertretungsplan", + messageBody); + await markMessageAsSent(messageUUID); + } + } +} + Future sendMessage(String title, String message, {int id = 0}) async { try { bool ongoingMessage = diff --git a/app/lib/client/storage.dart b/app/lib/client/storage.dart index 63cf5447..f35a2019 100644 --- a/app/lib/client/storage.dart +++ b/app/lib/client/storage.dart @@ -5,6 +5,7 @@ enum StorageKey { settingsPushService, settingsPushServiceIntervall, settingsPushServiceOngoing, + settingsPushServiceIOSTime, settingsUseCountly, settingsSelectedColor, settingsSelectedTheme, @@ -35,6 +36,8 @@ extension on StorageKey { return "settings-push-service-on"; case StorageKey.settingsPushServiceIntervall: return "settings-push-service-interval"; + case StorageKey.settingsPushServiceIOSTime: + return "settings-push-service-ios-time"; case StorageKey.settingsPushServiceOngoing: return "settings-push-service-notifications-ongoing"; case StorageKey.settingsUseCountly: @@ -78,6 +81,8 @@ extension on StorageKey { return "true"; case StorageKey.settingsPushServiceIntervall: return "15"; + case StorageKey.settingsPushServiceIOSTime: + return "7:40"; case StorageKey.settingsUseCountly: return "true"; case StorageKey.lastAppVersion: diff --git a/app/lib/home_page.dart b/app/lib/home_page.dart index 3a59f7b8..b5b59c37 100644 --- a/app/lib/home_page.dart +++ b/app/lib/home_page.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; +import 'package:sph_plan/background_service.dart'; import 'package:sph_plan/shared/apps.dart'; import 'package:sph_plan/shared/exceptions/client_status_exceptions.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -359,9 +360,20 @@ class _HomePageState extends State { ) : null, actions: [ - if (kDebugMode) IconButton(onPressed: (){ - throw ErrorDescription("Test Error in debug mode"); - }, icon: const Icon(Icons.nearby_error)) + if (kDebugMode) ...[ + IconButton( + onPressed: (){ + throw ErrorDescription("Test Error in debug mode"); + }, icon: const Icon(Icons.nearby_error), + tooltip: "Throw test Error", + ), + IconButton( + onPressed: (){ + updateNotifications(); + }, icon: const Icon(Icons.notifications), + tooltip: "Simulate notification update" + ) + ] ], ), body: doesSupportAnyApplet diff --git a/app/lib/view/data_storage/node_view.dart b/app/lib/view/data_storage/node_view.dart index 903be458..0eaa6ea4 100644 --- a/app/lib/view/data_storage/node_view.dart +++ b/app/lib/view/data_storage/node_view.dart @@ -38,7 +38,7 @@ class _DataStorageNodeViewState extends State { setState(() { loading = false; }); - } on LanisException catch (e) { + } on LanisException { setState(() { error = true; loading = false; diff --git a/app/lib/view/data_storage/root_view.dart b/app/lib/view/data_storage/root_view.dart index 1198b463..7b8e1154 100644 --- a/app/lib/view/data_storage/root_view.dart +++ b/app/lib/view/data_storage/root_view.dart @@ -42,7 +42,7 @@ class _DataStorageRootViewState extends State { setState(() { loading = false; }); - } on LanisException catch (e) { + } on LanisException { setState(() { error = true; loading = false; diff --git a/app/lib/view/settings/subsettings/notifications.dart b/app/lib/view/settings/subsettings/notifications.dart index d317204c..6fcbc71e 100644 --- a/app/lib/view/settings/subsettings/notifications.dart +++ b/app/lib/view/settings/subsettings/notifications.dart @@ -21,16 +21,7 @@ class NotificationsSettingsScreen extends StatelessWidget { ], ), body: ListView( - children: [ - if (Platform.isIOS) - ListTile( - leading: const Icon(Icons.info), - title: Text(AppLocalizations.of(context)!.sadlyNoSupport), - subtitle: - Text(AppLocalizations.of(context)!.noAppleMessageSupport), - ), - const NotificationElements(), - ], + children: const [NotificationElements()], ), ); } @@ -45,35 +36,40 @@ class NotificationElements extends StatefulWidget { class _NotificationElementsState extends State { bool _enableNotifications = true; - int _notificationInterval = 15; - bool _notificationsAreOngoing = false; - bool _notificationPermissionGranted = false; + int _androidNotificationInterval = 15; + bool _androidNotificationsOngoing = false; + bool _androidNotificationPermissionGranted = false; + TimeOfDay _iOSUpdateTime = const TimeOfDay(hour: 7, minute: 40); - Future loadSettingsVariables() async { + Future loadSettingsVars() async { _enableNotifications = (await globalStorage.read(key: StorageKey.settingsPushService)) == "true"; - _notificationInterval = int.parse( + _androidNotificationInterval = int.parse( await globalStorage.read(key: StorageKey.settingsPushServiceIntervall)); - _notificationsAreOngoing = (await globalStorage.read( + _androidNotificationsOngoing = (await globalStorage.read( key: StorageKey.settingsPushServiceOngoing)) == "true"; - _notificationPermissionGranted = await Permission.notification.isGranted; + _androidNotificationPermissionGranted = await Permission.notification.isGranted; + final String timeString = await globalStorage.read(key: StorageKey.settingsPushServiceIOSTime); + final List timeList = timeString.split(":"); + _iOSUpdateTime = TimeOfDay(hour: int.parse(timeList[0]), minute: int.parse(timeList[1])); } @override void initState() { super.initState(); // Use await to ensure that loadSettingsVariables completes before continuing - loadSettingsVariables().then((_) { + loadSettingsVars().then((_) { setState(() { // Set the state after loading the variables _enableNotifications = _enableNotifications; - _notificationInterval = _notificationInterval; - _notificationsAreOngoing = _notificationsAreOngoing; + _androidNotificationInterval = _androidNotificationInterval; + _androidNotificationsOngoing = _androidNotificationsOngoing; + _iOSUpdateTime = _iOSUpdateTime; - _notificationPermissionGranted = _notificationPermissionGranted; + _androidNotificationPermissionGranted = _androidNotificationPermissionGranted; }); }); } @@ -82,13 +78,13 @@ class _NotificationElementsState extends State { Widget build(BuildContext context) { return Column( children: [ - ListTile( + if (Platform.isAndroid) ListTile( title: Text( AppLocalizations.of(context)!.systemPermissionForNotifications), - trailing: Text(_notificationPermissionGranted + trailing: Text(_androidNotificationPermissionGranted ? AppLocalizations.of(context)!.granted : AppLocalizations.of(context)!.denied), - subtitle: !_notificationPermissionGranted + subtitle: !_androidNotificationPermissionGranted ? Text(AppLocalizations.of(context)! .systemPermissionForNotificationsExplained) : null, @@ -96,7 +92,7 @@ class _NotificationElementsState extends State { SwitchListTile( title: Text(AppLocalizations.of(context)!.pushNotifications), value: _enableNotifications, - onChanged: _notificationPermissionGranted + onChanged: _androidNotificationPermissionGranted ? (bool? value) async { setState(() { _enableNotifications = value!; @@ -109,43 +105,60 @@ class _NotificationElementsState extends State { subtitle: Text(AppLocalizations.of(context)!.activateToGetNotification), ), - SwitchListTile( + if (Platform.isAndroid) SwitchListTile( title: Text(AppLocalizations.of(context)!.persistentNotification), - value: _notificationsAreOngoing, - onChanged: _enableNotifications && _notificationPermissionGranted + value: _androidNotificationsOngoing, + onChanged: _enableNotifications && _androidNotificationPermissionGranted ? (bool? value) async { setState(() { - _notificationsAreOngoing = value!; + _androidNotificationsOngoing = value!; }); await globalStorage.write( key: StorageKey.settingsPushServiceOngoing, - value: _notificationsAreOngoing.toString()); + value: _androidNotificationsOngoing.toString()); } : null, ), - ListTile( + if (Platform.isAndroid) ListTile( title: Text(AppLocalizations.of(context)!.updateInterval), - trailing: Text('$_notificationInterval min', + trailing: Text('$_androidNotificationInterval min', style: const TextStyle(fontSize: 14)), - enabled: _enableNotifications && _notificationPermissionGranted, + enabled: _enableNotifications && _androidNotificationPermissionGranted, ), - Slider( - value: _notificationInterval.toDouble(), + if (Platform.isAndroid) Slider( + value: _androidNotificationInterval.toDouble(), min: 15, max: 180, - onChanged: _enableNotifications && _notificationPermissionGranted + onChanged: _enableNotifications && _androidNotificationPermissionGranted ? (double value) { setState(() { - _notificationInterval = value.toInt(); // Umwandlung zu int + _androidNotificationInterval = value.toInt(); // Umwandlung zu int }); } : null, onChangeEnd: (double value) async { await globalStorage.write( key: StorageKey.settingsPushServiceIntervall, - value: _notificationInterval.toString()); + value: _androidNotificationInterval.toString()); }, ), + if (Platform.isIOS) ListTile( + title: const Text("Time of day"), + subtitle: const Text("The time of day when the notifications are sent. Does only apply when the app is opened fairly often. "), + trailing: Text(_iOSUpdateTime.format(context), style: const TextStyle(fontSize: 20)), + onTap: () async { + final TimeOfDay? newTime = await showTimePicker( + context: context, + initialTime: _iOSUpdateTime, + ); + if (newTime != null) { + setState(() { + _iOSUpdateTime = newTime; + }); + } + globalStorage.write(key: StorageKey.settingsPushServiceIOSTime, value: "${_iOSUpdateTime.hour}:${_iOSUpdateTime.minute}"); + }, + ) ], ); } From 8aa5b4fdc57698ac7060f2869ea5452b4abb6439 Mon Sep 17 00:00:00 2001 From: Alessio Caputo <84250128+alessioC42@users.noreply.github.com> Date: Thu, 4 Jul 2024 17:23:37 +0200 Subject: [PATCH 4/7] disable notifications for first iOS app store release --- app/lib/background_service.dart | 1 - app/lib/main.dart | 6 ++- .../login/setup_screen_page_view_models.dart | 44 +++++++------------ app/lib/view/settings/settings.dart | 4 +- 4 files changed, 24 insertions(+), 31 deletions(-) diff --git a/app/lib/background_service.dart b/app/lib/background_service.dart index fdec6855..58b44fef 100644 --- a/app/lib/background_service.dart +++ b/app/lib/background_service.dart @@ -54,7 +54,6 @@ Future setupBackgroundService() async { } if (Platform.isIOS) { - logger.i("iOS detected, using one-off task"); try { String executionTime = await globalStorage.read( key: StorageKey.settingsPushServiceIOSTime); diff --git a/app/lib/main.dart b/app/lib/main.dart index bc2ec908..c6b01fa4 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -24,8 +24,10 @@ void main() async { runZonedGuarded>(() async { WidgetsFlutterBinding.ensureInitialized(); - await setupBackgroundService(); - await initializeNotifications(); + if (!Platform.isIOS) { + await setupBackgroundService(); + await initializeNotifications(); + } await initializeDateFormatting(); if (!kDebugMode && diff --git a/app/lib/view/login/setup_screen_page_view_models.dart b/app/lib/view/login/setup_screen_page_view_models.dart index a262d169..56a3f898 100644 --- a/app/lib/view/login/setup_screen_page_view_models.dart +++ b/app/lib/view/login/setup_screen_page_view_models.dart @@ -19,33 +19,23 @@ List setupScreenPageViewModels(BuildContext context) => [ height: 175.0), title: AppLocalizations.of(context)!.setupNonStudentTitle, body: AppLocalizations.of(context)!.setupNonStudent), - if (client.doesSupportFeature(SPHAppEnum.vertretungsplan)) ...[ - if (Platform.isIOS) - PageViewModel( - image: SvgPicture.asset( - "assets/undraw/undraw_new_notifications_re_xpcv.svg", - height: 175.0), - title: AppLocalizations.of(context)!.setupPushNotificationsTitle, - body: - "Benachrichtigungen werden für dich leider nicht unterstützt, da Apple es nicht ermöglicht, dass Apps periodisch im Hintergrund laufen. Du kannst aber die App öffnen, um zu sehen, ob es neue Vertretungen gibt."), - if (Platform.isAndroid) ...[ - PageViewModel( - image: SvgPicture.asset( - "assets/undraw/undraw_new_notifications_re_xpcv.svg", - height: 175.0), - title: AppLocalizations.of(context)!.setupPushNotificationsTitle, - body: AppLocalizations.of(context)!.setupPushNotifications), - PageViewModel( - image: SvgPicture.asset( - "assets/undraw/undraw_active_options_re_8rj3.svg", - height: 175.0), - title: AppLocalizations.of(context)!.setupPushNotificationsTitle, - bodyWidget: const Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [NotificationElements()], - )), - ] + if (client.doesSupportFeature(SPHAppEnum.vertretungsplan) && Platform.isAndroid) ...[ + PageViewModel( + image: SvgPicture.asset( + "assets/undraw/undraw_new_notifications_re_xpcv.svg", + height: 175.0), + title: AppLocalizations.of(context)!.setupPushNotificationsTitle, + body: AppLocalizations.of(context)!.setupPushNotifications), + PageViewModel( + image: SvgPicture.asset( + "assets/undraw/undraw_active_options_re_8rj3.svg", + height: 175.0), + title: AppLocalizations.of(context)!.setupPushNotificationsTitle, + bodyWidget: const Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [NotificationElements()], + )), ], PageViewModel( image: SvgPicture.asset("assets/undraw/undraw_add_color_re_buro.svg", diff --git a/app/lib/view/settings/settings.dart b/app/lib/view/settings/settings.dart index 41cf1c3e..8bf8fd53 100644 --- a/app/lib/view/settings/settings.dart +++ b/app/lib/view/settings/settings.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:sph_plan/view/settings/subsettings/about.dart'; import 'package:sph_plan/view/settings/subsettings/clear_cache.dart'; import 'package:sph_plan/view/settings/subsettings/countly_analysis.dart'; @@ -63,7 +65,7 @@ class _SettingsScreenState extends State { ); }, ), - if (client.doesSupportFeature(SPHAppEnum.vertretungsplan)) ...[ + if (client.doesSupportFeature(SPHAppEnum.vertretungsplan) && !Platform.isIOS) ...[ ListTile( leading: const Icon(Icons.notifications), title: Text(AppLocalizations.of(context)!.notifications), From b75ec39ce2a18ed862b1ba201541a64fa3e6b5dc Mon Sep 17 00:00:00 2001 From: Alessio Caputo <84250128+alessioC42@users.noreply.github.com> Date: Mon, 8 Jul 2024 16:00:29 +0200 Subject: [PATCH 5/7] chore: implement iOS background refresh (beta) --- .DS_Store | Bin 0 -> 8196 bytes app/ios/Podfile | 2 +- app/ios/Podfile.lock | 2 +- app/ios/Runner.xcodeproj/project.pbxproj | 13 +++++--- app/ios/Runner/AppDelegate.swift | 9 +++--- app/ios/Runner/Info.plist | 9 ++++-- app/lib/background_service.dart | 29 ++++++------------ app/lib/client/storage.dart | 5 --- app/lib/main.dart | 6 ++-- app/lib/view/settings/settings.dart | 4 +-- .../settings/subsettings/notifications.dart | 29 ++++-------------- app/pubspec.yaml | 7 +++-- 12 files changed, 45 insertions(+), 70 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4019d77ef2ca32f481b33a293601f722eb19b625 GIT binary patch literal 8196 zcmeHMO-~a+7=EX%vK4^^K{0Ah6}RgrsNv z0bWh~0mhqGe}LZf>cKzY#e*k(=c9Zqcrr02&P#Tl*_rq0yz^||-M$L|kV2z+9$*jv zEIb6-o!IPQVqP4(ETu=f5DD_381RZ0a1YPzUEXHEC}0#Y3K#{90!D#>Vb3$uRn z+*h-vH3}F7{!0bK{UL{kKu2Rsp}ciqBTE3p3=T`fzWnKrm=gntj>eWk96=+Bim0eW zml#A>4n)y$-qHRog^CVDu8g0fS0=hbA-eD&Iq43>QD|DDfKgz#0-|=$z*(q)4{@S? zAH%t`Kcz`xciRtKi19B(jt33wQHT=UHwkXXCifvSyq(_UM8f;PrnirgG2plsO>kZ$ zIBATk4)uiBkG;LTW>nX2D&BeZz>nhL;m;(M&g|RYVOdtEb+7c0H%qY_H==?YE{d~R z&I7OAcfC2EHp_PZIA^izGa5>QU!~}>bk}E9-YoD2t45MrQWI7-n=RXe>+7QpJU``MBng0T&e9Q_OJKsSJw1y$cOfB zzSwqqKiLDF2fMm^?B2dZz5Trd0|SST3?A(}cD!sK^4!WwEfjH%^MHqiph3%aPlUTI zRydmu=^E~GKBiCNlK%{+`Vxd0TD`wDH2buUKK)w*@?(_ zn6J+JxC;@vT(VSmD?Y2`uYFTGX7kQz)w3brme%TT_cW5cC;Aevl|mM9R>KkDZNV~r-6KrB0Ffn=BPIN{r)~$_gd&W?B;3Y0CwC@E>QlnI z8xsi=4%RTiDKs$|S1|=Eu!5c*qUsd22EqO|0 literal 0 HcmV?d00001 diff --git a/app/ios/Podfile b/app/ios/Podfile index 164df534..3e44f9c6 100644 --- a/app/ios/Podfile +++ b/app/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 9bc813dd..9bce4037 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -151,6 +151,6 @@ SPEC CHECKSUMS: url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 -PODFILE CHECKSUM: 7be2f5f74864d463a8ad433546ed1de7e0f29aef +PODFILE CHECKSUM: a57f30d18f102dd3ce366b1d62a55ecbef2158e5 COCOAPODS: 1.14.3 diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj index b9357869..19ebd9aa 100644 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -1,10 +1,10 @@ // !$*UTF8*$! { SystemCapabilities = { - com.apple.BackgroundModes = { - enabled = 1; - }; - }; + com.apple.BackgroundModes = { + enabled = 1; + }; + }; archiveVersion = 1; classes = { }; @@ -496,6 +496,7 @@ DEVELOPMENT_TEAM = DBR4MR8BAV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -503,6 +504,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.github.alessioc42.sph; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -681,6 +683,7 @@ DEVELOPMENT_TEAM = DBR4MR8BAV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -707,6 +710,7 @@ DEVELOPMENT_TEAM = DBR4MR8BAV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -714,6 +718,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.github.alessioc42.sph; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift index 0523f244..10964030 100644 --- a/app/ios/Runner/AppDelegate.swift +++ b/app/ios/Runner/AppDelegate.swift @@ -11,17 +11,18 @@ import flutter_local_notifications ) -> Bool { GeneratedPluginRegistrant.register(with: self) - if #available(iOS 10.0, *) { + if #available(iOS 13.0, *) { UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate } FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in GeneratedPluginRegistrant.register(with: registry) } - + WorkmanagerPlugin.registerTask(withIdentifier: "notificationservice") - // Set the minimum background fetch interval. - UIApplication.shared.setMinimumBackgroundFetchInterval(TimeInterval(60*40)) + + WorkmanagerPlugin.registerPeriodicTask(withIdentifier: "io.github.alessioc42.notificationservice", frequency: NSNumber(value: 30 * 60)) + UIApplication.shared.setMinimumBackgroundFetchInterval(TimeInterval(30 * 60)) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index a5afe27f..638594ab 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -5,8 +5,8 @@ BGTaskSchedulerPermittedIdentifiers notificationservice - io.github.alessioc42.notificationservice - + io.github.alessioc42.iOSBackgroundAppRefresh + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -41,11 +41,14 @@ UIBackgroundModes - fetch processing UILaunchStoryboardName LaunchScreen + ITSAppUsesNonExemptEncryption + + NSPhotoLibraryUsageDescription + When uploading a photo to the Lanis servers the app requires access to the users photos. UIMainStoryboardFile Main UIStatusBarHidden diff --git a/app/lib/background_service.dart b/app/lib/background_service.dart index 58b44fef..1819929a 100644 --- a/app/lib/background_service.dart +++ b/app/lib/background_service.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart' as crypto; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:intl/intl.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -34,6 +33,7 @@ Future setupBackgroundService() async { await Workmanager().initialize(callbackDispatcher, isInDebugMode: kDebugMode); const uniqueName = "notificationservice"; + const uniqueNameIOS = "io.github.alessioc42.notificationservice"; final constraints = Constraints( networkType: NetworkType.connected, requiresBatteryNotLow: true, @@ -55,24 +55,9 @@ Future setupBackgroundService() async { } if (Platform.isIOS) { try { - String executionTime = await globalStorage.read( - key: StorageKey.settingsPushServiceIOSTime); - final timeList = executionTime.split(":"); - TimeOfDay time = TimeOfDay(hour: int.parse(timeList[0]), minute: int.parse(timeList[1])); - DateTime now = DateTime.now(); - DateTime scheduledTime = DateTime(now.year, now.month, now.day, time.hour, time.minute); - - if (scheduledTime.isBefore(now)) { - scheduledTime = scheduledTime.add(const Duration(days: 1)); - } - - while (scheduledTime.weekday == DateTime.saturday || scheduledTime.weekday == DateTime.sunday) { - scheduledTime = scheduledTime.add(const Duration(days: 1)); - } - - await Workmanager().registerOneOffTask(uniqueName, uniqueName, - constraints: constraints, - initialDelay: scheduledTime.difference(now), + await Workmanager().registerPeriodicTask(uniqueNameIOS, uniqueNameIOS, + frequency: const Duration(days: 1), + constraints: constraints, ); } catch (e, s) { logger.e(e, stackTrace: s); @@ -81,6 +66,7 @@ Future setupBackgroundService() async { } Future initializeNotifications() async { + try { FlutterLocalNotificationsPlugin().initialize( const InitializationSettings( android: AndroidInitializationSettings('@drawable/ic_launcher'), @@ -91,7 +77,9 @@ Future initializeNotifications() async { requestSoundPermission: true ) ), - ); + );} catch (e, s) { + logger.e(e, stackTrace: s); + } } void onDidReceiveLocalNotification( @@ -120,6 +108,7 @@ Future updateNotifications() async { await client.loadFromStorage(); if (client.username == "" || client.password == "") { logger.w("No credentials found, aborting background fetch"); + return; } await client.login(backgroundFetch: true); final vPlan = diff --git a/app/lib/client/storage.dart b/app/lib/client/storage.dart index f35a2019..63cf5447 100644 --- a/app/lib/client/storage.dart +++ b/app/lib/client/storage.dart @@ -5,7 +5,6 @@ enum StorageKey { settingsPushService, settingsPushServiceIntervall, settingsPushServiceOngoing, - settingsPushServiceIOSTime, settingsUseCountly, settingsSelectedColor, settingsSelectedTheme, @@ -36,8 +35,6 @@ extension on StorageKey { return "settings-push-service-on"; case StorageKey.settingsPushServiceIntervall: return "settings-push-service-interval"; - case StorageKey.settingsPushServiceIOSTime: - return "settings-push-service-ios-time"; case StorageKey.settingsPushServiceOngoing: return "settings-push-service-notifications-ongoing"; case StorageKey.settingsUseCountly: @@ -81,8 +78,6 @@ extension on StorageKey { return "true"; case StorageKey.settingsPushServiceIntervall: return "15"; - case StorageKey.settingsPushServiceIOSTime: - return "7:40"; case StorageKey.settingsUseCountly: return "true"; case StorageKey.lastAppVersion: diff --git a/app/lib/main.dart b/app/lib/main.dart index c6b01fa4..f00369ea 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -24,10 +24,8 @@ void main() async { runZonedGuarded>(() async { WidgetsFlutterBinding.ensureInitialized(); - if (!Platform.isIOS) { - await setupBackgroundService(); - await initializeNotifications(); - } + await initializeNotifications(); + await setupBackgroundService(); await initializeDateFormatting(); if (!kDebugMode && diff --git a/app/lib/view/settings/settings.dart b/app/lib/view/settings/settings.dart index 8bf8fd53..41cf1c3e 100644 --- a/app/lib/view/settings/settings.dart +++ b/app/lib/view/settings/settings.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:sph_plan/view/settings/subsettings/about.dart'; import 'package:sph_plan/view/settings/subsettings/clear_cache.dart'; import 'package:sph_plan/view/settings/subsettings/countly_analysis.dart'; @@ -65,7 +63,7 @@ class _SettingsScreenState extends State { ); }, ), - if (client.doesSupportFeature(SPHAppEnum.vertretungsplan) && !Platform.isIOS) ...[ + if (client.doesSupportFeature(SPHAppEnum.vertretungsplan)) ...[ ListTile( leading: const Icon(Icons.notifications), title: Text(AppLocalizations.of(context)!.notifications), diff --git a/app/lib/view/settings/subsettings/notifications.dart b/app/lib/view/settings/subsettings/notifications.dart index 6fcbc71e..1b5e49fd 100644 --- a/app/lib/view/settings/subsettings/notifications.dart +++ b/app/lib/view/settings/subsettings/notifications.dart @@ -38,8 +38,7 @@ class _NotificationElementsState extends State { bool _enableNotifications = true; int _androidNotificationInterval = 15; bool _androidNotificationsOngoing = false; - bool _androidNotificationPermissionGranted = false; - TimeOfDay _iOSUpdateTime = const TimeOfDay(hour: 7, minute: 40); + bool _androidNotificationPermissionGranted = true; Future loadSettingsVars() async { _enableNotifications = @@ -52,9 +51,6 @@ class _NotificationElementsState extends State { "true"; _androidNotificationPermissionGranted = await Permission.notification.isGranted; - final String timeString = await globalStorage.read(key: StorageKey.settingsPushServiceIOSTime); - final List timeList = timeString.split(":"); - _iOSUpdateTime = TimeOfDay(hour: int.parse(timeList[0]), minute: int.parse(timeList[1])); } @override @@ -67,7 +63,6 @@ class _NotificationElementsState extends State { _enableNotifications = _enableNotifications; _androidNotificationInterval = _androidNotificationInterval; _androidNotificationsOngoing = _androidNotificationsOngoing; - _iOSUpdateTime = _iOSUpdateTime; _androidNotificationPermissionGranted = _androidNotificationPermissionGranted; }); @@ -92,7 +87,7 @@ class _NotificationElementsState extends State { SwitchListTile( title: Text(AppLocalizations.of(context)!.pushNotifications), value: _enableNotifications, - onChanged: _androidNotificationPermissionGranted + onChanged: (_androidNotificationPermissionGranted || Platform.isIOS) ? (bool? value) async { setState(() { _enableNotifications = value!; @@ -142,22 +137,10 @@ class _NotificationElementsState extends State { value: _androidNotificationInterval.toString()); }, ), - if (Platform.isIOS) ListTile( - title: const Text("Time of day"), - subtitle: const Text("The time of day when the notifications are sent. Does only apply when the app is opened fairly often. "), - trailing: Text(_iOSUpdateTime.format(context), style: const TextStyle(fontSize: 20)), - onTap: () async { - final TimeOfDay? newTime = await showTimePicker( - context: context, - initialTime: _iOSUpdateTime, - ); - if (newTime != null) { - setState(() { - _iOSUpdateTime = newTime; - }); - } - globalStorage.write(key: StorageKey.settingsPushServiceIOSTime, value: "${_iOSUpdateTime.hour}:${_iOSUpdateTime.minute}"); - }, + if (Platform.isIOS) const ListTile( + title: Text("iOS Device"), + subtitle: Text("This feature is currently in public testing. Please be aware, that this feature may not work as expected. The update interval is a minimum of 30 minutes. An update every 30 minutes is not guaranteed."), + leading: Icon(Icons.info), ) ], ); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 54e043ed..71cae017 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,7 +14,7 @@ publish_to: 'none' # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.18.1+39 +version: 2.19.0+40 environment: sdk: '>=3.1.3 <4.0.0' @@ -33,7 +33,10 @@ dependencies: html: ^0.15.4 flutter_launcher_icons: ^0.13.1 flutter_local_notifications: ^17.1.2 - workmanager: ^0.5.2 + workmanager: # better solution is required! + git: + url: https://github.com/fluttercommunity/flutter_workmanager.git + ref: b783000 permission_handler: ^11.0.1 package_info_plus: ^8.0.0 encrypt: ^5.0.3 From 33e0254b06700bb4b1abad57900dbc7c11a35826 Mon Sep 17 00:00:00 2001 From: Alessio Caputo <84250128+alessioC42@users.noreply.github.com> Date: Mon, 8 Jul 2024 16:21:26 +0200 Subject: [PATCH 6/7] fix: background service not initializing due to logic error in refactoring --- app/lib/background_service.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/lib/background_service.dart b/app/lib/background_service.dart index 1819929a..f539a188 100644 --- a/app/lib/background_service.dart +++ b/app/lib/background_service.dart @@ -26,7 +26,7 @@ Future setupBackgroundService() async { await Permission.notification.request(); } }); - if ((notificationsPermissionStatus ?? PermissionStatus.granted).isGranted && enableNotifications) return; + if (!((notificationsPermissionStatus ?? PermissionStatus.granted).isGranted && enableNotifications)) return; await Workmanager().cancelAll(); @@ -51,7 +51,6 @@ Future setupBackgroundService() async { constraints: constraints, initialDelay: const Duration(minutes: 3) ); - } if (Platform.isIOS) { try { From 9c32036ba5a84b9e90b1de1681e4da12041f83c5 Mon Sep 17 00:00:00 2001 From: Alessio Caputo <84250128+alessioC42@users.noreply.github.com> Date: Mon, 8 Jul 2024 16:26:39 +0200 Subject: [PATCH 7/7] remove permission check on iOS --- app/lib/background_service.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/lib/background_service.dart b/app/lib/background_service.dart index f539a188..16100440 100644 --- a/app/lib/background_service.dart +++ b/app/lib/background_service.dart @@ -18,15 +18,18 @@ Future setupBackgroundService() async { await globalStorage.read(key: StorageKey.settingsPushService) == "true"; - PermissionStatus? notificationsPermissionStatus; - await Permission.notification.isDenied.then((value) async { - if (value) { - notificationsPermissionStatus = - await Permission.notification.request(); - } - }); - if (!((notificationsPermissionStatus ?? PermissionStatus.granted).isGranted && enableNotifications)) return; + if (Platform.isAndroid){ + PermissionStatus? notificationsPermissionStatus; + await Permission.notification.isDenied.then((value) async { + if (value) { + notificationsPermissionStatus = + await Permission.notification.request(); + } + }); + if (!(notificationsPermissionStatus ?? PermissionStatus.granted).isGranted) return; + } + if (!enableNotifications) return; await Workmanager().cancelAll();