[Flutter] 백그라운드에서 띄운 푸시 알람 메시지를 터치해서 앱을 실행했을 때, getInitialMessage()가 동작하지 않는다.

2022. 2. 4. 11:30Programming/Flutter

반응형

꽤 예전에 FirebaseMessaging을 사용해서 전달받은 클라우드 메시지를, FlutterLocalNotifications를 사용해서 푸시 메시지로 띄우는 작업을 했었습니다. 이번에 맞닥뜨린 문제는 FlutterLocalNotifications를 사용해서 화면에 띄운 푸시메시지를 터치해서 앱이 실행됐을 때, FirebaseMassaging에서 제공하는 getInitialMessage()의 실행 결과값이 null로 반환되는 상황입니다. 물론 onMessageOpenedApp() 역시 기대한대로 동작하지 않습니다.

private Task<Map<String, Object>> getInitialMessage(Map<String, Object> arguments) {
    return Tasks.call(
        cachedThreadPool,
        () -> {
          if (initialMessage != null) {
            Map<String, Object> remoteMessageMap =
                FlutterFirebaseMessagingUtils.remoteMessageToMap(initialMessage);
            initialMessage = null;
            return remoteMessageMap;
          }

          if (mainActivity == null) {
            return null;
          }

          Intent intent = mainActivity.getIntent();

          if (intent == null || intent.getExtras() == null) {
            return null;
          }

          // Remote Message ID can be either one of the following...
          String messageId = intent.getExtras().getString("google.message_id");
          if (messageId == null) messageId = intent.getExtras().getString("message_id");

          // We only want to handle non-consumed initial messages.
          if (messageId == null || consumedInitialMessages.get(messageId) != null) {
            return null;
          }

          RemoteMessage remoteMessage =
              FlutterFirebaseMessagingReceiver.notifications.get(messageId);

          // If we can't find a copy of the remote message in memory then check from our persisted store.
          if (remoteMessage == null) {
            remoteMessage =
                FlutterFirebaseMessagingStore.getInstance().getFirebaseMessage(messageId);
            FlutterFirebaseMessagingStore.getInstance().removeFirebaseMessage(messageId);
          }

          if (remoteMessage == null) {
            return null;
          }

          consumedInitialMessages.put(messageId, true);
          return FlutterFirebaseMessagingUtils.remoteMessageToMap(remoteMessage);
        });
  }

FirebaseMessaging Github내의 getInitialMessage()를 살펴봅시다. 대충 다음의 절차에 따라서 동작하는 걸 알 수 있습니다.

  1. initialMessage값이 null이 아니면, FlutterFirebaseMessagingUtils.remoteMessageToMap에 저장된 remoteMessage를 반환합니다.
  2. mainActivityintent를 얻어온 뒤, 이 intent 내에 저장된 google.message_id값을 읽어옵니다. 이후 위에서 읽어온 messagId를 기반으로 FlutterFirebaseMessagingReceiver.notifications에 저장된 remoteMessage를 반환합니다.
  3. 메모리에서 remoteMessage를 찾지 못했다면, FlutterFirebaseMessagingStore에서 messageId를 키 값으로 하는, remoteMessage를 찾아서 반환합니다.

브레이크 포인트를 걸어서 디버깅해보면 원인을 쉽게 찾을 수 있을 것도 같지만, 백그라운드에서 메시지를 수신했을 때 동작하지 않는 원인을 찾아야하다보니 생각보다 원인을 찾기 어렵습니다. 크흡...

@Override
  public boolean onNewIntent(Intent intent) {
    if (intent == null || intent.getExtras() == null) {
      return false;
    }

    // Remote Message ID can be either one of the following...
    String messageId = intent.getExtras().getString("google.message_id");
    if (messageId == null) messageId = intent.getExtras().getString("message_id");
    if (messageId == null) {
      return false;
    }

    RemoteMessage remoteMessage = FlutterFirebaseMessagingReceiver.notifications.get(messageId);

    // If we can't find a copy of the remote message in memory then check from our persisted store.
    if (remoteMessage == null) {
      remoteMessage = FlutterFirebaseMessagingStore.getInstance().getFirebaseMessage(messageId);
      // Note we don't remove it here as the user may still call getInitialMessage.
    }

    if (remoteMessage == null) {
      return false;
    }

    // Store this message for later use by getInitialMessage.
    initialMessage = remoteMessage;

    FlutterFirebaseMessagingReceiver.notifications.remove(messageId);
    channel.invokeMethod(
        "Messaging#onMessageOpenedApp",
        FlutterFirebaseMessagingUtils.remoteMessageToMap(remoteMessage));
    mainActivity.setIntent(intent);
    return true;
  }

