Skip to content

Commit

Permalink
[flutter_local_notifications ] Notification actions for iOS, macOS, A…
Browse files Browse the repository at this point in the history
…ndroid (MaikuB#880)

* WIP - engine starts and executes dispatcher

* Ensure Dart side can receive notification callback in a simple handler with a ID parameter

* iOS implementation

* Update tests

* Configure action icons

* Support text actions on iOS & Android

* Support additional properties to fine-tune the notification action display

* Docs & cleanups

* typo

* submit notification payload in action callback as well

* Commit objc and swift formatting

* Google Java Format

* Clang Format

* Fix notification payload not updating on android

* Add notification id to background handler

Google Java Format

* Fix clashing request codes

* Replace result param usage with completion handler

* Improve example project

* xcode version update

* Clang Format

* Adds custom color support for Android action labels

* Adds basic macos support

* Swift Format

* Clang Format

* Unit test on Android

* CI update to run android unit tests

* use symlinks

* Google Java Format

* add action handler support for macOS

* Swift Format

* update example app to demonstrate usage of IsolateNameServer so action can trigger navigation

* Clang Format

* Clang Format

* restore link

* Formatting

* WIP

* Restore iOS' FlutterEngineManager

* formatting

* apply compat annotaiton suggestion

* Some docs about notification categories on mac/ios

* restore symlink

* Ensure to map options properly, add test

* add url_launcher example

* Fix Dart tests

* attempt to fix android unit tests

* Attempt to speed up integration test task

* only build debug app on android

* Add new `cancelNotification` flag, add various tests & refactor a bit

* Google Java Format

* ensure text input works on macos

* Swift Format

Co-authored-by: Pieter van Loon <[email protected]>
Co-authored-by: github-actions <>
Co-authored-by: runner <[email protected]>
Co-authored-by: runner <[email protected]>
Co-authored-by: runner <[email protected]>
Co-authored-by: runner <[email protected]>
Co-authored-by: runner <[email protected]>
Co-authored-by: Michael Bui <[email protected]>
Co-authored-by: runner <[email protected]>
Co-authored-by: runner <[email protected]>
Co-authored-by: runner <[email protected]>
  • Loading branch information
11 people committed Jan 3, 2022
1 parent 794c121 commit 8e1ad8e
Show file tree
Hide file tree
Showing 66 changed files with 2,755 additions and 285 deletions.
15 changes: 14 additions & 1 deletion .cirrus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ task:
- melos run build:example_linux

task:
name: Run all unit tests
name: Run all unit tests (Dart)
container:
image: cirrusci/flutter:stable
install_melos_script:
Expand All @@ -73,6 +73,17 @@ task:
- melos bootstrap
- melos run test:unit --no-select

task:
name: Run all unit tests (Android)
container:
image: cirrusci/flutter:stable
install_melos_script:
- dart pub global activate melos
test_script:
- export PATH="$PATH":"$HOME/.pub-cache/bin"
- melos bootstrap
- melos run test:unit:android

task:
name: Run integration tests (Android)
env:
Expand All @@ -96,7 +107,9 @@ task:
android-wait-for-emulator
test_script:
- export PATH="$PATH":"$HOME/.pub-cache/bin"
- flutter precache
- melos bootstrap
- melos run build:example_android
- melos run test:integration
task:
name: Run integration tests (iOS)
Expand Down
125 changes: 125 additions & 0 deletions flutter_local_notifications/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ A cross platform plugin for displaying local notifications.
- [General setup](#general-setup)
- [Handling notifications whilst the app is in the foreground](#handling-notifications-whilst-the-app-is-in-the-foreground)
- **[❓ Usage](#-usage)**
- [Notification Actions](#notification-actions)
- [Example app](#example-app)
- [API reference](#api-reference)
- **[Initialisation](#initialisation)**
Expand Down Expand Up @@ -274,6 +275,130 @@ void onDidReceiveLocalNotification(
Before going on to copy-paste the code snippets in this section, double-check you have configured your application correctly.
If you encounter any issues please refer to the API docs and the sample code in the `example` directory before opening a request on Github.

### Notification Actions

Notifications can now contain actions. These actions may be selected by the user when a App is sleeping or terminated and will wake up your app. However, it may not wake up the user-visible part of your App; but only the part of it which runs in the background.

This plugin contains handlers for iOS & Android to handle these cases and will allow you to specify a Dart entry point (a function).

When the user selects a action, the plugin will start a **separate Flutter Engine** which only exists to execute this callback.

**Configuration**:

*Android* does not require any configuration.

*iOS* will require a few steps:

Adjust `AppDelegate.m` and set the plugin registrant callback:

Add this function anywhere in AppDelegate.m:
``` objc
void registerPlugins(NSObject<FlutterPluginRegistry>* registry) {
[GeneratedPluginRegistrant registerWithRegistry:registry];
}
```
Extend `didFinishLaunchingWithOptions` and register the callback:
``` objc
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[GeneratedPluginRegistrant registerWithRegistry:self];
// Add this method
[FlutterLocalNotificationsPlugin setPluginRegistrantCallback:registerPlugins];
}
```

iOS Notification actions need to be configured before the App is started, using the `initialize` method:

``` dart
final IOSInitializationSettings initializationSettingsIOS = IOSInitializationSettings(
// ...
notificationCategories: [
const IOSNotificationCategory(
'demoCategory',
<IOSNotificationAction>[
IOSNotificationAction('id_1', 'Action 1'),
IOSNotificationAction(
'id_2',
'Action 2',
options: <IOSNotificationActionOption>{
IOSNotificationActionOption.destructive,
},
),
IOSNotificationAction(
'id_3',
'Action 3',
options: <IOSNotificationActionOption>{
IOSNotificationActionOption.foreground,
},
),
],
options: <IOSNotificationCategoryOption>{
IOSNotificationCategoryOption.hiddenPreviewShowTitle,
},
)
],
```

On iOS, the notification category will define which actions are availble. On Android, you can put the actions directly in the Notification object.

**Usage**:

You need to configure a **top level**, **static** method which will handle the action:

``` dart
void notificationTapBackground(String id) {
print('notification action tapped: $id');
}
```

The passed `id` parameter is the same specified in `NotificationAction`. Remember this function runs in a separate isolate! You will need to use a different mechanism to communicate with the main App.

Accessing plugins will work; however in particular on Android there is **no** access to the `Activity` context which means some plugins (like `url_launcher`) will require additional flags to start the main `Activity` again.

Specify this function as a parameter in the `initialize` method of this plugin:

``` dart
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onSelectNotification: (String payload) async {
// ...
},
backgroundHandler: notificationTapBackground,
);
```

**Specifying Actions on notifications**:

The notification actions are platform specifics and you have to specify them differently for each platform.

On iOS, the actions are defined on a category, please see the configuration section for details.

On Android, the actions are configured directly on the notification.

``` dart
Future<void> _showNotificationWithActions() async {
const AndroidNotificationDetails androidPlatformChannelSpecifics =
AndroidNotificationDetails(
'...',
'...',
'...',
actions: <AndroidNotificationAction>[
AndroidNotificationAction('id_1', 'Action 1'),
AndroidNotificationAction('id_2', 'Action 2'),
AndroidNotificationAction('id_3', 'Action 3'),
],
);
const NotificationDetails platformChannelSpecifics =
NotificationDetails(android: androidPlatformChannelSpecifics);
await flutterLocalNotificationsPlugin.show(
0, '...', '...', platformChannelSpecifics);
}
```

Each notification will have a internal ID & an public Action title.

### Example app

The [`example`](https://github.com/MaikuB/flutter_local_notifications/tree/master/flutter_local_notifications/example) directory has a sample application that demonstrates the features of this plugin.
Expand Down
11 changes: 11 additions & 0 deletions flutter_local_notifications/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@ apply plugin: 'com.android.library'
android {
compileSdkVersion 31

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

defaultConfig {
minSdkVersion 16
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

lintOptions {
disable 'InvalidPackage'
}
Expand All @@ -38,4 +44,9 @@ dependencies {
implementation "androidx.media:media:1.1.0"
implementation "com.google.code.gson:gson:2.8.6"
implementation "com.jakewharton.threetenabp:threetenabp:1.2.3"

testImplementation 'junit:junit:4.12'
testImplementation 'org.mockito:mockito-core:3.10.0'
testImplementation 'androidx.test:core:1.2.0'
testImplementation "org.robolectric:robolectric:4.7.3"
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application>
<receiver android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.dexterous.flutterlocalnotifications;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Keep;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.RemoteInput;
import com.dexterous.flutterlocalnotifications.isolate.IsolatePreferences;
import io.flutter.FlutterInjector;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.embedding.engine.dart.DartExecutor;
import io.flutter.embedding.engine.loader.FlutterLoader;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.EventChannel.EventSink;
import io.flutter.plugin.common.EventChannel.StreamHandler;
import io.flutter.view.FlutterCallbackInformation;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class ActionBroadcastReceiver extends BroadcastReceiver {
@VisibleForTesting
ActionBroadcastReceiver(IsolatePreferences preferences) {
this.preferences = preferences;
}

@Keep
public ActionBroadcastReceiver() {}

IsolatePreferences preferences;

public static final String ACTION_TAPPED =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_TAPPED";
public static final String ACTION_ID = "actionId";
public static final String NOTIFICATION_ID = "notificationId";
private static final String INPUT = "input";

public static final String INPUT_RESULT = "FlutterLocalNotificationsPluginInputResult";

@Nullable private static ActionEventSink actionEventSink;

@Nullable private static FlutterEngine engine;

@Override
public void onReceive(Context context, Intent intent) {
if (!ACTION_TAPPED.equalsIgnoreCase(intent.getAction())) {
return;
}

preferences = preferences == null ? new IsolatePreferences(context) : preferences;

final Map<String, Object> action = new HashMap<>();
final int notificationId = intent.getIntExtra(NOTIFICATION_ID, -1);
action.put(NOTIFICATION_ID, notificationId);
action.put(
ACTION_ID, intent.hasExtra(ACTION_ID) ? intent.getStringExtra(ACTION_ID) : "unknown");
action.put(
FlutterLocalNotificationsPlugin.PAYLOAD,
intent.hasExtra(FlutterLocalNotificationsPlugin.PAYLOAD)
? intent.getStringExtra(FlutterLocalNotificationsPlugin.PAYLOAD)
: "");

Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
if (remoteInput != null) {
action.put(INPUT, remoteInput.getString(INPUT_RESULT));
} else {
action.put(INPUT, "");
}

if (intent.getBooleanExtra(FlutterLocalNotificationsPlugin.CANCEL_NOTIFICATION, false)) {
NotificationManagerCompat.from(context).cancel(notificationId);
}

if (actionEventSink == null) {
actionEventSink = new ActionEventSink();
}
actionEventSink.addItem(action);

startEngine(context);
}

private static class ActionEventSink implements StreamHandler {

final List<Map<String, Object>> cache = new ArrayList<>();

@Nullable private EventSink eventSink;

public void addItem(Map<String, Object> item) {
if (eventSink != null) {
eventSink.success(item);
} else {
cache.add(item);
}
}

@Override
public void onListen(Object arguments, EventSink events) {
for (Map<String, Object> item : cache) {
events.success(item);
}

cache.clear();
eventSink = events;
}

@Override
public void onCancel(Object arguments) {
eventSink = null;
}
}

private void startEngine(Context context) {
FlutterCallbackInformation dispatcherHandle = preferences.lookupDispatcherHandle();

if (dispatcherHandle != null && engine == null) {
FlutterInjector injector = FlutterInjector.instance();
FlutterLoader loader = injector.flutterLoader();

loader.startInitialization(context);
loader.ensureInitializationComplete(context, null);

engine = new FlutterEngine(context);

String dartBundlePath = loader.findAppBundlePath();

EventChannel channel =
new EventChannel(
engine.getDartExecutor().getBinaryMessenger(),
"dexterous.com/flutter/local_notifications/actions");

channel.setStreamHandler(actionEventSink);

engine
.getDartExecutor()
.executeDartCallback(
new DartExecutor.DartCallback(context.getAssets(), dartBundlePath, dispatcherHandle));
}
}
}
Loading

0 comments on commit 8e1ad8e

Please sign in to comment.