diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2c1b2f11b..cf78c923f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -31,6 +31,8 @@ PODS: - FlutterMacOS - permission_handler_apple (9.1.1): - Flutter + - push_ios (0.0.1): + - Flutter - receive_sharing_intent (0.0.1): - Flutter - share_plus (0.0.1): @@ -63,6 +65,7 @@ DEPENDENCIES: - 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`) + - push_ios (from `.symlinks/plugins/push_ios/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -104,6 +107,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + push_ios: + :path: ".symlinks/plugins/push_ios/ios" receive_sharing_intent: :path: ".symlinks/plugins/receive_sharing_intent/ios" share_plus: @@ -135,6 +140,7 @@ SPEC CHECKSUMS: package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + push_ios: 2bd1b4d3f782209da1f571db1250af236957e807 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 8c79c744c..fe055f6bf 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ D458C5762B0D6F7D0090D826 /* popup.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = popup.html; sourceTree = ""; }; D458C5782B0D6F7D0090D826 /* popup.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = popup.css; sourceTree = ""; }; D458C57C2B0D6F7D0090D826 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D479D40B2B6812110016407E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; EBDAACB2112528B97EF7E9C7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FC3DF76093C9AE1242276289 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; FD47FFE0663417A4488AFA02 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -183,6 +184,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + D479D40B2B6812110016407E /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -585,6 +587,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = L7P596HY6P; ENABLE_BITCODE = NO; @@ -771,6 +774,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = L7P596HY6P; ENABLE_BITCODE = NO; @@ -795,6 +799,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = L7P596HY6P; ENABLE_BITCODE = NO; diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 000000000..903def2af --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/lib/main.dart b/lib/main.dart index 64a8eef50..08087718a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'package:overlay_support/overlay_support.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:push/push.dart' as push; import 'package:shared_preferences/shared_preferences.dart'; import 'package:thunder/account/bloc/account_bloc.dart'; import 'package:thunder/community/bloc/anonymous_subscriptions_bloc.dart'; @@ -60,50 +61,123 @@ void main() async { DartPingIOS.register(); } + // Set up initial instance + final String initialInstance = (await UserPreferences.instance).sharedPreferences.getString(LocalSettings.currentAnonymousInstance.name) ?? 'lemmy.ml'; + LemmyClient.instance.changeBaseUrl(initialInstance); + + runApp(ThunderApp(prefs: prefs)); + + if (!kIsWeb && Platform.isAndroid) { + // Set high refresh rate after app initialization + FlutterDisplayMode.setHighRefreshRate(); + } +} + +class ThunderApp extends StatefulWidget { + final SharedPreferences prefs; + + const ThunderApp({super.key, required this.prefs}); + + @override + State createState() => _ThunderAppState(); +} + +class _ThunderAppState extends State { /// Allows the top-level notification handlers to trigger actions farther down final StreamController notificationsStreamController = StreamController(); - if (!kIsWeb && Platform.isAndroid) { - // Initialize local notifications. Note that this doesn't request permissions or actually send any notifications. - // It's just hooking up callbacks and settings. - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - // Initialize the Android-specific settings, using the splash asset as the notification icon. - const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('splash'); - const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); - await flutterLocalNotificationsPlugin.initialize(initializationSettings, onDidReceiveNotificationResponse: (notificationResponse) => notificationsStreamController.add(notificationResponse)); + /// Stream which listens for incoming notification taps + late StreamSubscription onNotificationTapSubscription; + + /// Stream which listens for new tokens for push notifications + late StreamSubscription onNewTokenSubscription; + /// Initialize Android specific notification logic. This is only called when the app is running on Android. + void initAndroidNotificationLogic() async { // See if Thunder is launching because a notification was tapped. If so, we want to jump right to the appropriate page. - final NotificationAppLaunchDetails? notificationAppLaunchDetails = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); + final NotificationAppLaunchDetails? notificationAppLaunchDetails = await FlutterLocalNotificationsPlugin().getNotificationAppLaunchDetails(); + if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails!.notificationResponse != null) { notificationsStreamController.add(notificationAppLaunchDetails.notificationResponse!); } - // Initialize background fetch (this is async and can go run on its own). - if (prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false) { - initBackgroundFetch(); - } + // Register to receive BackgroundFetch events after app is terminated. + initHeadlessBackgroundFetch(); } - final String initialInstance = (await UserPreferences.instance).sharedPreferences.getString(LocalSettings.currentAnonymousInstance.name) ?? 'lemmy.ml'; - LemmyClient.instance.changeBaseUrl(initialInstance); + /// Initialize iOS specific notification logic. This is only called when the app is running on iOS. + void initIOSNotificationLogic() async { + final token = await push.Push.instance.token; + + /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. + debugPrint("Device token: $token"); + + // Handle new tokens generated from the device + onNewTokenSubscription = push.Push.instance.onNewToken.listen((token) { + /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. + debugPrint("Received new device token: $token"); + }); + + // Handle notification launching app from terminated state + push.Push.instance.notificationTapWhichLaunchedAppFromTerminated.then((data) { + if (data == null) return; + if (data.containsKey(repliesGroupKey)) { + notificationsStreamController.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + } + }); + + /// Handle notification taps. This triggers when the user taps on a notification when the app is on the foreground or background. + onNotificationTapSubscription = push.Push.instance.onNotificationTap.listen((data) { + debugPrint('Notification was tapped: Data: $data \n'); + + if (data.containsKey(repliesGroupKey)) { + notificationsStreamController.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + } + }); + } - runApp(ThunderApp(notificationsStream: notificationsStreamController.stream)); + /// Initializes shared notification logic for both Android and iOS + void initSharedNotificationLogic() async { + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - if (!kIsWeb && Platform.isAndroid) { - // Set high refresh rate after app initialization - FlutterDisplayMode.setHighRefreshRate(); + // Initialize the Android-specific settings, using the splash asset as the notification icon. + const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('splash'); + + // Initialize the iOS-specific settings. + const DarwinInitializationSettings initializationSettingsApple = DarwinInitializationSettings(requestAlertPermission: false, requestSoundPermission: false, requestBadgePermission: false); + + const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid, iOS: initializationSettingsApple); + await flutterLocalNotificationsPlugin.initialize(initializationSettings, onDidReceiveNotificationResponse: (notificationResponse) => notificationsStreamController.add(notificationResponse)); } - // Register to receive BackgroundFetch events after app is terminated. - if (!kIsWeb && Platform.isAndroid && (prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false)) { - initHeadlessBackgroundFetch(); + @override + void initState() { + super.initState(); + + if ((widget.prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false)) { + initSharedNotificationLogic(); + + if (Platform.isAndroid) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + initAndroidNotificationLogic(); + }); + } + + if (Platform.isIOS) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + initIOSNotificationLogic(); + }); + } + } } -} -class ThunderApp extends StatelessWidget { - final Stream notificationsStream; + @override + void dispose() { + onNewTokenSubscription.cancel(); + onNotificationTapSubscription.cancel(); - const ThunderApp({super.key, required this.notificationsStream}); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -122,7 +196,7 @@ class ThunderApp extends StatelessWidget { create: (context) => DeepLinksCubit(), ), BlocProvider( - create: (context) => NotificationsCubit(notificationsStream: notificationsStream), + create: (context) => NotificationsCubit(notificationsStream: notificationsStreamController.stream), ), BlocProvider( create: (context) => ThunderBloc(), diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index c1259dd4f..086bd918e 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -9,6 +9,7 @@ import 'package:android_intent_plus/android_intent.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:push/push.dart'; import 'package:thunder/core/enums/full_name_separator.dart'; import 'package:thunder/core/enums/local_settings.dart'; @@ -77,6 +78,9 @@ class _GeneralSettingsPageState extends State with SingleTi /// Not a setting, but tracks whether Android is allowing Thunder to send notifications bool? areAndroidNotificationsAllowed = false; + /// Not a setting, but tracks whether iOS is allowing Thunder to send notifications + UNNotificationSettings? areIOSNotificationsAllowed; + /// When enabled, authors and community names will be tappable when in compact view bool tappableAuthorCommunity = false; @@ -203,6 +207,12 @@ class _GeneralSettingsPageState extends State with SingleTi void _initPreferences() async { final prefs = (await UserPreferences.instance).sharedPreferences; + if (Platform.isIOS) { + Push.instance.getNotificationSettings().then((settings) => areIOSNotificationsAllowed = settings); + } else if (Platform.isAndroid) { + Push.instance.areNotificationsEnabled().then((areNotificationsEnabled) => areAndroidNotificationsAllowed = areNotificationsEnabled); + } + setState(() { // Default Sorts and Listing try { @@ -262,6 +272,8 @@ class _GeneralSettingsPageState extends State with SingleTi final theme = Theme.of(context); final l10n = AppLocalizations.of(context)!; + print(areIOSNotificationsAllowed); + return Scaffold( body: CustomScrollView( slivers: [ @@ -647,7 +659,7 @@ class _GeneralSettingsPageState extends State with SingleTi ), ), ), - if (!kIsWeb && Platform.isAndroid) + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), @@ -710,15 +722,27 @@ class _GeneralSettingsPageState extends State with SingleTi // This setState has no body because async operations aren't allowed, // but its purpose is to update areAndroidNotificationsAllowed. setState(() {}); + + if (value) { + // Ensure that background fetching is enabled. + initBackgroundFetch(); + initHeadlessBackgroundFetch(); + } else { + // Ensure that background fetching is disabled. + disableBackgroundFetch(); + } } - if (value) { - // Ensure that background fetching is enabled. - initBackgroundFetch(); - initHeadlessBackgroundFetch(); - } else { - // Ensure that background fetching is disabled. - disableBackgroundFetch(); + if (!kIsWeb && Platform.isIOS && value) { + // We're on iOS. Request notifications permissions if needed. + final IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); + + // This setState has no body because async operations aren't allowed, + // but its purpose is to update areIOSNotificationsAllowed. + setState(() {}); } }, subtitle: enableInboxNotifications diff --git a/pubspec.lock b/pubspec.lock index 02dba8b51..43195477c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1288,6 +1288,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" + push: + dependency: "direct main" + description: + name: push + sha256: "15f47b829ac0a0c4ae592ae5d7f1b57cf27f8637744a4f2fd5f4f5dd1563a0d0" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + push_android: + dependency: transitive + description: + name: push_android + sha256: e3ad795fc758363c5f1c1aab3af0fff10e50bd1e7cd23a51ed6cc3825673b107 + url: "https://pub.dev" + source: hosted + version: "0.5.0" + push_ios: + dependency: transitive + description: + name: push_ios + sha256: c6a284994ef8a2e09c4e92c89317b14912b56cb74b31fb2efba7b9d2b8f4ff60 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + push_macos: + dependency: transitive + description: + name: push_macos + sha256: "800997d1d6ca19aa957ab237a25074a732a7e36ef917ef4546fc75ec9aa1b9da" + url: "https://pub.dev" + source: hosted + version: "0.0.1" + push_platform_interface: + dependency: transitive + description: + name: push_platform_interface + sha256: "709c98b6e33cb0d76aa1e9017d0b16c17c077d0d0035a7b63d41ba19822a805e" + url: "https://pub.dev" + source: hosted + version: "0.5.0" receive_sharing_intent: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d54a75e1f..3b82639e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -107,6 +107,7 @@ dependencies: flutter_local_notifications: ^16.2.0 background_fetch: ^1.2.1 gal: ^2.2.0 + push: ^2.1.0 dev_dependencies: build_runner: ^2.4.6