initialMessage를 대체 어디서 넣어주나 했더니 onNewIntent에서 intent 내에 google.message_id를 키값으로 하는 데이터를 찾아서 initialMessage에 넣어줍니다. 여기서 onNewIntent는 안드로이드에서 Activity를 실행할 때 새로운 intent가 넘어올 때 실행되는 녀석이죠. 즉 푸시 메시지를 터치해서 앱을 실행했을 때, intent가 제대로 생성되지 않는다는 얘기가 됩니다.

AndroidManifest.xml에 설정이 문제인가싶어 아래의 내용을 추가해줬지만, 역시 동작하지 않습니다.

<intent-filter>
    <action android:name="FLUTTER_NOTIFICATION_CLICK" />
    <category android:name="android.intent.category.DEFAULT" />
</intent-filter>

여기까지 분석하다가 동작하지 않는 이유를 명확히 밝히기 위해서는, 라이프 사이클 및 코드 동작을 좀 더 면밀히 분석해봐야 할 것 같아서 패스. 예상할 수 있는 건 서버쪽에서 click_action을 지정해주지 않았기 때문에, FirebaseMessaging이 전달받은 클라우드 메시지를 intent로 넘겨주지도, 저장해놓지도 않기 때문이 아닐까 싶습니다. 결국 푸시 메시지를 터치했을 때 원하는 항목만 payload로 받아오면 동일한 결과를 얻을 수 있으므로, FlutterLocalNotifications 패키지에 포함된 getNotificationAppLaunchDetails()를 사용하여 대체 가능합니다. 물론 이건 FlutterLocalNotifications 패키지를 사용할 때만 해결 가능한 방법이니 참고해주세요. 서버쪽을 수정할 수 있다면 서버쪽을 수정하시는 게 좋...겠죠? 아무튼 저는 서버쪽은 건드릴 수 없는 상태이므로 FlutterLocalNotifications.getNotificationAppLaunchDetails()를 사용합니다.

FlutterLocalNotifications 패키지의 getNotificationAppLaunchDetails()의 안드로이드 쪽 내부 구현을 살펴보면 아래와 같습니다. 상수값 SELECT_NOTIFICATION, NOTIFICATION_LAUNCHED_APP, PAYLOAD는 모두 문자열로 된 키값입니다.

private void getNotificationAppLaunchDetails(Result result) {
  Map<String, Object> notificationAppLaunchDetails = new HashMap<>();
  String payload = null;
  Boolean notificationLaunchedApp =
      mainActivity != null
          && SELECT_NOTIFICATION.equals(mainActivity.getIntent().getAction())
          && !launchedActivityFromHistory(mainActivity.getIntent());
  notificationAppLaunchDetails.put(NOTIFICATION_LAUNCHED_APP, notificationLaunchedApp);
  if (notificationLaunchedApp) {
    payload = launchIntent.getStringExtra(PAYLOAD);
  }
  notificationAppLaunchDetails.put(PAYLOAD, payload);
  result.success(notificationAppLaunchDetails);
}

보면 다음의 조건을 모두 만족할 때, notificationLaunchedApp 값을 true로 판단합니다. 즉, 앱이 푸시 메시지를 터치해서 실행됐다고 판단한다는 얘기죠.

  • mainActivitynull이 아님
  • mainActivityintent내에 action값이 “SELECT_NOTIFICATION”임 (푸시 메시지를 선택함)
  • mainActivityintent를 확인해서, 다른 Activity로부터(Activity History로부터) 실행되지 않음

위의 조건을 모두 만족하면 launchIntent에서 “PAYLOAD” 키값으로 저장된 데이터를 가져와서, notificationLaunchedApp값과 함께 HashMap 형태로 넘겨줍니다. Result.success의 인자값으로 HashMapnotificationAppLaunchDetails을 넘겨줬으니, 플러터 코드에서 notificationAppLaunchDetails를 참조할 수 있겠죠?

사족을 붙이자면 launchedIntent는 다음과 같이, 플러터 앱이 실행될 때 registerWith에서 할당됩니다. 개인적으로는 deprecation 경고를 무시하도록 어노테이션을 걸어놓은게 쪼오끔 마음에 안들긴 합니다만, 고치자면 또 귀찮기 때문에 그러려니 합니다.

