Never give up

Flutter - FCM device to device, device to all devices 본문

Flutter

Flutter - FCM device to device, device to all devices

대기만성 개발자 2021. 4. 24. 02:35
반응형

알림 기능을 위해 FCM을 써야될 경우가 생기는데

 

공식문서가 생각보다 덜 친절하다보니 많이 질문을 올리기도 하고

 

필자도 구현하면서 조금 귀찮았던 부분이 있어서 이 부분에 대해 포스트 해보기로 했습니다

 

해당 예제에서는 FCM을 이해하는데 필요하다고 생각되는 부분만 짚고 넘어갈 예정이라

 

부분적으로 생략한 부분이 많아서 직접 구현하셔야되는게 조금 있는데

 

어려운 부분은 아니라고 생각돼서 넘어가겠습니다

(생략한 부분 : 회원가입과 로그인 및 기기 토큰값 firestore에 저장하는 작업 등등)

 

백그라운드 처리를 위한 메인부분

Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();

  print("Handling a background message: ${message.messageId}");
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  
  //이하 생략
}

포그라운드 및 UI처리부분

class _FCMTestMainState extends State<FCMTestMain> {
  String _getMessage = 'N/A';
  String _sendMessage = '';
  StreamController<String> _controller = StreamController<String>();
  FCMController _fcm = FCMController();

  @override
  void initState() {
    super.initState();

    fcm.getInitialMessage().then((value) {
      if (value != null) {
        _getMessage = value.toString();
      }
    });

    /// Foreground 메시지 처리부분
    FirebaseMessaging.onMessage.listen((message) {
      RemoteNotification notification = message.notification!;

      _getMessage =
          'title : ${notification.title}, message : ${notification.body}';

      _controller.add(_getMessage);

      ScaffoldMessenger.of(context)
          .showSnackBar(SnackBar(content: Text('Message is arrived')));

      print('Got a message whilst in the foreground!');
      print('Message data: ${message.data}');
    });
  }

  @override
  void dispose() {
    _controller.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    var model = Provider.of<ProfileData>(context);
    FocusScopeNode scope = FocusScope.of(context);
    return Scaffold(
      appBar: AppBar(
        title: Text('FCM test page'),
        actions: [
          IconButton(
              icon: Icon(Icons.power_settings_new),
              onPressed: () => auth.signOut().whenComplete(() {
                    Navigator.pop(context);
                  }))
        ],
      ),
      body: SafeArea(
        child: SingleChildScrollView(
          child: InkWell(
            focusColor: Colors.white,
            onTap: () {
              if (scope.hasFocus) {
                scope.unfocus();
              }
            },
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                StreamBuilder<String>(
                    stream: _controller.stream,
                    initialData: _getMessage,
                    builder: (context, snapshot) {
                      return ListTile(
                          title: Text('Message status : ${snapshot.data}'));
                    }),
                ListView.builder(
                  shrinkWrap: true,
                  itemCount: model.list.length,
                  itemBuilder: (_, index) {
                    var item = model.list[index];
                    bool isMyProfile = (auth.currentUser!.email == item.id);
                    return Card(
                      margin: EdgeInsets.all(8),
                      elevation: 8,
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          ListTile(
                            title: Text('Id : ${item.id}'),
                            trailing: isMyProfile
                                ? Switch(
                                    value: item.isSubscribed,
                                    onChanged: (value) async {
                                      StatusData statusData =
                                          await model.toggleSubscribe(item.id);

                                      if (statusData.isFailed) {
                                        ScaffoldMessenger.of(context)
                                            .showSnackBar(SnackBar(
                                                content: Text(
                                                    statusData.errorStack)));
                                      }
                                    })
                                : null,
                          ),
                          ListTile(
                              title: Text('DeviceToken : ${item.deviceToken}',
                                  maxLines: 2,
                                  overflow: TextOverflow.ellipsis)),
                          isMyProfile
                              ? Container()
                              : SizedBox(
                                  height: 50,
                                  child: TextField(
                                    textAlignVertical: TextAlignVertical.center,
                                    maxLines: 1,
                                    decoration: InputDecoration(
                                      border: OutlineInputBorder(),
                                      suffix: IconButton(
                                        icon: CircleAvatar(
                                            child: Icon(Icons.send)),
                                        onPressed: () async {
                                          print(item.id);
                                          if (_sendMessage.isNotEmpty) {
                                            StatusData statusData =
                                                await _fcm.sendMessage(
                                                    title: 'FCM test',
                                                    message: _sendMessage,
                                                    userToken:
                                                        item.deviceToken);

                                            if (statusData.isFailed) {
                                              ScaffoldMessenger.of(context)
                                                  .showSnackBar(SnackBar(
                                                      content: Text(statusData
                                                          .errorStack)));
                                            }
                                          }
                                        },
                                      ),
                                    ),
                                    onChanged: (value) {
                                      _sendMessage = value;
                                    },
                                  ),
                                )
                        ],
                      ),
                    );
                  },
                )
              ],
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.edit),
        onPressed: () {
          String notiAll = '';
          showDialog(
            context: context,
            builder: (_) => AlertDialog(
              title: Text('Send message to all users'),
              content: SizedBox(
                  height: 50,
                  child: TextField(
                    onChanged: (value) {
                      notiAll = value;
                    },
                  )),
              actions: [
                TextButton(
                    child: Text('Confirm'),
                    onPressed: () async {
                      if (notiAll.isNotEmpty) {
                        StatusData statusData = await _fcm.sendToAllUsers(
                            title: 'Notice', message: notiAll);

                        if (statusData.isFailed) {
                          ScaffoldMessenger.of(context).showSnackBar(
                              SnackBar(content: Text(statusData.errorStack)));
                        }
                        Navigator.pop(context);
                      }
                    }),
                TextButton(
                    child: Text('Cancel'),
                    onPressed: () => Navigator.pop(context)),
              ],
            ),
          );
        },
      ),
    );
  }
}

