diff --git a/.editorconfig b/.editorconfig index 3b8b35f5..b2d7e6fa 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,7 +18,7 @@ tab_width = 4 end_of_line = crlf trim_trailing_whitespace = true insert_final_newline = false -max_line_length = 140 +max_line_length = 200 csharp_indent_block_contents = true csharp_indent_braces = false diff --git a/Docs/FAQ.md b/Docs/FAQ.md index 53152c84..6f216032 100644 --- a/Docs/FAQ.md +++ b/Docs/FAQ.md @@ -29,10 +29,11 @@ If you receive an HTTP 429 you may need to back-off and send your messages later ### Why are my notifications not being delivered? Common reasons for notification delivery issues include: - Incorrect Firebase configuration: Wrong package name or bundle identifier. Wrong google-services.json or GoogleService-Info.plist file used. -- The app being killed or not running in the background. -- Notification permissions not configured in AndroidManifest and/or user not asked for giving consent. -- Notification events not subscribed. -- Network connectivity issues. +- Notification permissions not configured in AndroidManifest.xml. +- Notification permissions not requested from user. +- Notification events such as NotificationReceived not subscribed. +- Network connectivity issues on the receiving device. Particularly, if you use a simulator/emulator on a computer which is connected to slow/fragile network connectivity. +- Wait some seconds. Delivery of push notifications is not always immediate. ### How can I debug notification issues? - Verify your Firebase configuration and ensure that the correct services files are being used. diff --git a/Docs/FCM Plugin.FirebasePushNotifications.postman_collection.json b/Docs/FCM Plugin.FirebasePushNotifications.postman_collection.json index ac2a9e83..12b93545 100644 --- a/Docs/FCM Plugin.FirebasePushNotifications.postman_collection.json +++ b/Docs/FCM Plugin.FirebasePushNotifications.postman_collection.json @@ -36,7 +36,113 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body sent @ {{currentDateTime}}\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"Notification body @ {{currentDateTime}}\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Notification Message with priority=low", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"Low priority notification message @ {{currentDateTime}}\"\r\n },\r\n \"data\": {\r\n \"priority\": \"low\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Notification Message with priority=default", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"Default priority notification message @ {{currentDateTime}}\"\r\n },\r\n \"data\": {\r\n \"priority\": \"default\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -65,6 +171,73 @@ }, { "name": "Notification Message with priority=high", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"High priority notification message @ {{currentDateTime}}\"\r\n },\r\n \"data\": {\r\n \"priority\": \"high\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Notification Message with priority=high (2)", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], "request": { "method": "POST", "header": [ @@ -75,7 +248,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"priority\": \"high\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"High priority notification message @ {{currentDateTime}}\"\r\n },\r\n \"token\": \"{{fcm_token}}\",\r\n \"data\": {\r\n \"eventId\": \"32935962\",\r\n \"product_id\": \"111881503\",\r\n \"location\": \"{\\\"Timestamp\\\":1749168728,\\\"DateTime\\\":\\\"2025-06-06T00:12:08Z\\\",\\\"Longitude\\\":0,\\\"Latitude\\\":0,\\\"Altitude\\\":0,\\\"Accuracy\\\":17.0,\\\"Course\\\":null,\\\"Speed\\\":null}\",\r\n \"priority\": \"high\"\r\n },\r\n \"android\": {\r\n \"priority\": \"HIGH\",\r\n \"ttl\": \"2419200s\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -153,7 +326,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"priority\": \"high\",\r\n \"large_icon\": \"ic_notification_icon_large\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"large_icon\": \"ic_notification_icon_large\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -192,7 +365,46 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"priority\": \"high\",\r\n \"large_icon\": \"https://firebase.google.com/static/images/brand-guidelines/logo-vertical.png\",\r\n \"bigtextstyle\": \"0\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"large_icon\": \"https://firebase.google.com/static/images/brand-guidelines/logo-vertical.png\",\r\n \"bigtextstyle\": \"0\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Notification Message with data payload", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"value2\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -216,11 +428,438 @@ } ] } - }, - "response": [] - }, - { - "name": "Notification Message with priority=low", + }, + "response": [] + }, + { + "name": "Notification Message with data payload", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Medication Notification\",\r\n \"body\": \"Test notification sent via Postman\"\r\n },\r\n \"data\": {\r\n \"notificationAction\": \"medicationReminder\",\r\n \"reminderId\": \"85799965-48fc-11ef-90b6-6740da731683\",\r\n \"notifyDate\": \"2024-07-25T04:00Z\",\r\n \"medications[0].medicationRequestId\": \"f24d43aa-3290-41f0-95dc-4f5525532a03\",\r\n \"medications[0].medicationName\": \"Medication 1\",\r\n \"medications[0].dosage\": \"1\",\r\n \"userNotificationMessageId\": \"-490366181\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Notification Message with localizable keys", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"value2\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Data Messages", + "item": [ + { + "name": "Data Message", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"{{currentDateTime}}\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Data Message with priority=high", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"{{currentDateTime}}\",\r\n \"priority\": \"high\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Channels", + "item": [ + { + "name": "Notification Message with channel_id=test_channel_1", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification test_channel_1\",\r\n \"body\": \"Notification to channel_id=test_channel_1\"\r\n },\r\n \"data\": {\r\n \"channel_id\": \"test_channel_1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Notification Message with channel_id=test_channel_2", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification test_channel_2\",\r\n \"body\": \"Notification to channel_id=test_channel_2\"\r\n },\r\n \"data\": {\r\n \"channel_id\": \"test_channel_2\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Topics", + "item": [ + { + "name": "Notification Message with topic=general", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"Topic body @ {{currentDateTime}}\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ] + }, + "description": "## Send Message to Topic\n\nThis endpoint allows you to send a message to a specified topic in Firebase Cloud Messaging (FCM). It is useful for broadcasting notifications to multiple subscribers who are subscribed to the given topic.\n\n### Request\n\nThe request is a POST request to the following URL:\n\n```\nPOST https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send\n\n ```\n\n#### Request Body\n\nThe request body must be in JSON format and contains the following parameters:\n\n- `message`: An object that defines the message to be sent.\n \n - `topic`: A string that specifies the topic to which the message is sent.\n \n - `notification`: An object that contains the notification details.\n \n - `title`: A string representing the title of the notification.\n \n - `body`: A string that contains the body text of the notification.\n \n\n**Example Request Body:**\n\n``` json\n{\n \"message\": {\n \"topic\": \"general\",\n \"notification\": {\n \"title\": \"Topic Title\",\n \"body\": \"Topic Body @ {{currentDateTime}}\"\n }\n }\n}\n\n ```\n\n### Response\n\nUpon successful execution, the API will return a response with the following characteristics:\n\n- **Status Code**: `200 OK`\n \n- **Content-Type**: `application/json`\n \n- **Response Body**: An object that contains a `name` field, which indicates the identifier of the sent message.\n \n\n**Example Response:**\n\n``` json\n{\n \"name\": \"\"\n}\n\n ```\n\n### Usage Notes\n\n- Ensure that the `topic` specified in the request matches an existing topic that subscribers are registered to.\n \n- The `title` and `body` fields in the notification are customizable to provide relevant information to the users receiving the notifications.\n \n- This endpoint is part of the Firebase Cloud Messaging service, and appropriate authentication and permissions must be set up to use it effectively." + }, + "response": [] + }, + { + "name": "Notification Message with topic=general, tag=chat_111", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"Topic body @ {{currentDateTime}}\"\r\n },\r\n \"data\": {\r\n \"tag\": \"chat_111\",\r\n \"id\": \"1\"\r\n },\r\n \"apns\": {\r\n \"headers\": {\r\n \"apns-collapse-id\": \"1\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ] + }, + "description": "## Send Message to Topic\n\nThis endpoint allows you to send a message to a specified topic in Firebase Cloud Messaging (FCM). It is useful for broadcasting notifications to multiple subscribers who are subscribed to the given topic.\n\n### Request\n\nThe request is a POST request to the following URL:\n\n```\nPOST https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send\n\n ```\n\n#### Request Body\n\nThe request body must be in JSON format and contains the following parameters:\n\n- `message`: An object that defines the message to be sent.\n \n - `topic`: A string that specifies the topic to which the message is sent.\n \n - `notification`: An object that contains the notification details.\n \n - `title`: A string representing the title of the notification.\n \n - `body`: A string that contains the body text of the notification.\n \n\n**Example Request Body:**\n\n``` json\n{\n \"message\": {\n \"topic\": \"general\",\n \"notification\": {\n \"title\": \"Topic Title\",\n \"body\": \"Topic Body @ {{currentDateTime}}\"\n }\n }\n}\n\n ```\n\n### Response\n\nUpon successful execution, the API will return a response with the following characteristics:\n\n- **Status Code**: `200 OK`\n \n- **Content-Type**: `application/json`\n \n- **Response Body**: An object that contains a `name` field, which indicates the identifier of the sent message.\n \n\n**Example Response:**\n\n``` json\n{\n \"name\": \"\"\n}\n\n ```\n\n### Usage Notes\n\n- Ensure that the `topic` specified in the request matches an existing topic that subscribers are registered to.\n \n- The `title` and `body` fields in the notification are customizable to provide relevant information to the users receiving the notifications.\n \n- This endpoint is part of the Firebase Cloud Messaging service, and appropriate authentication and permissions must be set up to use it effectively." + }, + "response": [] + }, + { + "name": "Notification Message with topic=general, tag=chat_222", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], "request": { "method": "POST", "header": [ @@ -231,7 +870,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"priority\": \"low\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"Topic body @ {{currentDateTime}}\"\r\n },\r\n \"data\": {\r\n \"tag\": \"chat_222\",\r\n \"id\": \"1\"\r\n },\r\n \"apns\": {\r\n \"headers\": {\r\n \"apns-collapse-id\": \"1\"\r\n }\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -246,20 +885,38 @@ "projects", "{{project_id}}", "messages:send" - ], - "query": [ - { - "key": "", - "value": null, - "disabled": true - } ] - } + }, + "description": "## Send Message to Topic\n\nThis endpoint allows you to send a message to a specified topic in Firebase Cloud Messaging (FCM). It is useful for broadcasting notifications to multiple subscribers who are subscribed to the given topic.\n\n### Request\n\nThe request is a POST request to the following URL:\n\n```\nPOST https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send\n\n ```\n\n#### Request Body\n\nThe request body must be in JSON format and contains the following parameters:\n\n- `message`: An object that defines the message to be sent.\n \n - `topic`: A string that specifies the topic to which the message is sent.\n \n - `notification`: An object that contains the notification details.\n \n - `title`: A string representing the title of the notification.\n \n - `body`: A string that contains the body text of the notification.\n \n\n**Example Request Body:**\n\n``` json\n{\n \"message\": {\n \"topic\": \"general\",\n \"notification\": {\n \"title\": \"Topic Title\",\n \"body\": \"Topic Body @ {{currentDateTime}}\"\n }\n }\n}\n\n ```\n\n### Response\n\nUpon successful execution, the API will return a response with the following characteristics:\n\n- **Status Code**: `200 OK`\n \n- **Content-Type**: `application/json`\n \n- **Response Body**: An object that contains a `name` field, which indicates the identifier of the sent message.\n \n\n**Example Response:**\n\n``` json\n{\n \"name\": \"\"\n}\n\n ```\n\n### Usage Notes\n\n- Ensure that the `topic` specified in the request matches an existing topic that subscribers are registered to.\n \n- The `title` and `body` fields in the notification are customizable to provide relevant information to the users receiving the notifications.\n \n- This endpoint is part of the Firebase Cloud Messaging service, and appropriate authentication and permissions must be set up to use it effectively." }, "response": [] }, { - "name": "Notification Message with data payload", + "name": "Notification Message with topic=general, priority=low", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], "request": { "method": "POST", "header": [ @@ -270,7 +927,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"value2\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"Low priority notification message @ {{currentDateTime}}\"\r\n },\r\n \"data\": {\r\n \"priority\": \"low\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -298,12 +955,24 @@ "response": [] }, { - "name": "Notification Message with data payload", + "name": "Notification Message with topic=general, priority=default", "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, { "listen": "prerequest", "script": { "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", "" ], "type": "text/javascript", @@ -321,7 +990,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Medication Notification\",\r\n \"body\": \"Test notification sent via Postman\"\r\n },\r\n \"data\": {\r\n \"notificationAction\": \"medicationReminder\",\r\n \"reminderId\": \"85799965-48fc-11ef-90b6-6740da731683\",\r\n \"notifyDate\": \"2024-07-25T04:00Z\",\r\n \"medications[0].medicationRequestId\": \"f24d43aa-3290-41f0-95dc-4f5525532a03\",\r\n \"medications[0].medicationName\": \"Medication 1\",\r\n \"medications[0].dosage\": \"1\",\r\n \"userNotificationMessageId\": \"-490366181\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"Default priority notification message @ {{currentDateTime}}\"\r\n },\r\n \"data\": {\r\n \"priority\": \"default\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -349,7 +1018,31 @@ "response": [] }, { - "name": "Notification Message with localizable keys", + "name": "Notification Message with topic=general, priority=high", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], "request": { "method": "POST", "header": [ @@ -360,7 +1053,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"value2\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Notification Title\",\r\n \"body\": \"High priority notification message @ {{currentDateTime}}\"\r\n },\r\n \"data\": {\r\n \"priority\": \"high\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -386,28 +1079,9 @@ } }, "response": [] - } - ] - }, - { - "name": "Data Messages", - "item": [ + }, { - "name": "Data Message", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var moment = require('moment');", - "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", - "" - ], - "type": "text/javascript", - "packages": {} - } - } - ], + "name": "Notification Message with topic=general, icon=ic_number_one", "request": { "method": "POST", "header": [ @@ -418,7 +1092,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"{{currentDateTime}}\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"priority\": \"high\",\r\n \"icon\": \"ic_number_one\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -446,21 +1120,7 @@ "response": [] }, { - "name": "Data Message with priority=high", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var moment = require('moment');", - "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", - "" - ], - "type": "text/javascript", - "packages": {} - } - } - ], + "name": "Notification Message with topic=general, large_icon=ic_notification_icon_large", "request": { "method": "POST", "header": [ @@ -471,7 +1131,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"{{currentDateTime}}\",\r\n \"priority\": \"high\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"large_icon\": \"ic_notification_icon_large\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -497,14 +1157,9 @@ } }, "response": [] - } - ] - }, - { - "name": "Channels", - "item": [ + }, { - "name": "Notification Message with channel_id=test_channel_1", + "name": "Notification Message with topic=general, large_icon=url", "request": { "method": "POST", "header": [ @@ -515,7 +1170,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"token\": \"{{fcm_token}}\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"channel_id\": \"test_channel_1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Notification title\",\r\n \"body\": \"Notification body\"\r\n },\r\n \"data\": {\r\n \"large_icon\": \"https://firebase.google.com/static/images/brand-guidelines/logo-vertical.png\",\r\n \"bigtextstyle\": \"0\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -541,17 +1196,12 @@ } }, "response": [] - } - ] - }, - { - "name": "Topics", - "item": [ + }, { - "name": "Notification Message with topic=general", + "name": "Notification Message with topic=subscriber-updates", "event": [ { - "listen": "test", + "listen": "prerequest", "script": { "exec": [ "" @@ -561,11 +1211,9 @@ } }, { - "listen": "prerequest", + "listen": "test", "script": { "exec": [ - "var moment = require('moment');", - "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", "" ], "type": "text/javascript", @@ -583,7 +1231,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Topic Title\",\r\n \"body\": \"Topic Body @ {{currentDateTime}}\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"subscriber-updates\",\r\n \"notification\": {\r\n \"title\": \"NewsMagazine.com\",\r\n \"body\": \"This week's edition is now available.\"\r\n },\r\n \"data\": {\r\n \"volume\": \"3.21.15\",\r\n \"contents\": \"http://www.news-magazine.com/world-week/1234\"\r\n },\r\n \"android\": {\r\n \"priority\": \"normal\"\r\n },\r\n \"apns\": {\r\n \"headers\": {\r\n \"apns-priority\": \"5\"\r\n }\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -604,32 +1252,7 @@ "response": [] }, { - "name": "Data Message with topic=general", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "" - ], - "type": "text/javascript", - "packages": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [ - "var moment = require('moment');", - "", - "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", - "" - ], - "type": "text/javascript", - "packages": {} - } - } - ], + "name": "Notification Message with topic=weather_updates", "request": { "method": "POST", "header": [ @@ -640,7 +1263,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"{{currentDateTime}}\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"condition\": \"'weather_updates' in topics\",\r\n \"notification\": {\r\n \"title\": \"Weather Update\",\r\n \"body\": \"Pleasant with clouds and sun\"\r\n },\r\n \"data\": {\r\n \"sunrise\": \"1684926645\",\r\n \"sunset\": \"1684977332\",\r\n \"temp\": \"292.55\",\r\n \"feels_like\": \"292.87\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -661,10 +1284,10 @@ "response": [] }, { - "name": "Notification Message with topic=subscriber-updates", + "name": "Data Message with topic=general", "event": [ { - "listen": "prerequest", + "listen": "test", "script": { "exec": [ "" @@ -674,9 +1297,11 @@ } }, { - "listen": "test", + "listen": "prerequest", "script": { "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", "" ], "type": "text/javascript", @@ -694,7 +1319,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\":{\r\n \"topic\":\"subscriber-updates\",\r\n \"notification\":{\r\n \"title\" : \"NewsMagazine.com\",\r\n \"body\" : \"This week's edition is now available.\"\r\n },\r\n \"data\" : {\r\n \"volume\" : \"3.21.15\",\r\n \"contents\" : \"http://www.news-magazine.com/world-week/1234\"\r\n },\r\n \"android\":{\r\n \"priority\":\"normal\"\r\n },\r\n \"apns\":{\r\n \"headers\":{\r\n \"apns-priority\":\"5\"\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"{{currentDateTime}}\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -715,7 +1340,21 @@ "response": [] }, { - "name": "Notification Message with topic=weather_updates", + "name": "Data Message with topic=general, priority=high", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var moment = require('moment');", + "pm.environment.set('currentDateTime', moment().format((\"YYYY-MM-DD HH:mm:ss\")));", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], "request": { "method": "POST", "header": [ @@ -726,7 +1365,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"condition\": \"'weather_updates' in topics\",\r\n \"notification\": {\r\n \"title\": \"Weather Update\",\r\n \"body\": \"Pleasant with clouds and sun\"\r\n },\r\n \"data\": {\r\n \"sunrise\": \"1684926645\",\r\n \"sunset\": \"1684977332\",\r\n \"temp\": \"292.55\",\r\n \"feels_like\": \"292.87\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"data\": {\r\n \"key1\": \"value1\",\r\n \"key2\": \"{{currentDateTime}}\",\r\n \"priority\": \"high\"\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -741,6 +1380,13 @@ "projects", "{{project_id}}", "messages:send" + ], + "query": [ + { + "key": "", + "value": null, + "disabled": true + } ] } }, @@ -1412,7 +2058,7 @@ "response": [] }, { - "name": "navigate with priority=high", + "name": "meeting_invitation with priority=default", "request": { "method": "POST", "header": [ @@ -1423,7 +2069,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Navigation\",\r\n \"body\": \"Want start navigation?\"\r\n },\r\n \"data\": {\r\n \"priority\": \"high\",\r\n \"click_action\": \"navigate\"\r\n },\r\n \"apns\": {\r\n \"payload\": {\r\n \"aps\": {\r\n \"category\": \"navigate\"\r\n }\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Meeting Invitation\",\r\n \"body\": \"Want to participate?\"\r\n },\r\n \"data\": {\r\n \"click_action\": \"meeting_invitation\"\r\n },\r\n \"apns\": {\r\n \"payload\": {\r\n \"aps\": {\r\n \"category\": \"meeting_invitation\"\r\n }\r\n }\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -1439,12 +2085,13 @@ "{{project_id}}", "messages:send" ] - } + }, + "description": "- meeting_invitation\n \n- priority: default" }, "response": [] }, { - "name": "meeting_invitation with priority=default", + "name": "message with priority=high", "request": { "method": "POST", "header": [ @@ -1455,7 +2102,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Meeting Invitation\",\r\n \"body\": \"Want to participate?\"\r\n },\r\n \"data\": {\r\n \"click_action\": \"meeting_invitation\"\r\n },\r\n \"apns\": {\r\n \"payload\": {\r\n \"aps\": {\r\n \"category\": \"meeting_invitation\"\r\n }\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Message\",\r\n \"body\": \"Hello, how are you?\"\r\n },\r\n \"data\": {\r\n \"click_action\": \"message\",\r\n \"priority\": \"high\"\r\n },\r\n \"apns\": {\r\n \"payload\": {\r\n \"aps\": {\r\n \"category\": \"message\"\r\n }\r\n }\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", @@ -1477,7 +2124,7 @@ "response": [] }, { - "name": "contract with priority=default", + "name": "contract with priority=high", "request": { "method": "POST", "header": [ @@ -1488,7 +2135,39 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Contract\",\r\n \"body\": \"Accept contract?\"\r\n },\r\n \"data\": {\r\n \"click_action\": \"contract\"\r\n },\r\n \"apns\": {\r\n \"payload\": {\r\n \"aps\": {\r\n \"category\": \"contract\"\r\n }\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Contract\",\r\n \"body\": \"Accept contract?\"\r\n },\r\n \"data\": {\r\n \"click_action\": \"contract\",\r\n \"priority\": \"high\"\r\n },\r\n \"apns\": {\r\n \"payload\": {\r\n \"aps\": {\r\n \"category\": \"contract\"\r\n }\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", + "protocol": "https", + "host": [ + "fcm", + "googleapis", + "com" + ], + "path": [ + "v1", + "projects", + "{{project_id}}", + "messages:send" + ] + } + }, + "response": [] + }, + { + "name": "navigate with priority=high", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"message\": {\r\n \"topic\": \"general\",\r\n \"notification\": {\r\n \"title\": \"Navigation\",\r\n \"body\": \"Want start navigation?\"\r\n },\r\n \"data\": {\r\n \"priority\": \"high\",\r\n \"click_action\": \"navigate\"\r\n },\r\n \"apns\": {\r\n \"payload\": {\r\n \"aps\": {\r\n \"category\": \"navigate\"\r\n }\r\n }\r\n }\r\n }\r\n}" }, "url": { "raw": "https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send", diff --git a/Plugin.FirebasePushNotifications.sln b/Plugin.FirebasePushNotifications.sln index 2df1b09f..4c2cbf82 100644 --- a/Plugin.FirebasePushNotifications.sln +++ b/Plugin.FirebasePushNotifications.sln @@ -10,6 +10,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution azure-pipelines.yml = azure-pipelines.yml LICENSE = LICENSE README.md = README.md + ReleaseNotes.txt = ReleaseNotes.txt + global.json = global.json EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Plugin.FirebasePushNotifications", "Plugin.FirebasePushNotifications\Plugin.FirebasePushNotifications.csproj", "{49F1F62D-5A20-49A6-B508-408BE32B0DC6}" diff --git a/Plugin.FirebasePushNotifications.sln.DotSettings b/Plugin.FirebasePushNotifications.sln.DotSettings new file mode 100644 index 00000000..d4a07cdf --- /dev/null +++ b/Plugin.FirebasePushNotifications.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/Plugin.FirebasePushNotifications/Extensions/DictionaryExtensions.cs b/Plugin.FirebasePushNotifications/Extensions/DictionaryExtensions.cs index 126afe6f..02a9ada0 100644 --- a/Plugin.FirebasePushNotifications/Extensions/DictionaryExtensions.cs +++ b/Plugin.FirebasePushNotifications/Extensions/DictionaryExtensions.cs @@ -17,11 +17,11 @@ public static T GetValueOrDefault(this IDictionary items, string k return defaultValue; } - public static Ty GetValueOrDefault(this IDictionary items, string key, Ty defaultValue = default) + public static TY GetValueOrDefault(this IDictionary items, string key, TY defaultValue = default) { if (items.TryGetValue(key, out var value)) { - return (Ty)Convert.ChangeType(value, typeof(Ty)); + return (TY)Convert.ChangeType(value, typeof(TY)); } return defaultValue; diff --git a/Plugin.FirebasePushNotifications/IFirebasePushNotification.cs b/Plugin.FirebasePushNotifications/IFirebasePushNotification.cs index ebdd4068..fdb757f7 100644 --- a/Plugin.FirebasePushNotifications/IFirebasePushNotification.cs +++ b/Plugin.FirebasePushNotifications/IFirebasePushNotification.cs @@ -97,27 +97,27 @@ public interface IFirebasePushNotification /// /// Subscribe to . /// - void SubscribeTopic(string topic); + Task SubscribeTopicAsync(string topic); /// /// Subscribe to list of . /// - void SubscribeTopics(string[] topics); + Task SubscribeTopicsAsync(string[] topics); /// /// Unsubscribe from . /// - void UnsubscribeTopic(string topic); + Task UnsubscribeTopicAsync(string topic); /// /// Unsubscribe from list of . /// - void UnsubscribeTopics(string[] topics); + Task UnsubscribeTopicsAsync(string[] topics); /// /// Unsubscribe all topics. /// - void UnsubscribeAllTopics(); + Task UnsubscribeAllTopicsAsync(); /// /// Register for push notifications. diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/Channels/INotificationChannels.cs b/Plugin.FirebasePushNotifications/Platforms/Android/Channels/INotificationChannels.cs index e6ee353d..5267f542 100644 --- a/Plugin.FirebasePushNotifications/Platforms/Android/Channels/INotificationChannels.cs +++ b/Plugin.FirebasePushNotifications/Platforms/Android/Channels/INotificationChannels.cs @@ -1,4 +1,6 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Versioning; using Android.App; using Plugin.FirebasePushNotifications.Platforms.Channels; @@ -103,6 +105,7 @@ public partial interface INotificationChannels /// Opens the notification channel settings for . /// /// The notification channel identifier. + [SupportedOSPlatform("android26.0")] void OpenNotificationChannelSettings(string notificationChannelId); } } \ No newline at end of file diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/Channels/NotificationChannelRequest.cs b/Plugin.FirebasePushNotifications/Platforms/Android/Channels/NotificationChannelRequest.cs index aa8cb577..35fa4b0c 100644 --- a/Plugin.FirebasePushNotifications/Platforms/Android/Channels/NotificationChannelRequest.cs +++ b/Plugin.FirebasePushNotifications/Platforms/Android/Channels/NotificationChannelRequest.cs @@ -13,6 +13,14 @@ public class NotificationChannelRequest /// Sets or gets, the level of interruption of this notification channel. /// Default: NotificationImportance.Default /// + /// + /// Important note: + /// The importance of a notification channel cannot be changed programmatically after it is first created. + /// Deleting and recreating a notification channel with the same ID does not change its importance. + /// Only the user can change the notification settings for an existing channel in system settings. + /// If you want to change the importance of a notification channel, + /// delete the existing channel and create a new one with a different ID. + /// public NotificationImportance Importance { get; set; } = NotificationImportance.Default; /// @@ -63,7 +71,7 @@ public class NotificationChannelRequest public long[] VibrationPattern { get; set; } /// - /// Sets or gets, whether or not notifications posted to this channel are shown on the lockscreen in full or redacted form. + /// Sets or gets, whether notifications posted to this channel are shown on the lockscreen in full or redacted form. /// Default: NotificationVisibility.Public. /// public NotificationVisibility LockscreenVisibility { get; set; } = NotificationVisibility.Public; diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/Channels/NotificationChannels.cs b/Plugin.FirebasePushNotifications/Platforms/Android/Channels/NotificationChannels.cs index f54d360b..4be326f0 100644 --- a/Plugin.FirebasePushNotifications/Platforms/Android/Channels/NotificationChannels.cs +++ b/Plugin.FirebasePushNotifications/Platforms/Android/Channels/NotificationChannels.cs @@ -60,6 +60,11 @@ public IEnumerable ChannelGroups /// public void SetNotificationChannelGroups([NotNull] NotificationChannelGroupRequest[] notificationChannelGroupRequests) { + if (Build.VERSION.SdkInt < BuildVersionCodes.O) + { + return; + } + ArgumentNullException.ThrowIfNull(notificationChannelGroupRequests); var groupIds = notificationChannelGroupRequests @@ -68,11 +73,6 @@ public void SetNotificationChannelGroups([NotNull] NotificationChannelGroupReque this.logger.LogDebug($"SetNotificationChannelGroups: notificationChannelGroupRequests=[{string.Join(",", groupIds)}]"); - if (Build.VERSION.SdkInt < BuildVersionCodes.O) - { - return; - } - var notificationChannelGroupIdsToDelete = this.ChannelGroups.Select(c => c.Id); if (groupIds.Any()) @@ -88,6 +88,11 @@ public void SetNotificationChannelGroups([NotNull] NotificationChannelGroupReque /// public void CreateNotificationChannelGroups([NotNull] NotificationChannelGroupRequest[] notificationChannelGroupRequests) { + if (Build.VERSION.SdkInt < BuildVersionCodes.O) + { + return; + } + ArgumentNullException.ThrowIfNull(notificationChannelGroupRequests); var groupIds = notificationChannelGroupRequests @@ -96,11 +101,6 @@ public void CreateNotificationChannelGroups([NotNull] NotificationChannelGroupRe this.logger.LogDebug($"CreateNotificationChannelGroups: notificationChannelGroupRequests=[{string.Join(",", groupIds)}]"); - if (Build.VERSION.SdkInt < BuildVersionCodes.O) - { - return; - } - this.CreateNotificationChannelGroupsInternal(notificationChannelGroupRequests); } @@ -135,13 +135,13 @@ public void DeleteNotificationChannelGroup(string groupId) /// public void DeleteAllNotificationChannelGroups() { - this.logger.LogDebug("DeleteAllNotificationChannelGroups"); - if (Build.VERSION.SdkInt < BuildVersionCodes.O) { return; } + this.logger.LogDebug("DeleteAllNotificationChannelGroups"); + var groupIds = this.ChannelGroups .Select(g => g.Id) .ToArray(); @@ -152,15 +152,15 @@ public void DeleteAllNotificationChannelGroups() /// public void DeleteNotificationChannelGroups([NotNull] string[] groupIds) { - ArgumentNullException.ThrowIfNull(groupIds); - - this.logger.LogDebug($"DeleteNotificationChannelGroups: groupIds=[{string.Join(",", groupIds)}]"); - if (Build.VERSION.SdkInt < BuildVersionCodes.O) { return; } + ArgumentNullException.ThrowIfNull(groupIds); + + this.logger.LogDebug($"DeleteNotificationChannelGroups: groupIds=[{string.Join(",", groupIds)}]"); + this.DeleteNotificationChannelGroupsInternal(groupIds); } @@ -180,6 +180,11 @@ private void DeleteNotificationChannelGroupsInternal(string[] groupIds) /// public void SetNotificationChannels([NotNull] NotificationChannelRequest[] notificationChannelRequests) { + if (Build.VERSION.SdkInt < BuildVersionCodes.O) + { + return; + } + ArgumentNullException.ThrowIfNull(notificationChannelRequests); var notificationChannelRequestIds = notificationChannelRequests @@ -189,36 +194,11 @@ public void SetNotificationChannels([NotNull] NotificationChannelRequest[] notif this.logger.LogDebug( $"SetNotificationChannels: notificationChannelRequests=[{string.Join(",", notificationChannelRequestIds)}]"); - if (Build.VERSION.SdkInt < BuildVersionCodes.O) - { - return; - } - + // If no default notification channel is requested, we create it with predefined properties. if (!notificationChannelRequests.Any(c => c.IsDefault)) { - var metadata = MetadataHelper.GetMetadata(); - var channelId = metadata.GetString( - Constants.MetadataDefaultNotificationChannelId, - Constants.DefaultNotificationChannelId); - - var defaultNotificationChannelRequest = new NotificationChannelRequest - { - ChannelId = channelId, - ChannelName = Constants.DefaultNotificationChannelName, - LockscreenVisibility = NotificationVisibility.Public, - Importance = this.options.Android.DefaultNotificationImportance, - IsDefault = true - }; - - const string optionsPath = $"options.{nameof(FirebasePushNotificationOptions.Android)}." + - $"{nameof(FirebasePushNotificationAndroidOptions.NotificationChannels)}"; - - this.logger.LogDebug( - $"No default notification channel specified in {optionsPath}. Creating default notification channel with {Environment.NewLine}" + - $"> ChannelId={defaultNotificationChannelRequest.ChannelId}, {Environment.NewLine}" + - $"> ChannelName={defaultNotificationChannelRequest.ChannelName}, {Environment.NewLine}" + - $"> LockscreenVisibility={defaultNotificationChannelRequest.LockscreenVisibility}, {Environment.NewLine}" + - $"> Importance={defaultNotificationChannelRequest.Importance}"); + var defaultNotificationChannelId = GetDefaultNotificationChannelIds().First(); + var defaultNotificationChannelRequest = this.CreateDefaultNotificationChannelRequest(defaultNotificationChannelId); notificationChannelRequests = notificationChannelRequests .Prepend(defaultNotificationChannelRequest) @@ -242,9 +222,114 @@ public void SetNotificationChannels([NotNull] NotificationChannelRequest[] notif this.CreateNotificationChannelsInternal(notificationChannelRequests); } + public void EnsureDefaultNotificationChannel() + { + if (Build.VERSION.SdkInt < BuildVersionCodes.O) + { + return; + } + + var existingNotificationChannelIds = this.Channels + .Select(c => c.Id) + .ToArray(); + + // There is no concept of default notification channels in Android. + // Therefore, we need to find or create the default notification channel. + // 1. If there is no existing notification channel, we create a new one with default properties. + // 2. If we have exactly one notification channel, we treat it as the default notification channel. + // 3. If we have multiple notification channels, + // 3a. try to get the default notification channel from the list of existing notification channels, or + // 3b. we treat the first in the list as default notification channel. + var defaultNotificationChannelIds = GetDefaultNotificationChannelIds().ToArray(); + var defaultNotificationChannelId = existingNotificationChannelIds.Length switch + { + 0 => defaultNotificationChannelIds.First(), + 1 => existingNotificationChannelIds[0], + _ => existingNotificationChannelIds.FirstOrDefault(c => defaultNotificationChannelIds.Contains(c, StringComparer.InvariantCultureIgnoreCase)) ?? existingNotificationChannelIds[0], + }; + + var defaultNotificationChannelExists = existingNotificationChannelIds + .Any(c => string.Equals(c, defaultNotificationChannelId, StringComparison.InvariantCultureIgnoreCase)); + + this.logger.LogDebug( + $"EnsureDefaultNotificationChannel: existingNotificationChannelIds=[{string.Join(",", existingNotificationChannelIds)}] " + + $"--> defaultNotificationChannelId={defaultNotificationChannelId} ({(defaultNotificationChannelExists ? "existing" : "new")})"); + + if (!defaultNotificationChannelExists) + { + // If no default notification channel exists, we create one with predefined properties. + var defaultNotificationChannelRequest = this.CreateDefaultNotificationChannelRequest(defaultNotificationChannelId); + this.CreateNotificationChannelsInternal(new[] { defaultNotificationChannelRequest }); + } + else + { + this.Channels.SetDefaultNotificationChannelIdInternal(defaultNotificationChannelId); + } + } + + private NotificationChannelRequest CreateDefaultNotificationChannelRequest(string defaultNotificationChannelId) + { + var defaultNotificationChannelRequest = new NotificationChannelRequest + { + ChannelId = defaultNotificationChannelId, + ChannelName = Constants.DefaultNotificationChannelName, + IsDefault = true, + LockscreenVisibility = NotificationVisibility.Public, + Importance = this.options.Android.DefaultNotificationImportance, + }; + + const string optionsPath = $"options.{nameof(FirebasePushNotificationOptions.Android)}." + + $"{nameof(FirebasePushNotificationAndroidOptions.NotificationChannels)}"; + + this.logger.LogWarning( + $"Missing default notification channel (IsDefault=true) in {optionsPath}.{Environment.NewLine}" + + $"A default notification channel with the following properties will be created: {Environment.NewLine}" + + $"> ChannelId={defaultNotificationChannelRequest.ChannelId}, {Environment.NewLine}" + + $"> ChannelName={defaultNotificationChannelRequest.ChannelName}, {Environment.NewLine}" + + $"> IsDefault=true, {Environment.NewLine}" + + $"> LockscreenVisibility=NotificationVisibility.Public, {Environment.NewLine}" + + $"> Importance=NotificationImportance.{defaultNotificationChannelRequest.Importance}"); + + return defaultNotificationChannelRequest; + } + + private static IEnumerable GetDefaultNotificationChannelIds() + { + // Try to get the default notification channel ID from AndroidManifest.xml + { + var metadata = MetadataHelper.GetMetadata(); + var defaultNotificationChannelId = metadata.GetString( + key: Constants.MetadataDefaultNotificationChannelId, + defaultValue: null); + + if (!string.IsNullOrEmpty(defaultNotificationChannelId)) + { + yield return defaultNotificationChannelId; + } + } + + // Try to get the default notification channel ID from static string defined in this library + { + var defaultNotificationChannelId = Constants.DefaultNotificationChannelId; + if (!string.IsNullOrEmpty(defaultNotificationChannelId)) + { + yield return defaultNotificationChannelId; + } + else + { + yield return "default_channel_id"; + } + } + } + /// public void CreateNotificationChannels([NotNull] NotificationChannelRequest[] notificationChannelRequests) { + if (Build.VERSION.SdkInt < BuildVersionCodes.O) + { + return; + } + ArgumentNullException.ThrowIfNull(notificationChannelRequests); var newChannelIds = notificationChannelRequests @@ -253,11 +338,6 @@ public void CreateNotificationChannels([NotNull] NotificationChannelRequest[] no this.logger.LogDebug($"CreateNotificationChannels: notificationChannelRequests=[{string.Join(",", newChannelIds)}]"); - if (Build.VERSION.SdkInt < BuildVersionCodes.O) - { - return; - } - if (newChannelIds.Length == 0) { return; @@ -323,6 +403,7 @@ private void CreateNotificationChannelsInternal([NotNull] NotificationChannelReq } else { + this.logger.LogDebug($"Creating notification channel '{notificationChannelRequest.ChannelId}'"); this.notificationManager.CreateNotificationChannel(notificationChannel); } } @@ -337,13 +418,13 @@ private void CreateNotificationChannelsInternal([NotNull] NotificationChannelReq /// public void DeleteAllNotificationChannels() { - this.logger.LogDebug("DeleteAllNotificationChannels"); - if (Build.VERSION.SdkInt < BuildVersionCodes.O) { return; } + this.logger.LogDebug("DeleteAllNotificationChannels"); + var defaultNotificationChannelId = this.Channels.DefaultNotificationChannelId; var allNotificationChannelIds = this.Channels @@ -365,15 +446,15 @@ public void DeleteNotificationChannel([NotNull] string notificationChannelId) /// public void DeleteNotificationChannels([NotNull] string[] notificationChannelIds) { - ArgumentNullException.ThrowIfNull(notificationChannelIds); - - this.logger.LogDebug($"DeleteNotificationChannels: notificationChannelIds=[{string.Join(",", notificationChannelIds)}]"); - if (Build.VERSION.SdkInt < BuildVersionCodes.O) { return; } + ArgumentNullException.ThrowIfNull(notificationChannelIds); + + this.logger.LogDebug($"DeleteNotificationChannels: notificationChannelIds=[{string.Join(",", notificationChannelIds)}]"); + var channelIds = this.Channels .Select(c => c.Id) .ToArray(); @@ -421,6 +502,7 @@ private void CreateDefaultNotificationChannelInternal() // TODO } + /// public void OpenNotificationSettings() { this.logger.LogDebug("OpenNotificationSettings"); @@ -428,10 +510,27 @@ public void OpenNotificationSettings() try { var context = Android.App.Application.Context; - var newIntent = new Intent(Settings.ActionAppNotificationSettings); - newIntent.SetFlags(ActivityFlags.NewTask); - newIntent.PutExtra(Settings.ExtraAppPackage, context.PackageName); - context.StartActivity(newIntent); + + var intent = new Intent(); + var sdkInt = (int)Build.VERSION.SdkInt; + if (sdkInt >= (int)BuildVersionCodes.O) // >= Android 8.0 + { + intent.SetAction(Settings.ActionAppNotificationSettings); + intent.PutExtra(Settings.ExtraAppPackage, context.PackageName); + intent.PutExtra(Settings.ExtraChannelId, context.ApplicationInfo!.Uid); + } + else if (sdkInt is >= (int)BuildVersionCodes.Lollipop and < (int)BuildVersionCodes.O) // >= Android 5.0 && < Android 8.0 + { + intent.SetAction(Settings.ActionAppNotificationSettings); + intent.PutExtra("app_package", context.PackageName); + intent.PutExtra("app_uid", context.ApplicationInfo!.Uid); + } + else + { + return; + } + + context.StartActivity(intent); } catch (Exception e) { @@ -439,6 +538,7 @@ public void OpenNotificationSettings() } } + /// public void OpenNotificationChannelSettings([NotNull] string notificationChannelId) { this.logger.LogDebug($"OpenNotificationChannelSettings: notificationChannelId={notificationChannelId}"); diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/FirebasePushNotificationManager.cs b/Plugin.FirebasePushNotifications/Platforms/Android/FirebasePushNotificationManager.cs index 65e657e5..c8f2c972 100644 --- a/Plugin.FirebasePushNotifications/Platforms/Android/FirebasePushNotificationManager.cs +++ b/Plugin.FirebasePushNotifications/Platforms/Android/FirebasePushNotificationManager.cs @@ -43,8 +43,23 @@ private void ConfigurePlatform() { this.logger.LogDebug("ConfigurePlatform"); - this.notificationChannels.SetNotificationChannelGroups(this.options.Android.NotificationChannelGroups); - this.notificationChannels.SetNotificationChannels(this.options.Android.NotificationChannels); + var groups = this.options.Android.NotificationChannelGroups; + if (groups.Any()) + { + this.notificationChannels.SetNotificationChannelGroups(groups); + } + + var channels = this.options.Android.NotificationChannels; + if (channels.Any()) + { + // If we have NotificationChannels set, use them to configure the absolute set of notification channels. + this.notificationChannels.SetNotificationChannels(channels); + } + else + { + // Otherwise, ensure we have at least the default notification channel. + ((Channels.NotificationChannels)this.notificationChannels).EnsureDefaultNotificationChannel(); + } var context = Application.Context; var isFirebaseAppInitialized = FirebaseAppHelper.IsFirebaseAppInitialized(context); @@ -122,11 +137,6 @@ private void InitializeFirebaseAppFromFirebaseOptions(Context context, FirebaseO } } - protected override void OnNotificationReceived(IDictionary data) - { - this.NotificationBuilder?.OnNotificationReceived(data); - } - public void ProcessIntent(Activity activity, Intent intent) { if (activity == null) @@ -153,10 +163,6 @@ public void ProcessIntent(Activity activity, Intent intent) return; } - var extras = intent.GetExtrasDict(); - this.logger.LogDebug( - $"ProcessIntent: activity.Type={activityType.Name}, intent.Flags=[{intent.Flags}], intent.Extras=[{extras.ToDebugString()}]"); - var launchedFromHistory = intent.Flags.HasFlag(ActivityFlags.LaunchedFromHistory); if (launchedFromHistory) { @@ -164,6 +170,16 @@ public void ProcessIntent(Activity activity, Intent intent) return; } + if (string.Equals(intent.Action, Intent.ActionMain, StringComparison.InvariantCultureIgnoreCase)) + { + // Don't process the intent if intent action is android.intent.action.MAIN + return; + } + + var extras = intent.GetExtrasDict(); + this.logger.LogDebug( + $"ProcessIntent: activity.Type={activityType.Name}, intent.Flags=[{intent.Flags}], intent.Extras=[{extras.ToDebugString()}]"); + if (extras.Any()) { // Don't process old/historic intents which are recycled for whatever reason @@ -171,7 +187,7 @@ public void ProcessIntent(Activity activity, Intent intent) if (!intent.GetBooleanExtra(intentAlreadyHandledKey, false)) { intent.PutExtra(intentAlreadyHandledKey, true); - this.logger.LogDebug($"ProcessIntent: {intentAlreadyHandledKey} not present --> Process notification"); + this.logger.LogDebug($"ProcessIntent: {intentAlreadyHandledKey} not present → Process notification"); if (extras.TryGetInt(Constants.ActionNotificationIdKey, out var notificationId)) { @@ -195,13 +211,12 @@ public void ProcessIntent(Activity activity, Intent intent) else { var notificationActionId = extras.GetStringOrDefault(Constants.NotificationActionId); - this.HandleNotificationAction(extras, notificationCategoryId, notificationActionId, - NotificationCategoryType.Default); + this.HandleNotificationAction(extras, notificationCategoryId, notificationActionId, NotificationCategoryType.Default); } } else { - this.logger.LogDebug($"ProcessIntent: {intentAlreadyHandledKey} is present --> Notification already processed"); + this.logger.LogDebug($"ProcessIntent: {intentAlreadyHandledKey} is present → Notification already processed"); } } } @@ -276,16 +291,16 @@ public string Token public INotificationBuilder NotificationBuilder { get; set; } /// - public void SubscribeTopics(string[] topics) + public async Task SubscribeTopicsAsync(string[] topics) { - foreach (var t in topics) + foreach (var topic in topics) { - this.SubscribeTopic(t); + await this.SubscribeTopicAsync(topic); } } /// - public void SubscribeTopic(string topic) + public async Task SubscribeTopicAsync(string topic) { if (topic == null) { @@ -300,10 +315,12 @@ public void SubscribeTopic(string topic) var subscribedTopics = new HashSet(this.SubscribedTopics); if (!subscribedTopics.Contains(topic)) { - this.logger.LogDebug($"Subscribe: topic=\"{topic}\""); + this.logger.LogDebug($"SubscribeTopicAsync: topic=\"{topic}\""); - // TODO: Use AddOnCompleteListener(...) - FirebaseMessaging.Instance.SubscribeToTopic(topic); + var tcs = new TaskCompletionSource(); + var taskCompleteListener = new TaskCompleteListener(tcs); + FirebaseMessaging.Instance.SubscribeToTopic(topic).AddOnCompleteListener(taskCompleteListener); + await tcs.Task; subscribedTopics.Add(topic); @@ -312,12 +329,12 @@ public void SubscribeTopic(string topic) } else { - this.logger.LogInformation($"Subscribe: skipping topic \"{topic}\"; topic is already subscribed"); + this.logger.LogInformation($"SubscribeTopicAsync: skipping topic \"{topic}\"; topic is already subscribed"); } } /// - public void UnsubscribeTopics(string[] topics) + public async Task UnsubscribeTopicsAsync(string[] topics) { if (topics == null) { @@ -325,26 +342,31 @@ public void UnsubscribeTopics(string[] topics) } // TODO: Improve efficiency here (move to base class maybe) - foreach (var t in topics) + foreach (var topic in topics) { - this.UnsubscribeTopic(t); + await this.UnsubscribeTopicAsync(topic); } } /// - public void UnsubscribeAllTopics() + public async Task UnsubscribeAllTopicsAsync() { + var topics = this.SubscribedTopics.ToArray(); + this.logger.LogDebug($"UnsubscribeAllTopicsAsync: topics=[{string.Join(',', topics)}]"); + foreach (var topic in this.SubscribedTopics) { - // TODO: Use AddOnCompleteListener(...) - FirebaseMessaging.Instance.UnsubscribeFromTopic(topic); + var tcs = new TaskCompletionSource(); + var taskCompleteListener = new TaskCompleteListener(tcs); + FirebaseMessaging.Instance.UnsubscribeFromTopic(topic).AddOnCompleteListener(taskCompleteListener); + await tcs.Task; } this.SubscribedTopics = null; } /// - public void UnsubscribeTopic(string topic) + public async Task UnsubscribeTopicAsync(string topic) { if (topic == null) { @@ -359,10 +381,13 @@ public void UnsubscribeTopic(string topic) var subscribedTopics = new HashSet(this.SubscribedTopics); if (subscribedTopics.Contains(topic)) { - this.logger.LogDebug($"Unsubscribe: topic=\"{topic}\""); + this.logger.LogDebug($"UnsubscribeTopicAsync: topic=\"{topic}\""); + + var tcs = new TaskCompletionSource(); + var taskCompleteListener = new TaskCompleteListener(tcs); + FirebaseMessaging.Instance.UnsubscribeFromTopic(topic).AddOnCompleteListener(taskCompleteListener); + await tcs.Task; - // TODO: Use AddOnCompleteListener(...) - FirebaseMessaging.Instance.UnsubscribeFromTopic(topic); subscribedTopics.Remove(topic); // TODO: Improve write performance here; don't loop all topics one by one @@ -370,25 +395,27 @@ public void UnsubscribeTopic(string topic) } else { - this.logger.LogInformation($"Unsubscribe: skipping topic \"{topic}\"; topic is not subscribed"); + this.logger.LogInformation($"UnsubscribeTopicAsync: skipping topic \"{topic}\"; topic is not subscribed"); } } protected override void HandleTokenRefreshPlatform(string token) { - this.ResubscribeExistingTopics(); + _ = this.ResubscribeExistingTopicsAsync(); } /// /// Resubscribes all existing topics since the old instance id isn't valid anymore. /// This is obviously necessary but seems a very bad design decision... /// - private void ResubscribeExistingTopics() + private async Task ResubscribeExistingTopicsAsync() { foreach (var topic in this.SubscribedTopics) { - // TODO: Use AddOnCompleteListener(...) - FirebaseMessaging.Instance.SubscribeToTopic(topic); + var tcs = new TaskCompletionSource(); + var taskCompleteListener = new TaskCompleteListener(tcs); + FirebaseMessaging.Instance.SubscribeToTopic(topic).AddOnCompleteListener(taskCompleteListener); + await tcs.Task; } } diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/ILLink.Descriptors.xml b/Plugin.FirebasePushNotifications/Platforms/Android/ILLink.Descriptors.xml new file mode 100644 index 00000000..5a86e3db --- /dev/null +++ b/Plugin.FirebasePushNotifications/Platforms/Android/ILLink.Descriptors.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/NotificationBuilder.cs b/Plugin.FirebasePushNotifications/Platforms/Android/NotificationBuilder.cs index 88b09416..b7d39a4b 100644 --- a/Plugin.FirebasePushNotifications/Platforms/Android/NotificationBuilder.cs +++ b/Plugin.FirebasePushNotifications/Platforms/Android/NotificationBuilder.cs @@ -45,7 +45,7 @@ public virtual bool ShouldHandleNotificationReceived(IDictionary // we don't display any local notification. this.logger.LogDebug( $"ShouldHandleNotificationReceived returns false " + - $"(Reason: Key '{MessageNotificationKeys.NoUi}' is present)"); + $"(Reason: Data key '{MessageNotificationKeys.NoUi}' is present)"); return false; } @@ -55,99 +55,52 @@ public virtual bool ShouldHandleNotificationReceived(IDictionary // we don't display any local notification. this.logger.LogDebug( $"ShouldHandleNotificationReceived returns false " + - $"(Reason: Key '{Constants.SilentKey}' is present)"); + $"(Reason: Data key '{Constants.SilentKey}' is present)"); return false; } - if (notificationParams.IsNotification) - { - var isAppInBackground = !AppHelper.IsAppForeground(Application.Context); - if (isAppInBackground) - { - return true; - } - } + var isAppInForeground = AppHelper.IsAppForeground(Application.Context); + var isAppInBackground = !isAppInForeground; - var notificationImportance = GetNotificationImportance(data); - if (notificationImportance >= NotificationImportance.High) + if (!notificationParams.IsNotification) { - // In case we receive a notification with priority >= high - // we show it in a local notification popup. this.logger.LogDebug( - $"ShouldHandleNotificationReceived returns true " + - $"(Reason: Notification importance '{notificationImportance}' is greater or equal to 'high')"); - return true; + $"ShouldHandleNotificationReceived returns false " + + $"(Reason: Data-only notification)"); + return false; } - var defaultNotificationImportance = this.options.Android.DefaultNotificationImportance; - if (defaultNotificationImportance >= NotificationImportance.High) + if (isAppInBackground) { - // In case a default notification importance >= high is configured - // we show it in a local notification popup. this.logger.LogDebug( $"ShouldHandleNotificationReceived returns true " + - $"(Reason: Default notification importance '{defaultNotificationImportance}' is greater or equal to 'high')"); + $"(Reason: App runs in background mode)"); return true; } - var presentClickActionKeys = Constants.ClickActionKeys - .Where(data.ContainsKey) - .ToArray(); - - if (presentClickActionKeys.Length > 0) - { - var isAppInBackground = !AppHelper.IsAppForeground(Application.Context); - if (isAppInBackground) - { - // If we received a "click_action" or "category" - // and we run in background mode - // we need to show a local notification with action buttons. - this.logger.LogDebug( - $"ShouldHandleNotificationReceived returns true " + - $"(Reason: {(presentClickActionKeys.Length == 1 ? - $"Key '{presentClickActionKeys.Single()}' is present" : - $"Keys [{string.Join(",", presentClickActionKeys)}] are present")})"); - return true; - } - } - - var notificationChannel = this.GetNotificationChannel(data); - if (notificationChannel is { Importance: >= NotificationImportance.High }) + var notificationImportance = GetNotificationImportance(data); + if (notificationImportance >= NotificationImportance.High) { - // In case we receive a notification which targets a specific notification channel - // and the notification channel's importance is >= high - // we show it in a local notification popup. this.logger.LogDebug( $"ShouldHandleNotificationReceived returns true " + - $"(Reason: Target notification channel '{notificationChannel.Id}' " + - $"has importance '{notificationChannel.Importance}' greater or equal to 'high')"); + $"(Reason: Notification importance '{notificationImportance}' is higher than or equal to 'High')"); return true; } - if (data.ContainsKey(Constants.LargeIconKey)) - { - // If we received a "large_icon" - // we need to show a local notification with SetLargeIcon - this.logger.LogDebug( - $"ShouldHandleNotificationReceived returns true " + - $"(Reason: Key '{Constants.LargeIconKey}' present)"); - return true; - } + // if (data.ContainsKey(Constants.LargeIconKey)) + // { + // // If we received a "large_icon" + // // we need to show a local notification with SetLargeIcon + // this.logger.LogDebug( + // $"ShouldHandleNotificationReceived returns true " + + // $"(Reason: Key '{Constants.LargeIconKey}' present)"); + // return true; + // } this.logger.LogDebug("ShouldHandleNotificationReceived returns false"); return false; } - void INotificationBuilder.OnNotificationReceived(IDictionary data) - { - if (!this.ShouldHandleNotificationReceived(data)) - { - return; - } - - this.OnNotificationReceived(data); - } - /// /// This method is called if we have to build our own, custom notification using NotificationCompat.Builder. /// @@ -170,12 +123,13 @@ public virtual void OnNotificationReceived(IDictionary data) extras.PutString(kvp.Key, kvp.Value.ToString()); } - var notificationId = this.GetNotificationId(data); + var notificationId = GetNotificationId(data); extras.PutInt(Constants.ActionNotificationIdKey, notificationId); - if (data.TryGetString(Constants.NotificationTagKey, out var tag)) + var notificationTag = GetNotificationTag(data); + if (notificationTag != null) { - extras.PutString(Constants.ActionNotificationTagKey, tag); + extras.PutString(Constants.ActionNotificationTagKey, notificationTag); } var context = Application.Context; @@ -187,22 +141,36 @@ public virtual void OnNotificationReceived(IDictionary data) launchIntent.SetFlags(activityFlags); } - var notificationChannel = this.GetNotificationChannelOrDefault(data); - if (notificationChannel == null) + NotificationChannel notificationChannel; + string notificationChannelId; + if (Build.VERSION.SdkInt < BuildVersionCodes.O) + { + notificationChannel = null; + notificationChannelId = NotificationChannel.DefaultChannelId; + } + else { - this.logger.LogError( - $"NotificationCompat.Builder requires a notification channel to work properly. " + - $"Use {nameof(INotificationChannels)}.{nameof(INotificationChannels.CreateNotificationChannels)} " + - $"to create at least one notification channel."); - return; + notificationChannel = this.GetNotificationChannelOrDefault(data); + if (notificationChannel == null) + { + this.logger.LogError( + $"NotificationCompat.Builder requires a notification channel to work properly. " + + $"Use {nameof(INotificationChannels)}.{nameof(INotificationChannels.CreateNotificationChannels)} or " + + $"{nameof(INotificationChannels)}.{nameof(INotificationChannels.SetNotificationChannels)} " + + $"to create at least one notification channel."); + return; + } + + notificationChannelId = notificationChannel.Id; } - var notificationImportance = this.GetNotificationImportanceOrDefault(data); - if (notificationChannel.Importance < notificationImportance) + var notificationImportance = GetNotificationImportance(data); + if (notificationChannel is { Importance: var notificationChannelImportance } && + notificationChannelImportance < notificationImportance) { this.logger.LogWarning( - $"Notification channel '{notificationChannel.Id}' has importance '{notificationChannel.Importance}' " + - $"which is lower than notification importance '{notificationImportance}'"); + $"Notification channel with Id={notificationChannelId} has Importance={notificationChannelImportance} " + + $"which is lower than '{notificationImportance}'."); } var smallIconResource = this.GetSmallIconResource(data, context); @@ -213,7 +181,7 @@ public virtual void OnNotificationReceived(IDictionary data) var pendingIntent = PendingIntent.GetActivity(context, requestCode, launchIntent, PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable); - var notificationBuilder = new NotificationCompat.Builder(context, notificationChannel.Id) + var notificationBuilder = new NotificationCompat.Builder(context, notificationChannelId) .SetSmallIcon(smallIconResource) .SetAutoCancel(true) .SetWhen(Java.Lang.JavaSystem.CurrentTimeMillis()) @@ -255,10 +223,12 @@ public virtual void OnNotificationReceived(IDictionary data) if (Build.VERSION.SdkInt < BuildVersionCodes.O) { - var notificationPriority = GetNotificationPriority(notificationImportance); + // SetPriority was deprecated in API level 26. + var notificationImportanceOrDefault = notificationImportance ?? this.options.Android.DefaultNotificationImportance; + var notificationPriority = GetNotificationPriority(notificationImportanceOrDefault); notificationBuilder.SetPriority(notificationPriority); - var notificationVibrationPattern = GetNotificationVibrationPattern(notificationImportance); + var notificationVibrationPattern = GetNotificationVibrationPattern(notificationImportanceOrDefault); if (notificationVibrationPattern != null) { notificationBuilder.SetVibrate(notificationVibrationPattern); @@ -266,6 +236,7 @@ public virtual void OnNotificationReceived(IDictionary data) try { + // SetSound was deprecated in API level 26. var soundUri = this.GetSoundUri(data, context); notificationBuilder.SetSound(soundUri); } @@ -335,8 +306,8 @@ public virtual void OnNotificationReceived(IDictionary data) PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable); } - var icon = context.Resources.GetIdentifier(notificationAction.Icon ?? "", "drawable", context.PackageName); - var action = new NotificationCompat.Action.Builder(icon, notificationAction.Title, pendingActionIntent).Build(); + var iconResource = this.GetIconResourceFromDrawableOrMipmap(context, notificationAction.Icon); + var action = new NotificationCompat.Action.Builder(iconResource, notificationAction.Title, pendingActionIntent).Build(); notificationBuilder.AddAction(action); } } @@ -360,13 +331,13 @@ public virtual void OnNotificationReceived(IDictionary data) var notificationManager = (NotificationManager)context.GetSystemService(Context.NotificationService); var notification = notificationBuilder.Build(); - if (tag == null) + if (notificationTag == null) { notificationManager.Notify(notificationId, notification); } else { - notificationManager.Notify(tag, notificationId, notification); + notificationManager.Notify(notificationTag, notificationId, notification); } } @@ -470,12 +441,7 @@ private static long[] GetNotificationVibrationPattern(NotificationImportance not private Bitmap GetLargeIconBitmap(IDictionary data, Context context) { - var largeIconResource = this.GetIconResourceFromDrawable(context, data, Constants.LargeIconKey); - - if (largeIconResource == 0) - { - largeIconResource = this.GetIconResourceFromMipmap(context, data, Constants.LargeIconKey); - } + var largeIconResource = this.GetIconResourceFromDrawableOrMipmap(context, data, Constants.LargeIconKey); if (largeIconResource == 0 && data.TryGetString(Constants.LargeIconKey, out var largeIconUrl) && @@ -539,12 +505,7 @@ private Bitmap DownloadBitmap(string url) private int GetSmallIconResource(IDictionary data, Context context) { - var smallIconResource = this.GetIconResourceFromDrawable(context, data, Constants.IconKey); - - if (smallIconResource == 0) - { - smallIconResource = this.GetIconResourceFromMipmap(context, data, Constants.IconKey); - } + var smallIconResource = this.GetIconResourceFromDrawableOrMipmap(context, data, Constants.IconKey); if (smallIconResource == 0 && this.options.Android.DefaultIconResource is int defaultIconResource) { @@ -620,48 +581,55 @@ private string TryGetResourceName(Context context, int resid, string residName, return resourceName; } - private static int GetIconResourceFromDrawableOrMipmap___(Context context, IDictionary data, string dataKey) + private int GetIconResourceFromDrawableOrMipmap(Context context, IDictionary data, string iconKey) { - var iconResourceId = 0; + var iconResource = 0; - if (data.TryGetString(dataKey, out var iconKey) && iconKey != null) + if (data.TryGetString(iconKey, out var iconName)) { - iconResourceId = context.Resources.GetIdentifier(iconKey, "drawable", context.PackageName); - - if (iconResourceId == 0) - { - iconResourceId = context.Resources.GetIdentifier(iconKey, "mipmap", context.PackageName); - } + iconResource = this.GetIconResourceFromDrawableOrMipmap(context, iconName); } - return iconResourceId; + return iconResource; } - private int GetIconResourceFromDrawable(Context context, IDictionary data, string dataKey) + private int GetIconResourceFromDrawableOrMipmap(Context context, string iconName) { - return this.GetIconResource(context, data, dataKey, "drawable"); + if (string.IsNullOrEmpty(iconName)) + { + return 0; + } + + var iconResource = this.GetIconResourceFromDrawable(context, iconName); + + if (iconResource == 0) + { + iconResource = this.GetIconResourceFromMipmap(context, iconName); + } + + return iconResource; } - private int GetIconResourceFromMipmap(Context context, IDictionary data, string dataKey) + private int GetIconResourceFromMipmap(Context context, string iconName) { - return this.GetIconResource(context, data, dataKey, "mipmap"); + return this.GetIconResource(context, iconName, "mipmap"); } - private int GetIconResource(Context context, IDictionary data, string dataKey, string defType) + private int GetIconResourceFromDrawable(Context context, string iconName) { - var iconResourceId = 0; + return this.GetIconResource(context, iconName, "drawable"); + } - if (data.TryGetString(dataKey, out var iconKey) && iconKey != null) + private int GetIconResource(Context context, string iconName, string defType) + { + var resourceId = context.Resources.GetIdentifier(iconName, defType, context.PackageName); + var resourceName = this.TryGetResourceName(context, resourceId, nameof(resourceId), nameof(this.GetIconResource), defType); + if (resourceName == null) { - iconResourceId = context.Resources.GetIdentifier(iconKey, defType, context.PackageName); - var resourceName = this.TryGetResourceName(context, iconResourceId, nameof(iconResourceId), nameof(this.GetIconResource), defType); - if (resourceName == null) - { - iconResourceId = 0; - } + resourceId = 0; } - return iconResourceId; + return resourceId; } private int? GetNotificationColor(IDictionary data) @@ -760,27 +728,18 @@ private NotificationChannel GetNotificationChannel(IDictionary d return notificationChannel; } - private int GetNotificationId(IDictionary data) + private static int GetNotificationId(IDictionary data) { - var notificationId = 0; - - // TODO: Use TryGetInt here - if (data.TryGetString(Constants.IdKey, out var id)) - { - try - { - notificationId = Convert.ToInt32(id); - } - catch (Exception ex) - { - // Keep the default value of zero for the notify_id, but log the conversion problem. - this.logger.LogError(ex, $"Failed to convert {id} to an integer"); - } - } - + data.TryGetInt(Constants.IdKey, out var notificationId); return notificationId; } + private static string GetNotificationTag(IDictionary data) + { + data.TryGetString(Constants.NotificationTagKey, out var notificationTag); + return notificationTag; + } + private static NotificationImportance? GetNotificationImportance(IDictionary data) { NotificationImportance? notificationImportance = null; @@ -793,14 +752,21 @@ private int GetNotificationId(IDictionary data) return notificationImportance; } - private NotificationImportance GetNotificationImportanceOrDefault(IDictionary data) + private (NotificationImportance, string) GetNotificationImportanceOrDefault(IDictionary data) { + string notificationImportanceSource; + if (GetNotificationImportance(data) is not NotificationImportance notificationImportance) { notificationImportance = this.options.Android.DefaultNotificationImportance; + notificationImportanceSource = nameof(this.options.Android.DefaultNotificationImportance); + } + else + { + notificationImportanceSource = $"notification '{Constants.PriorityKey}' flag"; } - return notificationImportance; + return (notificationImportance, notificationImportanceSource); } private static NotificationImportance GetNotificationImportance(string priorityValue) @@ -918,8 +884,7 @@ private static string GetCategoryValue(IDictionary data) /// /// Notification builder. /// Data payload. - private void ResolveLocalizedParameters(NotificationCompat.Builder notificationBuilder, - IDictionary data) + private void ResolveLocalizedParameters(NotificationCompat.Builder notificationBuilder, IDictionary data) { // Resolve title localization if (data.TryGetString("title_loc_key", out var titleKey)) @@ -946,8 +911,7 @@ private void ResolveLocalizedParameters(NotificationCompat.Builder notificationB } } - private string GetLocalizedString(string name, string[] arguments, - NotificationCompat.Builder notificationBuilder) + private string GetLocalizedString(string name, string[] arguments, NotificationCompat.Builder notificationBuilder) { try { diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/NotificationPermissions.cs b/Plugin.FirebasePushNotifications/Platforms/Android/NotificationPermissions.cs index a7951aff..6a316b53 100644 --- a/Plugin.FirebasePushNotifications/Platforms/Android/NotificationPermissions.cs +++ b/Plugin.FirebasePushNotifications/Platforms/Android/NotificationPermissions.cs @@ -103,7 +103,7 @@ private bool IsAnyChannelBlocked(string[] channelIds) foreach (var channelId in channelIds) { var channel = this.notificationManager.GetNotificationChannel(channelId); - if (channel != null && channel.Importance == NotificationImportance.None) + if (channel is { Importance: NotificationImportance.None }) { return true; } diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/Options/FirebasePushNotificationAndroidOptions.cs b/Plugin.FirebasePushNotifications/Platforms/Android/Options/FirebasePushNotificationAndroidOptions.cs index bf0cfa48..39e7ce46 100644 --- a/Plugin.FirebasePushNotifications/Platforms/Android/Options/FirebasePushNotificationAndroidOptions.cs +++ b/Plugin.FirebasePushNotifications/Platforms/Android/Options/FirebasePushNotificationAndroidOptions.cs @@ -79,12 +79,12 @@ public virtual NotificationChannelRequest[] NotificationChannels public string NotificationBodyKey { get; set; } /// - /// The notification importance used by default - /// - if the notification data does not contain any "priority" flags. - /// - as notification importance for the default notification channel (if not specified). - /// Default value: NotificationImportance.Default + /// The notification importance used in following cases: + /// - If the notification data does not contain any "priority" flags. + /// - As notification importance for the default notification channel. + /// Default value: NotificationImportance.High /// - public NotificationImportance DefaultNotificationImportance { get; set; } = NotificationImportance.Default; + public NotificationImportance DefaultNotificationImportance { get; set; } = NotificationImportance.High; public int? DefaultIconResource { get; set; } diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/PNFirebaseMessagingService.cs b/Plugin.FirebasePushNotifications/Platforms/Android/PNFirebaseMessagingService.cs index 3130a4b6..2e40ee08 100644 --- a/Plugin.FirebasePushNotifications/Platforms/Android/PNFirebaseMessagingService.cs +++ b/Plugin.FirebasePushNotifications/Platforms/Android/PNFirebaseMessagingService.cs @@ -3,6 +3,7 @@ using Android.OS; using Firebase.Messaging; using Microsoft.Extensions.Logging; +using Plugin.FirebasePushNotifications.Utils; namespace Plugin.FirebasePushNotifications.Platforms { @@ -124,17 +125,22 @@ private void DispatchMessage(Intent intent) var data = intent.GetExtrasDict(); data.Remove("com.google.firebase.iid.WakeLockHolder.wakefulintent"); - if (this.notificationBuilder.ShouldHandleNotificationReceived(data)) + var handleNotification = this.notificationBuilder.ShouldHandleNotificationReceived(data); + + var isAppInForeground = AppHelper.IsAppForeground(); + if (isAppInForeground || handleNotification == false) { - this.notificationBuilder.OnNotificationReceived(data); + this.firebasePushNotification.HandleNotificationReceived(data); } - else + + if (handleNotification) { - this.firebasePushNotification.HandleNotificationReceived(data); + this.notificationBuilder.OnNotificationReceived(data); } } // TODO: Check if this code is still needed or if it can be removed. + /* private void OnMessageReceived(RemoteMessage remoteMessage) { this.logger.LogDebug("OnMessageReceived"); @@ -237,7 +243,7 @@ private void OnMessageReceived(RemoteMessage remoteMessage) } this.firebasePushNotification.HandleNotificationReceived(data); - } + }*/ private void HandleTokenIntent(Intent intent) { diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/PushNotificationActionReceiver.cs b/Plugin.FirebasePushNotifications/Platforms/Android/PushNotificationActionReceiver.cs index 91b88a16..ff8c1040 100644 --- a/Plugin.FirebasePushNotifications/Platforms/Android/PushNotificationActionReceiver.cs +++ b/Plugin.FirebasePushNotifications/Platforms/Android/PushNotificationActionReceiver.cs @@ -28,18 +28,17 @@ public override void OnReceive(Context context, Intent intent) var firebasePushNotification = IFirebasePushNotification.Current; firebasePushNotification.HandleNotificationAction(extras, notificationCategoryId, notificationActionId, NotificationCategoryType.Default); - var manager = context.GetSystemService(Context.NotificationService) as NotificationManager; - var notificationId = extras.GetValueOrDefault(Constants.ActionNotificationIdKey, -1); - if (notificationId != -1) + var notificationManager = context.GetSystemService(Context.NotificationService) as NotificationManager; + if (extras.TryGetInt(Constants.ActionNotificationIdKey, out var notificationId)) { var notificationTag = extras.GetStringOrDefault(Constants.ActionNotificationTagKey, null); if (notificationTag == null) { - manager.Cancel(notificationId); + notificationManager.Cancel(notificationId); } else { - manager.Cancel(notificationTag, notificationId); + notificationManager.Cancel(notificationTag, notificationId); } } } diff --git a/Plugin.FirebasePushNotifications/Platforms/Android/proguard.cfg b/Plugin.FirebasePushNotifications/Platforms/Android/proguard.cfg new file mode 100644 index 00000000..04a0f103 --- /dev/null +++ b/Plugin.FirebasePushNotifications/Platforms/Android/proguard.cfg @@ -0,0 +1,6 @@ +-dontwarn com.google.android.gms.** +-keep class com.google.android.gms.** { *; } +-keep class com.google.firebase.** { *; } +-keep class androidx.startup.AppInitializer +-keep class androidx.startup.InitializationProvider +-keep class androidx.startup.Initializer \ No newline at end of file diff --git a/Plugin.FirebasePushNotifications/Platforms/iOS/FirebasePushNotificationManager.cs b/Plugin.FirebasePushNotifications/Platforms/iOS/FirebasePushNotificationManager.cs index 0602f5d6..10e52fbc 100644 --- a/Plugin.FirebasePushNotifications/Platforms/iOS/FirebasePushNotificationManager.cs +++ b/Plugin.FirebasePushNotifications/Platforms/iOS/FirebasePushNotificationManager.cs @@ -311,8 +311,7 @@ public void DidReceiveRemoteNotification(NSDictionary userInfo) } /// - public void DidReceiveRemoteNotification(UIApplication application, NSDictionary userInfo, - Action completionHandler) + public void DidReceiveRemoteNotification(UIApplication application, NSDictionary userInfo, Action completionHandler) { this.logger.LogDebug("DidReceiveRemoteNotification(UIApplication, NSDictionary, Action)"); @@ -334,8 +333,7 @@ private void DidReceiveRemoteNotificationInternal(NSDictionary userInfo) this.HandleNotificationReceived(data); } - private void WillPresentNotification(UNUserNotificationCenter center, UNNotification notification, - Action completionHandler) + private void WillPresentNotification(UNUserNotificationCenter center, UNNotification notification, Action completionHandler) { if (OperatingSystem.IsIOSVersionAtLeast(18)) { @@ -361,11 +359,9 @@ private void WillPresentNotification(UNUserNotificationCenter center, UNNotifica completionHandler(notificationPresentationOptions); } - private static UNNotificationPresentationOptions GetNotificationPresentationOptions( - IDictionary data, - UNNotificationPresentationOptions defaultNotificationPresentationOptions) + private static UNNotificationPresentationOptions GetNotificationPresentationOptions(IDictionary data, UNNotificationPresentationOptions notificationPresentationOptions) { - var notificationPresentationOptions = defaultNotificationPresentationOptions; + var options = notificationPresentationOptions; var priority = GetPriorityValue(data); if (!string.IsNullOrEmpty(priority)) @@ -374,21 +370,21 @@ private static UNNotificationPresentationOptions GetNotificationPresentationOpti { if (UIDevice.CurrentDevice.CheckSystemVersion(14, 0)) { - if (!notificationPresentationOptions.HasFlag(UNNotificationPresentationOptions.List)) + if (!options.HasFlag(UNNotificationPresentationOptions.List)) { - notificationPresentationOptions |= UNNotificationPresentationOptions.List; + options |= UNNotificationPresentationOptions.List; } - if (!notificationPresentationOptions.HasFlag(UNNotificationPresentationOptions.Banner)) + if (!options.HasFlag(UNNotificationPresentationOptions.Banner)) { - notificationPresentationOptions |= UNNotificationPresentationOptions.Banner; + options |= UNNotificationPresentationOptions.Banner; } } else { - if (!notificationPresentationOptions.HasFlag(UNNotificationPresentationOptions.Alert)) + if (!options.HasFlag(UNNotificationPresentationOptions.Alert)) { - notificationPresentationOptions |= UNNotificationPresentationOptions.Alert; + options |= UNNotificationPresentationOptions.Alert; } } } @@ -396,27 +392,27 @@ private static UNNotificationPresentationOptions GetNotificationPresentationOpti { if (UIDevice.CurrentDevice.CheckSystemVersion(14, 0)) { - if (notificationPresentationOptions.HasFlag(UNNotificationPresentationOptions.List)) + if (options.HasFlag(UNNotificationPresentationOptions.List)) { - notificationPresentationOptions &= ~UNNotificationPresentationOptions.List; + options &= ~UNNotificationPresentationOptions.List; } - if (notificationPresentationOptions.HasFlag(UNNotificationPresentationOptions.Banner)) + if (options.HasFlag(UNNotificationPresentationOptions.Banner)) { - notificationPresentationOptions &= ~UNNotificationPresentationOptions.Banner; + options &= ~UNNotificationPresentationOptions.Banner; } } else { - if (notificationPresentationOptions.HasFlag(UNNotificationPresentationOptions.Alert)) + if (options.HasFlag(UNNotificationPresentationOptions.Alert)) { - notificationPresentationOptions &= ~UNNotificationPresentationOptions.Alert; + options &= ~UNNotificationPresentationOptions.Alert; } } } } - return notificationPresentationOptions; + return options; } private static string GetPriorityValue(IDictionary data) @@ -437,16 +433,16 @@ private static string GetPriorityValue(IDictionary data) } /// - public void SubscribeTopics(string[] topics) + public async Task SubscribeTopicsAsync(string[] topics) { - foreach (var t in topics) + foreach (var topic in topics) { - this.SubscribeTopic(t); + await this.SubscribeTopicAsync(topic); } } /// - public void SubscribeTopic(string topic) + public async Task SubscribeTopicAsync(string topic) { if (topic == null) { @@ -467,36 +463,36 @@ public void SubscribeTopic(string topic) var subscribedTopics = new HashSet(this.SubscribedTopics); if (!subscribedTopics.Contains(topic)) { - this.logger.LogDebug($"Subscribe: topic=\"{topic}\""); + this.logger.LogDebug($"SubscribeTopicAsync: topic=\"{topic}\""); - Firebase.CloudMessaging.Messaging.SharedInstance.Subscribe(topic); + await Firebase.CloudMessaging.Messaging.SharedInstance.SubscribeAsync(topic); subscribedTopics.Add(topic); this.SubscribedTopics = subscribedTopics.ToArray(); } else { - this.logger.LogInformation($"Subscribe: skipping topic \"{topic}\"; topic is already subscribed"); + this.logger.LogInformation($"SubscribeTopicAsync: skipping topic \"{topic}\"; topic is already subscribed"); } } /// - public void UnsubscribeAllTopics() + public async Task UnsubscribeAllTopicsAsync() { var topics = this.SubscribedTopics.ToArray(); - this.logger.LogDebug($"UnsubscribeAllTopics: topics=[{string.Join(',', topics)}]"); + this.logger.LogDebug($"UnsubscribeAllTopicsAsync: topics=[{string.Join(',', topics)}]"); foreach (var topic in topics) { - this.logger.LogDebug($"Unsubscribe: topic=\"{topic}\""); - Firebase.CloudMessaging.Messaging.SharedInstance.Unsubscribe(topic); + this.logger.LogDebug($"UnsubscribeAsync: topic=\"{topic}\""); + await Firebase.CloudMessaging.Messaging.SharedInstance.UnsubscribeAsync(topic); } this.SubscribedTopics = null; } /// - public void UnsubscribeTopics(string[] topics) + public async Task UnsubscribeTopicsAsync(string[] topics) { if (topics == null) { @@ -504,14 +500,14 @@ public void UnsubscribeTopics(string[] topics) } // TODO: Improve efficiency here (move to base class maybe) - foreach (var t in topics) + foreach (var topic in topics) { - this.UnsubscribeTopic(t); + await this.UnsubscribeTopicAsync(topic); } } /// - public void UnsubscribeTopic(string topic) + public async Task UnsubscribeTopicAsync(string topic) { if (topic == null) { @@ -532,16 +528,16 @@ public void UnsubscribeTopic(string topic) var subscribedTopics = new HashSet(this.SubscribedTopics); if (subscribedTopics.Contains(topic)) { - this.logger.LogDebug($"Unsubscribe: topic=\"{topic}\""); + this.logger.LogDebug($"UnsubscribeTopicAsync: topic=\"{topic}\""); - Firebase.CloudMessaging.Messaging.SharedInstance.Unsubscribe(topic); + await Firebase.CloudMessaging.Messaging.SharedInstance.UnsubscribeAsync(topic); subscribedTopics.Remove(topic); this.SubscribedTopics = subscribedTopics.ToArray(); } else { - this.logger.LogInformation($"Unsubscribe: skipping topic \"{topic}\"; topic is not subscribed"); + this.logger.LogInformation($"UnsubscribeTopicAsync: skipping topic \"{topic}\"; topic is not subscribed"); } } @@ -601,10 +597,10 @@ private void DidReceiveRegistrationToken(string fcmToken) this.HandleTokenRefresh(fcmToken); - this.TryDequeuePendingTopics(); + _ = this.TryDequeuePendingTopicsAsync(); } - private void TryDequeuePendingTopics() + private async Task TryDequeuePendingTopicsAsync() { if (!HasApnsToken) { @@ -615,11 +611,11 @@ private void TryDequeuePendingTopics() { if (pendingTopic.Subscribe) { - this.SubscribeTopic(pendingTopic.Topic); + await this.SubscribeTopicAsync(pendingTopic.Topic); } else { - this.UnsubscribeTopic(pendingTopic.Topic); + await this.UnsubscribeTopicAsync(pendingTopic.Topic); } } } @@ -646,6 +642,7 @@ public async void RemoveNotification(int id) .Where(u => $"{u.Request.Content.UserInfo[notificationIdKey]}".Equals($"{id}")) .Select(s => s.Request.Identifier) .ToArray(); + if (deliveredNotificationsMatches.Length > 0) { UNUserNotificationCenter.Current.RemoveDeliveredNotifications(deliveredNotificationsMatches); diff --git a/Plugin.FirebasePushNotifications/Plugin.FirebasePushNotifications.csproj b/Plugin.FirebasePushNotifications/Plugin.FirebasePushNotifications.csproj index 7a5359c8..ced1bafb 100644 --- a/Plugin.FirebasePushNotifications/Plugin.FirebasePushNotifications.csproj +++ b/Plugin.FirebasePushNotifications/Plugin.FirebasePushNotifications.csproj @@ -1,18 +1,18 @@  - net7.0;net7.0-android33.0;net7.0-ios;net8.0;net8.0-android34.0;net8.0-ios17.0 + net9.0;net9.0-android35.0;net9.0-ios18.0;net10.0;net10.0-android36.0;net10.0-ios26.0 Library true - 8.0.3 - 7.0.49 + 9.0.0 + 10.0.0 true enable disable true - True + - 12.0 + 12.2 24.0 true @@ -32,46 +32,18 @@ firebase;push;notification;notifications logo.png LICENSE + README.md https://github.com/thomasgalliker/Plugin.FirebasePushNotifications git https://github.com/thomasgalliker/Plugin.FirebasePushNotifications superdev GmbH false - 3.1 -- Extend INotificationChannels to manage notification channel groups. -- Internal refactoring of INotificationChannels implementation. -- Removed properties IsActive and IsDefault from NotificationChannelRequest. Set the default notification channel via UseFirebasePushNotifications(o => o.Android.DefaultNotificationChannelId = ...). -- Configure initial list of notification channels via o.Android.NotificationChannels and notification groups via o.Android.NotificationChannelGroups. - -3.0 -- Update firebase-ios-sdk by replacing nuget package Xamarin.Firebase.iOS.CloudMessaging with AdamE.Firebase.iOS.CloudMessaging. - -2.5 -- Move static properties from Android's FirebasePushNotificationManager to FirebasePushNotificationAndroidOptions. -- iOS 18 workaround for duplicate notifications in foreground mode. -- iOS options to override default UNNotificationPresentationOptions for notifications received in foreground mode. -- Handle gcm.notification.click_action payload as click_action in Android. - -2.4 -- Refactor instanciation of IFirebasePushNotification. -- Refactor startup procedure of platform-specific services. -- Add singleton instance INotificationPermissions.Current. - -2.3 -- General bug fixes and code cleanup. -- Bug fixes in the area of topic subscriptions. -- IFirebasePushNotification.Current. -- Add singleton instance IFirebasePushNotification.Current and INotificationPermissions.Current. - -2.2 -- Complete refactoring of the original 1.x implementation. -- Simplified APIs, less static code, support for dependency injection. - -1.0 -- Initial release. - - Copyright $([System.DateTime]::Now.ToString(`yyyy`)) © Thomas Galliker - README.md + Copyright $([System.DateTime]::Now.ToString(`yyyy`)) © Thomas Galliker + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/../ReleaseNotes.txt")) + true + snupkg + true + true @@ -80,30 +52,43 @@ - - + + true + + + + - - - + + + - - - + + + - - - + + + + + + - - - + + - - + + + + + + + + + diff --git a/README.md b/README.md index 4581fa95..38a5be99 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,36 @@ # Plugin.FirebasePushNotifications + [![Version](https://img.shields.io/nuget/v/Plugin.FirebasePushNotifications.svg)](https://www.nuget.org/packages/Plugin.FirebasePushNotifications) [![Downloads](https://img.shields.io/nuget/dt/Plugin.FirebasePushNotifications.svg)](https://www.nuget.org/packages/Plugin.FirebasePushNotifications) [![Buy Me a Coffee](https://img.shields.io/badge/support-buy%20me%20a%20coffee-FFDD00)](https://buymeacoffee.com/thomasgalliker) -Plugin.FirebasePushNotifications provides a seamless way to engage users and keep them informed about important events in your .NET MAUI applications. This open-source C# library integrates Firebase Cloud Messaging (FCM) into your .NET MAUI projects, enabling you to receive push notifications effortlessly. +Plugin.FirebasePushNotifications provides a seamless way to engage users and keep them informed about important events +in your .NET MAUI applications. This open-source C# library integrates Firebase Cloud Messaging (FCM) into your .NET +MAUI projects, enabling you to receive push notifications effortlessly. + +## Features -### Features -* Cross-platform Compatibility: Works seamlessly with .NET MAUI, ensuring a consistent push notification experience across different devices and platforms. +* Cross-platform Compatibility: Works seamlessly with .NET MAUI, ensuring a consistent push notification experience + across different devices and platforms. * Easy Integration: Simple setup process to incorporate Firebase Push Notifications into your .NET MAUI apps. -* Flexible Messaging: Utilize FCM's powerful features, such as targeted messaging, to send notifications based on user segments or specific conditions. +* Flexible Messaging: Utilize FCM's powerful features, such as targeted messaging, to send notifications based on user + segments or specific conditions. + +## Download and Install Plugin.FirebasePushNotifications -### Download and Install Plugin.FirebasePushNotifications This library is available on NuGet: https://www.nuget.org/packages/Plugin.FirebasePushNotifications Use the following command to install Plugin.FirebasePushNotifications using NuGet package manager console: PM> Install-Package Plugin.FirebasePushNotifications -You can use this library in any .NET MAUI project compatible to .NET 7 and higher. +## Setup + +### Setup Firebase Push Notifications + +- Go to https://console.firebase.google.com and create a new project. The setup of Firebase projects is not (yet?) + documented here. Contributors welcome! +- You have to download the resulting Firebase service files and integrate them into your .NET MAUI csproj file. + `google-services.json` is used by Android while `GoogleService-Info.plist` is accessible to iOS. Make sure the Include + and the Link paths match. -### Setup -#### Setup Firebase Push Notifications -- Go to https://console.firebase.google.com and create a new project. The setup of Firebase projects is not (yet?) documented here. Contributors welcome! -- You have to download the resulting Firebase service files and integrate them into your .NET MAUI csproj file. `google-services.json` is used by Android while `GoogleService-Info.plist` is accessible to iOS. Make sure the Include and the Link paths match. ``` @@ -29,10 +40,14 @@ You can use this library in any .NET MAUI project compatible to .NET 7 and highe ``` -- iOS apps need to be enabled to support push notifications. Turn on the "Push Notifications" capability of your app in the [Apple Developer Portal](https://developer.apple.com). -#### MAUI App Startup -This plugin provides an extension method for MauiAppBuilder `UseFirebasePushNotifications` which ensure proper startup and initialization. Call this method within your `MauiProgram` just as demonstrated in the MauiSampleApp: +- iOS apps need to be enabled to support push notifications. Turn on the "Push Notifications" capability of your app in + the [Apple Developer Portal](https://developer.apple.com). + +### MAUI App Startup + +This plugin provides an extension method for MauiAppBuilder `UseFirebasePushNotifications` which ensure proper startup +and initialization. Call this method within your `MauiProgram` just as demonstrated in the MauiSampleApp: ```csharp var builder = MauiApp.CreateBuilder() @@ -40,16 +55,22 @@ var builder = MauiApp.CreateBuilder() .UseFirebasePushNotifications(); ``` -`UseFirebasePushNotifications` has optional configuration parameters which are documented in another section of this document. +`UseFirebasePushNotifications` has optional configuration parameters which are documented in another section of this +document. + +### Android-specific Setup +- Copy the google-services.json to path location Platforms\Android\Resources\google-services.json (depending on what is + configured in the csproj file). +- Make sure your launcher activity (usually this is MainActivity - but not always) uses + `LaunchMode = LaunchMode.SingleTask`. You can also use a different LaunchMode; just be very sure what you do! -#### Android-specific Setup -- Copy the google-services.json to path location Platforms\Android\Resources\google-services.json (depending on what is configured in the csproj file). -- Make sure your launcher activity (usually this is MainActivity - but not always) uses `LaunchMode = LaunchMode.SingleTask`. You can also use a different LaunchMode; just be very sure what you do! +### iOS-specific Setup -#### iOS-specific Setup -- Copy the GoogleService-Info.plist to path location Platforms\iOS\GoogleService-Info.plist (depending on what is configured in the csproj file). +- Copy the GoogleService-Info.plist to path location Platforms\iOS\GoogleService-Info.plist (depending on what is + configured in the csproj file). - Extend the AppDelegate.cs file with following method exports: + ```csharp [Export("application:didRegisterForRemoteNotificationsWithDeviceToken:")] [BindingImpl(BindingImplOptions.GeneratedCode | BindingImplOptions.Optimizable)] @@ -73,11 +94,16 @@ public void DidReceiveRemoteNotification(UIApplication application, NSDictionary } ``` +## API Usage -### API Usage -`IFirebasePushNotification` is the main interface which handles most of the desired Firebase push notification features. This interface is injectable via dependency injection or accessible as a static singleton instance `IFirebasePushNotification.Current`. We strongly encourage you to use the dependency injection approach in order to keep your code testable. +`IFirebasePushNotification` is the main interface which handles most of the desired Firebase push notification features. +This interface is injectable via dependency injection or accessible as a static singleton instance +`IFirebasePushNotification.Current`. We strongly encourage you to use the dependency injection approach in order to keep +your code testable. + +The following lines of code demonstrate how the `IFirebasePushNotification` instance is injected in `MainViewModel` and +assigned to a local field for later use: -The following lines of code demonstrate how the `IFirebasePushNotification` instance is injected in `MainViewModel` and assigned to a local field for later use: ```csharp public MainViewModel( ILogger logger, @@ -88,39 +114,54 @@ public MainViewModel( } ``` -#### Managing Notification Permissions -Before we can receive any notification we need to make sure the user has given consent to receive notifications. `INotificationPermissions` is the service you can use to check the current authorization status or ask for notification permission. -You can either inject `INotificationPermissions` into your view models or access it via the the static singleton instance `INotificationPermissions.Current`. +### Notification Permissions + +Before we can receive any notification we need to make sure the user has given consent to receive notifications. +`INotificationPermissions` is the service you can use to check the current authorization status or ask for notification +permission. +You can either inject `INotificationPermissions` into your view models or access it via the the static singleton +instance `INotificationPermissions.Current`. - Check the current notification permission status: + ```csharp this.AuthorizationStatus = await this.notificationPermissions.GetAuthorizationStatusAsync(); ``` - Ask the user for notification permission: + ```csharp await this.notificationPermissions.RequestPermissionAsync(); ``` -Notification permissions are handled by the underlying operating system (iOS, Android). This library just wraps the platform-specific methods and provides a uniform API for them. +Notification permissions are handled by the underlying operating system (iOS, Android). This library just wraps the +platform-specific methods and provides a uniform API for them. + +### Register for Notifications -#### Receive Notifications -The main goal of a push notification client library is to receive notification messages. This library provides a set of classic .NET events to inform your code about incoming push notifications. -Before any notification event is received, we have to inform the Firebase client library, that we're ready to receive notifications. -`RegisterForPushNotificationsAsync` registers our app with the Firebase push notification backend and receives a token. This token is used by your own server/backend to send push notifications directly to this particular app instance. -The token may change after some time. It is not controllable by this library if/when the token is going to be updated. The `TokenRefreshed` event will be fired whenever a new token is available. +The main goal of a push notification client library is to receive notification messages. This library provides a set of +classic .NET events to inform your code about incoming push notifications. +Before any notification event is received, we have to inform the Firebase client library, that we're ready to receive +notifications. +`RegisterForPushNotificationsAsync` registers our app with the Firebase push notification backend and receives a token. +This token is used by your own server/backend to send push notifications directly to this particular app instance. +The token may change after some time. It is not controllable by this library if/when the token is going to be updated. +The `TokenRefreshed` event will be fired whenever a new token is available. See `Token` property and `TokenRefreshed` event provided by `IFirebasePushNotification` for more info. ```csharp await this.firebasePushNotification.RegisterForPushNotificationsAsync(); ``` -If we want to turn off any incoming notifications, we can unregister from push notifications. The `Token` can no longer be used to send push notifications to. +If we want to turn off any incoming notifications, we can unregister from push notifications. The `Token` can no longer +be used to send push notifications to. + ```csharp await this.firebasePushNotification.UnregisterForPushNotificationsAsync(); ``` -Following .NET events can be subscribed: +### Receive Notifications +Following .NET events can be subscribed. | Events | Description | |------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -130,25 +171,69 @@ Following .NET events can be subscribed: | `NotificationAction` | Is raised when the user taps a notification action. Notification actions allow users to make simple decisions when a notification is received, e.g. "Do you like to take your medicine?" could be answered with "Take medicine" and "Skip medicine". | | `NotificationDeleted` | Is raised when the user deletes a received notification. | -#### Topics +#### Notification Handling Behavior +The following table documents the behavior of each platform for incoming push notifications. We distinguish between notification message and data message. +- **Notification messages** must have a `notification` part with `title` and/or `body`. Other sections such as `data` are optional. +- **Data messages** must only have a `data` section (and can have platform specific options). The purpose of this message type is to send data-only messages without displaying a system notification popup. + +The behavior when receiving notifications is also different if the app runs in foreground or in background mode. +The following table illustrates some use cases with different message types, priority flags and app states. + +| Message Type | Data Payload | OS | App State | Notification Channel | Behavior | +|----------------------|--------------------|---------|------------|----------------------|-----------------------------------------------------------------------------| +| Notification message | - | Android | Foreground | | `NotificationReceived` event is fired | +| Notification message | - | iOS | Foreground | | `NotificationReceived` event is fired | +| Notification message | `priority: "low"` | Android | Foreground | | `NotificationReceived` event is fired | +| Notification message | `priority: "low"` | iOS | Foreground | | `NotificationReceived` event is fired | +| Notification message | `priority: "high"` | Android | Foreground | `Importance=Default` | `NotificationReceived` event is fired + Notification icon in status bar | +| Notification message | `priority: "high"` | Android | Foreground | `Importance=High` | `NotificationReceived` event is fired + System notification popup | +| Notification message | `priority: "high"` | iOS | Foreground | | `NotificationReceived` event is fired + System notification popup | +| Notification message | - | Android | Background | `Importance=Default` | Notification icon in status bar | +| Notification message | - | Android | Background | `Importance=High` | System notification popup | +| Notification message | - | iOS | Background | | System notification popup | +| Notification message | `priority: "low"` | Android | Background | `Importance=Default` | Notification icon in status bar | +| Notification message | `priority: "low"` | Android | Background | `Importance=High` | System notification popup | +| Notification message | `priority: "low"` | iOS | Background | | System notification popup | +| Notification message | `priority: "high"` | Android | Background | `Importance=Default` | Notification icon in status bar | +| Notification message | `priority: "high"` | Android | Background | `Importance=High` | System notification popup | +| Notification message | `priority: "high"` | iOS | Background | | System notification popup | +| | | | | | | +| Data message | | Android | Foreground | | `NotificationReceived` event is fired | +| Data message | | iOS | Foreground | | `NotificationReceived` event is fired | +| Data message | | Android | Background | | `NotificationReceived` event is fired as soon as app enters foreground mode | +| Data message | | iOS | Background | | `NotificationReceived` event is fired as soon as app enters foreground mode | + +*) _System notification popup: Official name is **heads-up notification** on Android and **banner notification** on +iOS._ + +*) _Notification channels exist on Android since Android 8.0 (API level 26)._ + +### Topics + The most common way of sending push notifications is by targeting notification message directly to push tokens. Firebase allows to send push notifications to groups of devices, so-called topics. -If a user subscribes to a topic, e.g. "weather_updates" you can send push notifications to this topic instead of a list of push tokens. +If a user subscribes to a topic, e.g. "weather_updates" you can send push notifications to this topic instead of a list +of push tokens. + +#### Subscribe to Topic + +Use method `SubscribeTopicAsync` with the name of the topic. -##### Subscribe to Topics -Use method `SubscribeTopic` with the name of the topic. ```csharp -this.firebasePushNotification.SubscribeTopic("weather_updates"); +this.firebasePushNotification.SubscribeTopicAsync("weather_updates"); ``` -Important: -- Make sure you did run `RegisterForPushNotificationsAsync` before you subscribe to topics. -- Topic names are case-sensitive: Registrations for topic `"weather_updates"` will not receive messages targeted to topic `"Weather_Updates"`. +> [!IMPORTANT] +> - Make sure you did run `RegisterForPushNotificationsAsync` before you subscribe to topics. +> - Topic names are case-sensitive: Registrations for topic `"weather_updates"` will not receive messages targeted to topic `"Weather_Updates"`. + +#### Send Notifications to Topic Subscribers -##### Send Notifications to Topic Subscribers -Use the Firebase Admin SDK (or any other HTTP client) to send a push notification targeting subscribers of the "weather_updates" topic: +Use the Firebase Admin SDK (or any other HTTP client) to send a push notification targeting subscribers of the topic `"weather_updates"`. +Instead of message property `to` which addresses an FCM token directly, we use `topic` to send notification messages to a whole group of subscribed devices. `HTTP POST https://fcm.googleapis.com/v1/projects/{{project_id}}/messages:send` + ``` { "message": { @@ -169,11 +254,16 @@ Use the Firebase Admin SDK (or any other HTTP client) to send a push notificatio ![Notification Topic weather_updates](Docs/notificationtopic_weatherupdates.png?raw=true) -#### Notification Actions -Notification actions are special buttons which allow for immediate response to a particular notification. A list of `NotificationActions` is consolidated within a `NotificationCategory`. +### Notification Actions + +Notification actions are special buttons which allow for immediate response to a particular notification. A list of +`NotificationActions` is consolidated within a `NotificationCategory`. + +#### Register Notification Actions + +The following example demonstrates the registration of a notification category with identifier "medication_intake" and +two actions "Take medicine" and "Skip medicine": -##### Register Notification Actions -The following example demonstrates the registration of a notification category with identifier "medication_intake" and two actions "Take medicine" and "Skip medicine": ```csharp var categories = new[] { @@ -186,18 +276,23 @@ var categories = new[] ``` Notification categories are usually registered at app startup time using the following method call: + ```csharp IFirebasePushNotification.Current.RegisterNotificationCategories(categories); ``` -##### Subscribe to Notification Actions -Subscribe the event `IFirebasePushNotification.NotificationAction` to get notified if a user presses one of the notification action buttons. +#### Subscribe to Notification Actions + +Subscribe the event `IFirebasePushNotification.NotificationAction` to get notified if a user presses one of the +notification action buttons. The delivered event args `FirebasePushNotificationResponseEventArgs` will let you know which action was pressed. -##### Send Notification Actions +#### Send Notification Actions + Use the Firebase Admin SDK (or any other HTTP client) to send a push notification with: `HTTP POST https://fcm.googleapis.com/v1/projects/myproject-b5ae1/messages:send` + ``` { "message": { @@ -229,23 +324,73 @@ If everything works fine, the mobile device with the given token displays the no ![Notification Category medication_intake](Docs/notificationcategory_takemedicine.png?raw=true) -#### More Push Notification Scenarios +### Notification Channels + +Notification channels are an Android feature introduced in Android 8.0 (API level 26) that let users manage notification +settings for different categories of notifications within the app. +Each notification channel represents a distinct type of notification (such as chat messages, medication intake, or +promotions) and allows users to customize notification preferences per channel, rather than for the whole app. + +#### Default Notification Channel + +Your app must always have at least one notification channel. This library will use this channel for any notifications +that are not targeting a specific channel. This ensures that push notifications are delivered even if a custom channel +is not set up. + +It is highly recommended to create the default notification channel by yourself so that all properties are under your +control. +Use `INotificationChannels.Channels` methods to create notification channels manually at startup or specify them in the +Android-specific options under `UseFirebasePushNotifications(o => o.Android.NotificationChannels = ...)`). +To get an idea of how to use the `NotificationChannels` option, take a look at `MauiProgram.cs` in the sample app in +this repository. + +#### Notification Channel Importance + +When your app (or this library) creates a notification channel, you must specify its importance (e.g., `Low`, `Default`, +`High`). +Importance controls how notifications are presented (such as whether they make a sound or appear as a heads-up +notification). + +> [!IMPORTANT] +> The importance level can only be set once when the channel is created. It cannot be changed afterward. +> If you need to modify a channel’s importance, you must create a new channel with a different ID. + +#### Notification Channel Groups + +Multiple notification channels may be grouped together within a notification channel group. +Use `INotificationChannels.ChannelGroups` methods to create/delete notification channel groups. + +### More Push Notification Scenarios + There are a lot of features in this library that can be controlled via specific data flags. The most common scenarios -are end-to-end tested using postman calls. You can find an up-to-date postman collection in this repository: -[FCM Plugin.FirebasePushNotifications.postman_collection.json]() +are end-to-end tested with the MauiSampleApp using postman calls. You can find an +up-to-date [postman collection]() in this repository. - Import the collection in postman. - Adjust the variables, especially the `project_id` and the `fcm_token` accordingly. -- Get a Bearer authentication token either by selecting the Auth Type "Firebase Cloud Messaging API (Oauth 2.0)" or by creating it manually via https://developers.google.com/oauthplayground (see this [youtube video](https://www.youtube.com/watch?v=PYfpBwupoMQ)). +- Get a Bearer authentication token either by selecting the Auth Type "Firebase Cloud Messaging API (Oauth 2.0)" or by + creating it manually via https://developers.google.com/oauthplayground (see + this [youtube video](https://www.youtube.com/watch?v=PYfpBwupoMQ)). ### Options + > *to be documented* -### Contribution -Your contribution is valuable! If you find a bug or want to propose a new feature, feel free to create a new issue [here](https://github.com/thomasgalliker/Plugin.FirebasePushNotifications/issues/new/choose). +## Contribution + +If you find a bug or want to propose a new feature, feel free to create a new +issue [here](https://github.com/thomasgalliker/Plugin.FirebasePushNotifications/issues/new/choose). Please use the **predefined issue templates** when submitting a new issue. -### Links +## Thank You + +Your contribution is valuable! +Open source software isn’t just something you can pick up for free — it represents the hard work and dedication of many +people who often not even know each other. +We sincerely appreciate the time, effort, and dedication shown by everyone who helps keep this plugin going forward. + +## Links + - FCM messages, data format, concepts and options: https://firebase.google.com/docs/cloud-messaging/concept-options diff --git a/ReleaseNotes.txt b/ReleaseNotes.txt new file mode 100644 index 00000000..37fa82f8 --- /dev/null +++ b/ReleaseNotes.txt @@ -0,0 +1,48 @@ +4.0 +- Improved notification channel handling during app startup. +- Data-only notifications are no longer displayed in the notification tray. +- NotificationBuilder support for Android API 26 and below (where no notification channels are available). +- OpenNotificationSettings now also works for Android API 26 and below. +- Synchronized notification handling behavior between Android and iOS. +- Renamed topic methods to follow the Async pattern: SubscribeToTopicAsync, UnsubscribeFromTopicAsync, etc. +- Use AddOnCompleteListener for asynchronous tasks in Android. +- Remove support for net7.0 and net8.0. +- Add support for net9.0 and net10.0. +- Bug fixes and refactorings. + +3.2 +- Improved default notification channel handling. +- Bug fixes and refactorings. + +3.1 +- Extend INotificationChannels to manage notification channel groups. +- Internal refactoring of INotificationChannels implementation. +- Removed properties IsActive and IsDefault from NotificationChannelRequest. Set the default notification channel via UseFirebasePushNotifications(o => o.Android.DefaultNotificationChannelId = ...). +- Configure initial list of notification channels via o.Android.NotificationChannels and notification groups via o.Android.NotificationChannelGroups. + +3.0 +- Update firebase-ios-sdk by replacing nuget package Xamarin.Firebase.iOS.CloudMessaging with AdamE.Firebase.iOS.CloudMessaging. + +2.5 +- Move static properties from Android's FirebasePushNotificationManager to FirebasePushNotificationAndroidOptions. +- iOS 18 workaround for duplicate notifications in foreground mode. +- iOS options to override default UNNotificationPresentationOptions for notifications received in foreground mode. +- Handle gcm.notification.click_action payload as click_action in Android. + +2.4 +- Refactor instanciation of IFirebasePushNotification. +- Refactor startup procedure of platform-specific services. +- Add singleton instance INotificationPermissions.Current. + +2.3 +- General bug fixes and code cleanup. +- Bug fixes in the area of topic subscriptions. +- IFirebasePushNotification.Current. +- Add singleton instance IFirebasePushNotification.Current and INotificationPermissions.Current. + +2.2 +- Complete refactoring of the original 1.x implementation. +- Simplified APIs, less static code, support for dependency injection. + +1.0 +- Initial release. \ No newline at end of file diff --git a/Samples/MauiSampleApp/App.xaml.cs b/Samples/MauiSampleApp/App.xaml.cs index 7662a911..bf23227f 100644 --- a/Samples/MauiSampleApp/App.xaml.cs +++ b/Samples/MauiSampleApp/App.xaml.cs @@ -1,24 +1,31 @@ -using MauiSampleApp.ViewModels; +using MauiSampleApp.Services; +using MauiSampleApp.ViewModels; using MauiSampleApp.Views; namespace MauiSampleApp { public partial class App : Application { + private readonly IServiceProvider serviceProvider; + public App(IServiceProvider serviceProvider) { + this.serviceProvider = serviceProvider; this.InitializeComponent(); + } - var mainPage = serviceProvider.GetRequiredService(); - this.MainPage = new NavigationPage(mainPage); + protected override Window CreateWindow(IActivationState activationState) + { + var mainPage = this.serviceProvider.GetRequiredService(); + return new Window(new NavigationPage(mainPage)); } protected override void OnResume() { - if (this.MainPage is NavigationPage { CurrentPage: MainPage { BindingContext: MainViewModel mainViewModel } }) + if (this.GetCurrentPage() is MainPage { BindingContext: MainViewModel mainViewModel }) { mainViewModel.OnResume(); } } } -} \ No newline at end of file +} diff --git a/Samples/MauiSampleApp/MauiProgram.cs b/Samples/MauiSampleApp/MauiProgram.cs index 185adc9a..289532e0 100644 --- a/Samples/MauiSampleApp/MauiProgram.cs +++ b/Samples/MauiSampleApp/MauiProgram.cs @@ -7,6 +7,7 @@ using Plugin.FirebasePushNotifications.Model.Queues; using MauiSampleApp.Services.Logging; using NLog.Extensions.Logging; +using Superdev.Maui; #if ANDROID using Android.App; @@ -66,6 +67,7 @@ public static MauiApp CreateMauiApp() // o.iOS.iOS18Workaround.Enable = true; #endif }) + .UseSuperdevMaui() .ConfigureFonts(fonts => { fonts.AddFont("IBMPlexSans-Regular.ttf", "IBMPlexSans"); diff --git a/Samples/MauiSampleApp/MauiSampleApp.csproj b/Samples/MauiSampleApp/MauiSampleApp.csproj index 4063ce16..5a00e097 100644 --- a/Samples/MauiSampleApp/MauiSampleApp.csproj +++ b/Samples/MauiSampleApp/MauiSampleApp.csproj @@ -1,11 +1,11 @@  - net8.0;net8.0-android;net8.0-ios + net10.0-android36.0;net10.0-ios26.2 Exe MauiSampleApp true - 8.0.100 + 10.0.51 true enable disable @@ -15,22 +15,17 @@ Firebase Push Demo - com.companyname.firebasepushdemo + ch.superdev.firebasepushdemo da8d1bd2-3ced-4171-b0da-3f1a7806e5af - 1.0 + 1.0.0 1 - 12.0 + 12.2 24.0 - - - Library - - None apk false - true + false SdkOnly @@ -134,13 +129,13 @@ - - - - - - - + + + + + + + diff --git a/Samples/MauiSampleApp/Platforms/Android/AndroidManifest.xml b/Samples/MauiSampleApp/Platforms/Android/AndroidManifest.xml index 9970642f..9ce82b1a 100644 --- a/Samples/MauiSampleApp/Platforms/Android/AndroidManifest.xml +++ b/Samples/MauiSampleApp/Platforms/Android/AndroidManifest.xml @@ -24,10 +24,6 @@ - - - - diff --git a/Samples/MauiSampleApp/Platforms/Android/Notifications/NotificationChannelSamples.cs b/Samples/MauiSampleApp/Platforms/Android/Notifications/NotificationChannelSamples.cs index b784bd45..44eaeb64 100644 --- a/Samples/MauiSampleApp/Platforms/Android/Notifications/NotificationChannelSamples.cs +++ b/Samples/MauiSampleApp/Platforms/Android/Notifications/NotificationChannelSamples.cs @@ -23,26 +23,44 @@ public static IEnumerable GetAll() { ChannelId = "test_channel_1", ChannelName = "Test Channel 1", - Description = "Description for test channel 1", + Description = "Low priority test channel", LockscreenVisibility = NotificationVisibility.Public, - Importance = NotificationImportance.High, + Importance = NotificationImportance.Low, }; yield return new NotificationChannelRequest { ChannelId = "test_channel_2", ChannelName = "Test Channel 2", - Description = "Description for test channel 2", - Group = NotificationChannelGroupSamples.TestGroup1.GroupId, + Description = "Default priority test channel", LockscreenVisibility = NotificationVisibility.Public, - Importance = NotificationImportance.High, + Importance = NotificationImportance.Default, }; yield return new NotificationChannelRequest { ChannelId = "test_channel_3", ChannelName = "Test Channel 3", - Description = "Description for test channel 3", + Description = "High priority test channel", + LockscreenVisibility = NotificationVisibility.Public, + Importance = NotificationImportance.High, + }; + + yield return new NotificationChannelRequest + { + ChannelId = "test_channel_4", + ChannelName = "Test Channel 4", + Description = "Test channel assigned to group 1", + Group = NotificationChannelGroupSamples.TestGroup1.GroupId, + LockscreenVisibility = NotificationVisibility.Public, + Importance = NotificationImportance.High, + }; + + yield return new NotificationChannelRequest + { + ChannelId = "test_channel_5", + ChannelName = "Test Channel 5", + Description = "Test channel assigned to group 1", Group = NotificationChannelGroupSamples.TestGroup1.GroupId, LockscreenVisibility = NotificationVisibility.Public, Importance = NotificationImportance.High, diff --git a/Samples/MauiSampleApp/Platforms/Android/Resources/drawable/ic_radiobox_checked.png b/Samples/MauiSampleApp/Platforms/Android/Resources/drawable/ic_radiobox_checked.png new file mode 100644 index 00000000..1192c5a2 Binary files /dev/null and b/Samples/MauiSampleApp/Platforms/Android/Resources/drawable/ic_radiobox_checked.png differ diff --git a/Samples/MauiSampleApp/Platforms/Android/Resources/drawable/ic_radiobox_unchecked.png b/Samples/MauiSampleApp/Platforms/Android/Resources/drawable/ic_radiobox_unchecked.png new file mode 100644 index 00000000..d8ee7956 Binary files /dev/null and b/Samples/MauiSampleApp/Platforms/Android/Resources/drawable/ic_radiobox_unchecked.png differ diff --git a/Samples/MauiSampleApp/Platforms/Android/Resources/values/colors.xml b/Samples/MauiSampleApp/Platforms/Android/Resources/values/colors.xml index d5df0927..8e7a6bb1 100644 --- a/Samples/MauiSampleApp/Platforms/Android/Resources/values/colors.xml +++ b/Samples/MauiSampleApp/Platforms/Android/Resources/values/colors.xml @@ -1,7 +1,7 @@ #FDA612 - #F6820C - #F6820C + #FDA612 + #FDA612 #FF00FF \ No newline at end of file diff --git a/Samples/MauiSampleApp/Services/ApplicationExtensions.cs b/Samples/MauiSampleApp/Services/ApplicationExtensions.cs new file mode 100644 index 00000000..538452d3 --- /dev/null +++ b/Samples/MauiSampleApp/Services/ApplicationExtensions.cs @@ -0,0 +1,17 @@ +namespace MauiSampleApp.Services +{ + internal static class ApplicationExtensions + { + public static Page GetRootPage(this Application application) + { + return application.Windows.FirstOrDefault()?.Page + ?? throw new InvalidOperationException("The application does not have an active window page."); + } + + public static Page GetCurrentPage(this Application application) + { + var page = application.GetRootPage(); + return page is NavigationPage navigationPage ? navigationPage.CurrentPage : page; + } + } +} diff --git a/Samples/MauiSampleApp/Services/DialogService.cs b/Samples/MauiSampleApp/Services/DialogService.cs index 3feb6122..03fa586a 100644 --- a/Samples/MauiSampleApp/Services/DialogService.cs +++ b/Samples/MauiSampleApp/Services/DialogService.cs @@ -6,8 +6,9 @@ public Task ShowDialogAsync(string title, string message, string cancel) { return MainThread.InvokeOnMainThreadAsync(async () => { - await Application.Current.MainPage.DisplayAlert(title, message, cancel); + var application = Application.Current ?? throw new InvalidOperationException("Application.Current is not available."); + await application.GetCurrentPage().DisplayAlertAsync(title, message, cancel); }); } } -} \ No newline at end of file +} diff --git a/Samples/MauiSampleApp/Services/MauiNavigationService.cs b/Samples/MauiSampleApp/Services/MauiNavigationService.cs index c50d29d5..01c4ea2e 100644 --- a/Samples/MauiSampleApp/Services/MauiNavigationService.cs +++ b/Samples/MauiSampleApp/Services/MauiNavigationService.cs @@ -12,12 +12,14 @@ public MauiNavigationService(IServiceProvider serviceProvider) public async Task PushAsync() where TPage : Page { var page = this.serviceProvider.GetRequiredService(); - await Application.Current.MainPage.Navigation.PushAsync(page); + var application = Application.Current ?? throw new InvalidOperationException("Application.Current is not available."); + await application.GetRootPage().Navigation.PushAsync(page); } public async Task PopAsync() { - await Application.Current.MainPage.Navigation.PopAsync(); + var application = Application.Current ?? throw new InvalidOperationException("Application.Current is not available."); + await application.GetRootPage().Navigation.PopAsync(); } } -} \ No newline at end of file +} diff --git a/Samples/MauiSampleApp/ViewModels/MainViewModel.cs b/Samples/MauiSampleApp/ViewModels/MainViewModel.cs index 73a65e31..a5263c4a 100644 --- a/Samples/MauiSampleApp/ViewModels/MainViewModel.cs +++ b/Samples/MauiSampleApp/ViewModels/MainViewModel.cs @@ -546,7 +546,10 @@ private async Task OpenNotificationChannelSettingsAsync() { #if ANDROID var defaultNotificationChannel = this.notificationChannels.Channels.GetDefault(); - this.notificationChannels.OpenNotificationChannelSettings(defaultNotificationChannel.Id); + if (defaultNotificationChannel != null) + { + this.notificationChannels.OpenNotificationChannelSettings(defaultNotificationChannel.Id); + } #endif } catch (Exception ex) @@ -674,7 +677,12 @@ void DeleteNotificationChannel(string id) this.UpdateNotificationChannels(); } - return new NotificationChannelViewModel(notificationChannelViewModelLogger, this.dialogService, DeleteNotificationChannel, c); + return new NotificationChannelViewModel( + notificationChannelViewModelLogger, + this.dialogService, + this.notificationChannels, + DeleteNotificationChannel, + c); }) .ToArray(); @@ -738,7 +746,7 @@ private async Task SubscribeToTopicAsync() try { var topic = this.Topic; - this.firebasePushNotification.SubscribeTopic(topic); + await this.firebasePushNotification.SubscribeTopicAsync(topic); this.UpdateSubscribedTopics(); this.Topic = null; } @@ -753,7 +761,7 @@ private async Task UnsubscribeFromTopicAsync(string topic) { try { - this.firebasePushNotification.UnsubscribeTopic(topic); + await this.firebasePushNotification.UnsubscribeTopicAsync(topic); this.UpdateSubscribedTopics(); } catch (Exception ex) @@ -772,7 +780,7 @@ private async Task UnsubscribeAllTopicsAsync() { try { - this.firebasePushNotification.UnsubscribeAllTopics(); + await this.firebasePushNotification.UnsubscribeAllTopicsAsync(); this.UpdateSubscribedTopics(); } catch (Exception ex) @@ -930,9 +938,10 @@ private async Task OpenUrlAsync(string url) } } - public void OnResume() + public async void OnResume() { - _ = this.UpdateAuthorizationStatusAsync(); + await this.UpdateAuthorizationStatusAsync(); + await this.GetNotificationChannelsAsync(); } } } \ No newline at end of file diff --git a/Samples/MauiSampleApp/ViewModels/NotificationCategorySamples.cs b/Samples/MauiSampleApp/ViewModels/NotificationCategorySamples.cs index 5ba0356b..47a01587 100644 --- a/Samples/MauiSampleApp/ViewModels/NotificationCategorySamples.cs +++ b/Samples/MauiSampleApp/ViewModels/NotificationCategorySamples.cs @@ -23,16 +23,16 @@ public static IEnumerable GetAll() }); yield return new NotificationCategory("contract", new[] { - new NotificationAction("Accept", "Accept", NotificationActionType.Default, "accept"), - new NotificationAction("Reject", "Reject", NotificationActionType.Default, "reject") + new NotificationAction("Accept", "Accept", NotificationActionType.Default, "ic_radiobox_checked"), + new NotificationAction("Reject", "Reject", NotificationActionType.Default, "ic_radiobox_unchecked") }); yield return new NotificationCategory("dismiss",new [] { - new NotificationAction("dismiss","Dismiss", NotificationActionType.Default), + new NotificationAction("dismiss","Dismiss", NotificationActionType.Destructive), }); yield return new NotificationCategory("navigate", new [] { - new NotificationAction("dismiss", "Dismiss", NotificationActionType.Default), + new NotificationAction("dismiss", "Dismiss", NotificationActionType.Destructive), new NotificationAction("navigate", "Navigate To", NotificationActionType.Foreground) }); } diff --git a/Samples/MauiSampleApp/ViewModels/NotificationChannelViewModel.cs b/Samples/MauiSampleApp/ViewModels/NotificationChannelViewModel.cs index 043fc026..2f06ed1a 100644 --- a/Samples/MauiSampleApp/ViewModels/NotificationChannelViewModel.cs +++ b/Samples/MauiSampleApp/ViewModels/NotificationChannelViewModel.cs @@ -29,15 +29,17 @@ public class NotificationChannelViewModel public NotificationChannelViewModel( ILogger logger, IDialogService dialogService, + INotificationChannels notificationChannels, Action deleteNotificationChannel, NotificationChannel notificationChannel) { this.ChannelId = notificationChannel.Id; this.ChannelName = notificationChannel.Name; + this.IsDefault = notificationChannel.Id == notificationChannels.Channels.DefaultNotificationChannelId; this.Description = notificationChannel.Description; - this.LockscreenVisibility = Enum.GetName(notificationChannel.LockscreenVisibility) ?? $"{notificationChannel.LockscreenVisibility}"; + this.LockscreenVisibility = Enum.GetName(typeof(NotificationVisibility), notificationChannel.LockscreenVisibility) ?? $"{notificationChannel.LockscreenVisibility}"; this.Group = notificationChannel.Group ?? "null"; - this.Importance = Enum.GetName(notificationChannel.Importance); + this.Importance = Enum.GetName(typeof(NotificationImportance), notificationChannel.Importance); this.logger = logger; this.dialogService = dialogService; @@ -49,6 +51,8 @@ public NotificationChannelViewModel( public string ChannelName { get; } + public bool IsDefault { get; } + public string Description { get; } public string LockscreenVisibility { get; } diff --git a/Samples/MauiSampleApp/Views/ItemTemplates/NotificationChannelItemTemplate.xaml b/Samples/MauiSampleApp/Views/ItemTemplates/NotificationChannelItemTemplate.xaml index 587e247a..4f77a659 100644 --- a/Samples/MauiSampleApp/Views/ItemTemplates/NotificationChannelItemTemplate.xaml +++ b/Samples/MauiSampleApp/Views/ItemTemplates/NotificationChannelItemTemplate.xaml @@ -7,9 +7,10 @@ x:DataType="vm:NotificationChannelViewModel"> + RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto">