Skip to content
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

<application
android:name=".BooketApplication"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ interface UserRepository {
val onboardingState: Flow<OnboardingState>

suspend fun setOnboardingCompleted(isCompleted: Boolean)

val isNotificationEnabled: Flow<Boolean>

suspend fun setNotificationEnabled(isEnabled: Boolean)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.ninecraft.booket.core.data.impl.repository
import com.ninecraft.booket.core.common.utils.runSuspendCatching
import com.ninecraft.booket.core.data.api.repository.UserRepository
import com.ninecraft.booket.core.data.impl.mapper.toModel
import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource
import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource
import com.ninecraft.booket.core.network.request.TermsAgreementRequest
import com.ninecraft.booket.core.network.service.ReedService
Expand All @@ -11,6 +12,7 @@ import javax.inject.Inject
internal class DefaultUserRepository @Inject constructor(
private val service: ReedService,
private val onboardingDataSource: OnboardingDataSource,
private val notificationDataSource: NotificationDataSource,
) : UserRepository {
override suspend fun agreeTerms(termsAgreed: Boolean) = runSuspendCatching {
service.agreeTerms(TermsAgreementRequest(termsAgreed)).toModel()
Expand All @@ -25,4 +27,10 @@ internal class DefaultUserRepository @Inject constructor(
override suspend fun setOnboardingCompleted(isCompleted: Boolean) {
onboardingDataSource.setOnboardingCompleted(isCompleted)
}

override val isNotificationEnabled = notificationDataSource.isNotificationEnabled

override suspend fun setNotificationEnabled(isEnabled: Boolean) {
notificationDataSource.setNotificationEnabled(isEnabled)
}
}
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
Copy link
Copy Markdown
Contributor

@easyhooon easyhooon Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 default값 true로 변경한 이유가 있나여?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 앱을 참고해보니 Opt-out 전략(기본 상태값을 true로 둠)을 사용하고 있었고, 사용자가 토글을 Off로 변경했을 때 “명시적 거절”로 간주해 이후에는 권한을 재허용하더라도 Off 상태를 유지하는 것 같더라구요,,

ㅇㅎ

}

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
Expand Up @@ -6,11 +6,13 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.ninecraft.booket.core.datastore.api.datasource.BookRecentSearchDataSource
import com.ninecraft.booket.core.datastore.api.datasource.LibraryRecentSearchDataSource
import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource
import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource
import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource
import com.ninecraft.booket.core.datastore.impl.datasource.DefaultLibraryRecentSearchDataSource
import com.ninecraft.booket.core.datastore.impl.datasource.DefaultOnboardingDataSource
import com.ninecraft.booket.core.datastore.impl.datasource.DefaultBookRecentSearchDataSource
import com.ninecraft.booket.core.datastore.impl.datasource.DefaultNotificationDataSource
import com.ninecraft.booket.core.datastore.impl.datasource.DefaultTokenDataSource
import dagger.Binds
import dagger.Module
Expand All @@ -27,11 +29,13 @@ object DataStoreModule {
private const val BOOK_RECENT_SEARCH_DATASTORE_NAME = "BOOK_RECENT_SEARCH_DATASTORE"
private const val LIBRARY_RECENT_SEARCH_DATASTORE_NAME = "LIBRARY_RECENT_SEARCH_DATASTORE"
private const val ONBOARDING_DATASTORE_NAME = "ONBOARDING_DATASTORE"
private const val NOTIFICATION_DATASTORE_NAME = "NOTIFICATION_DATASTORE"

private val Context.tokenDataStore by preferencesDataStore(name = TOKEN_DATASTORE_NAME)
private val Context.bookRecentSearchDataStore by preferencesDataStore(name = BOOK_RECENT_SEARCH_DATASTORE_NAME)
private val Context.libraryRecentSearchDataStore by preferencesDataStore(name = LIBRARY_RECENT_SEARCH_DATASTORE_NAME)
private val Context.onboardingDataStore by preferencesDataStore(name = ONBOARDING_DATASTORE_NAME)
private val Context.notificationDataStore by preferencesDataStore(name = NOTIFICATION_DATASTORE_NAME)

@TokenDataStore
@Provides
Expand Down Expand Up @@ -60,6 +64,13 @@ object DataStoreModule {
fun provideOnboardingDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.onboardingDataStore

@NotificationDataStore
@Provides
@Singleton
fun provideNotificationDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.notificationDataStore
}

@Module
Expand Down Expand Up @@ -89,4 +100,10 @@ abstract class DataStoreBindModule {
abstract fun bindOnboardingDataSource(
defaultOnboardingDataSource: DefaultOnboardingDataSource,
): OnboardingDataSource

@Binds
@Singleton
abstract fun bindNotificationDataSource(
defaultNotificationDataSource: DefaultNotificationDataSource,
): NotificationDataSource
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ annotation class LibraryRecentSearchDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OnboardingDataStore

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class NotificationDataStore
1 change: 1 addition & 0 deletions feature/home/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -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
Expand Down Expand Up @@ -89,6 +89,10 @@ class HomePresenter @AssistedInject constructor(
restoreState = true,
)
}

is HomeUiEvent.OnNotificationPermissionResult -> {
// TODO: 서버 동기화, FCM 토큰 전송
}
Comment on lines +93 to +95
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

서버 동기화 및 FCM 토큰 전송 구현이 필요합니다.

알림 권한 결과를 받았지만 실제 처리 로직이 누락되어 있습니다. 다음 구현이 필요합니다:

  • 권한 허용 시: FCM 토큰 생성 및 서버 전송
  • 권한 거부 시: 서버에 알림 비활성화 상태 동기화
  • UserRepository의 setNotificationEnabled 호출하여 로컬 상태 업데이트

이 기능의 구현을 도와드릴까요? 또는 별도 이슈로 추적하시겠습니까?

🤖 Prompt for AI Agents
In
feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt
around lines 93 to 95, the handler for
HomeUiEvent.OnNotificationPermissionResult is empty; implement server sync and
FCM token handling: when permission granted, obtain the FCM token (using
FirebaseMessaging.getToken or your app's token provider), call your API client
to send/register the token on the server and then call
UserRepository.setNotificationEnabled(true) to update local state; when
permission denied, call the server API to mark notifications disabled (or update
the user's preferences) and call UserRepository.setNotificationEnabled(false);
ensure you perform network calls off the main thread (coroutine/dispatcher), add
error handling and logging for token retrieval and server calls, and emit/update
any UI state/events to reflect success/failure.

}
}

Expand Down
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
Expand All @@ -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
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

권한이 이미 허용된 경우 이벤트가 누락됩니다.

Line 69에서 권한이 이미 허용된 것을 확인했지만, 이 경우 OnNotificationPermissionResult(true) 이벤트를 발생시키지 않습니다. 이로 인해 Presenter가 권한 허용 상태를 인지하지 못하고 서버 동기화가 누락될 수 있습니다.

다음과 같이 수정하세요:

 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
}
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) {
state.eventSink(HomeUiEvent.OnNotificationPermissionResult(true))
} else {
permissionLauncher.launch(permission)
}
🤖 Prompt for AI Agents
In feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt
around lines 65 to 73, the branch that checks for POST_NOTIFICATIONS only
launches the permission flow when not granted but does not dispatch the success
event when permission is already granted; add an else branch (or the equivalent
flow) that immediately sends the OnNotificationPermissionResult(true) event to
the same presenter/viewModel handler used for permissionLauncher results so the
Presenter receives and processes the granted state (ensuring server sync is
triggered).

} else {
state.eventSink(HomeUiEvent.OnNotificationPermissionResult(true))
}
Comment on lines +74 to +76
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Android 13 미만 기기의 알림 상태를 정확하게 확인하세요.

Android 13 미만에서는 POST_NOTIFICATIONS 권한이 없지만, 사용자가 시스템 설정에서 알림을 비활성화할 수 있습니다. 무조건 true를 반환하면 실제 알림 활성화 상태와 불일치가 발생합니다.

NotificationManagerCompat.areNotificationsEnabled()를 사용하여 실제 알림 상태를 확인하세요:

+import androidx.core.app.NotificationManagerCompat
+
 } else {
-    state.eventSink(HomeUiEvent.OnNotificationPermissionResult(true))
+    val notificationManager = NotificationManagerCompat.from(context)
+    state.eventSink(HomeUiEvent.OnNotificationPermissionResult(notificationManager.areNotificationsEnabled()))
 }

Committable suggestion skipped: line range outside the PR's diff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그러면 Android13 이상 버전에서 ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED로 체크하는 대신 notificationManager.areNotificationsEnabled()로 체크해도 되겠네?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seoyoon513, 좋은 질문입니다! 하지만 Android 13 이상에서는 두 가지를 함께 사용하는 것이 좋고, checkSelfPermission()areNotificationsEnabled()로 대체하면 안 됩니다.

두 메서드의 차이점:

  1. checkSelfPermission() (Android 13+)

    • 런타임 권한(POST_NOTIFICATIONS)이 부여되었는지 확인
    • 권한 요청 다이얼로그를 표시할지 결정하는 데 필요
  2. areNotificationsEnabled()

    • 시스템 설정에서 알림이 활성화되어 있는지 확인
    • 권한이 부여된 후에도 사용자가 설정에서 알림을 끌 수 있음

