Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DRAFT: Add initial implementation for iOS push notifications using APNs #1095

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
D458C5762B0D6F7D0090D826 /* popup.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = popup.html; sourceTree = "<group>"; };
D458C5782B0D6F7D0090D826 /* popup.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = popup.css; sourceTree = "<group>"; };
D458C57C2B0D6F7D0090D826 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D479D40B2B6812110016407E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
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 = "<group>"; };
FD47FFE0663417A4488AFA02 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -183,6 +184,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
D479D40B2B6812110016407E /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
128 changes: 101 additions & 27 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ThunderApp> createState() => _ThunderAppState();
}

class _ThunderAppState extends State<ThunderApp> {
/// Allows the top-level notification handlers to trigger actions farther down
final StreamController<NotificationResponse> notificationsStreamController = StreamController<NotificationResponse>();

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<NotificationResponse> notificationsStream;
@override
void dispose() {
onNewTokenSubscription.cancel();
onNotificationTapSubscription.cancel();

const ThunderApp({super.key, required this.notificationsStream});
super.dispose();
}

@override
Widget build(BuildContext context) {
Expand All @@ -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(),
Expand Down
40 changes: 32 additions & 8 deletions lib/settings/pages/general_settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -77,6 +78,9 @@ class _GeneralSettingsPageState extends State<GeneralSettingsPage> 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;

Expand Down Expand Up @@ -203,6 +207,12 @@ class _GeneralSettingsPageState extends State<GeneralSettingsPage> 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 {
Expand Down Expand Up @@ -262,6 +272,8 @@ class _GeneralSettingsPageState extends State<GeneralSettingsPage> with SingleTi
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;

print(areIOSNotificationsAllowed);

return Scaffold(
body: CustomScrollView(
slivers: [
Expand Down Expand Up @@ -647,7 +659,7 @@ class _GeneralSettingsPageState extends State<GeneralSettingsPage> with SingleTi
),
),
),
if (!kIsWeb && Platform.isAndroid)
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
Expand Down Expand Up @@ -710,15 +722,27 @@ class _GeneralSettingsPageState extends State<GeneralSettingsPage> 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<IOSFlutterLocalNotificationsPlugin>();

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
Expand Down
40 changes: 40 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading