[Android] FCM Background 에서 수신하여 진입 화면 변경 하는 법

easyhooon·2025년 6월 26일
2

서두

최근 오픈 커뮤니티에서 많이 언급되는 질문들에 대한 나의 해결법을 공유해보고자 한다.

문제 발생

포그라운드에서 FCM 을 수신하는 방법은 FirebaseMessagingService 내에 onMessageReceived 함수내에서 비교적 간편하게 구현할 수 있다.

하지만 문제는 앱이 백그라운드에 진입했을 때 발생한다.

앱의 아이콘이 statusbar에 보이긴 하나, 포그라운드에서 처럼 사용자가 알림이 왔는지 확인할 수 있는 장치(불빛 효과)등이 동작하지 않는다.

또한, FCM 을 통해 전달받은 데이터로 초기 진입화면을 알림과 관련된 화면으로 바꿔줘야 할때에도(ex. 웨이팅 알림을 클릭해서 진입할 경우, 웨이팅 화면으로 바로 진입) 데이터를 수신 받을 수 없는 문제가 발생하기도 한다.

문제 해결

https://firebase.google.com/docs/cloud-messaging/android/receive#handling_messages

정책상 백그라운드에서 FCM 을 수신할 경우, notification key 를 통해 전달되는 value가 존재하면 FirebaseMessagingService 내에 onMessageReceived 함수를 통해 수신할 수 없다. 이는 공식 문서에도 설명이 잘 나와있다.

https://stackoverflow.com/questions/37358462/firebase-onmessagereceived-not-called-when-app-in-background

Background에서 onMessageReceived가 호출 안되는 현상에 관하여

구글링을 통해 여러 해결 방법들을 찾을 수 있었고, 위에 글의 2번째 방법(handleIntent 함수 오버라이딩)을 시도해보려고 하였으나, 아쉽게도 firebase 버전이 달라져 내부 코드가 변경된 것인지 이를 적용해볼 순 없었다.

onMessageReceived is provided for most message types, with the following exceptions:

  • Notification messages delivered when your app is in the background. In this case, the notification is delivered to the device’s system tray. A user tap on a notification opens the app launcher by default.
  • Messages with both notification and data payload, when received in the background. In this case, the notification is delivered to the device’s system tray, and the data payload is delivered in the extras of the intent of your launcher Activity.

그래도 다행히 공식문서에 언급된 방법대로, launcher Activity에서 intent 를 통해 data payload 를 받아 처리하는 방식을 통해 해결할 수 있었다.

Data Payload 는 FCM 을 통해 전송되는 사용자 정의 데이터로 Key-Value 형태로 구성되며, 앱에서 필요한 커스텀 정보를 전달할 때 사용한다.

{
  "notification": {
    "title": "웨이팅 알림",
    "body": "대기 순서가 되었습니다."
  },
  "data": {
    "boothId": "12345",
    "waitingId": "67890",
    "type": "waiting_notification"
  }
}

나의 경우 SplashActivity를 별도로 구현하여 사용하고 있기에, SplashActivity 내 onCreate() 에서 intent를 통해 FCM 에 의해 전달 받은 data 를 bundle(key, value)의 형태로 받을 수 있었다.

@SuppressLint("CustomSplashScreen")
@AndroidEntryPoint
class SplashActivity : ComponentActivity() {
    @Inject
    lateinit var introNavigator: IntroNavigator

    @Inject
    lateinit var mainNavigator: MainNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        installSplashScreen()
        super.onCreate(savedInstanceState)

		// 앱이 백그라운드에서 FCM 을 수신했는지 판단(boothId 또는 waitingId를 FCM을 통해 받았는지 확인)
        val hasFcmData = intent?.extras?.let { extras ->
            extras.getString("boothId") != null ||
                extras.getString("waitingId") != null
        } ?: false

