Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FCM 受信のベースを実装 #114

Merged
merged 3 commits into from
Aug 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/mottai_flutter_app/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
include: ../../analysis_options.yaml

analyzer:
plugins:
- custom_lint
31 changes: 18 additions & 13 deletions packages/mottai_flutter_app/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,28 @@
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>プロフィール画像などを撮影するために端末のカメラを使用します。</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>現在地を取得するため位置情報サービスを使用します。</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app requires to add file to your photo library your microphone</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>プロフィール画像などを選択するために端末の画像ライブラリを使用します。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
Expand All @@ -58,18 +76,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>現在地を取得するため位置情報サービスを使用します。</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>プロフィール画像などを選択するために端末の画像ライブラリを使用します。</string>
<key>NSCameraUsageDescription</key>
<string>プロフィール画像などを撮影するために端末のカメラを使用します。</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app requires to add file to your photo library your microphone</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
</array>
</dict>
</plist>
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import '../../../chat/ui/chat_room.dart';
import '../../../chat/ui/chat_rooms.dart';
import '../../../job/ui/job_detail.dart';
import '../../../map/ui/map.dart';
import '../../../package_info.dart';
import '../../../push_notification/firebase_messaging.dart';
import '../../../scaffold_messenger_controller.dart';
import '../../../user/user.dart';
import '../../../user/user_mode.dart';
Expand All @@ -23,7 +25,7 @@ import '../../web_link/ui/web_link_stub.dart';

