Skip to content
Merged
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ dependencies {
implementation(libs.compose.material.icons.core)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.hilt.navigation.compose)
Expand All @@ -129,10 +130,14 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.analytics)
implementation(libs.firebase.messaging)

implementation(libs.androidx.core.ktx)

testImplementation(libs.junit)

androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.gamss.android.app.push

import android.Manifest
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import com.gamss.android.app.MainActivity
import com.gamss.android.app.R
import com.google.firebase.messaging.RemoteMessage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class GamssFirebaseMessagingServiceInstrumentedTest {

/** API 33 미만은 이 권한 자체가 없어 GrantPermissionRule이 조용히 no-op 처리한다. */
@get:Rule
val notificationPermissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
} else {
GrantPermissionRule.grant()
}

@Test
fun data_페이로드에서_제목과_본문을_매핑한다() {
val message = RemoteMessage.Builder("dummy@fcm.googleapis.com")
.addData("title", "데이터 제목")
.addData("body", "데이터 본문")
.build()

val content = message.toNotificationContentOrNull()

assertEquals("데이터 제목", content?.title)
assertEquals("데이터 본문", content?.body)
}

@Test
fun title이_전혀_없으면_null이다() {
val message = RemoteMessage.Builder("dummy@fcm.googleapis.com").build()

assertNull(message.toNotificationContentOrNull())
}

/**
* GamssFirebaseMessagingService.showNotification()과 동일한 채널 id·아이콘·인텐트 구성으로
* 실제 알림을 올려, 매니페스트의 채널/아이콘 리소스 설정이 이 기기의 OS에서 유효한지 확인한다.
*/
@Test
fun 서비스와_동일한_구성으로_알림을_올리면_시스템에_표시된다() {
val context = ApplicationProvider.getApplicationContext<Context>()
val notificationManager = context.getSystemService(NotificationManager::class.java)
notificationManager.activeNotifications.forEach { notificationManager.cancel(it.id) }

val contentIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP,
),
PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, context.getString(R.string.default_notification_channel_id))
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("온디바이스_검증_알림")
.setContentText("instrumented test 로 보낸 메시지")
.setAutoCancel(true)
.setContentIntent(contentIntent)
.build()

val id = System.currentTimeMillis().toInt()
NotificationManagerCompat.from(context).notify(id, notification)

val posted = waitUntil(timeoutMillis = 3_000) {
notificationManager.activeNotifications.any { it.id == id }
}

assertTrue("알림이 표시되지 않았습니다", posted)
}

private fun waitUntil(timeoutMillis: Long, condition: () -> Boolean): Boolean {
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() < deadline) {
if (condition()) return true
Thread.sleep(200)
}
return condition()
}
}
15 changes: 15 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".push.GamssFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/default_notification_channel_id" />
</application>

</manifest>
15 changes: 15 additions & 0 deletions app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.gamss.android.app

import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import android.util.Log
import androidx.core.content.getSystemService
import com.gamss.android.domain.config.InitializeRemoteConfigUseCase
import com.gamss.android.domain.safety.RefreshRiskLexiconUseCase
import dagger.hilt.android.HiltAndroidApp
Expand Down Expand Up @@ -32,7 +36,18 @@ class GamssApplication : Application() {

override fun onCreate() {
super.onCreate()
createNotificationChannel()
applicationScope.launch { refreshRiskLexicon() }
applicationScope.launch { initializeRemoteConfig() }
}

private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
getString(R.string.default_notification_channel_id),
getString(R.string.default_notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
getSystemService<NotificationManager>()?.createNotificationChannel(channel)
}
}
23 changes: 23 additions & 0 deletions app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ package com.gamss.android.app.main
import androidx.lifecycle.ViewModel
import com.gamss.android.domain.auth.ObserveSessionStateUseCase
import com.gamss.android.domain.auth.RestoreSessionUseCase
import com.gamss.android.domain.auth.SessionState
import com.gamss.android.domain.config.GetRemoteConfigFlagUseCase
import com.gamss.android.domain.config.ObserveRemoteConfigReadyUseCase
import com.gamss.android.domain.config.RemoteConfigKey
import com.gamss.android.domain.push.MarkNotificationPermissionPromptedUseCase
import com.gamss.android.domain.push.ShouldPromptNotificationPermissionUseCase
import com.gamss.android.domain.push.SyncDeviceTokenUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
Expand All @@ -18,11 +22,15 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds

@HiltViewModel
@Suppress("LongParameterList")
class MainViewModel @Inject constructor(
private val restoreSessionUseCase: RestoreSessionUseCase,
private val observeSessionStateUseCase: ObserveSessionStateUseCase,
private val observeRemoteConfigReadyUseCase: ObserveRemoteConfigReadyUseCase,
private val getRemoteConfigFlagUseCase: GetRemoteConfigFlagUseCase,
private val syncDeviceTokenUseCase: SyncDeviceTokenUseCase,
private val shouldPromptNotificationPermissionUseCase: ShouldPromptNotificationPermissionUseCase,
private val markNotificationPermissionPromptedUseCase: MarkNotificationPermissionPromptedUseCase,
) : ViewModel(), ContainerHost<MainState, Unit> {

override val container = container<MainState, Unit>(MainState())
Expand All @@ -41,9 +49,24 @@ class MainViewModel @Inject constructor(
sessionState to useCardFeature
}.collect { (sessionState, useCardFeature) ->
reduce { state.copy(sessionState = sessionState, useCardFeature = useCardFeature) }
syncDeviceTokenIfAuthenticated(sessionState)
}
}

fun syncDeviceToken() = intent {
syncDeviceTokenIfAuthenticated(state.sessionState)
}

suspend fun shouldPromptNotificationPermission(): Boolean = shouldPromptNotificationPermissionUseCase()

fun markNotificationPermissionPrompted() = intent {
markNotificationPermissionPromptedUseCase()
}

private suspend fun syncDeviceTokenIfAuthenticated(sessionState: SessionState) {
if (sessionState == SessionState.Authenticated) syncDeviceTokenUseCase()
}

/**
* 원격 설정이 늦어지면 콜드스타트를 붙잡지 않고 기본값으로 먼저 진행한다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
Expand All @@ -38,6 +42,11 @@ fun GamssRootNavHost(
) {
val state by mainViewModel.collectAsState()

LifecycleStartEffect(Unit) {
mainViewModel.syncDeviceToken()
onStopOrDispose { }
}

when (state.sessionState) {
SessionState.Loading -> {
Box(
Expand All @@ -51,6 +60,9 @@ fun GamssRootNavHost(
else -> RootNavDisplay(
sessionState = state.sessionState,
useCardFeature = state.useCardFeature,
shouldPromptNotificationPermission = mainViewModel::shouldPromptNotificationPermission,
onNotificationPermissionPrompted = mainViewModel::markNotificationPermissionPrompted,
onNotificationPermissionResult = mainViewModel::syncDeviceToken,
)
}
}
Expand All @@ -59,14 +71,32 @@ fun GamssRootNavHost(
private fun RootNavDisplay(
sessionState: SessionState,
useCardFeature: Boolean,
shouldPromptNotificationPermission: suspend () -> Boolean,
onNotificationPermissionPrompted: () -> Unit,
onNotificationPermissionResult: () -> Unit,
) {
val initialKey = if (sessionState == SessionState.Authenticated) MainKey else LoginKey
val backStack = rememberNavBackStack(initialKey)
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = {},
onResult = { onNotificationPermissionResult() },
)

// 저장된 안내 이력은 비동기로 반영되므로, 온보딩에서 메인으로 넘어가는 동안에는 이 상태로 중복 요청을 막는다.
var promptedInSession by remember { mutableStateOf(false) }

fun markNotificationPermissionPrompted() {
promptedInSession = true
onNotificationPermissionPrompted()
}

fun requestNotificationPermission() {
markNotificationPermissionPrompted()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}

fun replaceRoot(destination: NavKey) {
if (backStack.lastOrNull() != destination) {
backStack.clear()
Expand Down Expand Up @@ -95,15 +125,23 @@ private fun RootNavDisplay(
}
entry<OnboardingKey> {
OnboardingScreen(
onComplete = { replaceRoot(MainKey) },
onNotificationPermissionRequest = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
onComplete = {
// 권한 안내는 동의/거절 모두 온보딩에서 끝난다. 거절도 결정으로 남겨 메인에서 다시 묻지 않는다.
markNotificationPermissionPrompted()
replaceRoot(MainKey)
},
onNotificationPermissionRequest = { requestNotificationPermission() },
)
}
entry<MainKey> { MainScreen(useCardFeature = useCardFeature) }
entry<MainKey> {
// 온보딩을 거치지 않고 들어오는 기존 사용자에게만 한 번 요청한다.
LaunchedEffect(Unit) {
if (!promptedInSession && shouldPromptNotificationPermission()) {
requestNotificationPermission()
}
}
MainScreen(useCardFeature = useCardFeature)
}
},
),

Expand Down
Loading
Loading