FCM controller class

class FCMController {
  static String _serverKey = 'yourKey';//firebase 페이지에서 확인할 수 있습니다

  Future<void> iosPermission() async {
    NotificationSettings settings = await fcm.requestPermission(
      alert: true,
      announcement: false,
      badge: true,
      carPlay: false,
      criticalAlert: false,
      provisional: false,
      sound: false,
    );

    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      print('User granted permission');
    } else if (settings.authorizationStatus ==
        AuthorizationStatus.provisional) {
      print('User granted provisional permission');
    } else {
      print('User declined or has not accepted permission');
    }
  }

  Future<StatusData> sendMessage(
      {required String userToken,
      required String title,
      required String message}) async {
    StatusData statusData = StatusData();

    late http.Response response;

    try {
      response = await http.post(
          Uri.parse('https://fcm.googleapis.com/fcm/send'),
          headers: <String, String>{
            'Content-Type': 'application/json',
            'Authorization': 'key=$_serverKey'
          },
          body: jsonEncode({
            'notification': {'title': title, 'body': message, 'sound': 'true'},
            'priority': 'high',
            'ttl': '60s',
            'data': {
              'click_action': 'FLUTTER_NOTIFICATION_CLICK',
              'id': '1',
              'status': 'done',
            },
            'to': userToken
          }));
    } catch (e) {
      statusData.hasError();
      statusData.errorStack = e.toString();
    }

    print('statusCode : ${response.statusCode}');

    return statusData;
  }

  Future<StatusData> sendToAllUsers(
      {required String title, required String message}) async {
    StatusData statusData = StatusData();

    late http.Response response;

    try {
      response = await http.post(
          Uri.parse('https://fcm.googleapis.com/fcm/send'),
          headers: <String, String>{
            'Content-Type': 'application/json',
            'Authorization': 'key=$_serverKey'
          },
          body: jsonEncode({
            'notification': {'title': title, 'body': message, 'sound': 'true'},
            'priority': 'high',
            'ttl': '60s',
            'data': {
              'click_action': 'FLUTTER_NOTIFICATION_CLICK',
              'id': '1',
              'status': 'done'
            },
            'to': '/topics/test'
          }));
    } catch (e) {
      statusData.hasError();
      statusData.errorStack = e.toString();
    }

    print('statusCode : ${response.statusCode}');

    return statusData;
  }
}