@SuppressWarnings("deprecation")
public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registrar registrar) {
  FlutterLocalNotificationsPlugin plugin = new FlutterLocalNotificationsPlugin();
  plugin.setActivity(registrar.activity());
  registrar.addNewIntentListener(plugin);
  plugin.onAttachedToEngine(registrar.context(), registrar.messenger());
}

private void setActivity(Activity flutterActivity) {
  this.mainActivity = flutterActivity;
  if (mainActivity != null) {
    launchIntent = mainActivity.getIntent();
  }
}

자 그럼, 왜 FirebaseMessaginggetInitialMessage()는 동작하지 않지만, getNotificationAppLaunchDetails()은 동작하는지, createNotification을 살펴보면 알 수 있습니다.

protected static Notification createNotification(
      Context context, NotificationDetails notificationDetails) {
  NotificationChannelDetails notificationChannelDetails =
      NotificationChannelDetails.fromNotificationDetails(notificationDetails);
  if (canCreateNotificationChannel(context, notificationChannelDetails)) {
    setupNotificationChannel(context, notificationChannelDetails);
  }
  Intent intent = getLaunchIntent(context);
  intent.setAction(SELECT_NOTIFICATION);
  intent.putExtra(PAYLOAD, notificationDetails.payload);
  int flags = PendingIntent.FLAG_UPDATE_CURRENT;
  if (VERSION.SDK_INT >= VERSION_CODES.M) {
    flags |= PendingIntent.FLAG_IMMUTABLE;
  }
  PendingIntent pendingIntent =
      PendingIntent.getActivity(context, notificationDetails.id, intent, flags);
  DefaultStyleInformation defaultStyleInformation =
      (DefaultStyleInformation) notificationDetails.styleInformation;
  NotificationCompat.Builder builder =
      new NotificationCompat.Builder(context, notificationDetails.channelId)
          .setContentTitle(
              defaultStyleInformation.htmlFormatTitle
                  ? fromHtml(notificationDetails.title)
                  : notificationDetails.title)
          .setContentText(
              defaultStyleInformation.htmlFormatBody
                  ? fromHtml(notificationDetails.body)
                  : notificationDetails.body)
          .setTicker(notificationDetails.ticker)
          .setAutoCancel(BooleanUtils.getValue(notificationDetails.autoCancel))
          .setContentIntent(pendingIntent)
          .setPriority(notificationDetails.priority)
          .setOngoing(BooleanUtils.getValue(notificationDetails.ongoing))
          .setOnlyAlertOnce(BooleanUtils.getValue(notificationDetails.onlyAlertOnce));

  ...... (생략)

  setVisibility(notificationDetails, builder);
  applyGrouping(notificationDetails, builder);
  setSound(context, notificationDetails, builder);
  setVibrationPattern(notificationDetails, builder);
  setLights(notificationDetails, builder);
  setStyle(context, notificationDetails, builder);
  setProgress(notificationDetails, builder);
  setCategory(notificationDetails, builder);
  setTimeoutAfter(notificationDetails, builder);
  Notification notification = builder.build();
  if (notificationDetails.additionalFlags != null
      && notificationDetails.additionalFlags.length > 0) {
    for (int additionalFlag : notificationDetails.additionalFlags) {
      notification.flags |= additionalFlag;
    }
  }
  return notification;
}

대충 보면 아시겠지만 createNotification에서 notification을 만들 때, intent내에 action“SELECT_NOTIFICATION”으로 설정하고, “PAYLOAD” 키 값에 payload를 넣어주고 있습니다. 그러니까 결국 flutterLocalNotifications 패키지를 사용해서 띄운 푸시 메시지를 터치했을 때, intent를 얻어오면 actionpayload값이 모두 flutterLocalNotifications 패키지가 의도한대로 값이 설정되어있으니 getNotificationAppLaunchDetails()는 정상적으로 동작하는 셈이죠.

대충 앱이 백그라운드 상태일 때 FlutterLocalNotifications 패키지를 사용해서 푸시 메시지를 출력했을 때, 푸시 메시지를 터치해서 앱을 실행했음에도 불구하고 getInitialMessage()가 동작하지 않아서, 삽질끝에 getNotificationAppLaunchDetails()는 멀쩡히 실행되는 걸 보고 뭐가 다른가 살짝 뜯어본 글이었습니다. ‘ ㅈ’)/

반응형