        setContent {
  			// ...

            UnifestTheme {
                SplashRoute(
                    navigateToIntro = {
                        introNavigator.navigateFrom(
                            activity = this@SplashActivity,
                            withFinish = true,
                        )
                    },
                    navigateToMain = {
                        if (hasFcmData) {
                            // 앱이 백그라운드 상태일 때, 알림을 수신한 경우
                            mainNavigator.navigateFrom(
                                activity = this@SplashActivity,
                                withFinish = true,
                            ) {
                                flags = Intent.FLAG_ACTIVITY_NEW_TASK or
                                    Intent.FLAG_ACTIVITY_CLEAR_TOP or
                                    Intent.FLAG_ACTIVITY_SINGLE_TOP
                                intent.extras?.let { putExtras(it) }
                                this
                            }
                        } else {
                            mainNavigator.navigateFrom(
                                activity = this@SplashActivity,
                                withFinish = true,
                            )
                        }
                    },
                )
            }
        }
    }
}

전체 feature 모듈의 Navigation 중앙 컨트룰러 역할을 수행하는 feature:main 모듈내에 MainActivity로 (FCM으로 부터 전달받은) data payload 를 intent 를 통해 전달한 후에

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
        setContent {
            val navigator: MainNavController = rememberMainNavController()
            // ...

            UnifestTheme {
                MainScreen(
                    navigator = navigator,
                )
            }
        }
    }
}

MainScreen 내에서 전달 받은 data payload 관련 처리를 수행하였다.

MainActivity 가 아닌, MainScreen 에서 처리한 이유는 navigator(navController)를 통한 화면 이동을 MainScreen 에서 전부 처리하고 있기 때문이다.


@Composable
internal fun MainScreen(
    navigator: MainNavController = rememberMainNavController(),
) {
	// ...

	// data 처리 함수 ! 
    HandleNewIntent(navigator = navigator)

    UnifestScaffold(
        bottomBar = {
			// ...
        },
        snackbarHost = {
			// ...
        },
        containerColor = MaterialTheme.colorScheme.background,
    ) { innerPadding ->
        NavHost(
			// ...
        ) {
       		// ...NavGrapth
        }
    }
}

@Composable
private fun HandleNewIntent(navigator: MainNavController) {
    val activity = LocalContext.current as ComponentActivity
    DisposableEffect(Unit) {
        val onNewIntentConsumer = Consumer<Intent> { intent ->
            // waiting 관련 FCM 을 수신한 경우
            if (intent.getStringExtra("waitingId") != null) {
                val waitingId = intent.getStringExtra("waitingId")
                Timber.d("navigate_to_waiting -> waitingId: $waitingId")
                if (waitingId != null) {
                    // waiting 화면으로 이동
                    navigator.navigate(MainTab.WAITING)
                }
            } else if (intent.getStringExtra("boothId") != null) {
                val boothId = intent.getStringExtra("boothId")
                Timber.d("navigate_to_booth -> boothId: $boothId")
                if (boothId != null) {
                    // booth 확성기 관련 FCM 을 수신한 경우, intent 를 통해 전달받은 boothId 를 담아서 boothDetail 화면으로 이동  
                    navigator.navigateToBoothDetail(boothId.toLong())
                }
            }
        }
        activity.addOnNewIntentListener(onNewIntentConsumer)
        onDispose { 
        	// 메모리 누수 방지를 위해 리스너 제거
			activity.removeOnNewIntentListener(onNewIntentConsumer) }
    	}
}

Consumer?

https://developer.android.com/reference/java/util/function/Consumer

Consumer 는 Java 8에서 도입된 함수형 인터페이스로, 단일 입력을 받아서 결과를 반환하지 않는 작업을 수행하는 함수를 나타낸다.

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

addOnNewIntentListener 함수를 통해 새로운 Intent를 받았을 때 실행할 콜백 함수를 Activity 에 등록하기 위해 Consumer 를 사용하였다.

Consumer 에서는 Intent 객체를 받아 데이터를 파싱, 네비게이션을 수행하고, 별도의 값을 반환하진 않는다.

MainActivity의 onNewIntent() 콜백에서 해당 로직을 처리해도 되지만, 조금 더 Compose 스럽게(?) 구현해보기 위해 Consumer 를 도입 해보았다.

