こんにちは、株式会社Pentagonでエンジニアをしているtsurumiです。
今回の記事はFirebase Cloud FunctionsでFlutterで作成したアプリについて、FCM(Firebase Cloud Message)で個別の端末へのPush通知を送る手順を解説してます。
【こんな人に読んで欲しい】
Flutterでアプリを作っていてPush通知の機能を実装したいと考えている方
【この記事を読むメリット】
Flutterでのpush通知の導入方法がわかる
Cloud Functionsでの簡単な実装方法がわかる
【結論】
今回のサンプルアプリはユーザー一覧から好きなユーザーをタップすると、相手にpush通知が届くという非常にシンプルなものになっています。
この記事を読んでカスタマイズしていただくと、例えばチャットを送ったタイミングでFunctionsを発火してメッセージ内容を送れます。
また、iosのpush通知は証明書周りの設定がややこしいので、今回はAndroidのみでpush通知を検証しています。ご了承下さい。
Firebaseのプロジェクトを作成する
まず、Firebase consoleから新しいプロジェクトを作成しましょう。
Functionsを使用する場合はSpeak(無料)プランでは使用できないので、FirebaseのプランをSpeak(無料)からBlaze(従量制)へ変更しましょう。
Functionsの実装
Firebase cliをインストールする
npm install -g firebase-tools
Firebase にログインする
firebase login
ログインが完了したら先ほど作成したプロジェクトIDを確認しましょう
firebase projects:list
確認ができたら
firebase use (Project ID)
選択ができたら
firebase init
を実行します。
実行するとfirebaseのサービスのどのサービスを使用するのかを聞かれます。
今回は Functionsとemulatorsを選択します。
言語は JavaScript
と TypeScript
を選択できますが、今回はTypeScriptを選択しましょう。
これらのコマンドを実行すると以下のようなファイル構造のものが作られます。
src/index.tsを開いてみましょう。
export const helloWorld = functions.https.onRequest((request, response) => {
functions.logger.info("Hello logs!", {structuredData: true});
response.send("Hello from Firebase!");
});
上記のようなコードがあると思います。
まずは、この関数をローカルサーバーを立てて実行してみましょう
cd functions
npm run serve
ブラウザでhttp://localhost:4001/functions にアクセスできるようになります。
開くと下記のようなログがあると思います。
http://localhost:5001/{projectid}/asia-northeast1/helloWorld にアクセスしてみましょう。
次に、push通知を送る関数を作成します。
const pushMessage = (fcmToken: string, text: string) => ({
notification: {
title: "テスト",
body: ${text}
,
},
token: fcmToken,
});
export const sendPushNotification =
functions
.https.onRequest(async (request, response) => {
console.log(request.body);
const token: string = request.body["data"]["token"];
const res = await admin.messaging()
.send(pushMessage(token, "テストテストテスト"));
console.log(res);
response.status(200).send(
{
"status": 200,
"data": "ok!",
}
);
});
上記のコードで完成です。
今回のpush通知はfcm tokenを各ユーザーのコレクションに保存しているので、そのtokenを使用して送るようなものになっています。
次にデプロイしましょう。
package.jsonのscriptの中に
“scripts”: {
“deploy”: “tsc && firebase deploy --only functions:sendPushNotification”
}
を追加して
npm run deploy
を実行しましょう。
すると、firebase consoleのFunctionsを開くと
が確認できると思います。
これが確認できたら、問題ありません!
Flutter側の実装
今回、Flutterで使用するパッケージは以下のものです。
hooks_riverpod: ^0.14.0+4
flutter_hooks: ^0.17.0
freezed_annotation: ^0.14.2
firebase_analytics: ^8.3.1
firebase_auth: ^3.1.0
firebase_core: ^1.8.0
firebase_messaging: ^10.0.9
flamingo:
flamingo_annotation:
cloud_functions: ^3.0.5
flutter_local_notifications: ^9.0.1
flutter_app_badger: ^1.3.0
rxdart: ^0.27.2
freezed: ^0.14.2
json_serializable:
flamingo_generator:
次にfirestoreを使うので、Userコレクションのドキュメントのモデル定義をしていきましょう。
今回はflamingoというライブラリを使用していきます。
user.dart
import 'package:flamingo/flamingo.dart';
import 'package:flamingo_annotation/flamingo_annotation.dart';
part 'user.flamingo.dart';
class User extends Document<User> {
User({
String? id,
DocumentSnapshot<Map<String, dynamic>>? snapshot,
Map<String, dynamic>? values,
}) : super(id: id, snapshot: snapshot, values: values);
@Field()
String? name;
@Field()
String? fcmToken;
@override
Map<String, dynamic> toData() => _$toData(this);
@override
void fromData(Map<String, dynamic> data) => _$fromData(this, data);
}
次にサインアップ・ログイン画面をFirebase authを活用して作成しましょう。
作成したら下記のコードを追加しましょう。
user_notifier.dart
@freezed
class UserState with _$UserState {
const factory UserState({
User? user,
@Default(<User>[]) List<User> userList,
}) = _UserState;
}
class UserNotifier extends StateNotifier<UserState> {
UserNotifier(this._read) : super(const UserState());
Future<void> init() async {
await fetchUser();
await updateUserFcm();
await fetchUsers();
logger!.info('user notifier done');
}
final Reader _read;
DocumentAccessorRepository get documentAccessorRepository =>
_read(documentAccessorProvider);
AuthRepository get authRepository => _read(authServiceProvider);
FirebaseMessagingRepository get firebaseMessagingRepository =>
_read(firebaseMessagingProvider);
FirebaseFunctionsRepository get firebaseFunctionsRepository =>
_read(firebaseFunctionsRepositoryProvider);
Future<void> fetchUser() async {
final currentUser = authRepository.currentUser;
if (currentUser == null) {
return;
}
final user =
await documentAccessorRepository.load<User>(User(id: currentUser.uid));
state = state.copyWith(user: user);
}
Future<void> updateUserFcm() async {
final token = await firebaseMessagingRepository.getToken();
if (state.user == null) {
return;
}
final updateUser = state.user!..fcmToken = token;
await documentAccessorRepository.update(updateUser);
}
Future<void> createUser(String uid) async {
final token = await firebaseMessagingRepository.getToken();
final user = User(id: uid)
..name = ''
..fcmToken = token;
await documentAccessorRepository.save(user);
}
Future<void> fetchUsers() async {
if (state.user == null) {
return;
}
final collectionPaging = CollectionPaging<User>(
query: User().collectionRef,
decode: (snap) => User(snapshot: snap),
);
final users = await collectionPaging.load();
users.removeWhere((ele) => ele.id == state.user!.id);
state = state.copyWith(userList: users);
}
Future<void> pushNotification(String fcmToken) async {
await firebaseFunctionsRepository.sendNotification(fcmToken);
}
}
document_accessor_data_source.dart
import 'package:flamingo/flamingo.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final documentAccessorProvider = Provider<DocumentAccessorRepository>((ref) {
return DocumentAccessor();
});
auth_data_source.dart
final authServiceProvider = Provider<AuthRepository>((ref) {
return AuthService();
});
/// ユーザ
class AuthService extends AuthRepository {
final _auth = FirebaseAuth.instance;
final _user = PublishSubject<User>();
StreamSubscription<User?>? _authStateDisposer;
@override
Stream<User> get onAuthStateChanged => _user;
@override
void fetchAuthStateChanges() {
_authStateDisposer ??= _auth.authStateChanges().listen((User? user) async {
if (user != null) {
_user.add(user);
}
});
}
@override
Future<void> dispose() async {
await _user.close();
await _authStateDisposer?.cancel();
}
@override
User? get currentUser => _auth.currentUser;
@override
String? get loggedInUserId => _auth.currentUser!.uid;
@override
bool get isLoggedIn => _auth.currentUser != null;
@override
LoginType loginType({User? user}) {
if (user != null) {
return _loginType(user);
} else {
return _loginType(_auth.currentUser);
}
}
@override
Future<UserCredential> signInWithAnonymously() => _auth.signInAnonymously();
@override
AuthCredential emailAuthProviderCredential(String email, String password) {
return EmailAuthProvider.credential(email: email, password: password);
}
@override
Future<UserCredential> createAccount(String email, String password) async {
final userCredential = await _auth.createUserWithEmailAndPassword(
email: email, password: password);
return userCredential;
}
@override
Future<UserCredential> linkWithCredential(AuthCredential credential) async {
return currentUser!.linkWithCredential(credential);
}
@override
Future<UserCredential> signInEmail(String email, String password) async {
final userCredential = await _auth.signInWithEmailAndPassword(
email: email, password: password);
return userCredential;
}
@override
Future<void> sendVerificationEmail() async {
if (currentUser != null) {
await currentUser?.sendEmailVerification();
}
}
@override
Future<void> passwordReset(String email) async {
await _auth.sendPasswordResetEmail(email: email);
}
@override
Future<void> signOut() async {
await _auth.signOut();
}
@override
Future<void> userDelete(User user) async {
await user.delete();
}
LoginType _loginType(User? user) {
if (user == null) {
return LoginType.invalid;
}
if (user.isAnonymous) {
return LoginType.anonymously;
} else if (!user.isAnonymous &&
user.email != null &&
user.email!.isNotEmpty) {
return LoginType.email;
} else {
return LoginType.invalid;
}
}
@override
FirebaseAuthResultStatus handleException(FirebaseAuthException e) {
switch (e.code) {
case emailAlreadyInUse:
return FirebaseAuthResultStatus.emailAlreadyInUse;
case invalidEmail:
return FirebaseAuthResultStatus.invalidEmail;
case operationNotAllowed:
return FirebaseAuthResultStatus.operationNotAllowed;
case weakPassword:
return FirebaseAuthResultStatus.weakPassword;
case userDisabled:
return FirebaseAuthResultStatus.userDisabled;
case userNotFound:
return FirebaseAuthResultStatus.userNotFound;
case wrongPassword:
return FirebaseAuthResultStatus.wrongPassword;
default:
return FirebaseAuthResultStatus.undefined;
}
}
@override
String errorCodeMessage(FirebaseAuthResultStatus result) {
switch (result) {
case FirebaseAuthResultStatus.emailAlreadyInUse:
return 'このメールアドレスはすでに登録されています。';
case FirebaseAuthResultStatus.invalidEmail:
return 'このメールアドレスは有効ではありません。';
case FirebaseAuthResultStatus.operationNotAllowed:
return 'メールアドレスとパスワードでのログインは有効になっていません。';
case FirebaseAuthResultStatus.weakPassword:
return 'パスワードの強度が十分ではありません。';
case FirebaseAuthResultStatus.userDisabled:
return 'このメールアドレスは無効になっています。';
case FirebaseAuthResultStatus.userNotFound:
return 'このメールアドレスのアカウントは存在しません。';
case FirebaseAuthResultStatus.wrongPassword:
return 'パスワードが間違ってます。';
case FirebaseAuthResultStatus.undefined:
return '';
}
}
}
firebase_messagin_data_source.dart
final firebaseMessagingProvider = Provider<FirebaseMessagingRepository>((ref) {
final messaging = FirebaseMessaging.instance;
return FirebaseMessagingDataSource(messaging);
});
class FirebaseMessagingDataSource implements FirebaseMessagingRepository {
FirebaseMessagingDataSource(this._firebaseMessaging);
final FirebaseMessaging _firebaseMessaging;
final _message = PublishSubject<RemoteMessage>();
final _openedApp = PublishSubject<RemoteMessage>();
final _token = PublishSubject<String>();
bool _isFetch = false;
@override
Stream<RemoteMessage> get message => _message;
@override
Stream<RemoteMessage> get openedApp => _openedApp;
@override
Stream<String> get token => _token;
@override
void fetchMessaging() {
if (_isFetch) {
return;
}
_isFetch = true;
FirebaseMessaging.onMessage.listen((event) {
logger!.fine('onMessage: ${event.data}');
_message.add(event);
});
FirebaseMessaging.onMessageOpenedApp.listen((event) {
logger!.fine('onMessageOpenedApp: ${event.data}');
_openedApp.add(event);
});
_firebaseMessaging.onTokenRefresh.listen((event) {
logger!.fine('onTokenRefresh: $event');
_token.add(event);
});
}
@override
Future<void> requestPermission() async {
if (Platform.isIOS) {
await _firebaseMessaging.requestPermission();
}
}
@override
Future<String?> getToken() => _firebaseMessaging.getToken();
@override
Future<void> subscribeToTopic(String topic) =>
_firebaseMessaging.subscribeToTopic(topic);
@override
Future<void> subscribeTopicAll() =>
_firebaseMessaging.subscribeToTopic(_topicsAll);
}
/// Topics
const _topicsAll = 'all';
firebase_functions_data_source.dart
final firebaseFunctionsRepositoryProvider =
Provider<FirebaseFunctionsRepository>((ref) {
final functions = FirebaseFunctions.instance;
return FirebaseFunctionsDataSource(functions);
});
const sendPushNotification = 'sendPushNotification';
class FirebaseFunctionsDataSource implements FirebaseFunctionsRepository {
FirebaseFunctionsDataSource(this._functions) {
// FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
}
final FirebaseFunctions _functions;
@override
Future<void> sendNotification(String fcmToken) async {
final body = <String, dynamic>{
'token': fcmToken,
};
final callable = _functions.httpsCallable(sendPushNotification);
try {
final res = await callable.call<String>(body);
logger!.fine(res.data);
} on FirebaseFunctionsException catch (e) {
logger!.warning(
'Code: ${e.code}\nmessage: ${e.message}\ndetails: ${e.details}');
}
}
@override
Future<void> helloWorld() async {}
}
サインアップ後にUserNotifier内のcreateUserの関数を呼び出して、Userドキュメントを作成しましょう。
次にアプリ起動時の処理を追加します。
main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Flamingo.initializeApp();
runApp(ProviderScope(child: App()));
}
app.dart
class App extends HookWidget {
const App({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: F.title,
navigatorKey: useProvider(navigatorKeyProvider),
theme: ThemeData(
primarySwatch: Colors.blue,
),
localizationsDelegates: const [
GlobalCupertinoLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
...AppLocalizations.localizationsDelegates,
],
supportedLocales: AppLocalizations.supportedLocales,
home: useProvider(appNotifierProvider.select((state) => state.isLoding))
? Container(
alignment: Alignment.center,
color: Colors.white,
child: const CupertinoActivityIndicator(),
)
: const AppPage(),
);
}
}
app_notifier.dart
final appNotifierProvider = StateNotifierProvider<AppNotifier, AppState>((ref) {
return AppNotifier(ref.read);
});
@freezed
class AppState with _$AppState {
const factory AppState({
@Default(true) bool isLoding,
}) = _AppState;
}
class AppNotifier extends StateNotifier<AppState> {
AppNotifier(this._read) : super(const AppState()) {
_configure();
}
final Reader _read;
UserNotifier get userNotifier => _read(userNotifierProvider.notifier);
PushNotificationNotifier get pushNotificationNotifier =>
_read(pushNotificationNotifierProvider);
Future<void> _configure() async {
await pushNotificationNotifier.removeBadge();
await userNotifier.init();
await pushNotificationNotifier.init();
state = state.copyWith(isLoding: false);
}
}
まず、最初にpushNotificationNotifier.removeBadgeを読んでいるのは、push通知がきた時にアプリのアイコンにバッチがついている場合にそれを消す処理です。
次に、userNotifierでログインしているuserの情報や、fcmトークンの更新、ユーザー一覧の取得などの処理をしています。
次のpushNotificationNotifier.init() はpush通知の権限の許可や、push通知をリッスン処理などを呼び出しています。
下記がコードです。
push_notification_notifier.dart
final pushNotificationNotifierProvider =
ChangeNotifierProvider<PushNotificationNotifier>((ref) {
return PushNotificationNotifier(ref.read);
});
class PushNotificationNotifier extends ChangeNotifier {
PushNotificationNotifier(this._read);
final Reader _read;
FirebaseMessagingRepository get firebaseMessagingRepository =>
_read(firebaseMessagingProvider);
LocalNotificationRepository get localNotificationRepository =>
_read(localNotificationDataSourceProvider);
Future<void> init() async {
await firebaseMessagingRepository.requestPermission();
await localNotificationRepository.configure();
firebaseMessagingRepository.fetchMessaging();
await firebaseMessagingRepository.subscribeTopicAll();
firebaseMessagingRepository.message.listen((event) async {
if (Platform.isAndroid) {
final notification = event.notification;
if (notification == null) {
return;
}
await localNotificationRepository.show(
title: notification.title ?? '',
body: notification.body ?? '',
);
}
});
firebaseMessagingRepository.openedApp.listen((event) async {
logger!.info('openedApp');
final isAppBadgeSupported = await FlutterAppBadger.isAppBadgeSupported();
logger!.info('isAppBadgeSupported $isAppBadgeSupported');
if (isAppBadgeSupported) {
FlutterAppBadger.removeBadge();
}
});
}
/// バッジリセット
Future<void> removeBadge() async {
final isAppBadgeSupported = await FlutterAppBadger.isAppBadgeSupported();
if (isAppBadgeSupported) {
FlutterAppBadger.removeBadge();
}
}
}
local_notification_data_source.dart
final localNotificationDataSourceProvider =
Provider<LocalNotificationRepository>((ref) {
return LocalNotificationDataSource(FlutterLocalNotificationsPlugin());
});
class LocalNotificationDataSource implements LocalNotificationRepository {
LocalNotificationDataSource(this._flutterLocalNotificationsPlugin);
final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin;
@override
Future<void> configure({
Future<dynamic> Function(String? payload)? onSelectNotification,
}) async {
const initializationSettingsAndroid =
AndroidInitializationSettings('app_icon');
const initializationSettingsIOS = IOSInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const initializationSettings = InitializationSettings(
android: initializationSettingsAndroid, iOS: initializationSettingsIOS);
await _flutterLocalNotificationsPlugin.initialize(initializationSettings,
onSelectNotification: onSelectNotification);
logger!.fine('configure done.');
}
@override
Future<void> show({
required String title,
required String body,
String? payload,
String? androidChannelId,
String? androidChannelName,
String? androidChannelDescription,
}) async {
final androidPlatformChannelSpecifics = AndroidNotificationDetails(
androidChannelId ?? _defaultAndroidChannelId,
androidChannelName ?? _defaultAndroidChannelName,
channelDescription:
androidChannelDescription ?? _defaultAndroidChannelDescription,
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker',
);
const iOSPlatformChannelSpecifics = IOSNotificationDetails();
final platformChannelSpecifics = NotificationDetails(
android: androidPlatformChannelSpecifics,
iOS: iOSPlatformChannelSpecifics);
await _flutterLocalNotificationsPlugin
.show(0, title, body, platformChannelSpecifics, payload: payload);
}
}
const _defaultAndroidChannelId = 'high_importance_channel';
const _defaultAndroidChannelName = 'channel_name';
const _defaultAndroidChannelDescription = 'channel_description';
次にユーザ一覧ページを作成していきます。
class UserListPage extends TabWidgetPage {
const UserListPage({Key? key}) : super(key: key);
@override
void onBottomNavigationTap() {}
@override
Widget build(BuildContext context) {
final users = useProvider(userNotifierProvider).userList;
final notifier = useProvider(userNotifierProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('ユーザー一覧'),
),
body: ListView(
children: users.map((e) {
return Column(
children: [
ListTile(
onTap: () {
logger!.info(e.toData());
notifier.pushNotification(e.fcmToken!);
},
title: Text(e.name!),
),
const SizedBox(
height: 1,
width: double.infinity,
child: ColoredBox(
color: kAppBlack,
),
),
],
);
}).toList(),
),
);
}
}
↑のような画面ができると思います。
ユーザーのところをタップするとFunctionsが発火し、そのユーザーのところへpush通知が届く処理が走ります。
まとめ
以上で、FunctionsからFlutterのアプリへpush通知を送る手順になります。
なお、この実装をする際には2人分のユーザーが入りますので、シュミレーターまたは実機を2つ立ち上げましょう。
今回記事にしたコードをもっとカスタムすれば、LINEのようなメッセージを送った時にpush通知にメッセージを表示するなどもできます。