/// 開発中の各ページへの導線を表示するページ。
@RoutePage()
class DevelopmentItemsPage extends ConsumerWidget {
class DevelopmentItemsPage extends StatefulHookConsumerWidget {
const DevelopmentItemsPage({super.key});

/// [AutoRoute] で指定するパス文字列。
Expand All @@ -33,7 +35,22 @@ class DevelopmentItemsPage extends ConsumerWidget {
static const location = path;

@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<DevelopmentItemsPage> createState() =>
_DevelopmentItemsPageState();
}

class _DevelopmentItemsPageState extends ConsumerState<DevelopmentItemsPage> {
@override
void initState() {
super.initState();
Future.wait<void>([
ref.read(initializeFirebaseMessagingProvider)(),
ref.read(getFcmTokenProvider)(),
]);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('開発ページ'),
Expand Down Expand Up @@ -231,13 +248,14 @@ class _DrawerChildState extends ConsumerState<_DrawerChild> {

@override
Widget build(BuildContext context) {
final packageInfo = ref.watch(packageInfoProvider);
return ListView(
children: [
DrawerHeader(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('mottai-app-dev'),
Text(packageInfo.packageName),
if (ref.watch(isHostProvider)) ...[
const Gap(8),
Text(
Expand Down
3 changes: 3 additions & 0 deletions packages/mottai_flutter_app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:package_info_plus/package_info_plus.dart';

import 'firebase_options.dart';
import 'package_info.dart';
import 'push_notification/firebase_messaging.dart';
import 'router/router.dart';
import 'scaffold_messenger_controller.dart';
import 'user/user.dart';
Expand All @@ -23,6 +24,8 @@ void main() async {
ProviderScope(
overrides: [
packageInfoProvider.overrideWithValue(await PackageInfo.fromPlatform()),
firebaseMessagingProvider
.overrideWithValue(await getFirebaseMessagingInstance()),
userModeStateProvider.overrideWith(
(ref) => hostDocumentExists ? UserMode.host : UserMode.worker,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import 'dart:convert';
import 'dart:io';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

/// FCM の Payload に含まれる、通知タップ時に画面遷移を期待している時のキー名。
const _fcmPayloadLocationKey = 'location';

/// FirebaseMessaging のインスタンスを提供するプロバイダ。ProviderScope.override で上書きする。
final firebaseMessagingProvider =
Provider<FirebaseMessaging>((_) => throw UnimplementedError());

/// iOS のフォアグラウンドでの通知受信の設定を済ませて [FirebaseMessaging] のインスタンスを
/// 返す。
/// ProviderScope.overrides で上書きする際に使用する。
Future<FirebaseMessaging> getFirebaseMessagingInstance() async {
final messaging = FirebaseMessaging.instance;
if (Platform.isIOS) {
// Push 通知をフォアグラウンドでも受け取るよう設定する。
await messaging.setForegroundNotificationPresentationOptions(
alert: true,
badge: true,
sound: true,
);
}
return messaging;
}

/// [FirebaseMessaging] 関係の初期化処理を行うメソッドを提供する [Provider].
final initializeFirebaseMessagingProvider =
Provider.autoDispose<Future<void> Function()>(
(ref) => () async {
await ref.read(firebaseMessagingProvider).requestPermission();
await ref.read(_initializeLocalNotificationProvider)();
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
await ref.read(_getInitialMessageProvider)();
ref.read(_onMessageProvider)();
ref.read(_onMessageOpenedAppProvider)();
},
);

/// FCM トークンを取得する [Provider].
final getFcmTokenProvider = Provider.autoDispose<Future<String?> Function()>(
(ref) => () => ref.read(firebaseMessagingProvider).getToken(),
);

/// terminated (!= background) の状態から、通知によってアプリを開いた場合に非 null となる
/// [RemoteMessage] による挙動を提供する [Provider].
final _getInitialMessageProvider =
Provider.autoDispose<Future<void> Function()>(
(ref) => () async {
/// terminated (!= background) の状態から
/// 通知によってアプリを開いた場合に remoteMessage が非 null となる。
final remoteMessage = await FirebaseMessaging.instance.getInitialMessage();
if (remoteMessage != null) {
debugPrint('🔥 Open app from FCM when terminated.');
final data = remoteMessage.data;
await ref.read(_handleNotificationDataProvider)(data);
}
},
);

/// foreground の状態で通知が届いたときの [RemoteMessage] を提供する [StreamProvider].
final _onMessageStreamProvider = StreamProvider<RemoteMessage>(
(ref) => FirebaseMessaging.onMessage,
);

/// Android で foreground 時に通知が届いた場合の挙動を提供する [Provider].
/// iOS では何もしない。
final _onMessageProvider = Provider(
(ref) => () => ref.listen<AsyncValue<RemoteMessage>>(
_onMessageStreamProvider,
(previous, next) async {
final remoteMessage = next.value;
final androidNotification = remoteMessage?.notification?.android;
if (remoteMessage == null || androidNotification == null) {
return;
}
await _showLocalNotification(remoteMessage);
},
),
);

/// Android におけるローカル通知のデフォルトのタイトル。
const _androidLocalNotificationDefaultTitle = 'NPO 法人 MOTTAI';

/// Android におけるローカル通知のデフォルトの本文。
const _androidLocalNotificationDefaultBody = '新着通知があります。';

/// Android 向け。
/// FCM の [RemoteMessage] を受け付けて、[FlutterLocalNotificationsPlugin] で通知を
/// 表示する。
Future<void> _showLocalNotification(RemoteMessage remoteMessage) async {
final remoteNotification = remoteMessage.notification;
if (remoteNotification == null) {
return;
}
final title =
remoteNotification.title ?? _androidLocalNotificationDefaultTitle;
final body = remoteNotification.body ?? _androidLocalNotificationDefaultBody;
await _flutterLocalNotificationsPlugin.show(
remoteNotification.hashCode,
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
_androidLocalNotificationChannel.id,
_androidLocalNotificationChannel.name,
channelDescription: _androidLocalNotificationChannel.description,
importance: _androidLocalNotificationChannel.importance,
priority: Priority.max,
ticker: 'ticker',
visibility: NotificationVisibility.public,
),
),
payload: json.encode(remoteMessage.data),
);
}

/// foreground or background (!= terminated) の状態で通知が届いたときの
/// [RemoteMessage] を提供する [StreamProvider].
final _onMessageOpenedAppStreamProvider = StreamProvider<RemoteMessage>(
(ref) => FirebaseMessaging.onMessageOpenedApp,
);

/// foreground or background (!= terminated) の状態から通知によってアプリを開いた場合の
/// 挙動を提供する [Provider].
final _onMessageOpenedAppProvider = Provider(
(ref) => () => ref.listen<AsyncValue<RemoteMessage>>(
_onMessageOpenedAppStreamProvider,
(previous, next) async {
final remoteMessage = next.value;
if (remoteMessage == null) {
return;
}
if (remoteMessage.data.containsKey(_fcmPayloadLocationKey)) {
debugPrint('🔥 FCM notification tapped.');
final data = remoteMessage.data;
await ref.read(_handleNotificationDataProvider)(data);
}
},
),
);

/// background から起動した際に Firebase を有効化する。
/// グローバルに記述する必要がある。
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage _) async {
debugPrint('Received remote message on background.');
// 初期が完了している場合は初期化をスキップする。
if (Firebase.apps.isNotEmpty && Platform.isAndroid) {
return;
}
await Firebase.initializeApp();
}

/// [FlutterLocalNotificationsPlugin] インスタンス。
final _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

/// Android におけるローカル通知のチャンネル ID.
const _androidLocalNotificationChannelId = 'high_importance_channel';

/// Android におけるローカル通知のチャンネル名。
const _androidLocalNotificationChannelName = 'お知らせ';

/// Android におけるローカル通知のチャンネルの説明。
const _androidLocalNotificationChannelDescription = 'アプリ内からのお知らせを通知します。';

/// Android におけるローカル通知のチャンネルの重要度。
const _androidLocalNotificationChannelImportance = Importance.max;

/// [AndroidNotificationChannel] インスタンス。
const _androidLocalNotificationChannel = AndroidNotificationChannel(
_androidLocalNotificationChannelId,
_androidLocalNotificationChannelName,
description: _androidLocalNotificationChannelDescription,
importance: _androidLocalNotificationChannelImportance,
);

/// Android におけるローカル通知のデフォルトのアイコン。
// TODO: 適切なアイコンに変更する。
const _androidLocalNotificationDefaultIcon =
'@drawable/transparent_notification_icon';

/// Android で foreground で通知を受け取ったとき、通知を表示するための初期設定。
/// iOS では LocalNotification は使用しない想定。
final _initializeLocalNotificationProvider = Provider.autoDispose(
(ref) => () async {
// iOS では何もしない。
if (Platform.isIOS) {
return;
}
await _flutterLocalNotificationsPlugin.initialize(
const InitializationSettings(
android: AndroidInitializationSettings(
_androidLocalNotificationDefaultIcon,
),
// iOS の場合は上で早期 return しているので、iOS の設定は記述しない。
),
onDidReceiveNotificationResponse: (notificationResponse) async {
final payloadString = notificationResponse.payload;
if (payloadString == null) {
return;
}
debugPrint('🔥 onSelect FCM notification when foreground on Android.');
final data = jsonDecode(payloadString) as Map<String, dynamic>;
await ref.read(_handleNotificationDataProvider)(data);
},
);
await _flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(_androidLocalNotificationChannel);
},
);

/// Map<String, dynamic> 型の通知から得られるデータから location や data を取り出して
/// 画面遷移する共通処理を提供する Provider。
final _handleNotificationDataProvider =
Provider.autoDispose<Future<void> Function(Map<String, dynamic>)>(
(ref) => (data) async {
final location = data[_fcmPayloadLocationKey] as String;
debugPrint(location);
if (data.containsKey(_fcmPayloadLocationKey)) {
// TODO: 適切な画面遷移の Callback を外から指定できるようにする。
// // location: `/` の場合は、すべての画面を取り除く
// if (location == ref.read(appRouterProvider).initialRoute) {
// ref.read(navigationServiceProvider).popUntilFirstRoute();
// } else {
// await ref
// .read(navigationServiceProvider)
// .pushOnCurrentTab(location: location, arguments: data);
// }
}
},
);
Loading
Loading