주의해야 할 점(Activity launchMode 및 Intent Flag)

launchMode

AndroidManifest 파일 내에 intent를 처리하는 Activity의 launchMode 를 singleTop 으로 설정해줘야 한다.

singleTop 설정을 적용하면, 해당 액티비티가 이미 스택에 최상단에 있는 경우 해당 액티비티의 새 인스턴스를 생성하지 않고, 기존 액티비티 인스턴스에서 onNewIntent() 메서드가 호출된다. (정상적으로 동작됨)

Intent Flag

MainActivity 를 Intent 를 통해 실행 시킬 때,

@SuppressLint("CustomSplashScreen")
@AndroidEntryPoint
class SplashActivity : ComponentActivity() {
    @Inject
    lateinit var introNavigator: IntroNavigator

    @Inject
    lateinit var mainNavigator: MainNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        installSplashScreen()
        super.onCreate(savedInstanceState)

		// 앱이 백그라운드에서 FCM 을 수신했는지 판단(boothId 또는 waitingId를 FCM을 통해 받았는지 확인)
        val hasFcmData = intent?.extras?.let { extras ->
            extras.getString("boothId") != null ||
                extras.getString("waitingId") != null
        } ?: false

        setContent {
			// ...

            UnifestTheme {
                SplashRoute(
                    navigateToIntro = {
                        introNavigator.navigateFrom(
                            activity = this@SplashActivity,
                            withFinish = true,
                        )
                    },
                    navigateToMain = {
                        if (hasFcmData) {
                            // 앱이 백그라운드 상태일 때, 알림을 수신한 경우
                            mainNavigator.navigateFrom(
                                activity = this@SplashActivity,
                                withFinish = true,
                            ) {
                                // Intent Flag 추가!!!
								// NEW_TASK 는 시스템 또는 다른 앱으로 부터 액티비티를 시작할 때 필요한 플래그 이므로, 같은 앱내에서는 생략이 가능하다.
                                flags = Intent.FLAG_ACTIVITY_NEW_TASK or
                                    Intent.FLAG_ACTIVITY_CLEAR_TOP or
                                    Intent.FLAG_ACTIVITY_SINGLE_TOP
                                intent.extras?.let { putExtras(it) }
                                this
                            }
                        } else {
                            mainNavigator.navigateFrom(
                                activity = this@SplashActivity,
                                withFinish = true,
                            )
                        }
                    },
                )
            }
        }
    }
}

위의 코드에서 처럼 flag 를 설정하여 대상 액티비티 위의 모든 액티비티를 제거하고, 중복 인스턴스 생성을 방지하여야 FCM 관련 처리가 정상적으로 동작함을 보장할 수 있다.

글을 작성하면서 NEW_TASK flag 가 진짜 필요한가? 추가 조사해봤는데 다른 앱으로 부터 액티비티를 시작하는 경우가 아니기에(SplashActivity가 MainActivity를 시작) 필요 없을 것으로 판단된다. 불필요하므로 제거 해야겠다...TODO 추가!

전체 코드는 아래 링크를 통해 확인 할 수 있다.
https://github.com/Project-Unifest/unifest-android

reference)
https://firebase.google.com/docs/cloud-messaging/android/receive?hl=ko#handling_messages

https://stackoverflow.com/questions/37711082/how-to-handle-notification-when-app-in-background-in-firebase

https://medium.com/@jms8732/background%EC%97%90%EC%84%9C-onmessagereceived%EA%B0%80-%ED%98%B8%EC%B6%9C-%EC%95%88%EB%90%98%EB%8A%94-%ED%98%84%EC%83%81%EC%97%90-%EA%B4%80%ED%95%98%EC%97%AC-7595df624d91

https://developer.android.com/reference/androidx/core/util/Consumer

https://androidx.de/androidx/core/util/Consumer.html

https://developer.android.com/reference/java/util/function/Consumer

https://m.blog.naver.com/tkddlf4209/221625929903

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글