Android 13+ 시나리오:

  • 사용자가 권한을 부여(PERMISSION_GRANTED) → 하지만 시스템 설정에서 알림 비활성화 가능
  • 이 경우 checkSelfPermission() = true, areNotificationsEnabled() = false

현재 코드의 목적은 권한 요청 다이얼로그를 표시할지 결정하는 것이므로, Android 13+에서는 checkSelfPermission()을 사용해야 합니다.

권장 접근 방식:

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()))
}

하지만 현재 구조에서는 permissionLauncher의 콜백에서 결과를 처리하므로, 현재 코드를 유지하되 Android 12 이하에서만 areNotificationsEnabled()를 사용하는 것이 맞습니다.


🧠 Learnings used
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#46
File: feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/InfiniteLazyColumn.kt:83-95
Timestamp: 2025-07-14T00:46:03.843Z
Learning: seoyoon513과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

android13에서 알림 허용 토글은 하나밖에 없고, 해당 토글을 on/off 하면 areNotificationsEnabled()과 POST_NOTIFICATIONS 권한 체크가 같이 변경되는데? on하면 둘 다 true off하면 둘 다 false임

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai 대답해줘

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seoyoon513, 맞습니다! 제가 잘못 설명했네요. 정정해주셔서 감사합니다.

Android 13+에서는 말씀하신 대로 POST_NOTIFICATIONS 권한과 시스템 알림 설정이 완전히 동기화되어 있습니다:

  • 사용자가 권한을 부여하면 → 시스템 알림 토글 ON, checkSelfPermission() = true, areNotificationsEnabled() = true
  • 사용자가 설정에서 토글을 OFF하면 → 런타임 권한도 자동으로 취소되어 checkSelfPermission() = false, areNotificationsEnabled() = false

따라서 현재 PR의 코드가 올바릅니다:

  • Android 13+: checkSelfPermission()만으로 충분 (권한과 알림 상태가 동일)
  • Android 12 이하: areNotificationsEnabled()로 실제 알림 상태 확인 필요 (제안한 수정 사항)

최종적으로 Android 12 이하에서만 다음과 같이 수정하시면 됩니다:

} else {
    val notificationManager = NotificationManagerCompat.from(context)
    state.eventSink(HomeUiEvent.OnNotificationPermissionResult(notificationManager.areNotificationsEnabled()))
}

✏️ Learnings added
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#192
File: feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt:74-76
Timestamp: 2025-10-17T08:01:18.359Z
Learning: Android 13 이상에서는 POST_NOTIFICATIONS 런타임 권한과 시스템 알림 설정(areNotificationsEnabled)이 완전히 동기화되어 있음. 하나의 토글로 둘 다 함께 변경되므로, checkSelfPermission() 체크만으로 충분함. Android 12 이하에서는 런타임 권한이 없으므로 areNotificationsEnabled()만 사용해야 함.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seoyoon513, 이미 위의 댓글에서 답변을 드렸습니다!

말씀하신 대로 Android 13 이상에서는 POST_NOTIFICATIONS 권한과 시스템 알림 설정이 완전히 동기화되어 있어서 현재 코드가 올바릅니다. 제가 처음에 잘못 설명한 부분을 정정해주셔서 감사드리며, learning으로도 기록했습니다.

