Firebase Cloud Messaging
Đăng ký/Huỷ đăng ký FCM
- PSFirebaseMessaging
- PSFirebaseMessagingProxy
- App
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_app_badger/flutter_app_badger.dart';
import 'package:piscale_chat_flutter/piscale_chat_flutter.dart';
import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
class PSFirebaseMessaging {
PSChatApiClient? apiClient;
StreamSubscription? _onTokenRefreshSubscription;
String? _preventDuplicateFcmToken;
PSFirebaseMessaging({this.apiClient}) {
_onTokenRefreshSubscription =
FirebaseMessaging.instance.onTokenRefresh.listen(
(fcmToken) {
if (_preventDuplicateFcmToken != fcmToken) {
_preventDuplicateFcmToken = fcmToken;
_registerToken(fcmToken);
}
},
);
_requestNotificationPermission();
}
update(PSChatApiClient? apiClient) {
if (this.apiClient != apiClient) {
this.apiClient = apiClient;
_requestNotificationPermission();
}
}
dispose() {
_onTokenRefreshSubscription?.cancel();
}
_requestNotificationPermission() async {
final apiClient = this.apiClient;
if (apiClient == null) return;
final settings = await FirebaseMessaging.instance.requestPermission(
alert: true,
announcement: true,
badge: true,
carPlay: true,
criticalAlert: true,
provisional: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized ||
settings.authorizationStatus == AuthorizationStatus.provisional) {
logger.e('User granted permission');
if (Platform.isAndroid) {
await setupFlutterNotifications();
} else if (Platform.isIOS) {
await FirebaseMessaging.instance.getAPNSToken();
}
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken != null &&
fcmToken.isNotEmpty &&
_preventDuplicateFcmToken != fcmToken) {
_preventDuplicateFcmToken = fcmToken;
_registerToken(fcmToken);
}
} else {
logger.e('User declined or has not accepted permission');
}
}
Future<void> _registerToken(String fcmToken) async {
final apiClient = this.apiClient;
final prevFcmToken = await secureStorage.read(key: fcmTokenKey);
if (apiClient != null && fcmToken != prevFcmToken) {
await secureStorage.write(key: fcmTokenKey, value: fcmToken);
try {
final request = await _createNotificationRequestBody(fcmToken);
if (request == null) return;
await apiClient.notificationApi.register(request);
} catch (e) {
if (e is DioException) {
final response = e.response;
final errorResponse =
response == null ? null : response.data as Map<String, dynamic>;
if (errorResponse != null) {
final messageCode = errorResponse.containsKey("message_code")
? errorResponse["message_code"] as String
: null;
if (messageCode == "M4016_DEVICE_TOKEN_INVALID") {
await FirebaseMessaging.instance.deleteToken();
}
}
}
logger.e("registerFcmToken: ", error: e);
}
}
}
Future<void> unregisterToken() async {
final apiClient = this.apiClient;
final fcmToken = await secureStorage.read(key: fcmTokenKey);
if (apiClient != null && fcmToken != null && fcmToken.isNotEmpty) {
try {
final request = await _createNotificationRequestBody(fcmToken);
if (request == null) return;
await apiClient.notificationApi.unregister(request);
dispose();
await secureStorage.delete(key: fcmTokenKey);
await FirebaseMessaging.instance.deleteToken();
await FlutterAppBadger.removeBadge();
} catch (e) {
logger.e("unregisterFcmToken: ", error: e);
}
}
}
Future<PSChatNotificationRequestDto?> _createNotificationRequestBody(
String token,
) async {
PSChatNotificationDeviceType? type;
String? deviceName;
String? sdkVersion;
final deviceInfoPlugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
type = PSChatNotificationDeviceType.android;
final androidInfo = await deviceInfoPlugin.androidInfo;
deviceName = "${androidInfo.manufacturer} ${androidInfo.model}";
sdkVersion =
"Android ${androidInfo.version.release} ${androidInfo.version.sdkInt}";
} else if (Platform.isIOS) {
type = PSChatNotificationDeviceType.ios;
final iosInfo = await deviceInfoPlugin.iosInfo;
deviceName = iosInfo.utsname.machine;
sdkVersion = "iOS ${iosInfo.systemVersion}";
}
if (type == null || deviceName == null || sdkVersion == null) return null;
final deviceId = await getDeviceId();
final packageInfo = await PackageInfo.fromPlatform();
return PSChatNotificationRequestDto(
appVersion: "Flutter ${packageInfo.version}-${packageInfo.buildNumber}",
deviceId: deviceId,
deviceName: deviceName,
sdkVersion: sdkVersion,
token: token,
type: type,
);
}
}
import 'package:piscale_chat_flutter/piscale_chat_flutter.dart';
import 'package:provider/provider.dart';
class PSFirebaseMessagingProxy extends StatelessWidget {
final Widget? child;
const PSFirebaseMessagingProxy({
super.key,
this.child,
});
@override
Widget build(BuildContext context) {
return ProxyProvider<PSChatApiClient?, PSFirebaseMessaging>(
create: (context) {
return PSFirebaseMessaging(
apiClient: context.read<PSChatApiClient?>(),
);
},
update: (context, apiClient, psFirebase) {
return psFirebase!..update(apiClient);
},
dispose: (_, value) {
value.dispose();
},
updateShouldNotify: (previous, current) =>
previous.apiClient != current.apiClient,
lazy: false,
child: child,
);
}
}
Hãy thêm PSFirebaseMessagingProxy
vào phía dưới PSChat
như sau:
import 'package:piscale_chat_flutter/piscale_chat_flutter.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
builder: (context, child) {
return PSChat(
userId: 'USER_ID',
deviceId: 'DEVICE_ID',
apiClientOptions: PSChatApiClientOptions(
appId: 'APP_ID',
fetchToken: fetchToken,
),
child: PSFirebaseMessagingProxy(child: child),
);
},
);
}
}
Hiển thị thông báo
Payload FCM sẽ có cấu trúc như sau:
{
"Tokens": [
"bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1..."
],
"Data": {
"avatar_url": "https://thumb-i-1.piscale.com/64x64/smart/100001/c1cb0279-8286-40b8-9082-66f8593316c2/IMG_6546.jpeg",
"is_rtf": "false",
"ps_message_id": "537",
"ps_thread_id": "37791233165968",
"sender_id": "6872016393687471",
"sender_name": "Nghĩa",
"text": "",
"title": "🎧 Zalo - Tran Nghia"
},
"Notification": {
"title": "🎧 Zalo - Tran Nghia",
"body": "Nghĩa: Bạn nhận được video"
},
"Android": {
"collapse_key": "37791233165968",
"priority": "high"
},
"Webpush": null,
"APNS": {
"payload": {
"aps": {
"category": "communications",
"content-available": 1,
"mutable-content": 1
},
"notifee_options": {
"ios": {
"communicationInfo": {
"conversationId": "37791233165968",
"messageId": "537",
"sender": {
"avatar": "https://thumb-i-1.piscale.com/64x64/smart/100001/c1cb0279-8286-40b8-9082-66f8593316c2/IMG_6546.jpeg",
"displayName": "🎧 Zalo - Tran Nghia",
"id": "6872016393687471"
}
}
}
}
}
},
"FCMOptions": {}
}
Do trong payload có khối notification nên hệ thống sẽ tự động hiển thị thông báo trên thiết bị của người dùng theo mặc định. Nếu ứng dụng của bạn có implement hàm FirebaseMessaging.onBackgroundMessage()
để show thông báo local thì cần chú ý ignore với payload của chat.
Khi nhấn vào thông báo
void _handleMessage(RemoteMessage message) {
final data = message.data;
final String threadId = data['ps_thread_id'] ?? '';
final String messageId = data['ps_message_id'] ?? '';
if (threadId.isNotEmpty) {
final context = rootNavigatorKey.currentContext;
if (context != null) {
try {
Utils.popUntilPath(context, AppPage.threads.toPath);
context.push(
AppPage.messages.toPath,
extra: MessagesPageExtra(
targetThreadId: threadId,
targetMessageId:
messageId.isEmpty ? null : int.parse(messageId),
),
);
} catch (e) {
logger.e("KKK: pushReplacement: ", error: e);
}
} else {
logger.e("KKK: rootNavigatorKey.currentContext null");
}
}
}
Communication Notifications
Trên ios có hỗ trợ tính năng Communication Notifications. Thông báo kiểu dạng đó sẽ có dạng như sau:
Để hiển thị thông báo dạng này bạn có thể tham khảo hướng dẫn.
Về cơ bản bạn phải làm những bước như sau:
- Mở project ios (.xcworkspace) bằng
xcode
- Thêm capability
Communication Notifications
- Updated App's
Info.plist
với keyNSUserActivityTypes
và typeArray
. This array should containINSendMessageIntent
to support messaging. - Tạo
Notification Service Extension
và chỉnh sửa trong đây. Ví dụ như sau:
NotificationService.swift
import UserNotifications
import Intents
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
/// This is called when a notification is received.
/// - Parameters:
/// - request: The notification request.
/// - contentHandler: The callback that needs to be called when the notification is ready to be displayed.
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
// the OS already did some work for us so we do make a work copy. If something does not go the way we expect or we run in a timeout we still have an attempt that can be displayed.
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
// unwrapping makes the compiler happy
if(bestAttemptContent == nil) {
return;
}
// this is the FCM / APNS payload defined by the server / caller.
// Its the custom '"data"' object you provide in the FCM json.
let payload: [AnyHashable : Any] = bestAttemptContent!.userInfo
let psThreadId: String? = payload["ps_thread_id"] as? String
// this is set by the server to indicate that this is a chat message
if(psThreadId != nil) {
_handleChatMessage(payload: payload)
return;
}
// if we do not know the type we just pass the notification through
// this is the case when we get a plain FCM / APNS notification
if let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
/// Handles a chat message notification. It tries to display it as a communication notification.
/// - Parameter payload: The FCM / APNS payload, defined by the server / caller.
func _handleChatMessage(payload: [AnyHashable : Any]) {
guard let content = bestAttemptContent else {
return
}
guard let contentHandler = contentHandler else {
return
}
let chatRoomName: String = payload["ps_thread_id"] as! String
let senderId: String = payload["sender_id"] as! String
let senderDisplayName: String = payload["title"] as! String
let senderThumbnail: String? = payload["avatar_url"] as? String
guard let senderThumbnail: String = senderThumbnail else {
return
}
var senderAvatar: INImage? = nil
if !senderThumbnail.isEmpty {
guard let senderThumbnailUrl: URL = URL(string: senderThumbnail) else {
return
}
let senderThumbnailFileName: String = senderThumbnailUrl.lastPathComponent
guard let senderThumbnailImageData: Data = try? Data(contentsOf: senderThumbnailUrl),
let senderThumbnailImageFileUrl: URL = downloadAttachment(data: senderThumbnailImageData, fileName: senderThumbnailFileName),
let senderThumbnailImageFileData: Data = try? Data(contentsOf: senderThumbnailImageFileUrl) else {
return
}
senderAvatar = INImage(imageData: senderThumbnailImageFileData)
}
// profile picture that will be displayed in the notification (left side)
// let senderAvatar: INImage = INImage(imageData: senderThumbnailImageFileData)
var personNameComponents = PersonNameComponents()
personNameComponents.nickname = senderDisplayName
// the person that sent the message
// we need that as it is used by the OS trying to identify/match the sender with a contact
// Setting ".unknown" as type will prevent the OS from trying to match the sender with a contact
// as here this is an internal identifier and not a phone number or email
let senderPerson = INPerson(
personHandle: INPersonHandle(
value: senderId,
type: .unknown
),
nameComponents: personNameComponents,
displayName: senderDisplayName,
image: senderAvatar,
contactIdentifier: nil,
customIdentifier: nil,
isMe: false, // this makes the OS recognize this as a sender
suggestionType: .none
)
// this is just a dummy person that will be used as the recipient
let selfPerson = INPerson(
personHandle: INPersonHandle(
value: "00000000-0000-0000-0000-000000000000", // no need to set a real value here
type: .unknown
),
nameComponents: nil,
displayName: nil,
image: nil,
contactIdentifier: nil,
customIdentifier: nil,
isMe: true, // this makes the OS recognize this as "US"
suggestionType: .none
)
// the actual message. We use the OS to send us ourselves a message.
let incomingMessagingIntent = INSendMessageIntent(
recipients: [selfPerson],
outgoingMessageType: .outgoingMessageText, // This marks the message as outgoing
content: content.body, // this will replace the content.body
speakableGroupName: nil,
conversationIdentifier: chatRoomName, // this will be used as the conversation title
serviceName: nil,
sender: senderPerson, // this marks the message sender as the person we defined above
attachments: []
)
if !senderThumbnail.isEmpty {
incomingMessagingIntent.setImage(senderAvatar, forParameterNamed: \.sender)
}
let interaction = INInteraction(intent: incomingMessagingIntent, response: nil)
interaction.direction = .incoming
do {
// we now update / patch / convert our attempt to a communication notification.
bestAttemptContent = try content.updating(from: incomingMessagingIntent) as? UNMutableNotificationContent
// everything went alright, we are ready to display our notification.
contentHandler(bestAttemptContent!)
} catch let error {
print("error \(error)")
}
}
/// Called just before the extension will be terminated by the system.
/// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
/// Shorthand for creating a notification attachment.
/// - Parameters:
/// - identifier: Unique identifier for the attachment. So it can be referenced within a Notification Content extension for example.
/// - fileName: The name of the file. This is the name that will be used to store the name on disk.
/// - data: A Data object based on the remote url.
/// - options: A dictionary of options. See Apple's documentation for more information.
/// - Returns: A UNNotificationAttachment object.
func createNotificationAttachment(identifier: String, fileName: String, data: Data, options: [NSObject : AnyObject]?) -> UNNotificationAttachment? {
do {
if let fileURL: URL = downloadAttachment(data: data, fileName: fileName) {
let attachment: UNNotificationAttachment = try UNNotificationAttachment.init(identifier: identifier, url: fileURL, options: options)
return attachment
}
return nil
} catch let error {
print("error \(error)")
}
return nil
}
/// Downloads a file from a remote url and stores it in a temporary folder.
/// - Parameters:
/// - data: A Data object based on the remote url.
/// - fileName: The name of the file. This is the name that will be used to store the name on disk.
/// - Returns: A URL object pointing to the temporary file on the phone. This can be used by a Notification Content extension for example.
func downloadAttachment(data: Data, fileName: String) -> URL? {
// Create a temporary file URL to write the file data to
let fileManager = FileManager.default
let tmpSubFolderName = ProcessInfo.processInfo.globallyUniqueString
let tmpSubFolderURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(tmpSubFolderName, isDirectory: true)
do {
// prepare temp subfolder
try fileManager.createDirectory(at: tmpSubFolderURL, withIntermediateDirectories: true, attributes: nil)
let fileURL: URL = tmpSubFolderURL.appendingPathComponent(fileName)
// Save the image data to the local file URL
try data.write(to: fileURL)
return fileURL
} catch let error {
print("error \(error)")
}
return nil
}
}