Skip to content
Open
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
10 changes: 0 additions & 10 deletions app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import com.gamss.android.domain.analytics.AnalyticsTracker
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.push.MarkNotificationPermissionPromptedUseCase
import com.gamss.android.domain.push.ShouldPromptNotificationPermissionUseCase
import com.gamss.android.domain.push.SyncDeviceTokenUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import org.orbitmvi.orbit.ContainerHost
Expand All @@ -21,8 +19,6 @@ class MainViewModel @Inject constructor(
private val restoreSessionUseCase: RestoreSessionUseCase,
private val observeSessionStateUseCase: ObserveSessionStateUseCase,
private val syncDeviceTokenUseCase: SyncDeviceTokenUseCase,
private val shouldPromptNotificationPermissionUseCase: ShouldPromptNotificationPermissionUseCase,
private val markNotificationPermissionPromptedUseCase: MarkNotificationPermissionPromptedUseCase,
private val analyticsTracker: AnalyticsTracker,
) : ViewModel(), ContainerHost<MainState, Unit> {

Expand All @@ -48,12 +44,6 @@ class MainViewModel @Inject constructor(
syncDeviceTokenIfAuthenticated(state.sessionState)
}

suspend fun shouldPromptNotificationPermission(): Boolean = shouldPromptNotificationPermissionUseCase()

fun markNotificationPermissionPrompted() = intent {
markNotificationPermissionPromptedUseCase()
}

fun trackScreenView(key: NavKey) {
val screenView = key.toScreenView() ?: return
analyticsTracker.track(SCREEN_VIEW_EVENT, screenView.properties + ("screen" to screenView.name))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
package com.gamss.android.app.navigation

import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
Expand All @@ -12,9 +8,6 @@ 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
Expand Down Expand Up @@ -57,9 +50,6 @@ fun GamssRootNavHost(

else -> RootNavDisplay(
sessionState = state.sessionState,
shouldPromptNotificationPermission = mainViewModel::shouldPromptNotificationPermission,
onNotificationPermissionPrompted = mainViewModel::markNotificationPermissionPrompted,
onNotificationPermissionResult = mainViewModel::syncDeviceToken,
onScreenView = mainViewModel::trackScreenView,
)
}
Expand All @@ -68,9 +58,6 @@ fun GamssRootNavHost(
@Composable
private fun RootNavDisplay(
sessionState: SessionState,
shouldPromptNotificationPermission: suspend () -> Boolean,
onNotificationPermissionPrompted: () -> Unit,
onNotificationPermissionResult: () -> Unit,
onScreenView: (NavKey) -> Unit,
) {
val initialKey = if (sessionState == SessionState.Authenticated) MainKey else LoginKey
Expand All @@ -79,25 +66,6 @@ private fun RootNavDisplay(
LaunchedEffect(currentKey) {
currentKey?.let(onScreenView)
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
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) {
Expand Down Expand Up @@ -130,22 +98,9 @@ private fun RootNavDisplay(
)
}
entry<OnboardingKey> {
OnboardingScreen(
onComplete = {
// 권한 안내는 동의/거절 모두 온보딩에서 끝난다. 거절도 결정으로 남겨 메인에서 다시 묻지 않는다.
markNotificationPermissionPrompted()
replaceRoot(MainKey)
},
onNotificationPermissionRequest = { requestNotificationPermission() },
)
OnboardingScreen(onComplete = { replaceRoot(MainKey) })
}
entry<MainKey> {
// 온보딩을 거치지 않고 들어오는 기존 사용자에게만 한 번 요청한다.
LaunchedEffect(Unit) {
if (!promptedInSession && shouldPromptNotificationPermission()) {
requestNotificationPermission()
}
}
MainScreen(onScreenView = onScreenView)
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.gamss.android.feature.onboarding.component
package com.gamss.android.core.ui.permission

import androidx.compose.foundation.background
import androidx.compose.foundation.border
Expand All @@ -25,11 +25,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.gamss.android.core.designsystem.button.GamssButton
import com.gamss.android.core.designsystem.button.GamssButtonVariant
import com.gamss.android.core.designsystem.component.GamssIcons
import com.gamss.android.core.designsystem.component.GamssText
import com.gamss.android.core.designsystem.theme.GamssTheme
import com.gamss.android.feature.onboarding.R
import com.gamss.android.core.ui.R

private val DialogMaxWidth = 360.dp
private val DialogHorizontalMargin = 20.dp
Expand All @@ -38,10 +37,9 @@ private val PermissionIconSize = 24.dp
private val PermissionInfoIconSize = 20.dp

@Composable
internal fun NotificationPermissionDialog(
fun NotificationPermissionDialog(
onDismiss: () -> Unit,
onDisagree: () -> Unit,
onAgree: () -> Unit,
onConfirm: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
Expand All @@ -53,18 +51,14 @@ internal fun NotificationPermissionDialog(
.padding(horizontal = DialogHorizontalMargin),
contentAlignment = Alignment.Center,
) {
PermissionDialogContent(
onDisagree = onDisagree,
onAgree = onAgree,
)
PermissionDialogContent(onConfirm = onConfirm)
}
}
}

@Composable
private fun PermissionDialogContent(
onDisagree: () -> Unit,
onAgree: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
Expand All @@ -78,35 +72,25 @@ private fun PermissionDialogContent(
.padding(GamssTheme.spacing.spacing500),
) {
GamssText(
text = stringResource(R.string.onboarding_permission_title),
text = stringResource(R.string.notification_permission_title),
style = GamssTheme.typography.title3,
color = GamssTheme.colors.gray950,
)
Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200))
GamssText(
text = stringResource(R.string.onboarding_permission_subtitle),
text = stringResource(R.string.notification_permission_subtitle),
style = GamssTheme.typography.body3Medium,
color = GamssTheme.colors.gray500,
)
Spacer(modifier = Modifier.height(PermissionPanelTopSpacing))
PermissionPanel()
Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing500))
Row(
// 거절은 시스템 팝업에서만 받는다. 여기서 따로 받으면 OS 와 다른 거절 이력이 생긴다.
GamssButton(
label = stringResource(R.string.notification_permission_confirm),
onClick = onConfirm,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100),
) {
GamssButton(
label = stringResource(R.string.onboarding_permission_disagree),
onClick = onDisagree,
modifier = Modifier.weight(1f),
variant = GamssButtonVariant.Secondary,
)
GamssButton(
label = stringResource(R.string.onboarding_permission_agree),
onClick = onAgree,
modifier = Modifier.weight(1f),
)
}
)
}
}

Expand Down Expand Up @@ -136,17 +120,17 @@ private fun PermissionPanel() {
) {
Icon(
painter = painterResource(GamssIcons.NotificationAlert),
contentDescription = stringResource(R.string.onboarding_notification_permission_description),
contentDescription = stringResource(R.string.notification_permission_description),
tint = GamssTheme.colors.red,
modifier = Modifier.size(PermissionIconSize),
)
GamssText(
text = stringResource(R.string.onboarding_notification_permission_name),
text = stringResource(R.string.notification_permission_name),
style = GamssTheme.typography.body4Medium,
color = GamssTheme.colors.gray900,
)
GamssText(
text = stringResource(R.string.onboarding_notification_permission_reason),
text = stringResource(R.string.notification_permission_reason),
style = GamssTheme.typography.body4Medium,
color = GamssTheme.colors.gray500,
)
Expand All @@ -162,7 +146,7 @@ private fun PermissionPanel() {
modifier = Modifier.size(PermissionInfoIconSize),
)
GamssText(
text = stringResource(R.string.onboarding_permission_optional),
text = stringResource(R.string.notification_permission_optional),
style = GamssTheme.typography.caption2,
color = GamssTheme.colors.gray400,
)
Expand All @@ -182,7 +166,7 @@ private fun NotificationPermissionDialogPreview() {
.padding(DialogHorizontalMargin),
contentAlignment = Alignment.Center,
) {
PermissionDialogContent(onDisagree = {}, onAgree = {})
PermissionDialogContent(onConfirm = {})
}
}
}
8 changes: 8 additions & 0 deletions core/ui/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,12 @@
<string name="safety_emergency_call_description">119에 전화하기</string>
<string name="safety_call_unavailable">전화 앱을 열 수 없어요. %1$s로 직접 전화해 주세요.</string>
<string name="safety_agency_close_button_label">닫기</string>