혹시 추가로 궁금하신 점이나 확인이 필요한 부분이 있으신가요?


🧠 Learnings used
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#46
File: feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/InfiniteLazyColumn.kt:83-95
Timestamp: 2025-07-14T00:46:03.843Z
Learning: seoyoon513과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Android 13+에서도 areNotificationsEnabled()로 체크해도 되는거지?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seoyoon513, 네, 맞습니다! Android 13+에서도 areNotificationsEnabled()로 체크해도 됩니다.

말씀하신 대로 Android 13+에서는 두 값이 완전히 동기화되어 있기 때문에:

  • checkSelfPermission(POST_NOTIFICATIONS)
  • areNotificationsEnabled()

두 방법 모두 동일한 결과를 반환합니다.

오히려 areNotificationsEnabled()를 모든 Android 버전에서 사용하면 코드가 더 간단해집니다:

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
Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#192
File: feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt:74-76
Timestamp: 2025-10-17T08:01:18.359Z
Learning: Android 13 이상에서는 POST_NOTIFICATIONS 런타임 권한과 시스템 알림 설정(areNotificationsEnabled)이 완전히 동기화되어 있음. 하나의 토글로 둘 다 함께 변경되므로, checkSelfPermission() 체크만으로 충분함. Android 12 이하에서는 런타임 권한이 없으므로 areNotificationsEnabled()만 사용해야 함.

Learnt from: seoyoon513
PR: YAPP-Github/Reed-Android#46
File: feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/InfiniteLazyColumn.kt:83-95
Timestamp: 2025-07-14T00:46:03.843Z
Learning: seoyoon513과 팀은 한국어 주석을 선호하며, 한국어 주석을 영어로 번역하라는 제안을 하지 않아야 함

}
}

ReedScaffold(
modifier = modifier.fillMaxSize(),
bottomBar = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ sealed interface HomeUiEvent : CircuitUiEvent {
) : HomeUiEvent
data object OnRetryClick : HomeUiEvent
data class OnTabSelected(val tab: MainTab) : HomeUiEvent
data class OnNotificationPermissionResult(val isGranted: Boolean) : HomeUiEvent
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,21 @@ internal fun SettingsUi(
)
},
)
SettingItem(
title = stringResource(R.string.settings_notification),
onItemClick = {
state.eventSink(SettingsUiEvent.OnNotificationClick)
},
action = {
Icon(
imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right),
contentDescription = "Right Chevron Icon",
tint = Color.Unspecified,
)
},
)
if (!state.isGuestMode) {
SettingItem(
title = stringResource(R.string.settings_notification),
onItemClick = {
state.eventSink(SettingsUiEvent.OnNotificationClick)
},
action = {
Icon(
imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right),
contentDescription = "Right Chevron Icon",
tint = Color.Unspecified,
)
},
)
}
SettingItem(
title = stringResource(R.string.settings_terms_of_service),
onItemClick = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

권한/시스템 설정과 토글 상태의 동기화가 부족합니다 — 토글 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
In
feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt
around lines 24-26 (also apply same changes to 33-37 and 41-43): the UI
currently reads only the DataStore preference so the toggle can appear ON while
system permission is denied and enabling the toggle does nothing; update the
presenter to observe both the stored preference and the system notification
permission state (e.g., collect a Flow or state for permission) and expose a
composed UI state where finalToggle = preference && systemPermission; when user
toggles ON, if system permission is missing, trigger a permission request flow
(or navigate to system settings) and only set the stored preference after
permission is granted (or keep preference but reflect disabled UI until
permission granted), and when permission is revoked update the UI to reflect
disabled notifications; use the existing coroutine scope to collect/merge flows
and update the exposed state so the toggle always represents “preference ∧
system allowed.”

fun handleEvent(event: NotificationUiEvent) {
when (event) {
Expand All @@ -28,7 +31,9 @@ class NotificationPresenter @AssistedInject constructor(
}

is NotificationUiEvent.OnNotificationToggle -> {
isNotificationEnabled = event.enabled
scope.launch {
userRepository.setNotificationEnabled(event.enabled)
}
}
}
}
Expand Down
Loading
Loading