()!!
232 | registerEventHandle(mContext, cbId)
233 | return result.success(true)
234 | }
235 | // TODO: register handle with filter
236 | "setFilter" -> {
237 | // TODO
238 | }
239 | else -> result.notImplemented()
240 | }
241 | }
242 | }
243 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # flutter_notification_listener
4 |
5 | [](https://pub.dartlang.org/packages/flutter_notification_listener)
6 | [](https://pub.dev/packages/flutter_notification_listener/score)
7 | [](https://pub.dev/packages/flutter_notification_listener/score)
8 | [](https://pub.dev/packages/flutter_notification_listener/score)
9 | [](https://github.com/jiusanzhou/flutter_notification_listener/blob/master/LICENSE)
10 |
11 | Flutter plugin to listen for all incoming notifications for Android.
12 |
13 |
14 |
15 | ---
16 |
17 | ## Features
18 |
19 | - **Service**: start a service to listen the notifications.
20 | - **Simple**: it's simple to access notification's fields.
21 | - **Backgrounded**: execute the dart code in the background and auto start the service after reboot.
22 | - **Interactive**: the notification is interactive in flutter.
23 |
24 | ## Installtion
25 |
26 | Open the `pubspec.yaml` file located inside the app folder, and add `flutter_notification_listener`: under `dependencies`.
27 | ```yaml
28 | dependencies:
29 | flutter_notification_listener:
30 | ```
31 |
32 | The latest version is
33 | [](https://pub.dartlang.org/packages/flutter_notification_listener)
34 |
35 | Then you should install it,
36 | - From the terminal: Run `flutter pub get`.
37 | - From Android Studio/IntelliJ: Click Packages get in the action ribbon at the top of `pubspec.yaml`.
38 | - From VS Code: Click Get Packages located in right side of the action ribbon at the top of `pubspec.yaml`.
39 |
40 | ## Quick Start
41 |
42 | **1. Register the service in the manifest**
43 |
44 | The plugin uses an Android system service to track notifications. To allow this service to run on your application, the following code should be put inside the Android manifest, between the `application` tags.
45 |
46 | ```xml
47 |
50 |
51 |
52 |
53 |
54 | ```
55 |
56 | And don't forget to add the permissions to the manifest,
57 | ```xml
58 |
59 |
60 | ```
61 |
62 | **2. Init the plugin and add listen handler**
63 |
64 | We have a default static event handler which send event with a channel.
65 | So if you can listen the event in the ui logic simply.
66 |
67 | ```dart
68 | // define the handler for ui
69 | void onData(NotificationEvent event) {
70 | print(event.toString());
71 | }
72 |
73 | Future initPlatformState() async {
74 | NotificationsListener.initialize();
75 | // register you event handler in the ui logic.
76 | NotificationsListener.receivePort.listen((evt) => onData(evt));
77 | }
78 | ```
79 |
80 | **3. Check permission and start the service**
81 |
82 | ```dart
83 | void startListening() async {
84 | print("start listening");
85 | var hasPermission = await NotificationsListener.hasPermission;
86 | if (!hasPermission) {
87 | print("no permission, so open settings");
88 | NotificationsListener.openPermissionSettings();
89 | return;
90 | }
91 |
92 | var isR = await NotificationsListener.isRunning;
93 |
94 | if (!isR) {
95 | await NotificationsListener.startService();
96 | }
97 |
98 | setState(() => started = true);
99 | }
100 | ```
101 |
102 | ---
103 |
104 | Please check the [./example/lib/main.dart](./example/lib/main.dart) for more detail.
105 |
106 | ## Usage
107 |
108 | ### Start the service after reboot
109 |
110 | It's every useful while you want to start listening notifications automatically after reboot.
111 |
112 | Register a broadcast receiver in the `AndroidManifest.xml`,
113 | ```xml
114 |
116 |
117 |
118 |
119 |
120 | ```
121 |
122 | Then the listening service will start automatically when the system fired the `BOOT_COMPLETED` intent.
123 |
124 |
125 | And don't forget to add the permissions to the manifest,
126 | ```xml
127 |
128 |
129 |
130 |
131 | ```
132 |
133 | ### Execute task without UI thread
134 |
135 | > You should know that the function `(evt) => onData(evt)` would **not be called** if the ui thread is not running.
136 |
137 | **:warning: It's recommended that you should register your own static function `callbackHandle` to handle the event which make sure events consumed.**
138 |
139 | That means the `callbackHandle` static function is guaranteed, while the channel handle function is not. This is every useful when you should persist the events to the database.
140 |
141 | > For Flutter 3.x:
142 | Annotate the _callback function with `@pragma('vm:entry-point')` to prevent Flutter from stripping out this function on services.
143 |
144 | We want to run some code in background without UI thread, like persist the notifications to database or storage.
145 |
146 | 1. Define your own callback to handle the incoming notifications.
147 | ```dart
148 | @pragma('vm:entry-point')
149 | static void _callback(NotificationEvent evt) {
150 | // persist data immediately
151 | db.save(evt)
152 |
153 | // send data to ui thread if necessary.
154 | // try to send the event to ui
155 | print("send evt to ui: $evt");
156 | final SendPort send = IsolateNameServer.lookupPortByName("_listener_");
157 | if (send == null) print("can't find the sender");
158 | send?.send(evt);
159 | }
160 | ```
161 |
162 | 2. Register the handler when invoke the `initialize`.
163 | ```dart
164 | Future initPlatformState() async {
165 | // register the static to handle the events
166 | NotificationsListener.initialize(callbackHandle: _callback);
167 | }
168 | ```
169 |
170 | 3. Listen events in the UI thread if necessary.
171 | ```dart
172 | // define the handler for ui
173 | void onData(NotificationEvent event) {
174 | print(event.toString());
175 | }
176 |
177 | Future initPlatformState() async {
178 | // ...
179 | // register you event handler in the ui logic.
180 | NotificationsListener.receivePort.listen((evt) => onData(evt));
181 | }
182 | ```
183 |
184 | ### Change notification of listening service
185 |
186 | Before you start the listening service, you can offer some parameters.
187 | ```dart
188 | await NotificationsListener.startService({
189 | bool foreground = true, // use false will not promote to foreground and without a notification
190 | String title = "Change the title",
191 | String description = "Change the text",
192 | });
193 | ```
194 |
195 | ### Tap the notification
196 |
197 | We can tap the notification if it can be triggered in the flutter side.
198 |
199 |
200 | For example, tap the notification automatically when the notification arrived.
201 |
202 | ```dart
203 | // define the handler for ui
204 | void onData(NotificationEvent event) {
205 | print(event.toString());
206 | // tap the notification automatically
207 | // usually remove the notification
208 | if (event.canTap) event.tap();
209 | }
210 | ```
211 |
212 | ### Tap action of the notification
213 |
214 | The notifications from some applications will setted the actions.
215 | We can interact with the notificaions in the flutter side.
216 |
217 | For example, make the notification as readed automatically when the notification arrived.
218 |
219 | ```dart
220 | // define the handler for ui
221 | void onData(NotificationEvent event) {
222 | print(event.toString());
223 |
224 | events.actions.forEach(act => {
225 | // semantic code is 2 means this is an ignore action
226 | if (act.semantic == 2) {
227 | act.tap();
228 | }
229 | })
230 | }
231 | ```
232 |
233 | ### Reply to conversation of the notification
234 |
235 | Android provider a quick replying method in the notification.
236 | So we can use this to implement a reply logic in the flutter.
237 |
238 | For example, reply to the conversation automatically when the notification arrived.
239 |
240 | ```dart
241 | // define the handler for ui
242 | void onData(NotificationEvent event) {
243 | print(event.toString());
244 |
245 | events.actions.forEach(act => {
246 | // semantic is 1 means reply quick
247 | if (act.semantic == 1) {
248 | Map map = {};
249 | act.inputs.forEach((e) {
250 | print("set inputs: ${e.label}<${e.resultKey}>");
251 | map[e.resultKey] = "Auto reply from flutter";
252 | });
253 |
254 | // send to the data
255 | act.postInputs(map);
256 | }
257 | })
258 | }
259 | ```
260 |
261 | ## API Reference
262 |
263 | ### Object `NotificationEvent`
264 |
265 | Fields of `NotificationEvent`:
266 | - `uniqueId`: `String`, unique id of the notification which generated from `key`.
267 | - `key`: `String`, key of the status bar notification, required android sdk >= 20.
268 | - `packageName`: `String`, package name of the application which notification posted by.
269 | - `uid`: `int`, uid of the notification, required android sdk >= 29.
270 | - `channelId`: `String` channel if of the notification, required android sdk >= 26.
271 | - `id`: `int`, id of the notification.
272 | - `createAt`: `DateTime`, created time of the notfication in the flutter side.
273 | - `timestamp`: `int`, post time of the notfication.
274 | - `title`: `title`, title of the notification.
275 | - `text`: `String`, text of the notification.
276 | - `hasLargeIcon`: `bool`, if this notification has a large icon.
277 | - `largeIcon`: `Uint8List`, large icon of the notification which setted by setLargeIcon. To display as a image use the Image.memory widget.
278 | - `canTap`: `bool`, if this notification has content pending intent.
279 | - `raw`: `Map`, the original map of this notification, you can get all fields.
280 |
281 | Other original fields in `raw` which not assgin to the class:
282 | - `subText`: `String`, subText of the notification.
283 | - `summaryText`: `String`, summaryText of the notification.
284 | - `textLines`: `List`, multi text lines of the notification.
285 | - `showWhen`: `bool`, if show the time of the notification.
286 |
287 | Methods for notification:
288 | - `Future tap()`: tap the notification if it can be triggered, you should check `canTap` first. Normally will clean up the notification.
289 | - `Future getFull()`: get the full notification object from android.
290 |
291 | ### Object `Action`
292 |
293 | Fields of `Action`:
294 | - `id`: `int`, the index of the action in the actions array
295 | - `title`: `String`, title of the action
296 | - `semantic`: `int`, semantic type of the action, check below for details
297 | - `inputs`: `ActionInput`, emote inputs list of the action
298 |
299 | Action's semantic types:
300 | ```
301 | SEMANTIC_ACTION_ARCHIVE = 5;
302 | SEMANTIC_ACTION_CALL = 10;
303 | SEMANTIC_ACTION_DELETE = 4;
304 | SEMANTIC_ACTION_MARK_AS_READ = 2;
305 | SEMANTIC_ACTION_MARK_AS_UNREAD = 3;
306 | SEMANTIC_ACTION_MUTE = 6;
307 | SEMANTIC_ACTION_NONE = 0;
308 | SEMANTIC_ACTION_REPLY = 1;
309 | SEMANTIC_ACTION_THUMBS_DOWN = 9;
310 | SEMANTIC_ACTION_THUMBS_UP = 8;
311 | SEMANTIC_ACTION_UNMUTE = 7;
312 | ```
313 |
314 | For more details, please see [Notification.Action Constants](https://developer.android.com/reference/android/app/Notification.Action#constants_1).
315 |
316 |
317 | Methods of `Action`:
318 | - `Future tap()`: tap the action of the notification. If action's semantic code is `1`, it can't be tapped.
319 | - `Future postInputs(Map map)`: post inputs to the notification, useful for replying automaticly. Only works when semantic code is `1`.
320 |
321 | ### Object `ActionInput`
322 |
323 | Fields of `ActionInput`:
324 | - `label`: `String`, label for input.
325 | - `resultKey`: `String`, result key for input. Must use correct to post data to inputs.
326 |
327 |
328 | ### Class `NotificationsListener`
329 |
330 | Fields of `NotificationsListener`:
331 | - `isRunning`: `bool`, check if the listener service is running.
332 | - `hasPermission`: `bool`, check if grant the permission to start the listener service.
333 | - `receivePort`: `ReceivePort`, default receive port for listening events.
334 |
335 | Static methods of `NotificationsListener`:
336 | - `Future initialize()`: initialize the plugin, must be called at first.
337 | - `Future registerEventHandle(EventCallbackFunc callback)`: register the event handler which will be called from android service, **shoube be static function**.
338 | - `Future openPermissionSettings()`: open the system listen notifactoin permission setting page.
339 | - `Future startService({...})`: start the listening service. arguments,
340 | - `foreground`: `bool`, optional, promote the service to foreground.
341 | - `subTitle`: `String`, optional, sub title of the service's notification.
342 | - `title`: `String`, optional, title of the service's notification.
343 | - `description`: `String`, optional, text contenet of the service's notification.
344 | - `showWhen`: `bool`, optional
345 | - `Future stopService()`: stop the listening service.
346 | - `Future promoteToForeground({...})` proomte the service to the foreground. *Arguments are same `startService`*.
347 | - `Future demoteToBackground()`: demote the service to background.
348 |
349 | ## Known Issues
350 |
351 | - If the service is not foreground, service will start failed after reboot.
352 |
353 | ## Support
354 |
355 | Did you find this plugin useful? Please consider to make a donation to help improve it!
356 |
357 | ## Contributing
358 |
359 | Contributions are always welcome!
360 |
--------------------------------------------------------------------------------
/android/src/main/kotlin/im/zoe/labs/flutter_notification_listener/Utils.kt:
--------------------------------------------------------------------------------
1 | package im.zoe.labs.flutter_notification_listener
2 |
3 | import android.app.Notification
4 | import android.app.PendingIntent
5 | import android.app.Person
6 | import android.app.RemoteInput
7 | import android.content.Context
8 | import android.content.IntentSender
9 | import android.content.pm.ApplicationInfo
10 | import android.graphics.Bitmap
11 | import android.graphics.Canvas
12 | import android.graphics.drawable.BitmapDrawable
13 | import android.graphics.drawable.Drawable
14 | import android.graphics.drawable.Icon
15 | import android.os.Build
16 | import android.os.Bundle
17 | import android.os.UserHandle
18 | import android.service.notification.StatusBarNotification
19 | import android.util.Log
20 | import io.flutter.plugin.common.JSONMessageCodec
21 | import org.json.JSONObject
22 | import java.io.ByteArrayOutputStream
23 | import java.math.BigInteger
24 | import java.nio.ByteBuffer
25 | import java.security.MessageDigest
26 |
27 | class Utils {
28 | companion object {
29 | fun Drawable.toBitmap(): Bitmap {
30 | if (this is BitmapDrawable) {
31 | return this.bitmap
32 | }
33 |
34 | val bitmap = Bitmap.createBitmap(this.intrinsicWidth, this.intrinsicHeight, Bitmap.Config.ARGB_8888)
35 | val canvas = Canvas(bitmap)
36 | this.setBounds(0, 0, canvas.width, canvas.height)
37 | this.draw(canvas)
38 |
39 | return bitmap
40 | }
41 |
42 | fun md5(input:String): String {
43 | val md = MessageDigest.getInstance("MD5")
44 | return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0')
45 | }
46 |
47 |
48 | fun convertBitmapToByteArray(bitmap: Bitmap): ByteArray {
49 | val stream = ByteArrayOutputStream()
50 | bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
51 | return stream.toByteArray()
52 | }
53 | }
54 |
55 | class Marshaller {
56 |
57 | val convertorFactory = HashMap, Convertor>()
58 |
59 | init {
60 | // improve performance
61 | register { passConvertor(it) }
62 | register { passConvertor(it) }
63 | register { passConvertor(it) }
64 | register { passConvertor(it) }
65 | register { passConvertor(it) }
66 |
67 | // basic types
68 | register { it?.toString() }
69 |
70 | // collections type
71 | register> { arrayConvertor(it as List<*>) }
72 | // register> { arrayConvertor(it as Array<*>) }
73 | register> { obj ->
74 | val items = mutableListOf()
75 | (obj as Array<*>).forEach {
76 | items.add(marshal(it))
77 | }
78 | items
79 | }
80 | /*
81 | register> { obj ->
82 | val items = mutableSetOf()
83 | (obj as LinkedHashSet<*>).forEach {
84 | items.add(marshal(it))
85 | }
86 | items
87 | }*/
88 |
89 | // extends type
90 | register {
91 | val v = it as StatusBarNotification
92 | val map = HashMap()
93 | map["id"] = v.id
94 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
95 | map["groupKey"] = v.groupKey
96 | }
97 | map["isClearable"] = v.isClearable
98 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
99 | map["isGroup"] = v.isGroup
100 | }
101 | map["isOngoing"] = v.isOngoing
102 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
103 | map["key"] = v.key
104 | }
105 | map["packageName"] = v.packageName
106 | map["postTime"] = v.postTime
107 | map["tag"] = v.tag
108 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
109 | map["uid"] = v.uid
110 | }
111 | map["notification"] = marshal(v.notification)
112 | map
113 | }
114 | register {
115 | val v = it as Notification
116 | val map = HashMap()
117 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
118 | map["channelId"] = v.channelId
119 | }
120 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
121 | map["category"] = v.category
122 | }
123 | map["extras"] = marshal(v.extras)
124 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
125 | map["color"] = v.color
126 | }
127 | map["contentIntent"] = marshal(v.contentIntent)
128 | map["deleteIntent"] = marshal(v.deleteIntent)
129 | map["fullScreenIntent"] = marshal(v.fullScreenIntent)
130 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
131 | map["group"] = v.group
132 | }
133 | map["actions"] = marshal(v.actions)
134 | map["when"] = v.`when`
135 | map["tickerText"] = marshal(v.tickerText)
136 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
137 | map["tickerText"] = marshal(v.settingsText)
138 | }
139 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
140 | map["timeoutAfter"] = v.timeoutAfter
141 | }
142 | map["number"] = v.number
143 | map["sound"] = v.sound?.toString()
144 | map
145 | }
146 | register {
147 | val v = it as PendingIntent
148 | val map = HashMap()
149 | map["creatorPackage"] = v.creatorPackage
150 | map["creatorUid"] = v.creatorUid
151 | map
152 | }
153 | register {
154 | val v = it as ApplicationInfo
155 | val map = HashMap()
156 | map["name"] = v.name
157 | map["processName"] = v.processName
158 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
159 | map["category"] = v.category
160 | }
161 | map["dataDir"] = v.dataDir
162 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
163 | map["deviceProtectedDataDir"] = v.deviceProtectedDataDir
164 | }
165 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
166 | map["appComponentFactory"] = v.appComponentFactory
167 | }
168 | map["manageSpaceActivityName"] = v.manageSpaceActivityName
169 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
170 | map["deviceProtectedDataDir"] = v.deviceProtectedDataDir
171 | }
172 | map["publicSourceDir"] = v.publicSourceDir
173 | map["sourceDir"] = v.sourceDir
174 | map
175 | }
176 |
177 | register { convertBitmapToByteArray(it as Bitmap) }
178 | register { obj ->
179 | val v = obj as Bundle
180 | val map = HashMap()
181 | val keys = obj.keySet()
182 | keys.forEach { map[it] = marshal(v.get(it)) }
183 | map
184 | }
185 | register { obj ->
186 | val v = obj as Notification.Action
187 | val map = HashMap()
188 | map["title"] = v.title
189 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
190 | map["semantic"] = v.semanticAction
191 | }
192 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
193 | map["inputs"] = marshal(v.remoteInputs)
194 | map["extras"] = marshal(v.extras)
195 | }
196 | map
197 | }
198 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
199 | register {
200 | val v = it as RemoteInput
201 | val map = HashMap()
202 | map["label"] = v.label
203 | map["resultKey"] = v.resultKey
204 | map["choices"] = marshal(v.choices)
205 | map["extras"] = marshal(v.extras)
206 | map
207 | }
208 | }
209 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
210 | register { ignoreConvertor(it) }
211 | }
212 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
213 | register {
214 | val v = it as Notification.MessagingStyle
215 | val map = HashMap()
216 | map["conversationTitle"] = marshal(v.conversationTitle)
217 | map["messages"] = marshal(v.messages)
218 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
219 | map["user"] = marshal(v.user)
220 | }
221 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
222 | map["historicMessages"] = marshal(v.historicMessages)
223 | }
224 | map
225 | }
226 | }
227 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
228 | register {
229 | val v = it as Person
230 | val map = HashMap()
231 | map["isBot"] = v.isBot
232 | map["isImportant"] = v.isImportant
233 | map["key"] = v.key
234 | map["name"] = v.name
235 | map["uri"] = v.uri
236 | map
237 | }
238 | }
239 | }
240 |
241 | private fun arrayConvertor(obj: T): List
242 | where T: List<*> {
243 | val items = mutableListOf()
244 | (obj as List<*>).forEach { items.add(marshal(it)) }
245 | return items
246 | }
247 |
248 | private fun ignoreConvertor(obj: Any?): Any? {
249 | return null
250 | }
251 |
252 | private fun passConvertor(obj: Any?): Any? {
253 | return obj
254 | }
255 |
256 | inline fun register(noinline fn: Convertor) {
257 | convertorFactory[T::class.java] = fn
258 | }
259 |
260 | fun marshal(obj: Any?): Any? {
261 | if (obj == null) return null
262 | // get the type of obj? and return
263 | // can we use get directly?
264 | for (et in convertorFactory) {
265 | if (et.key.isAssignableFrom(obj.javaClass)) {
266 | return et.value.invoke(obj)
267 | }
268 | }
269 | return obj
270 | }
271 |
272 | companion object {
273 | val instance = Marshaller()
274 |
275 | fun marshal(obj: Any?): Any? {
276 | return instance.marshal(obj)
277 | }
278 |
279 | inline fun register(noinline fn: Convertor) {
280 | return instance.register(fn)
281 | }
282 | }
283 | }
284 |
285 | class PromoteServiceConfig {
286 | var foreground: Boolean? = false
287 | var title: String? = "Flutter Notification Listener"
288 | var subTitle: String? = null
289 | var description: String? = "Let's scraping the notifications ..."
290 | var showWhen: Boolean? = false
291 |
292 | fun toMap(): Map {
293 | val map = HashMap()
294 | map["foreground"] = foreground
295 | map["title"] = title
296 | map["subTitle"] = subTitle
297 | map["description"] = description
298 | map["showWhen"] = showWhen
299 | return map
300 | }
301 |
302 | override fun toString(): String {
303 | return JSONObject(toMap()).toString()
304 | }
305 |
306 | fun save(context: Context) {
307 | val str = toString()
308 | Log.d(TAG, "save the promote config: $str")
309 | context.getSharedPreferences(FlutterNotificationListenerPlugin.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
310 | .edit()
311 | .putString(FlutterNotificationListenerPlugin.PROMOTE_SERVICE_ARGS_KEY, str)
312 | .apply()
313 | }
314 |
315 | companion object {
316 | val TAG = "PromoteConfig"
317 |
318 | fun fromMap(map: Map<*, *>?): PromoteServiceConfig {
319 | val cfg = PromoteServiceConfig()
320 | map?.let { m ->
321 | m["foreground"]?.let { cfg.foreground = it as Boolean? }
322 | m["title"]?.let { cfg.title = it as String? }
323 | m["subTitle"]?.let { cfg.subTitle = it as String? }
324 | m["description"]?.let { cfg.description = it as String? }
325 | m["showWhen"]?.let { cfg.showWhen = it as Boolean? }
326 | }
327 | return cfg
328 | }
329 |
330 | fun fromString(str: String = "{}"): PromoteServiceConfig {
331 | val cfg = PromoteServiceConfig()
332 | val map = JSONObject(str)
333 | map.let { m ->
334 | try {
335 | m["foreground"].let { cfg.foreground = it as Boolean? }
336 | m["title"].let { cfg.title = it as String? }
337 | m["subTitle"].let { cfg.subTitle = it as String? }
338 | m["description"].let { cfg.description = it as String? }
339 | m["showWhen"].let { cfg.showWhen = it as Boolean? }
340 | } catch (e: Exception) {
341 | e.printStackTrace()
342 | }
343 | }
344 | return cfg
345 | }
346 |
347 | fun load(context: Context): PromoteServiceConfig {
348 | val str = context.getSharedPreferences(FlutterNotificationListenerPlugin.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
349 | .getString(FlutterNotificationListenerPlugin.PROMOTE_SERVICE_ARGS_KEY, "{}")
350 | Log.d(TAG, "load the promote config: ${str.toString()}")
351 | return fromString(str?:"{}")
352 | }
353 | }
354 | }
355 | }
356 |
357 | typealias Convertor = (Any) -> Any?
--------------------------------------------------------------------------------
/android/src/main/kotlin/im/zoe/labs/flutter_notification_listener/NotificationsHandlerService.kt:
--------------------------------------------------------------------------------
1 | package im.zoe.labs.flutter_notification_listener
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.NotificationChannel
5 | import android.app.NotificationManager
6 | import android.app.RemoteInput
7 | import android.content.ComponentName
8 | import android.content.Context
9 | import android.content.Intent
10 | import android.content.pm.PackageManager
11 | import android.os.Build
12 | import android.os.Bundle
13 | import android.os.Handler
14 | import android.os.PowerManager
15 | import android.provider.Settings
16 | import android.service.notification.NotificationListenerService
17 | import android.service.notification.StatusBarNotification
18 | import android.text.TextUtils
19 | import android.util.Log
20 | import androidx.annotation.NonNull
21 | import androidx.annotation.RequiresApi
22 | import androidx.core.app.NotificationCompat
23 | import io.flutter.FlutterInjector
24 | import io.flutter.embedding.engine.FlutterEngine
25 | import io.flutter.embedding.engine.FlutterEngineCache
26 | import io.flutter.embedding.engine.dart.DartExecutor
27 | import io.flutter.plugin.common.MethodCall
28 | import io.flutter.plugin.common.MethodChannel
29 | import io.flutter.view.FlutterCallbackInformation
30 | import org.json.JSONObject
31 | import java.util.*
32 | import java.util.concurrent.atomic.AtomicBoolean
33 | import kotlin.collections.HashMap
34 |
35 | class NotificationsHandlerService: MethodChannel.MethodCallHandler, NotificationListenerService() {
36 | private val queue = ArrayDeque()
37 | private lateinit var mBackgroundChannel: MethodChannel
38 | private lateinit var mContext: Context
39 |
40 | // notification event cache: packageName_id -> event
41 | private val eventsCache = HashMap()
42 |
43 | override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: MethodChannel.Result) {
44 | when (call.method) {
45 | "service.initialized" -> {
46 | initFinish()
47 | return result.success(true)
48 | }
49 | // this should move to plugin
50 | "service.promoteToForeground" -> {
51 | // add data
52 | val cfg = Utils.PromoteServiceConfig.fromMap(call.arguments as Map<*, *>).apply {
53 | foreground = true
54 | }
55 | return result.success(promoteToForeground(cfg))
56 | }
57 | "service.demoteToBackground" -> {
58 | return result.success(demoteToBackground())
59 | }
60 | "service.tap" -> {
61 | // tap the notification
62 | Log.d(TAG, "tap the notification")
63 | val args = call.arguments?>()
64 | val uid = args!![0]!! as String
65 | return result.success(tapNotification(uid))
66 | }
67 | "service.tap_action" -> {
68 | // tap the action
69 | Log.d(TAG, "tap action of notification")
70 | val args = call.arguments?>()
71 | val uid = args!![0]!! as String
72 | val idx = args[1]!! as Int
73 | return result.success(tapNotificationAction(uid, idx))
74 | }
75 | "service.send_input" -> {
76 | // send the input data
77 | Log.d(TAG, "set the content for input and the send action")
78 | val args = call.arguments?>()
79 | val uid = args!![0]!! as String
80 | val idx = args[1]!! as Int
81 | val data = args[2]!! as Map<*, *>
82 | return result.success(sendNotificationInput(uid, idx, data))
83 | }
84 | "service.get_full_notification" -> {
85 | val args = call.arguments?>()
86 | val uid = args!![0]!! as String
87 | if (!eventsCache.contains(uid)) {
88 | return result.error("notFound", "can't found this notification $uid", "")
89 | }
90 | return result.success(Utils.Marshaller.marshal(eventsCache[uid]?.mSbn))
91 | }
92 | else -> {
93 | Log.d(TAG, "unknown method ${call.method}")
94 | result.notImplemented()
95 | }
96 | }
97 | }
98 |
99 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
100 | // if get shutdown release the wake lock
101 | when (intent?.action) {
102 | ACTION_SHUTDOWN -> {
103 | (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
104 | newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG).apply {
105 | if (isHeld) release()
106 | }
107 | }
108 | Log.i(TAG, "stop notification handler service!")
109 | disableServiceSettings(mContext)
110 | stopForeground(true)
111 | stopSelf()
112 | }
113 | else -> {
114 |
115 | }
116 | }
117 | return START_STICKY
118 | }
119 |
120 | override fun onCreate() {
121 | super.onCreate()
122 |
123 | mContext = this
124 |
125 | // store the service instance
126 | instance = this
127 |
128 | Log.i(TAG, "notification listener service onCreate")
129 | startListenerService(this)
130 | }
131 |
132 | override fun onDestroy() {
133 | super.onDestroy()
134 | Log.i(TAG, "notification listener service onDestroy")
135 | val bdi = Intent(mContext, RebootBroadcastReceiver::class.java)
136 | // remove notification
137 | sendBroadcast(bdi)
138 | }
139 |
140 | override fun onTaskRemoved(rootIntent: Intent?) {
141 | super.onTaskRemoved(rootIntent)
142 | Log.i(TAG, "notification listener service onTaskRemoved")
143 | }
144 |
145 | override fun onNotificationPosted(sbn: StatusBarNotification) {
146 | super.onNotificationPosted(sbn)
147 |
148 | FlutterInjector.instance().flutterLoader().startInitialization(mContext)
149 | FlutterInjector.instance().flutterLoader().ensureInitializationComplete(mContext, null)
150 |
151 | val evt = NotificationEvent(mContext, sbn)
152 |
153 | // store the evt to cache
154 | eventsCache[evt.uid] = evt
155 |
156 | synchronized(sServiceStarted) {
157 | if (!sServiceStarted.get()) {
158 | Log.d(TAG, "service is not start try to queue the event")
159 | queue.add(evt)
160 | } else {
161 | Log.d(TAG, "send event to flutter side immediately!")
162 | Handler(mContext.mainLooper).post { sendEvent(evt) }
163 | }
164 | }
165 | }
166 |
167 | override fun onNotificationRemoved(sbn: StatusBarNotification?) {
168 | super.onNotificationRemoved(sbn)
169 | if (sbn == null) return
170 | val evt = NotificationEvent(mContext, sbn)
171 | // remove the event from cache
172 | eventsCache.remove(evt.uid)
173 | Log.d(TAG, "notification removed: ${evt.uid}")
174 | }
175 |
176 | private fun initFinish() {
177 | Log.d(TAG, "service's flutter engine initialize finished")
178 | synchronized(sServiceStarted) {
179 | while (!queue.isEmpty()) sendEvent(queue.remove())
180 | sServiceStarted.set(true)
181 | }
182 | }
183 |
184 | private fun promoteToForeground(cfg: Utils.PromoteServiceConfig? = null): Boolean {
185 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
186 | Log.e(TAG, "promoteToForeground need sdk >= 26")
187 | return false
188 | }
189 |
190 | if (cfg?.foreground != true) {
191 | Log.i(TAG, "no need to start foreground: ${cfg?.foreground}")
192 | return false
193 | }
194 |
195 | // first is not running already, start at first
196 | if (!FlutterNotificationListenerPlugin.isServiceRunning(mContext, this.javaClass)) {
197 | Log.e(TAG, "service is not running")
198 | return false
199 | }
200 |
201 | // get args from store or args
202 | val cfg = cfg ?: Utils.PromoteServiceConfig.load(this)
203 | // make the service to foreground
204 |
205 | // take a wake lock
206 | (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
207 | newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG).apply {
208 | setReferenceCounted(false)
209 | acquire()
210 | }
211 | }
212 |
213 | // create a channel for notification
214 | val channel = NotificationChannel(CHANNEL_ID, "Flutter Notifications Listener Plugin", NotificationManager.IMPORTANCE_HIGH)
215 | val imageId = resources.getIdentifier("ic_launcher", "mipmap", packageName)
216 | (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(channel)
217 |
218 | val notification = NotificationCompat.Builder(this, CHANNEL_ID)
219 | .setContentTitle(cfg.title)
220 | .setContentText(cfg.description)
221 | .setShowWhen(cfg.showWhen ?: false)
222 | .setSubText(cfg.subTitle)
223 | .setSmallIcon(imageId)
224 | .setPriority(NotificationCompat.PRIORITY_HIGH)
225 | .setCategory(NotificationCompat.CATEGORY_SERVICE)
226 | .build()
227 |
228 | Log.d(TAG, "promote the service to foreground")
229 | startForeground(ONGOING_NOTIFICATION_ID, notification)
230 |
231 | return true
232 | }
233 |
234 | private fun demoteToBackground(): Boolean {
235 | Log.d(TAG, "demote the service to background")
236 | (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
237 | newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG).apply {
238 | if (isHeld) release()
239 | }
240 | }
241 | stopForeground(true)
242 | return true
243 | }
244 |
245 | private fun tapNotification(uid: String): Boolean {
246 | Log.d(TAG, "tap the notification: $uid")
247 | if (!eventsCache.containsKey(uid)) {
248 | Log.d(TAG, "notification is not exits: $uid")
249 | return false
250 | }
251 | val n = eventsCache[uid] ?: return false
252 | n.mSbn.notification.contentIntent.send()
253 | return true
254 | }
255 |
256 | private fun tapNotificationAction(uid: String, idx: Int): Boolean {
257 | Log.d(TAG, "tap the notification action: $uid @$idx")
258 | if (!eventsCache.containsKey(uid)) {
259 | Log.d(TAG, "notification is not exits: $uid")
260 | return false
261 | }
262 | val n = eventsCache[uid]
263 | if (n == null) {
264 | Log.e(TAG, "notification is null: $uid")
265 | return false
266 | }
267 | if (n.mSbn.notification.actions.size <= idx) {
268 | Log.e(TAG, "tap action out of range: size ${n.mSbn.notification.actions.size} index $idx")
269 | return false
270 | }
271 |
272 | val act = n.mSbn.notification.actions[idx]
273 | if (act == null) {
274 | Log.e(TAG, "notification $uid action $idx not exits")
275 | return false
276 | }
277 | act.actionIntent.send()
278 | return true
279 | }
280 |
281 | private fun sendNotificationInput(uid: String, idx: Int, data: Map<*, *>): Boolean {
282 | Log.d(TAG, "tap the notification action: $uid @$idx")
283 | if (!eventsCache.containsKey(uid)) {
284 | Log.d(TAG, "notification is not exits: $uid")
285 | return false
286 | }
287 | val n = eventsCache[uid]
288 | if (n == null) {
289 | Log.e(TAG, "notification is null: $uid")
290 | return false
291 | }
292 | if (n.mSbn.notification.actions.size <= idx) {
293 | Log.e(TAG, "send inputs out of range: size ${n.mSbn.notification.actions.size} index $idx")
294 | return false
295 | }
296 |
297 | val act = n.mSbn.notification.actions[idx]
298 | if (act == null) {
299 | Log.e(TAG, "notification $uid action $idx not exits")
300 | return false
301 | }
302 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
303 | if (act.remoteInputs == null) {
304 | Log.e(TAG, "notification $uid action $idx remote inputs not exits")
305 | return false
306 | }
307 |
308 | val intent = Intent()
309 | val bundle = Bundle()
310 | act.remoteInputs.forEach {
311 | if (data.containsKey(it.resultKey as String)) {
312 | Log.d(TAG, "add input content: ${it.resultKey} => ${data[it.resultKey]}")
313 | bundle.putCharSequence(it.resultKey, data[it.resultKey] as String)
314 | }
315 | }
316 | RemoteInput.addResultsToIntent(act.remoteInputs, intent, bundle)
317 | act.actionIntent.send(mContext, 0, intent)
318 | Log.d(TAG, "send the input action success")
319 | return true
320 | } else {
321 | Log.e(TAG, "not implement :sdk < KITKAT_WATCH")
322 | return false
323 | }
324 | }
325 |
326 | companion object {
327 |
328 | var callbackHandle = 0L
329 |
330 | @SuppressLint("StaticFieldLeak")
331 | @JvmStatic
332 | var instance: NotificationsHandlerService? = null
333 |
334 | @JvmStatic
335 | private val TAG = "NotificationsListenerService"
336 |
337 | private const val ONGOING_NOTIFICATION_ID = 100
338 | @JvmStatic
339 | private val WAKELOCK_TAG = "IsolateHolderService::WAKE_LOCK"
340 | @JvmStatic
341 | val ACTION_SHUTDOWN = "SHUTDOWN"
342 |
343 | private const val CHANNEL_ID = "flutter_notifications_listener_channel"
344 |
345 | @JvmStatic
346 | private var sBackgroundFlutterEngine: FlutterEngine? = null
347 | @JvmStatic
348 | private val sServiceStarted = AtomicBoolean(false)
349 |
350 | private const val BG_METHOD_CHANNEL_NAME = "flutter_notification_listener/bg_method"
351 |
352 | private const val ENABLED_NOTIFICATION_LISTENERS = "enabled_notification_listeners"
353 | private const val ACTION_NOTIFICATION_LISTENER_SETTINGS = "android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"
354 |
355 | const val NOTIFICATION_INTENT_KEY = "object"
356 | const val NOTIFICATION_INTENT = "notification_event"
357 |
358 | fun permissionGiven(context: Context): Boolean {
359 | val packageName = context.packageName
360 | val flat = Settings.Secure.getString(context.contentResolver, ENABLED_NOTIFICATION_LISTENERS)
361 | if (!TextUtils.isEmpty(flat)) {
362 | val names = flat.split(":").toTypedArray()
363 | for (name in names) {
364 | val componentName = ComponentName.unflattenFromString(name)
365 | val nameMatch = TextUtils.equals(packageName, componentName?.packageName)
366 | if (nameMatch) {
367 | return true
368 | }
369 | }
370 | }
371 |
372 | return false
373 | }
374 |
375 | fun openPermissionSettings(context: Context): Boolean {
376 | context.startActivity(Intent(ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
377 | return true
378 | }
379 |
380 | fun enableServiceSettings(context: Context) {
381 | toggleServiceSettings(context, PackageManager.COMPONENT_ENABLED_STATE_ENABLED)
382 | }
383 |
384 | fun disableServiceSettings(context: Context) {
385 | toggleServiceSettings(context, PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
386 | }
387 |
388 | private fun toggleServiceSettings(context: Context, state: Int) {
389 | val receiver = ComponentName(context, NotificationsHandlerService::class.java)
390 | val pm = context.packageManager
391 | pm.setComponentEnabledSetting(receiver, state, PackageManager.DONT_KILL_APP)
392 | }
393 |
394 | fun updateFlutterEngine(context: Context) {
395 | Log.d(TAG, "call instance update flutter engine from plugin init")
396 | instance?.updateFlutterEngine(context)
397 | // we need to `finish init` manually
398 | instance?.initFinish()
399 | }
400 | }
401 |
402 | private fun getFlutterEngine(context: Context): FlutterEngine {
403 | var eng = FlutterEngineCache.getInstance().get(FlutterNotificationListenerPlugin.FLUTTER_ENGINE_CACHE_KEY)
404 | if (eng != null) return eng
405 |
406 | Log.i(TAG, "flutter engine cache is null, create a new one")
407 | eng = FlutterEngine(context)
408 |
409 | // ensure initialization
410 | FlutterInjector.instance().flutterLoader().startInitialization(context)
411 | FlutterInjector.instance().flutterLoader().ensureInitializationComplete(context, arrayOf())
412 |
413 | // call the flutter side init
414 | // get the call back handle information
415 | val cb = context.getSharedPreferences(FlutterNotificationListenerPlugin.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
416 | .getLong(FlutterNotificationListenerPlugin.CALLBACK_DISPATCHER_HANDLE_KEY, 0)
417 |
418 | if (cb != 0L) {
419 | Log.d(TAG, "try to find callback: $cb")
420 | val info = FlutterCallbackInformation.lookupCallbackInformation(cb)
421 | val args = DartExecutor.DartCallback(context.assets,
422 | FlutterInjector.instance().flutterLoader().findAppBundlePath(), info)
423 | // call the callback
424 | eng.dartExecutor.executeDartCallback(args)
425 | } else {
426 | Log.e(TAG, "Fatal: no callback register")
427 | }
428 |
429 | FlutterEngineCache.getInstance().put(FlutterNotificationListenerPlugin.FLUTTER_ENGINE_CACHE_KEY, eng)
430 | return eng
431 | }
432 |
433 | private fun updateFlutterEngine(context: Context) {
434 | Log.d(TAG, "update the flutter engine of service")
435 | // take the engine
436 | val eng = getFlutterEngine(context)
437 | sBackgroundFlutterEngine = eng
438 |
439 | // set the method call
440 | mBackgroundChannel = MethodChannel(eng.dartExecutor.binaryMessenger, BG_METHOD_CHANNEL_NAME)
441 | mBackgroundChannel.setMethodCallHandler(this)
442 | }
443 |
444 | private fun startListenerService(context: Context) {
445 | Log.d(TAG, "start listener service")
446 | synchronized(sServiceStarted) {
447 | // promote to foreground
448 | // TODO: take from intent, currently just load form store
449 | promoteToForeground(Utils.PromoteServiceConfig.load(context))
450 |
451 | // we should to update
452 | Log.d(TAG, "service's flutter engine is null, should update one")
453 | updateFlutterEngine(context)
454 |
455 | sServiceStarted.set(true)
456 | }
457 | Log.d(TAG, "service start finished")
458 | }
459 |
460 | private fun sendEvent(evt: NotificationEvent) {
461 | Log.d(TAG, "send notification event: ${evt.data}")
462 | if (callbackHandle == 0L) {
463 | callbackHandle = mContext.getSharedPreferences(FlutterNotificationListenerPlugin.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
464 | .getLong(FlutterNotificationListenerPlugin.CALLBACK_HANDLE_KEY, 0)
465 | }
466 |
467 | // why mBackgroundChannel can be null?
468 |
469 | try {
470 | // don't care about the method name
471 | mBackgroundChannel.invokeMethod("sink_event", listOf(callbackHandle, evt.data))
472 | } catch (e: Exception) {
473 | e.printStackTrace()
474 | }
475 | }
476 |
477 | }
478 |
479 |
--------------------------------------------------------------------------------