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

Feature/notifications #1000

Merged
merged 16 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
17 changes: 14 additions & 3 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ if (keystorePropertiesFile.exists()) {

android {
namespace "com.hjiangsu.thunder"
compileSdkVersion flutter.compileSdkVersion
// Use variable for version requested by background_fetch and flutter_local_notifications
compileSdkVersion rootProject.ext.compileSdkVersion
ndkVersion flutter.ndkVersion

compileOptions {
// The following line is required by flutter_local_notifications
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
Expand All @@ -52,9 +55,12 @@ android {
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion 21
targetSdkVersion flutter.targetSdkVersion
// Use variable for version requested by background_fetch and flutter_local_notifications
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
// The following line is required by flutter_local_notifications
multiDexEnabled true
}

signingConfigs {
Expand All @@ -80,4 +86,9 @@ flutter {
source '../..'
}

dependencies {}
dependencies {
// The following 3 dependencies are required by flutter_local_notifications
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.window:window-java:1.0.0'
}
4 changes: 4 additions & 0 deletions android/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>

<!-- In Debug mode, request the EXACT_ALARM permission. -->
<!-- This allows us to run background_fetch at lower intervals for the sake of quicker testing. -->
<uses-permission android:minSdkVersion="34" android:name="android.permission.USE_EXACT_ALARM" />
</manifest>
6 changes: 5 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
<!-- The tools namespace is needed by background_fetch -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.baseflow.permissionhandler">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- The tools:replace line is needed by background_fetch -->
<application
tools:replace="android:label"
android:label="Thunder"
android:name="${applicationName}"
android:usesCleartextTraffic="true"
Expand Down
14 changes: 13 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
buildscript {
ext.kotlin_version = '1.7.10'
// Versions recommended/needed by flutter_local_notifications and background_fetch
// These can be upgraded over time.
ext {
compileSdkVersion = 33
targetSdkVersion = 33
appCompatVersion = "1.6.1"
}
repositories {
google()
mavenCentral()
}

dependencies {
classpath 'com.android.tools.build:gradle:7.3.0'
// flutter_local_notifications requires gradle version 7.3.1
classpath 'com.android.tools.build:gradle:7.3.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
Expand All @@ -15,6 +23,10 @@ allprojects {
repositories {
google()
mavenCentral()
// The following section is required by background_fetch
maven {
url "${project(':background_fetch').projectDir}/libs"
}
}
}

Expand Down
21 changes: 12 additions & 9 deletions lib/core/enums/full_name_separator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,28 @@ enum FullNameSeparator {
const FullNameSeparator(this.label);
}

String generateUserFullName(BuildContext context, name, instance) {
final ThunderState thunderState = context.read<ThunderBloc>().state;
return switch (thunderState.userSeparator) {
String generateUserFullName(BuildContext? context, name, instance, {FullNameSeparator? userSeparator}) {
assert(context != null || userSeparator != null);
userSeparator ??= context!.read<ThunderBloc>().state.userSeparator;
return switch (userSeparator) {
FullNameSeparator.dot => '$name · $instance',
FullNameSeparator.at => '$name@$instance',
};
}

String generateUserFullNameSuffix(BuildContext context, instance) {
final ThunderState thunderState = context.read<ThunderBloc>().state;
return switch (thunderState.userSeparator) {
String generateUserFullNameSuffix(BuildContext? context, instance, {FullNameSeparator? userSeparator}) {
assert(context != null || userSeparator != null);
userSeparator ??= context!.read<ThunderBloc>().state.userSeparator;
return switch (userSeparator) {
FullNameSeparator.dot => ' · $instance',
FullNameSeparator.at => '@$instance',
};
}

String generateCommunityFullName(BuildContext context, name, instance) {
final ThunderState thunderState = context.read<ThunderBloc>().state;
return switch (thunderState.communitySeparator) {
String generateCommunityFullName(BuildContext? context, name, instance, {FullNameSeparator? communitySeparator}) {
assert(context != null || communitySeparator != null);
communitySeparator ??= context!.read<ThunderBloc>().state.communitySeparator;
return switch (communitySeparator) {
FullNameSeparator.dot => '$name · $instance',
FullNameSeparator.at => '$name@$instance',
};
Expand Down
1 change: 1 addition & 0 deletions lib/core/enums/local_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ enum LocalSettings {
useDisplayNamesForUsers(name: 'setting_use_display_names_for_users', label: 'Show User Display Names'),
markPostAsReadOnMediaView(name: 'setting_general_mark_post_read_on_media_view', label: 'Mark Read After Viewing Media'),
showInAppUpdateNotification(name: 'setting_notifications_show_inapp_update', label: 'Get notified of new GitHub releases'),
enableInboxNotifications(name: 'setting_enable_inbox_notifications', label: ''),
scoreCounters(name: 'setting_score_counters', label: "Display User Scores"),
appLanguageCode(name: 'setting_app_language_code', label: 'App Language'),

Expand Down
28 changes: 28 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
"@back": {},
"backButton": "Back button",
"@backButton": {},
"backgroundCheckWarning": "Note that notification checks will consume additional battery",
"@backgroundCheckWarning": {
"description": "Warning for enabling notifications"
},
"backToTop": "Back To Top",
"@backToTop": {},
"blockCommunity": "Block Community",
Expand Down Expand Up @@ -285,6 +289,10 @@
"@dimReadPosts": {
"description": "Description of the effect on read posts."
},
"disable": "Disable",
"@disable": {
"description": "Action for disabling something"
},
"dismissRead": "Dismiss Read",
"@dismissRead": {},
"displayUserScore": "Display User Scores (Karma).",
Expand Down Expand Up @@ -317,6 +325,10 @@
"@enableFeedFab": {
"description": "Enable the Floating Action Button for the feed"
},
"enableInboxNotifications": "Enable Inbox Notifications (Experimental)",
"@enableInboxNotifications": {
"description": "Setting name for inbox notifications"
},
"enablePostFab": "Enable Floating Button on Posts",
"@enablePostFab": {
"description": "Enable the Floating Action Button for the post"
Expand Down Expand Up @@ -587,6 +599,14 @@
"@notificationsBehaviourSettings": {
"description": "Subcategory in Setting -> General"
},
"notificationsNotAllowed": "Notifications are not allowed for Thunder in system settings",
"@notificationsNotAllowed": {
"description": "Description for when notifications are now allowed for app"
},
"notificationsWarningDialog": "Notifications are an experimental feature which may not function correctly on all devices.\n\n· Checks will occur every ~15 minutes and will consume additional battery.\n\n· Disable battery optimizations for a higher likelihood of successful notifications.\n\nSee the following page for more information.",
"@notificationsWarningDialog": {
"description": "The content of the warning dialog for the notifications feature"
},
"off": "off",
"@off": {},
"ok": "OK",
Expand Down Expand Up @@ -965,6 +985,10 @@
"@unblockInstance": {
"description": "Tooltip for unblocking an instance"
},
"understandEnable": "I Understand, Enable",
"@understandEnable": {
"description": "Action for acknowledging and enabling something"
},
"unexpectedError": "Unexpected Error",
"@unexpectedError": {},
"unsubscribe": "Unsubscribe",
Expand Down Expand Up @@ -1017,6 +1041,10 @@
"@visitInstance": {},
"visitUserProfile": "Visit User Profile",
"@visitUserProfile": {},
"warning": "Warning",
"@warning": {
"description": "Heading for warning dialogs"
},
"xDownvotes": "{x} downvotes",
"@xDownvotes": {},
"xScore": "{x} score",
Expand Down
114 changes: 114 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import 'dart:async';
import 'dart:io';

import 'package:background_fetch/background_fetch.dart';
import 'package:dart_ping_ios/dart_ping_ios.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// External Packages
import 'package:flutter_bloc/flutter_bloc.dart';
import "package:flutter_displaymode/flutter_displaymode.dart";
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:l10n_esperanto/l10n_esperanto.dart';
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:shared_preferences/shared_preferences.dart';
import 'package:thunder/account/bloc/account_bloc.dart';
import 'package:thunder/community/bloc/anonymous_subscriptions_bloc.dart';
import 'package:thunder/community/bloc/community_bloc.dart';
Expand All @@ -31,6 +35,7 @@ import 'package:thunder/thunder/thunder.dart';
import 'package:thunder/user/bloc/user_bloc.dart';
import 'package:thunder/utils/global_context.dart';
import 'package:flutter/foundation.dart';
import 'package:thunder/utils/notifications.dart';

void main() async {
WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
Expand All @@ -47,12 +52,38 @@ void main() async {
DartPingIOS.register();
}

// 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: onDidReceiveNotificationResponse);

// 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();
if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails!.notificationResponse?.payload == repliesGroupKey) {
thunderPageController = PageController(initialPage: 3);
}

// Initialize background fetch (this is async and can go run on its own).
final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences;
if (!kIsWeb && Platform.isAndroid && (prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false)) {
initBackgroundFetch();
}

final String initialInstance = (await UserPreferences.instance).sharedPreferences.getString(LocalSettings.currentAnonymousInstance.name) ?? 'lemmy.ml';
LemmyClient.instance.changeBaseUrl(initialInstance);

runApp(const ThunderApp());

// Set high refresh rate after app initialization
FlutterDisplayMode.setHighRefreshRate();

// Register to receive BackgroundFetch events after app is terminated.
if (!kIsWeb && Platform.isAndroid && (prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false)) {
initHeadlessBackgroundFetch();
}
}

class ThunderApp extends StatelessWidget {
Expand Down Expand Up @@ -168,3 +199,86 @@ class ThunderApp extends StatelessWidget {
);
}
}

// ---------------- START LOCAL NOTIFICATION STUFF ---------------- //

/// This seems to a notification callback handler that is only need for iOS.
void onDidReceiveLocalNotification(int id, String? title, String? body, String? payload) {}

/// This is the notification handler that runs when a notification is tapped while the app is running.
void onDidReceiveNotificationResponse(NotificationResponse details) {
switch (details.payload) {
case repliesGroupKey:
// Navigate to the inbox page
thunderPageController.jumpToPage(3);
break;
default:
break;
}
}

// ---------------- END LOCAL NOTIFICATION STUFF ---------------- //

// ---------------- START BACKGROUND FETCH STUFF ---------------- //

/// This method handles "headless" callbacks,
/// i.e., whent the app is not running
@pragma('vm:entry-point')
void backgroundFetchHeadlessTask(HeadlessTask task) async {
if (task.timeout) {
BackgroundFetch.finish(task.taskId);
return;
}
// Run the poll!
await pollRepliesAndShowNotifications();
BackgroundFetch.finish(task.taskId);
}

/// The method initializes background fetching while the app is running
Future<void> initBackgroundFetch() async {
await BackgroundFetch.configure(
BackgroundFetchConfig(
minimumFetchInterval: 15,
stopOnTerminate: false,
startOnBoot: true,
enableHeadless: true,
requiredNetworkType: NetworkType.NONE,
requiresBatteryNotLow: false,
requiresStorageNotLow: false,
requiresCharging: false,
requiresDeviceIdle: false,
// Uncomment this line (and set the minimumFetchInterval to 1) for quicker testing.
//forceAlarmManager: true,
),
// This is the callback that handles background fetching while the app is running.
(String taskId) async {
// Run the poll!
await pollRepliesAndShowNotifications();
BackgroundFetch.finish(taskId);
},
// This is the timeout callback.
(String taskId) async {
BackgroundFetch.finish(taskId);
},
);
}

void disableBackgroundFetch() async {
await BackgroundFetch.configure(
BackgroundFetchConfig(
minimumFetchInterval: 15,
stopOnTerminate: true,
startOnBoot: false,
enableHeadless: false,
),
() {},
() {},
);
}

// This method initializes background fetching while the app is not running
void initHeadlessBackgroundFetch() async {
BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask);
}

// ---------------- END BACKGROUND FETCH STUFF ---------------- //
6 changes: 4 additions & 2 deletions lib/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,21 @@ import 'package:thunder/settings/settings.dart';
import 'package:thunder/thunder/thunder.dart';
import 'package:thunder/user/pages/user_settings_page.dart';

PageController thunderPageController = PageController(initialPage: 0);

final GoRouter router = GoRouter(
debugLogDiagnostics: true,
routes: <GoRoute>[
GoRoute(
name: 'home',
path: '/',
builder: (BuildContext context, GoRouterState state) => const Thunder(),
builder: (BuildContext context, GoRouterState state) => Thunder(pageController: thunderPageController),
routes: const <GoRoute>[],
),
GoRoute(
name: 'settings',
path: '/settings',
builder: (BuildContext context, GoRouterState state) => SettingsPage(),
builder: (BuildContext context, GoRouterState state) => const SettingsPage(),
routes: <GoRoute>[
GoRoute(
name: 'general',
Expand Down
Loading
Loading