<string name="notification_permission_title">앱 사용을 위해\n접근 권한을 허용해주세요</string>
<string name="notification_permission_subtitle">더 나은 서비스 환경을 위해\n아래의 접근 권한 허용이 필요합니다.</string>
<string name="notification_permission_description">알림 권한</string>
<string name="notification_permission_name">알림</string>
<string name="notification_permission_reason">카드 및 대화 알림 시 사용</string>
<string name="notification_permission_optional">허용하지 않아도 앱을 이용할 수 있어요.</string>
<string name="notification_permission_confirm">확인</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,16 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

/**
* 온보딩에서 권한을 결정한 직후 메인 화면이 같은 값을 읽는다. 디스크 쓰기가 끝나기를 기다리면
* 그 사이에 요청이 한 번 더 나가므로 메모리에 먼저 반영한다.
*/
@Singleton
internal class DataStoreNotificationPermissionPromptHistory @Inject constructor(
@param:NotificationPermissionPromptDataStore private val dataStore: DataStore<Preferences>,
) : NotificationPermissionPromptHistory {

@Volatile
private var prompted: Boolean = false

override suspend fun hasPrompted(): Boolean {
if (prompted) return true
val stored = withContext(Dispatchers.IO) {
dataStore.data.first()[PROMPTED_KEY] == true
}
if (stored) prompted = true
return stored
override suspend fun hasPrompted(): Boolean = withContext(Dispatchers.IO) {
dataStore.data.first()[PROMPTED_KEY] == true
}

override suspend fun markPrompted() {
prompted = true
withContext(Dispatchers.IO) {
dataStore.edit { preferences -> preferences[PROMPTED_KEY] = true }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
package com.gamss.android.domain.push

/**
* 알림 권한 안내를 이미 노출했는지 기록한다.
*
* 시스템은 한 번 거절된 권한에 대해 다이얼로그를 다시 띄우지 않는다. 이력을 남기지 않으면
* 실행마다 요청을 반복해 사용자의 거절을 무시하는 셈이 되므로 결정 시점을 기억한다.
*/
/** 시스템 권한 팝업에서 한 번 거절했는지 기록한다. OS 가 알려 주지 않는 값이라 직접 남긴다. */
interface NotificationPermissionPromptHistory {

suspend fun hasPrompted(): Boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package com.gamss.android.domain.push

import com.gamss.android.domain.usecase.NoParamUseCase
import com.gamss.android.domain.usecase.UseCase
import javax.inject.Inject

/**
* 시스템 권한 팝업을 띄울 수 있을 때만 묻는다. [params] 는 OS 의 shouldShowRequestPermissionRationale 값이다.
*
* OS 는 한 번도 묻지 않은 상태와 영구 거절을 똑같이 false 로 보고하므로 거절 이력으로 둘을 구분한다.
*/
class ShouldPromptNotificationPermissionUseCase @Inject constructor(
private val notificationPermissionChecker: NotificationPermissionChecker,
private val promptHistory: NotificationPermissionPromptHistory,
) : NoParamUseCase<Boolean> {
) : UseCase<Boolean, Boolean> {

override suspend fun invoke(): Boolean =
!notificationPermissionChecker.isGranted() && !promptHistory.hasPrompted()
override suspend fun invoke(params: Boolean): Boolean =
!notificationPermissionChecker.isGranted() && (params || !promptHistory.hasPrompted())
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,27 @@ import org.junit.Test
class ShouldPromptNotificationPermissionUseCaseTest {

@Test
fun `권한이 없고 안내 이력도 없으면 요청한다`() = runBlocking {
val useCase = ShouldPromptNotificationPermissionUseCase(
notificationPermissionChecker = FakeNotificationPermissionChecker(granted = false),
promptHistory = FakeNotificationPermissionPromptHistory(prompted = false),
)

assertTrue(useCase())
fun `권한이 없고 거절한 적이 없으면 요청한다`() = runBlocking {
assertTrue(useCase(granted = false, prompted = false)(false))
}

@Test
fun `이미 안내한 적이 있으면 요청하지 않는다`() = runBlocking {
val useCase = ShouldPromptNotificationPermissionUseCase(
notificationPermissionChecker = FakeNotificationPermissionChecker(granted = false),
promptHistory = FakeNotificationPermissionPromptHistory(prompted = true),
)
fun `한 번 거절해 OS 가 다시 물을 수 있으면 요청한다`() = runBlocking {
assertTrue(useCase(granted = false, prompted = true)(true))
}

assertFalse(useCase())
@Test
fun `거절한 뒤 OS 가 더 묻지 않으면 요청하지 않는다`() = runBlocking {
assertFalse(useCase(granted = false, prompted = true)(false))
}

@Test
fun `권한이 이미 허용되어 있으면 요청하지 않는다`() = runBlocking {
val useCase = ShouldPromptNotificationPermissionUseCase(
notificationPermissionChecker = FakeNotificationPermissionChecker(granted = true),
promptHistory = FakeNotificationPermissionPromptHistory(prompted = false),
)

assertFalse(useCase())
assertFalse(useCase(granted = true, prompted = false)(false))
}

private fun useCase(granted: Boolean, prompted: Boolean) = ShouldPromptNotificationPermissionUseCase(
notificationPermissionChecker = FakeNotificationPermissionChecker(granted = granted),
promptHistory = FakeNotificationPermissionPromptHistory(prompted = prompted),
)
}
Loading
Loading