-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 알림 권한 요청 및 설정 연동 구현 #192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7ac5be1
86f97bc
1190e03
39b097d
4069548
94aaa4c
e9969fd
c0a83f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.ninecraft.booket.core.datastore.api.datasource | ||
|
|
||
| import kotlinx.coroutines.flow.Flow | ||
|
|
||
| interface NotificationDataSource { | ||
| val isNotificationEnabled: Flow<Boolean> | ||
| suspend fun setNotificationEnabled(isEnabled: Boolean) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package com.ninecraft.booket.core.datastore.impl.datasource | ||
|
|
||
| import androidx.datastore.core.DataStore | ||
| import androidx.datastore.preferences.core.Preferences | ||
| import androidx.datastore.preferences.core.booleanPreferencesKey | ||
| import androidx.datastore.preferences.core.edit | ||
| import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource | ||
| import com.ninecraft.booket.core.datastore.impl.di.NotificationDataStore | ||
| import com.ninecraft.booket.core.datastore.impl.util.handleIOException | ||
| import kotlinx.coroutines.flow.Flow | ||
| import kotlinx.coroutines.flow.map | ||
| import javax.inject.Inject | ||
|
|
||
| class DefaultNotificationDataSource @Inject constructor( | ||
| @NotificationDataStore private val dataStore: DataStore<Preferences>, | ||
| ) : NotificationDataSource { | ||
| override val isNotificationEnabled: Flow<Boolean> = dataStore.data | ||
| .handleIOException() | ||
| .map { prefs -> | ||
| prefs[NOTIFICATION_ENABLED] ?: true | ||
| } | ||
|
|
||
| override suspend fun setNotificationEnabled(isEnabled: Boolean) { | ||
| dataStore.edit { prefs -> | ||
| prefs[NOTIFICATION_ENABLED] = isEnabled | ||
| } | ||
| } | ||
|
|
||
| companion object Companion { | ||
| private val NOTIFICATION_ENABLED = booleanPreferencesKey("NOTIFICATION_ENABLED") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ dependencies { | |
| implementations( | ||
| libs.logger, | ||
|
|
||
| libs.androidx.activity.compose, | ||
| libs.lottie.compose, | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,9 +11,9 @@ import com.ninecraft.booket.core.data.api.repository.BookRepository | |
| import com.ninecraft.booket.core.model.RecentBookModel | ||
| import com.ninecraft.booket.core.model.UserState | ||
| import com.ninecraft.booket.feature.screens.BookDetailScreen | ||
| import com.ninecraft.booket.feature.screens.BookSearchScreen | ||
| import com.ninecraft.booket.feature.screens.HomeScreen | ||
| import com.ninecraft.booket.feature.screens.RecordScreen | ||
| import com.ninecraft.booket.feature.screens.BookSearchScreen | ||
| import com.ninecraft.booket.feature.screens.SettingsScreen | ||
| import com.skydoves.compose.effects.RememberedEffect | ||
| import com.slack.circuit.codegen.annotations.CircuitInject | ||
|
|
@@ -89,6 +89,10 @@ class HomePresenter @AssistedInject constructor( | |
| restoreState = true, | ||
| ) | ||
| } | ||
|
|
||
| is HomeUiEvent.OnNotificationPermissionResult -> { | ||
| // TODO: 서버 동기화, FCM 토큰 전송 | ||
| } | ||
|
Comment on lines
+93
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 서버 동기화 및 FCM 토큰 전송 구현이 필요합니다. 알림 권한 결과를 받았지만 실제 처리 로직이 누락되어 있습니다. 다음 구현이 필요합니다:
이 기능의 구현을 도와드릴까요? 또는 별도 이슈로 추적하시겠습니까? 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,9 @@ | ||||||||||||||||||||||||||||||||||||||||||
| package com.ninecraft.booket.feature.home | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| import android.content.pm.PackageManager | ||||||||||||||||||||||||||||||||||||||||||
| import android.os.Build | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.activity.compose.rememberLauncherForActivityResult | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.foundation.background | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.foundation.layout.Arrangement | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.foundation.layout.Box | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -19,10 +23,13 @@ import androidx.compose.foundation.shape.CircleShape | |||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.foundation.verticalScroll | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.material3.Text | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.runtime.Composable | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.runtime.LaunchedEffect | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.ui.Modifier | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.ui.draw.clip | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.ui.platform.LocalContext | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.ui.res.stringResource | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.compose.ui.unit.dp | ||||||||||||||||||||||||||||||||||||||||||
| import androidx.core.content.ContextCompat | ||||||||||||||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.designsystem.DevicePreview | ||||||||||||||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.designsystem.theme.HomeBg | ||||||||||||||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.designsystem.theme.ReedTheme | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -48,6 +55,28 @@ internal fun HomeUi( | |||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||
| HandleHomeSideEffects(state = state) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| val context = LocalContext.current | ||||||||||||||||||||||||||||||||||||||||||
| val permissionLauncher = rememberLauncherForActivityResult( | ||||||||||||||||||||||||||||||||||||||||||
| contract = ActivityResultContracts.RequestPermission(), | ||||||||||||||||||||||||||||||||||||||||||
| ) { granted -> | ||||||||||||||||||||||||||||||||||||||||||
| state.eventSink(HomeUiEvent.OnNotificationPermissionResult(granted)) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!state.isGuestMode) { | ||||||||||||||||||||||||||||||||||||||||||
| LaunchedEffect(Unit) { | ||||||||||||||||||||||||||||||||||||||||||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | ||||||||||||||||||||||||||||||||||||||||||
| val permission = android.Manifest.permission.POST_NOTIFICATIONS | ||||||||||||||||||||||||||||||||||||||||||
| val isGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!isGranted) { | ||||||||||||||||||||||||||||||||||||||||||
| permissionLauncher.launch(permission) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+65
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 권한이 이미 허용된 경우 이벤트가 누락됩니다. Line 69에서 권한이 이미 허용된 것을 확인했지만, 이 경우 다음과 같이 수정하세요: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = android.Manifest.permission.POST_NOTIFICATIONS
val isGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
- if (!isGranted) {
+ if (isGranted) {
+ state.eventSink(HomeUiEvent.OnNotificationPermissionResult(true))
+ } else {
permissionLauncher.launch(permission)
}
} else {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||
| state.eventSink(HomeUiEvent.OnNotificationPermissionResult(true)) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+74
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Android 13 미만 기기의 알림 상태를 정확하게 확인하세요. Android 13 미만에서는
+import androidx.core.app.NotificationManagerCompat
+
} else {
- state.eventSink(HomeUiEvent.OnNotificationPermissionResult(true))
+ val notificationManager = NotificationManagerCompat.from(context)
+ state.eventSink(HomeUiEvent.OnNotificationPermissionResult(notificationManager.areNotificationsEnabled()))
}
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 그러면 Android13 이상 버전에서 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @seoyoon513, 좋은 질문입니다! 하지만 Android 13 이상에서는 두 가지를 함께 사용하는 것이 좋고, 두 메서드의 차이점:
Android 13+ 시나리오:
현재 코드의 목적은 권한 요청 다이얼로그를 표시할지 결정하는 것이므로, Android 13+에서는 권장 접근 방식: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = android.Manifest.permission.POST_NOTIFICATIONS
val isGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
if (!isGranted) {
permissionLauncher.launch(permission)
} else {
// 권한은 있지만 실제 알림 상태 확인
val notificationManager = NotificationManagerCompat.from(context)
state.eventSink(HomeUiEvent.OnNotificationPermissionResult(notificationManager.areNotificationsEnabled()))
}
} else {
// Android 12 이하에서는 런타임 권한 없이 시스템 설정만 확인
val notificationManager = NotificationManagerCompat.from(context)
state.eventSink(HomeUiEvent.OnNotificationPermissionResult(notificationManager.areNotificationsEnabled()))
}하지만 현재 구조에서는 🧠 Learnings used
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. android13에서 알림 허용 토글은 하나밖에 없고, 해당 토글을 on/off 하면 areNotificationsEnabled()과 POST_NOTIFICATIONS 권한 체크가 같이 변경되는데? on하면 둘 다 true off하면 둘 다 false임
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @coderabbitai 대답해줘 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @seoyoon513, 맞습니다! 제가 잘못 설명했네요. 정정해주셔서 감사합니다. Android 13+에서는 말씀하신 대로
따라서 현재 PR의 코드가 올바릅니다:
최종적으로 Android 12 이하에서만 다음과 같이 수정하시면 됩니다: } else {
val notificationManager = NotificationManagerCompat.from(context)
state.eventSink(HomeUiEvent.OnNotificationPermissionResult(notificationManager.areNotificationsEnabled()))
}✏️ Learnings added
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @seoyoon513, 이미 위의 댓글에서 답변을 드렸습니다! 말씀하신 대로 Android 13 이상에서는 혹시 추가로 궁금하신 점이나 확인이 필요한 부분이 있으신가요? 🧠 Learnings used
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Android 13+에서도 areNotificationsEnabled()로 체크해도 되는거지? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @seoyoon513, 네, 맞습니다! Android 13+에서도 말씀하신 대로 Android 13+에서는 두 값이 완전히 동기화되어 있기 때문에:
두 방법 모두 동일한 결과를 반환합니다. 오히려 if (!state.isGuestMode) {
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = android.Manifest.permission.POST_NOTIFICATIONS
val isGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
if (!isGranted) {
permissionLauncher.launch(permission)
} else {
val notificationManager = NotificationManagerCompat.from(context)
state.eventSink(HomeUiEvent.OnNotificationPermissionResult(notificationManager.areNotificationsEnabled()))
}
} else {
val notificationManager = NotificationManagerCompat.from(context)
state.eventSink(HomeUiEvent.OnNotificationPermissionResult(notificationManager.areNotificationsEnabled()))
}
}
}또는 권한 launcher의 콜백을 수정하여 더 일관되게 처리할 수도 있습니다: val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { _ ->
val notificationManager = NotificationManagerCompat.from(context)
state.eventSink(HomeUiEvent.OnNotificationPermissionResult(notificationManager.areNotificationsEnabled()))
}두 접근 방식 모두 유효하니 프로젝트에 맞는 방식을 선택하시면 됩니다! 👍 🧠 Learnings used |
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| ReedScaffold( | ||||||||||||||||||||||||||||||||||||||||||
| modifier = modifier.fillMaxSize(), | ||||||||||||||||||||||||||||||||||||||||||
| bottomBar = { | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,24 +2,27 @@ package com.ninecraft.booket.feature.settings.notification | |
|
|
||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.getValue | ||
| import androidx.compose.runtime.mutableStateOf | ||
| import androidx.compose.runtime.setValue | ||
| import androidx.compose.runtime.rememberCoroutineScope | ||
| import com.ninecraft.booket.core.data.api.repository.UserRepository | ||
| import com.ninecraft.booket.feature.screens.NotificationScreen | ||
| import com.slack.circuit.codegen.annotations.CircuitInject | ||
| import com.slack.circuit.retained.rememberRetained | ||
| import com.slack.circuit.retained.collectAsRetainedState | ||
| import com.slack.circuit.runtime.Navigator | ||
| import com.slack.circuit.runtime.presenter.Presenter | ||
| import dagger.assisted.Assisted | ||
| import dagger.assisted.AssistedFactory | ||
| import dagger.assisted.AssistedInject | ||
| import dagger.hilt.android.components.ActivityRetainedComponent | ||
| import kotlinx.coroutines.launch | ||
|
|
||
| class NotificationPresenter @AssistedInject constructor( | ||
| @Assisted val navigator: Navigator, | ||
| private val userRepository: UserRepository, | ||
| ) : Presenter<NotificationUiState> { | ||
| @Composable | ||
| override fun present(): NotificationUiState { | ||
| var isNotificationEnabled by rememberRetained { mutableStateOf(false) } | ||
| val scope = rememberCoroutineScope() | ||
| val isNotificationEnabled by userRepository.isNotificationEnabled.collectAsRetainedState(initial = false) | ||
|
|
||
|
Comment on lines
+24
to
26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 권한/시스템 설정과 토글 상태의 동기화가 부족합니다 — 토글 ON 시 권한 미허용을 처리하고, 최종 토글은 “선호 ∧ 시스템 허용”으로 합성하세요. 현재는 DataStore 값만으로 UI를 그립니다. 권한이 꺼져 있어도 토글이 켜져 보일 수 있고, ON으로 바꿔도 실제 푸시가 막힙니다. 아래처럼 간단히 보완할 수 있습니다. @@
- val scope = rememberCoroutineScope()
- val isNotificationEnabled by userRepository.isNotificationEnabled.collectAsRetainedState(initial = false)
+ val scope = rememberCoroutineScope()
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val userPrefEnabled by userRepository.isNotificationEnabled.collectAsRetainedState(initial = false)
+ // 시스템 알림 허용 상태(권한/채널 포함)를 조회
+ val systemEnabled = androidx.core.app.NotificationManagerCompat.from(context).areNotificationsEnabled()
+ val effectiveEnabled = userPrefEnabled && systemEnabled
@@
- is NotificationUiEvent.OnNotificationToggle -> {
- scope.launch {
- userRepository.setNotificationEnabled(event.enabled)
- }
- }
+ is NotificationUiEvent.OnNotificationToggle -> {
+ // 권한/시스템이 꺼져 있는데 사용자가 ON으로 요청한 경우 설정 화면으로 유도
+ if (event.enabled && !systemEnabled) {
+ val intent = android.content.Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
+ }
+ context.startActivity(intent)
+ } else {
+ scope.launch {
+ runCatching { userRepository.setNotificationEnabled(event.enabled) }
+ .onFailure { /* TODO: 에러 노출/로그 처리 */ }
+ }
+ }
+ }
@@
- return NotificationUiState(
- isNotificationEnabled = isNotificationEnabled,
+ return NotificationUiState(
+ isNotificationEnabled = effectiveEnabled,
eventSink = ::handleEvent,
)추가로, Android 13+에서 권한 요청을 이 화면에서도 직접 처리하려면(시스템 설정 이동 대신) 권한 런처/권한 라이브러리를 주입하는 접근을 고려해 주세요. Also applies to: 33-37, 41-43 🤖 Prompt for AI Agents |
||
| fun handleEvent(event: NotificationUiEvent) { | ||
| when (event) { | ||
|
|
@@ -28,7 +31,9 @@ class NotificationPresenter @AssistedInject constructor( | |
| } | ||
|
|
||
| is NotificationUiEvent.OnNotificationToggle -> { | ||
| isNotificationEnabled = event.enabled | ||
| scope.launch { | ||
| userRepository.setNotificationEnabled(event.enabled) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이거 default값 true로 변경한 이유가 있나여?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ㅇㅎ