profile data class

class ProfileData extends ChangeNotifier {
  final CollectionReference _profile = cloud.collection('profile');
  List<ProfileDataModel> list = [];

  Future<void> readData() async {
    list.clear();

    QuerySnapshot snapshot = await _profile.get();

    snapshot.docs.forEach((element) {
      list.add(ProfileDataModel.fromJson(element.data()));
    });
  }

  Future<StatusData> saveProfile() async {
    StatusData statusData = StatusData();

    ProfileDataModel model = ProfileDataModel();

    User user = auth.currentUser!;
    String email = user.email!;

    model.id = email;
    model.deviceToken = (await fcm.getToken())!;

    try {
      await _profile.doc(email).set(model.toJson());
    } catch (e) {
      statusData.hasError();
      statusData.errorStack = e.toString();
    }

    if (!statusData.isFailed) {
      list.add(model);
    }

    return statusData;
  }

  Future<String> appleToken() async {
    String appleDeviceToken = '';
    try {
      appleDeviceToken = (await fcm.getAPNSToken())!;
    } catch (e) {
      print(e.toString());
    }

    return appleDeviceToken;
  }

  Future<StatusData> toggleSubscribe(String id) async {
    StatusData statusData = StatusData();

    ProfileDataModel profileData = list.singleWhere((element) => element.id == id);

    try {
      if (profileData.isSubscribed) {
        await fcm.unsubscribeFromTopic('test');
        profileData.isSubscribed = false;
      } else {
        await fcm.subscribeToTopic('test');
        profileData.isSubscribed = true;
      }

      await _profile.doc(profileData.id).set(profileData.toJson());
    } catch (e) {
      statusData.hasError();
      statusData.errorStack = e.toString();
    }

    if (!statusData.isFailed) {
      notifyListeners();
    }

    return statusData;
  }
}

마지막으로 필자의 간단한 에러 처리 클래스

class StatusData {
  bool isFailed;
  String errorStack;

  StatusData({this.isFailed = false, this.errorStack = ''});

  void hasError() {
    isFailed = true;
  }
}

생략해도 UI부분 때문에 소스코드가 좀 길어졌네요..

 

백그라운드 부분은 onBackgroundMessage, 포그라운드 부분은 onMessage.listen에서 처리를 해주고

 

필자의 경우 UI부분에서 streamBuilder와 snackbar를 이용해서 메시지를 출력해주는 정도만 하고 있습니다

 

FCM을 개인과 개인이 주고받을 때 그리고 개인과 다수가 받을 때 따로따로 보면 다음과 같습니다

 

- 개인과 개인이 메시지를 보낼 때

  1. 기기 토큰값을 기기로부터 가져온다

  2. 기기 토큰값을 서버에 저장한다

  3. 메시지를 보낼 유저의 토큰값을 가져온다

  4. 메시지를 보낸다

  5. 앱이 꺼져있으면 백그라운드 켜져있으면 포그라운드에서 처리한다

 

- 개인이 단체한테 메시지를 보낼 때

  1. 유저가 해당 topic를 subscribe한다

  2. 메시지를 보낸다

  3. subscribe한 유저들은 메시지를 받는다

  4. 위 5번과 동일

 

이 부분만 이해하면 나머지는 열심히 삽질(?)하다보면 되더군요

 

그리고 주의해야되는 부분은 app이 terminated 되었을 때는 별도의 처리를 해줘야되는거 같고

 

device token값이 항상 동일하지는 않았던거 같아서 공식문서를 확인해 보니 다음과 같은 부분이 있었습니다

// Any time the token refreshes, store this in the database too.
FirebaseMessaging.instance.onTokenRefresh.listen(saveTokenToDatabase);

 

추가로 메시지를 보낼때 같이 보낼 수 있는 데이터들(?)은 공식문서에서 확인을 하시면 될거 같습니다

(링크 : firebase.google.com/docs/reference/fcm/rest/v1/projects.messages)

 

반응형
Comments