diff --git a/docs/convention/mvi.md b/docs/convention/mvi.md index 4b0fde7d1..544c997a4 100644 --- a/docs/convention/mvi.md +++ b/docs/convention/mvi.md @@ -171,27 +171,30 @@ ViewModel 테스트는 작업 결과 신호와 소비 후 초기화를, 화면 ## 강제 -`MviContractKonsistTest` 가 셋을 본다. +`MviContractKonsistTest` 가 넷을 본다. | 규칙 | 내용 | | --- | --- | | A | `MviViewModel` 상속체는 `MutableStateFlow`·`MutableSharedFlow`·`Channel` 을 직접 선언하지 않는다 | | B | `feature/*/presentation` 의 ViewModel 은 `MviViewModel` 을 상속한다 | | C | `MviIntent`·`ReducerEvent` 를 직접 구현하는 타입은 `sealed interface` 다 | +| D | 아직 전환하지 않은 `feature/*/presentation` ViewModel 도 이벤트용 `Channel`·`MutableSharedFlow` 를 새로 선언하지 않는다 (#1502) | 규칙 B 는 아직 전환하지 않은 ViewModel 을 `PENDING_MVI_MIGRATION` 예외로 둔다(가드 도입 시점 49개). 전환할 때마다 목록에서 빼고, **목록이 비면 예외 자체를 지운다.** `app` 의 ViewModel 은 규칙 B 의 범위 밖이다. -세 규칙은 **프로덕션 소스만** 본다. 테스트 더블이 `MviViewModel` 을 상속하며 보조 상태 홀더를 드는 것은 +규칙 D 는 규칙 B 의 예외에 든 ViewModel 이 전환 전까지 이벤트 스트림을 늘리지 못하게 한다. 규칙 A 는 전환한 ViewModel 만 보기 때문이다. 가드를 세울 때 이미 있던 선언은 `LEGACY_EVENT_STREAMS` 기준선으로 인정하고, 그 ViewModel 이 전환되거나 지워지면 기준선에서도 뺀다. 전환 전 상태 홀더인 `MutableStateFlow` 는 대상이 아니다. + +네 규칙은 **프로덕션 소스만** 본다. 테스트 더블이 `MviViewModel` 을 상속하며 보조 상태 홀더를 드는 것은 규칙 A 의 대상이 아니다 — 더블은 계약을 지키는 대상이 아니라 계약을 흉내 내는 도구다. -규칙 A·B 의 상속 판정은 **중간 추상 베이스를 낀 사슬까지** 따라간다. Konsist 의 +규칙 A·B·D 의 상속 판정은 **중간 추상 베이스를 낀 사슬까지** 따라간다. Konsist 의 `parents(indirectParents = true)` 로는 안 되고(0.17.3 에서 이 스코프 구성으로는 직계와 같은 목록을 돌려준다 — 실측), 스캔한 파일에서 이름 색인을 만들어 직접 걷는다. ### 가드가 «안» 보는 것 - **`onIntent` 밖의 public 진입점.** 상속체가 `fun refreshOnReturn()` 같은 public 함수를 노출해도 - 세 규칙 어디도 막지 않는다. 화면이 ViewModel 을 직접 부르는 통로가 `onIntent` 하나여야 한다는 것은 + 네 규칙 어디도 막지 않는다. 화면이 ViewModel 을 직접 부르는 통로가 `onIntent` 하나여야 한다는 것은 규약이지 CI 계약이 아니다. 리뷰에서 본다. - **`reduce` 밖의 전이.** 규칙 A 는 «상태 홀더 선언» 을 막을 뿐, `dispatch` 를 거치지 않는 다른 경로를 전수로 잡지는 못한다. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt new file mode 100644 index 000000000..54ccab240 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.setting.presentation.account + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface ConnectedAccountsIntent : MviIntent { + data class Toggle( + val provider: String, + val enabled: Boolean, + ) : ConnectedAccountsIntent + + /** 화면이 플랫폼 인증으로 받아낸 [accessToken] 으로 [provider] 를 연결한다. */ + data class Link( + val provider: String, + val accessToken: String, + ) : ConnectedAccountsIntent + + /** 화면이 처리한 연결 요청 [provider] 를 되돌려준다. 그사이 다른 제공자 요청이 올라왔으면 그 요청은 남는다. */ + data class ConsumeLinkRequest( + val provider: String, + ) : ConnectedAccountsIntent +} + +internal sealed interface ConnectedAccountsReducerEvent : ReducerEvent { + data class Loaded( + val accounts: List, + ) : ConnectedAccountsReducerEvent + + data class LoadFailed( + val message: String, + ) : ConnectedAccountsReducerEvent + + data class AccountsChanged( + val accounts: List, + ) : ConnectedAccountsReducerEvent + + data class ChangeFailed( + val message: String, + ) : ConnectedAccountsReducerEvent + + data class LinkRequested( + val provider: String, + ) : ConnectedAccountsReducerEvent + + data class LinkRequestConsumed( + val provider: String, + ) : ConnectedAccountsReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 27398d8cb..807850e88 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -3,9 +3,9 @@ package com.afternote.feature.setting.presentation.account import android.app.Activity import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -15,11 +15,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.common.auth.requestKakaoAccessToken import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.findActivity +import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.feature.setting.presentation.BuildConfig import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.account.social.KakaoAuthResult import com.afternote.feature.setting.presentation.account.social.requestGoogleIdToken import com.afternote.feature.setting.presentation.account.social.toKakaoAuthResult +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @Composable internal fun ConnectedAccountsScreen( @@ -33,16 +37,20 @@ internal fun ConnectedAccountsScreen( val snackbarHostState = remember { SnackbarHostState() } val kakaoAccountLinkFailedMessage = stringResource(R.string.setting_kakao_account_link_failed) val googleAccountLinkFailedMessage = stringResource(R.string.setting_google_account_link_failed) + // 소비가 ObserveSignal 의 effect 를 다시 시작시켜도 인증·스낵바는 끝까지 간다. 예전 이벤트 수집처럼 + // 요청을 하나씩 처리하도록 화면 코루틴에서 잠금으로 줄을 세운다. + val linkScope = rememberCoroutineScope() + val linkMutex = remember { Mutex() } - LaunchedEffect(Unit) { - viewModel.events.collect { event -> - when (event) { - is ConnectedAccountsEvent.ShowError -> { - snackbarHostState.showSnackbar(event.message) - } - - is ConnectedAccountsEvent.RequestLink -> { - when (event.provider) { + uiState.pendingLinkProvider?.let { pendingProvider -> + ObserveSignal( + signal = pendingProvider, + consumed = ConnectedAccountsIntent.ConsumeLinkRequest(pendingProvider), + onIntent = viewModel::onIntent, + ) { provider -> + linkScope.launch { + linkMutex.withLock { + when (provider) { "kakao" -> { val activity = context.findActivity() val authResult = @@ -51,15 +59,13 @@ internal fun ConnectedAccountsScreen( ?: KakaoAuthResult.Failure when (authResult) { is KakaoAuthResult.Success -> { - viewModel.link("kakao", authResult.accessToken) + viewModel.onIntent(ConnectedAccountsIntent.Link("kakao", authResult.accessToken)) } KakaoAuthResult.Cancelled -> {} KakaoAuthResult.Failure -> { - snackbarHostState.showSnackbar( - kakaoAccountLinkFailedMessage, - ) + snackbarHostState.showSnackbar(kakaoAccountLinkFailedMessage) } } } @@ -69,14 +75,13 @@ internal fun ConnectedAccountsScreen( context = context, credentialManager = credentialManager, serverClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, - ).onSuccess { token -> viewModel.link("google", token) } - .onFailure { e -> - if (e !is CoreAuthFailure.UserCancelledAuth) { - viewModel.notifyLinkError( - googleAccountLinkFailedMessage, - ) - } + ).onSuccess { token -> + viewModel.onIntent(ConnectedAccountsIntent.Link("google", token)) + }.onFailure { e -> + if (e !is CoreAuthFailure.UserCancelledAuth) { + snackbarHostState.showSnackbar(googleAccountLinkFailedMessage) } + } } } } @@ -88,7 +93,7 @@ internal fun ConnectedAccountsScreen( uiState = uiState, snackbarHostState = snackbarHostState, onBack = onBack, - onToggle = viewModel::onToggle, + onToggle = { provider, enabled -> viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider, enabled)) }, modifier = modifier, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt index 7fb4ecbf5..e17aaa182 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt @@ -1,17 +1,17 @@ package com.afternote.feature.setting.presentation.account +import com.afternote.core.ui.mvi.UiState + data class ConnectedAccountsUiState( val isLoading: Boolean = false, val accounts: List = emptyList(), val errorMessage: String? = null, -) - -sealed interface ConnectedAccountsEvent { - data class RequestLink( - val provider: String, - ) : ConnectedAccountsEvent - - data class ShowError( - val message: String, - ) : ConnectedAccountsEvent -} + /** + * 화면이 플랫폼 인증(카카오 SDK·Credential Manager)을 시작해야 하는 제공자 신호다 (#1502). + * + * ViewModel 은 Activity·Context 에 닿지 않으므로 토큰을 직접 받지 못한다. 화면이 이 신호를 받아 + * 인증을 돌린 뒤 토큰만 [ConnectedAccountsIntent.Link] 로 돌려보내고, 받은 즉시 + * [ConnectedAccountsIntent.ConsumeLinkRequest] 로 되돌린다. + */ + val pendingLinkProvider: String? = null, +) : UiState diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt index 6801c0e90..50f8d516f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt @@ -1,17 +1,12 @@ package com.afternote.feature.setting.presentation.account -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.domain.result.runCatchingCancellable import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingAccountRepository import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,59 +15,100 @@ internal class ConnectedAccountsViewModel @Inject constructor( private val accountRepository: SettingAccountRepository, - ) : ViewModel() { - private val _uiState = MutableStateFlow(ConnectedAccountsUiState(isLoading = true)) - val uiState = _uiState.asStateFlow() - - private val _events = Channel(Channel.BUFFERED) - val events = _events.receiveAsFlow() - + ) : MviViewModel( + ConnectedAccountsUiState(isLoading = true), + ) { init { loadConnectedAccounts() } + override fun onIntent(intent: ConnectedAccountsIntent) { + when (intent) { + is ConnectedAccountsIntent.Toggle -> { + toggle(intent.provider, intent.enabled) + } + + is ConnectedAccountsIntent.Link -> { + link(intent.provider, intent.accessToken) + } + + is ConnectedAccountsIntent.ConsumeLinkRequest -> { + dispatch( + ConnectedAccountsReducerEvent.LinkRequestConsumed(intent.provider), + ) + } + } + } + + override fun reduce( + state: ConnectedAccountsUiState, + event: ConnectedAccountsReducerEvent, + ): ConnectedAccountsUiState = + when (event) { + is ConnectedAccountsReducerEvent.Loaded -> { + state.copy(isLoading = false, accounts = event.accounts) + } + + is ConnectedAccountsReducerEvent.LoadFailed -> { + state.copy(isLoading = false, errorMessage = event.message) + } + + is ConnectedAccountsReducerEvent.AccountsChanged -> { + state.copy(accounts = event.accounts) + } + + is ConnectedAccountsReducerEvent.ChangeFailed -> { + state.copy(errorMessage = event.message) + } + + is ConnectedAccountsReducerEvent.LinkRequested -> { + state.copy(pendingLinkProvider = event.provider) + } + + // 늦게 도착한 소비가 그사이 올라온 다른 제공자 요청을 지우지 않게 한다. + is ConnectedAccountsReducerEvent.LinkRequestConsumed -> { + if (state.pendingLinkProvider == event.provider) state.copy(pendingLinkProvider = null) else state + } + } + private fun loadConnectedAccounts() { viewModelScope.launch { runCatchingCancellable { accountRepository.getConnectedAccounts() } .onSuccess { accounts -> - _uiState.update { it.copy(isLoading = false, accounts = accounts.toStateList()) } + dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList())) }.onFailure { - _uiState.update { it.copy(isLoading = false, errorMessage = "계정 정보를 불러올 수 없습니다.") } + dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) } } } - fun onToggle( + private fun toggle( provider: String, enabled: Boolean, ) { if (enabled) { - viewModelScope.launch { _events.send(ConnectedAccountsEvent.RequestLink(provider)) } + dispatch(ConnectedAccountsReducerEvent.LinkRequested(provider)) } else { unlink(provider) } } - fun notifyLinkError(message: String) { - viewModelScope.launch { _events.send(ConnectedAccountsEvent.ShowError(message)) } - } - - fun link( + private fun link( provider: String, accessToken: String, ) { viewModelScope.launch { runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) } - .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } - .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결에 실패했습니다.") } } + .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } + .onFailure { dispatch(ConnectedAccountsReducerEvent.ChangeFailed("계정 연결에 실패했습니다.")) } } } private fun unlink(provider: String) { viewModelScope.launch { runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) } - .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } - .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결 해제에 실패했습니다.") } } + .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } + .onFailure { dispatch(ConnectedAccountsReducerEvent.ChangeFailed("계정 연결 해제에 실패했습니다.")) } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt index 011c18032..dfe059cdf 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -22,9 +22,9 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -36,15 +36,17 @@ import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.repeatOnLifecycle import com.afternote.core.ui.findActivity +import com.afternote.core.ui.mvi.ObserveFlag import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.component.DeviceAlarmOffSection import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch @Composable internal fun NotificationSettingScreen( @@ -56,7 +58,6 @@ internal fun NotificationSettingScreen( val context = LocalContext.current val activity = remember(context) { context.findActivity() } val snackbarHostState = remember { SnackbarHostState() } - val lifecycleOwner = LocalLifecycleOwner.current val marketingConsentSaveFailedMessage = stringResource(R.string.setting_marketing_consent_save_failed) val openNotificationSettings = { val intent = @@ -67,7 +68,7 @@ internal fun NotificationSettingScreen( } val notificationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - viewModel.refreshDeviceAlarmStatus() + viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) if ( !granted && activity != null && @@ -81,21 +82,66 @@ internal fun NotificationSettingScreen( } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.refreshDeviceAlarmStatus() + viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) } - LaunchedEffect(viewModel, lifecycleOwner, marketingConsentSaveFailedMessage) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.events.collect { event -> - when (event) { - PushNotificationEvent.MarketingConsentSaveFailed -> { - snackbarHostState.showSnackbar(marketingConsentSaveFailedMessage) - } - } - } + // 소비가 ObserveFlag 의 effect 를 다시 시작시켜도 스낵바가 끊기지 않게 화면 코루틴에 띄운다. + val snackbarScope = rememberCoroutineScope() + + // 화면이 STARTED 인 동안만 마케팅 동의 저장 실패를 안내한다. 화면이 없는 동안의 실패는 다음 진입에 재생하지 않는다 (#558). + // 멈출 때는 떠 있던 안내와 줄 선 안내도 거둔다. 화면 코루틴은 STOP 에 취소되지 않으므로 그대로 두면 재진입에 뜬다. + LifecycleStartEffect(viewModel) { + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + onStopOrDispose { + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) + snackbarScope.coroutineContext.cancelChildren() } } + ObserveFlag( + raised = uiState.isMarketingConsentSaveFailed, + consumed = PushNotificationIntent.ConsumeMarketingConsentSaveFailure, + onIntent = viewModel::onIntent, + ) { + snackbarScope.launch { snackbarHostState.showSnackbar(marketingConsentSaveFailedMessage) } + } + + NotificationSettingContent( + uiState = uiState, + snackbarHostState = snackbarHostState, + onBack = onBack, + onPushNotificationClick = onPushNotificationClick, + onDeviceAlarmClick = { + val permissionGranted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + when (notificationPermissionAction(Build.VERSION.SDK_INT, permissionGranted)) { + NotificationPermissionAction.RequestPermission -> { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + NotificationPermissionAction.OpenSettings -> { + openNotificationSettings() + } + } + }, + onMarketingConsentChange = { consent, checked -> + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(consent, checked)) + }, + ) +} + +@Composable +private fun NotificationSettingContent( + uiState: PushNotificationUiState, + snackbarHostState: SnackbarHostState, + onBack: () -> Unit, + onPushNotificationClick: () -> Unit, + onDeviceAlarmClick: () -> Unit, + onMarketingConsentChange: (MarketingConsent, Boolean) -> Unit, +) { Scaffold( topBar = { DetailTopBar( @@ -117,22 +163,8 @@ internal fun NotificationSettingScreen( modifier = Modifier .fillMaxWidth() - .clickable { - val permissionGranted = - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - when (notificationPermissionAction(Build.VERSION.SDK_INT, permissionGranted)) { - NotificationPermissionAction.RequestPermission -> { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - - NotificationPermissionAction.OpenSettings -> { - openNotificationSettings() - } - } - }.padding(vertical = 12.dp), + .clickable(onClick = onDeviceAlarmClick) + .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -160,9 +192,9 @@ internal fun NotificationSettingScreen( } else { DeviceAlarmOffSection( uiState = uiState, - onSmsCheck = viewModel::onSmsChecked, - onEmailCheck = viewModel::onEmailChecked, - onPushCheck = viewModel::onPushChecked, + onSmsCheck = { onMarketingConsentChange(MarketingConsent.SMS, it) }, + onEmailCheck = { onMarketingConsentChange(MarketingConsent.EMAIL, it) }, + onPushCheck = { onMarketingConsentChange(MarketingConsent.PUSH, it) }, ) } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt new file mode 100644 index 000000000..0ac593a7a --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt @@ -0,0 +1,95 @@ +package com.afternote.feature.setting.presentation.notification + +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface PushNotificationIntent : MviIntent { + /** 기기 알림 허용 여부를 다시 읽는다. 화면 재개와 권한 요청 결과 때 보낸다. */ + data object RefreshDeviceAlarmStatus : PushNotificationIntent + + data class ChangeMarketingConsent( + val consent: MarketingConsent, + val checked: Boolean, + ) : PushNotificationIntent + + data class TogglePushSetting( + val setting: PushSetting, + val on: Boolean, + ) : PushNotificationIntent + + data object RetrySave : PushNotificationIntent + + data object DismissSaveFailure : PushNotificationIntent + + /** + * 알림 설정 화면이 STARTED 에 들어섰다. 이때부터 마케팅 동의 저장 실패를 안내 신호로 올린다. + * + * STARTED 밖에서 난 실패는 신호로 남기지 않고, [MarketingFeedbackStopped] 가 남은 신호도 걷는다. + * 화면이 없는 동안의 안내를 다음 진입에 재생하지 않는 #558 계약이다. + */ + data object MarketingFeedbackStarted : PushNotificationIntent + + data object MarketingFeedbackStopped : PushNotificationIntent + + data object ConsumeMarketingConsentSaveFailure : PushNotificationIntent +} + +internal sealed interface PushNotificationReducerEvent : ReducerEvent { + data class DeviceAlarmStatusRead( + val on: Boolean, + ) : PushNotificationReducerEvent + + data object PushSettingsLoading : PushNotificationReducerEvent + + data class PushSettingsLoaded( + val setting: UserPushSetting, + ) : PushNotificationReducerEvent + + data object PushSettingsLoadFailed : PushNotificationReducerEvent + + data class MarketingConsentsLoaded( + val consent: UserMarketingConsent, + ) : PushNotificationReducerEvent + + data class MarketingConsentChanged( + val consent: MarketingConsent, + val checked: Boolean, + ) : PushNotificationReducerEvent + + /** [requested] 로 바꾸려던 저장이 실패했다. 값은 그 반대로 되돌린다. */ + data class MarketingConsentSaveFailed( + val consent: MarketingConsent, + val requested: Boolean, + ) : PushNotificationReducerEvent + + data class PushSettingSaving( + val update: PushSettingUpdate, + ) : PushNotificationReducerEvent + + data class PushSettingSaved( + val setting: PushSetting, + ) : PushNotificationReducerEvent + + data class PushSettingSaveFailed( + val update: PushSettingUpdate, + val previous: Boolean, + val failure: PushNotificationSaveFailure, + ) : PushNotificationReducerEvent + + data object SaveFailureDismissed : PushNotificationReducerEvent + + data class MarketingFeedbackActiveChanged( + val active: Boolean, + ) : PushNotificationReducerEvent + + data object MarketingConsentSaveFailureConsumed : PushNotificationReducerEvent +} + +/** 기기 알림이 꺼졌을 때 받는 마케팅 알림 채널. */ +internal enum class MarketingConsent { + SMS, + EMAIL, + PUSH, +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt index ea1317618..ae9c1ec4e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt @@ -6,11 +6,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.popup.NetworkErrorPopup import com.afternote.core.ui.popup.ServerErrorPopup -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.notification.component.DeviceAlarmOffSection -import com.afternote.feature.setting.presentation.notification.component.PushToggleSection @Composable internal fun PushNotificationScreen( @@ -22,23 +17,23 @@ internal fun PushNotificationScreen( PushNotificationContent( uiState = uiState, onBack = onBack, - onNewsletterToggle = viewModel::onNewsletterToggle, - onMindRecordToggle = viewModel::onMindRecordToggle, - onAfternoteToggle = viewModel::onAfternoteToggle, + onNewsletterToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, it)) }, + onMindRecordToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, it)) }, + onAfternoteToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, it)) }, ) when (uiState.saveFailure) { PushNotificationSaveFailure.NETWORK -> { NetworkErrorPopup( - onRetry = viewModel::onSaveFailureRetry, - onDismiss = viewModel::onSaveFailureDismiss, + onRetry = { viewModel.onIntent(PushNotificationIntent.RetrySave) }, + onDismiss = { viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) }, ) } PushNotificationSaveFailure.SERVER -> { ServerErrorPopup( - onRetry = viewModel::onSaveFailureRetry, - onDismiss = viewModel::onSaveFailureDismiss, + onRetry = { viewModel.onIntent(PushNotificationIntent.RetrySave) }, + onDismiss = { viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) }, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt index 81ee23a7c..adcf6fd7a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt @@ -1,6 +1,8 @@ package com.afternote.feature.setting.presentation.notification -data class PushNotificationUiState( +import com.afternote.core.ui.mvi.UiState + +internal data class PushNotificationUiState( val isLoading: Boolean = false, val isDeviceAlarmOn: Boolean = false, // 마케팅 알림 (기기 알림 꺼졌을 때) @@ -15,13 +17,34 @@ data class PushNotificationUiState( val isMindRecordUpdating: Boolean = false, val isAfternoteUpdating: Boolean = false, val saveFailure: PushNotificationSaveFailure? = null, -) + /** + * [saveFailure] 에서 재시도할 변경. [saveFailure] 와 같은 전이에서 함께 채우고 함께 비운다. + * + * 슬롯 하나 — 서로 다른 토글이 연달아 실패해도 재시도 대상은 마지막 실패만 남긴다. + * 이미 실패한 토글은 이전 값으로 롤백되어 화면에 "안 켜짐"으로 보이므로, + * 조용히 사라지는 것은 재시도 "대상"뿐이다. 여러 실패를 동시에 재시도하는 요구가 + * 없어 의도적으로 단순화했다 (#558 리뷰 합의). + */ + val failedUpdate: PushSettingUpdate? = null, + /** 알림 설정 화면이 STARTED 인 동안만 true 다. 이때만 마케팅 동의 저장 실패를 안내 신호로 올린다 (#558). */ + val isMarketingFeedbackActive: Boolean = false, + /** 마케팅 동의 저장 실패 안내 신호. 화면이 스낵바를 띄우고 소비 Intent 로 내린다 (#1502). */ + val isMarketingConsentSaveFailed: Boolean = false, +) : UiState enum class PushNotificationSaveFailure { NETWORK, SERVER, } -sealed interface PushNotificationEvent { - data object MarketingConsentSaveFailed : PushNotificationEvent +/** 기기 알림이 켜졌을 때 받는 서비스 알림 종류. */ +internal enum class PushSetting { + NEWSLETTER, + MIND_RECORD, + AFTERNOTE, } + +internal data class PushSettingUpdate( + val setting: PushSetting, + val on: Boolean, +) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index 74af974b2..1eb6e3a3f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -3,22 +3,15 @@ package com.afternote.feature.setting.presentation.notification import android.content.Context import android.util.Log import androidx.core.app.NotificationManagerCompat -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.PushSettingFailure import com.afternote.core.domain.result.runCatchingCancellable +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingNotificationRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel @@ -28,56 +21,144 @@ internal class PushNotificationViewModel @ApplicationContext private val context: Context, private val notificationRepository: SettingNotificationRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(PushNotificationUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - // 화면이 없는 동안의 안내는 다음 진입에 재생하지 않는다. - private val _events = - MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val events = _events.asSharedFlow() - - // 슬롯 하나 — 서로 다른 토글이 연달아 실패해도 재시도 대상은 마지막 실패만 남긴다. - // 이미 실패한 토글은 위에서 이전 값으로 롤백되어 화면에 "안 켜짐"으로 보이므로, - // 조용히 사라지는 것은 재시도 "대상"뿐이다. 여러 실패를 동시에 재시도하는 요구가 - // 없어 의도적으로 단순화했다 (#558 리뷰 합의). - private var failedUpdate: PushSettingUpdate? = null - + ) : MviViewModel(PushNotificationUiState()) { init { refreshDeviceAlarmStatus() loadPushSettings() loadMarketingConsents() } - fun refreshDeviceAlarmStatus() { + override fun onIntent(intent: PushNotificationIntent) { + when (intent) { + PushNotificationIntent.RefreshDeviceAlarmStatus -> { + refreshDeviceAlarmStatus() + } + + is PushNotificationIntent.ChangeMarketingConsent -> { + updateMarketingConsent(intent.consent, intent.checked) + } + + is PushNotificationIntent.TogglePushSetting -> { + updatePushSetting(PushSettingUpdate(intent.setting, intent.on)) + } + + PushNotificationIntent.RetrySave -> { + retrySave() + } + + PushNotificationIntent.DismissSaveFailure -> { + dispatch(PushNotificationReducerEvent.SaveFailureDismissed) + } + + PushNotificationIntent.MarketingFeedbackStarted -> { + dispatch(PushNotificationReducerEvent.MarketingFeedbackActiveChanged(active = true)) + } + + PushNotificationIntent.MarketingFeedbackStopped -> { + dispatch(PushNotificationReducerEvent.MarketingFeedbackActiveChanged(active = false)) + } + + PushNotificationIntent.ConsumeMarketingConsentSaveFailure -> { + dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailureConsumed) + } + } + } + + override fun reduce( + state: PushNotificationUiState, + event: PushNotificationReducerEvent, + ): PushNotificationUiState = + when (event) { + is PushNotificationReducerEvent.DeviceAlarmStatusRead -> { + state.copy(isDeviceAlarmOn = event.on) + } + + PushNotificationReducerEvent.PushSettingsLoading -> { + state.copy(isLoading = true) + } + + is PushNotificationReducerEvent.PushSettingsLoaded -> { + state.copy( + isLoading = false, + isNewsletterOn = event.setting.timeLetter, + isMindRecordOn = event.setting.mindRecord, + isAfternoteOn = event.setting.afterNote, + ) + } + + PushNotificationReducerEvent.PushSettingsLoadFailed -> { + state.copy(isLoading = false) + } + + is PushNotificationReducerEvent.MarketingConsentsLoaded -> { + state.copy( + isSmsChecked = event.consent.sms, + isEmailChecked = event.consent.email, + isPushChecked = event.consent.push, + ) + } + + is PushNotificationReducerEvent.MarketingConsentChanged -> { + state.withMarketingConsent(event.consent, event.checked) + } + + // 화면이 STARTED 밖이면 값만 되돌리고 안내 신호는 올리지 않는다 (#558). + is PushNotificationReducerEvent.MarketingConsentSaveFailed -> { + state + .withMarketingConsent(event.consent, !event.requested) + .copy(isMarketingConsentSaveFailed = state.isMarketingConsentSaveFailed || state.isMarketingFeedbackActive) + } + + is PushNotificationReducerEvent.PushSettingSaving -> { + state.withValue(event.update.setting, event.update.on).withUpdating(event.update.setting, updating = true) + } + + is PushNotificationReducerEvent.PushSettingSaved -> { + state.withUpdating(event.setting, updating = false) + } + + is PushNotificationReducerEvent.PushSettingSaveFailed -> { + state + .withValue(event.update.setting, event.previous) + .withUpdating(event.update.setting, updating = false) + .copy(saveFailure = event.failure, failedUpdate = event.update) + } + + PushNotificationReducerEvent.SaveFailureDismissed -> { + state.copy(saveFailure = null, failedUpdate = null) + } + + // 비활성으로 바뀌면 아직 소비되지 않은 안내도 걷는다 — 다음 진입에 재생하지 않는다. + is PushNotificationReducerEvent.MarketingFeedbackActiveChanged -> { + state.copy( + isMarketingFeedbackActive = event.active, + isMarketingConsentSaveFailed = state.isMarketingConsentSaveFailed && event.active, + ) + } + + PushNotificationReducerEvent.MarketingConsentSaveFailureConsumed -> { + state.copy(isMarketingConsentSaveFailed = false) + } + } + + private fun refreshDeviceAlarmStatus() { val deviceAlarmOn = NotificationManagerCompat.from(context).areNotificationsEnabled() Log.d(TAG, "refreshDeviceAlarmStatus: deviceAlarmOn=$deviceAlarmOn") - _uiState.update { it.copy(isDeviceAlarmOn = deviceAlarmOn) } + dispatch(PushNotificationReducerEvent.DeviceAlarmStatusRead(deviceAlarmOn)) } private fun loadPushSettings() { viewModelScope.launch { Log.d(TAG, "loadPushSettings: start") - _uiState.update { it.copy(isLoading = true) } + dispatch(PushNotificationReducerEvent.PushSettingsLoading) runCatchingCancellable { notificationRepository.getMyPushSettings() } .onSuccess { setting -> Log.d(TAG, "loadPushSettings: success=$setting") - _uiState.update { - it.copy( - isLoading = false, - isNewsletterOn = setting.timeLetter, - isMindRecordOn = setting.mindRecord, - isAfternoteOn = setting.afterNote, - ) - } + dispatch(PushNotificationReducerEvent.PushSettingsLoaded(setting)) }.onFailure { e -> // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). Log.e(TAG, "loadPushSettings: failed", e) - _uiState.update { it.copy(isLoading = false) } + dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) } } } @@ -88,88 +169,44 @@ internal class PushNotificationViewModel runCatchingCancellable { notificationRepository.getMyMarketingConsents() } .onSuccess { consent -> Log.d(TAG, "loadMarketingConsents: success=$consent") - _uiState.update { - it.copy( - isSmsChecked = consent.sms, - isEmailChecked = consent.email, - isPushChecked = consent.push, - ) - } + dispatch(PushNotificationReducerEvent.MarketingConsentsLoaded(consent)) }.onFailure { e -> Log.e(TAG, "loadMarketingConsents: failed", e) } } } - fun onSmsChecked(checked: Boolean) { - _uiState.update { it.copy(isSmsChecked = checked) } - viewModelScope.launch { - runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = checked, email = null, push = null) } - .onSuccess { Log.d(TAG, "onSmsChecked: success, checked=$checked") } - .onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_SMS_CONSENT)) - _uiState.update { it.copy(isSmsChecked = !checked) } - _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) - } - } - } - - fun onEmailChecked(checked: Boolean) { - _uiState.update { it.copy(isEmailChecked = checked) } - viewModelScope.launch { - runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = null, email = checked, push = null) } - .onSuccess { Log.d(TAG, "onEmailChecked: success, checked=$checked") } - .onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_EMAIL_CONSENT)) - _uiState.update { it.copy(isEmailChecked = !checked) } - _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) - } - } - } - - fun onPushChecked(checked: Boolean) { - _uiState.update { it.copy(isPushChecked = checked) } + private fun updateMarketingConsent( + consent: MarketingConsent, + checked: Boolean, + ) { + dispatch(PushNotificationReducerEvent.MarketingConsentChanged(consent, checked)) viewModelScope.launch { - runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = null, email = null, push = checked) } - .onSuccess { Log.d(TAG, "onPushChecked: success, checked=$checked") } - .onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_PUSH_CONSENT)) - _uiState.update { it.copy(isPushChecked = !checked) } - _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) - } + runCatchingCancellable { + notificationRepository.updateMyMarketingConsents( + sms = checked.takeIf { consent == MarketingConsent.SMS }, + email = checked.takeIf { consent == MarketingConsent.EMAIL }, + push = checked.takeIf { consent == MarketingConsent.PUSH }, + ) + }.onSuccess { + Log.d(TAG, "updateMarketingConsent: success, consent=$consent, checked=$checked") + }.onFailure { e -> + errorReporter.recordFailure(e, mapOf(KEY_STAGE to consent.reportingStage())) + dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailed(consent, requested = checked)) + } } } - fun onNewsletterToggle(on: Boolean) { - updatePushSetting(PushSettingUpdate(PushSetting.NEWSLETTER, on)) - } - - fun onMindRecordToggle(on: Boolean) { - updatePushSetting(PushSettingUpdate(PushSetting.MIND_RECORD, on)) - } - - fun onAfternoteToggle(on: Boolean) { - updatePushSetting(PushSettingUpdate(PushSetting.AFTERNOTE, on)) - } - - fun onSaveFailureDismiss() { - failedUpdate = null - _uiState.update { it.copy(saveFailure = null) } - } - - fun onSaveFailureRetry() { - val update = failedUpdate ?: return - failedUpdate = null - _uiState.update { it.copy(saveFailure = null) } + private fun retrySave() { + val update = currentState.failedUpdate ?: return + dispatch(PushNotificationReducerEvent.SaveFailureDismissed) updatePushSetting(update) } private fun updatePushSetting(update: PushSettingUpdate) { - if (_uiState.value.isUpdating(update.setting)) return - val previousValue = _uiState.value.valueOf(update.setting) - _uiState.update { - it.withValue(update.setting, update.on).withUpdating(update.setting, updating = true) - } + if (currentState.isUpdating(update.setting)) return + val previousValue = currentState.valueOf(update.setting) + dispatch(PushNotificationReducerEvent.PushSettingSaving(update)) viewModelScope.launch { runCatchingCancellable { notificationRepository.updateMyPushSettings( @@ -178,7 +215,7 @@ internal class PushNotificationViewModel afterNote = update.on.takeIf { update.setting == PushSetting.AFTERNOTE }, ) }.onSuccess { - _uiState.update { it.withUpdating(update.setting, updating = false) } + dispatch(PushNotificationReducerEvent.PushSettingSaved(update.setting)) }.onFailure { failure -> errorReporter.recordFailure( failure, @@ -187,13 +224,7 @@ internal class PushNotificationViewModel KEY_PUSH_SETTING to update.setting.reportingName(), ), ) - failedUpdate = update - _uiState.update { - it - .withValue(update.setting, previousValue) - .withUpdating(update.setting, updating = false) - .copy(saveFailure = failure.toSaveFailure()) - } + dispatch(PushNotificationReducerEvent.PushSettingSaveFailed(update, previousValue, failure.toSaveFailure())) } } } @@ -203,18 +234,9 @@ internal class PushNotificationViewModel private const val KEY_STAGE = "stage" private const val KEY_PUSH_SETTING = "push_setting" private const val STAGE_PUSH_SETTING_UPDATE = "push_setting_update" - private const val STAGE_SMS_CONSENT = "sms_consent_update" - private const val STAGE_EMAIL_CONSENT = "email_consent_update" - private const val STAGE_PUSH_CONSENT = "push_consent_update" } } -private enum class PushSetting { - NEWSLETTER, - MIND_RECORD, - AFTERNOTE, -} - /** 진단 속성 값. 토글 종류만 담는 고정 문자열이라 사용자 정보가 섞이지 않는다. */ private fun PushSetting.reportingName(): String = when (this) { @@ -223,10 +245,13 @@ private fun PushSetting.reportingName(): String = PushSetting.AFTERNOTE -> "afternote" } -private data class PushSettingUpdate( - val setting: PushSetting, - val on: Boolean, -) +/** 마케팅 동의 저장 실패의 진단 단계. 채널 이름만 담는 고정 문자열이다. */ +private fun MarketingConsent.reportingStage(): String = + when (this) { + MarketingConsent.SMS -> "sms_consent_update" + MarketingConsent.EMAIL -> "email_consent_update" + MarketingConsent.PUSH -> "push_consent_update" + } private fun PushNotificationUiState.valueOf(setting: PushSetting): Boolean = when (setting) { @@ -262,6 +287,16 @@ private fun PushNotificationUiState.withUpdating( PushSetting.AFTERNOTE -> copy(isAfternoteUpdating = updating) } +private fun PushNotificationUiState.withMarketingConsent( + consent: MarketingConsent, + checked: Boolean, +): PushNotificationUiState = + when (consent) { + MarketingConsent.SMS -> copy(isSmsChecked = checked) + MarketingConsent.EMAIL -> copy(isEmailChecked = checked) + MarketingConsent.PUSH -> copy(isPushChecked = checked) + } + private fun Throwable.toSaveFailure(): PushNotificationSaveFailure = when (this) { is PushSettingFailure.NetworkUnavailable -> PushNotificationSaveFailure.NETWORK diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt index d908c8d8e..039935aac 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt @@ -16,7 +16,7 @@ import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.PushNotificationUiState @Composable -fun DeviceAlarmOffSection( +internal fun DeviceAlarmOffSection( uiState: PushNotificationUiState, onSmsCheck: (Boolean) -> Unit, onEmailCheck: (Boolean) -> Unit, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt index aca5f1757..081e521ad 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt @@ -6,7 +6,7 @@ import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.PushNotificationUiState @Composable -fun PushToggleSection( +internal fun PushToggleSection( uiState: PushNotificationUiState, onNewsletterToggle: (Boolean) -> Unit, onMindRecordToggle: (Boolean) -> Unit, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index f070c9a2a..1f0df3aec 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -25,7 +25,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository.ConnectedAccountLinkCall -import com.afternote.feature.setting.presentation.account.ConnectedAccountsEvent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.applock.AppLockSetupScreen import com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel @@ -46,10 +46,8 @@ import com.afternote.feature.setting.presentation.profile.ProfileEditScreen import com.afternote.feature.setting.presentation.profile.ProfileEditUiState import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -124,14 +122,13 @@ class SettingAccountSecurityTest { !linkViewModel.uiState.value.isLoading } - composeRule.runOnIdle { linkViewModel.onToggle(provider = "google", enabled = true) } - val request = awaitEvent(linkViewModel.events) + composeRule.runOnIdle { linkViewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) } - assertEquals(ConnectedAccountsEvent.RequestLink("google"), request) + assertEquals("google", linkViewModel.uiState.value.pendingLinkProvider) assertTrue(linkRepository.connectedLinkCalls.isEmpty()) linkRepository.onLinkConnectedAccount = { _, _ -> throw IllegalStateException("oauth rejected") } - composeRule.runOnIdle { linkViewModel.link(provider = "google", accessToken = "google-token") } + composeRule.runOnIdle { linkViewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-token")) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { linkViewModel.uiState.value.errorMessage == "계정 연결에 실패했습니다." } @@ -152,7 +149,7 @@ class SettingAccountSecurityTest { } assertTrue(unlinkRepository.connectedUnlinkCalls.isEmpty()) - composeRule.runOnIdle { unlinkViewModel.onToggle(provider = "google", enabled = false) } + composeRule.runOnIdle { unlinkViewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { unlinkRepository.connectedUnlinkCalls.size == 1 } @@ -344,11 +341,6 @@ class SettingAccountSecurityTest { assertTrue(runBlocking { authRepository.isLoggedIn.first() }) } - private fun awaitEvent(events: Flow): T = - runBlocking { - withTimeout(TIMEOUT_MILLIS) { events.first() } - } - private companion object { const val TIMEOUT_MILLIS = 5_000L const val RECEIVER_ID = 77L diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 3780c5f5e..08c4f6b51 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -38,6 +38,7 @@ import com.afternote.feature.setting.domain.UpdateReceiverInfoUseCase import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError import com.afternote.feature.setting.presentation.delivery.DeliveryConditionIntent @@ -46,7 +47,10 @@ import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.home.WithdrawUiState import com.afternote.feature.setting.presentation.navigation.SettingRoute +import com.afternote.feature.setting.presentation.notification.MarketingConsent +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.notification.PushSetting import com.afternote.feature.setting.presentation.profile.ProfileEditEvent import com.afternote.feature.setting.presentation.profile.ProfileEditIntent import com.afternote.feature.setting.presentation.profile.ProfileEditUiState @@ -143,7 +147,7 @@ class SettingCompletionTest { !viewModel.uiState.value.isLoading } - composeRule.runOnIdle { viewModel.onNewsletterToggle(false) } + composeRule.runOnIdle { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.pushUpdateCalls.size == 1 } @@ -162,7 +166,7 @@ class SettingCompletionTest { } assertFalse(viewModel.uiState.value.isNewsletterOn) - composeRule.runOnIdle { viewModel.onMindRecordToggle(false) } + composeRule.runOnIdle { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.pushUpdateCalls.size == 2 } @@ -186,10 +190,10 @@ class SettingCompletionTest { assertTrue(viewModel.uiState.value.saveFailure != null) composeRule.runOnIdle { - viewModel.onAfternoteToggle(false) - viewModel.onSmsChecked(true) - viewModel.onEmailChecked(true) - viewModel.onPushChecked(true) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, true)) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, true)) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.PUSH, true)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.pushUpdateCalls.size == 3 @@ -228,7 +232,7 @@ class SettingCompletionTest { } composeRule.runOnIdle { - viewModel.link(provider = "google", accessToken = "google-access-token") + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-access-token")) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.connectedLinkCalls.size == 1 @@ -259,7 +263,7 @@ class SettingCompletionTest { ) composeRule.runOnIdle { - viewModel.onToggle(provider = "google", enabled = false) + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.connectedUnlinkCalls.size == 1 diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt index fd9f4e6b3..56787ff68 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt @@ -12,17 +12,17 @@ import com.afternote.core.model.user.UserMarketingConsent import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel -import com.afternote.feature.setting.presentation.notification.PushNotificationEvent +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel import com.afternote.feature.setting.presentation.profile.ProfileEditIntent import com.afternote.feature.setting.presentation.profile.ProfileEditUiState import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain @@ -100,7 +100,7 @@ class SettingCoroutineCancellationTest { val store = storeHolding(viewModel) runCurrent() - viewModel.link(provider = "google", accessToken = "google-token") + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-token")) runCurrent() assertTrue(pending.isStarted) val pendingState = viewModel.uiState.value @@ -125,7 +125,7 @@ class SettingCoroutineCancellationTest { val store = storeHolding(viewModel) runCurrent() - viewModel.onToggle(provider = "naver", enabled = false) + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "naver", enabled = false)) runCurrent() assertTrue(pending.isStarted) val pendingState = viewModel.uiState.value @@ -225,8 +225,7 @@ class SettingCoroutineCancellationTest { val reporter = RecordingErrorReporter() val viewModel = pushViewModel(repository, reporter) val store = storeHolding(viewModel) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) runCurrent() // 푸시 설정 조회는 먼저 정상 종료해 안정된 상태를 만든다. 매달린 것은 마케팅 조회뿐이다. @@ -239,7 +238,7 @@ class SettingCoroutineCancellationTest { assertTrue(pending.isCancelled) assertEquals(stableState, viewModel.uiState.value) - assertTrue(events.isEmpty()) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) assertEquals(0, reporter.recordedStages.size) assertEquals(emptyList(), pushFailureLogs()) } @@ -273,8 +272,7 @@ class SettingCoroutineCancellationTest { }, failureReporter, ) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { failureViewModel.events.collect { events += it } } + failureViewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) runCurrent() // 평범한 실패는 진단 로그 한 줄로 끝난다. 화면 값은 기본값 그대로이고 안내도 띄우지 않는다. @@ -282,7 +280,7 @@ class SettingCoroutineCancellationTest { assertTrue(failureViewModel.uiState.value.isSmsChecked) assertTrue(failureViewModel.uiState.value.isEmailChecked) assertFalse(failureViewModel.uiState.value.isPushChecked) - assertTrue(events.isEmpty()) + assertFalse(failureViewModel.uiState.value.isMarketingConsentSaveFailed) assertEquals(0, failureReporter.recordedStages.size) } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt index 0f68bccef..3e40f3677 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt @@ -16,7 +16,9 @@ import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.home.SettingScreen import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.notification.PushSetting import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -106,7 +108,7 @@ class SettingFlowTest { composeRule.setContent { AfternoteTheme {} } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isLoading } - composeRule.runOnIdle { viewModel.onNewsletterToggle(false) } + composeRule.runOnIdle { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) } composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.isNewsletterOn } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt new file mode 100644 index 000000000..6baf72799 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt @@ -0,0 +1,149 @@ +package com.afternote.feature.setting.presentation.account + +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectedAccountsMviTest { + private val repository = FakeSettingAccountRepository(connectedAccounts = ACCOUNTS) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `토글을 켜면 저장소를 부르지 않고 연결 요청 신호만 올린다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) + + assertEquals("google", viewModel.uiState.value.pendingLinkProvider) + assertTrue(repository.connectedLinkCalls.isEmpty()) + assertTrue(repository.connectedUnlinkCalls.isEmpty()) + } + + @Test + fun `토글을 끄면 신호 없이 바로 해제한다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "naver", enabled = false)) + + assertNull(viewModel.uiState.value.pendingLinkProvider) + assertEquals(listOf("naver"), repository.connectedUnlinkCalls) + assertFalse( + viewModel.uiState.value.accounts + .first { it.provider == "naver" } + .isConnected, + ) + } + + @Test + fun `연결 요청 신호는 소비할 때까지 남고 소비하면 걷힌다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "kakao", enabled = true)) + // 다른 작업이 지나가도 신호는 그대로다. + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "naver", enabled = false)) + assertEquals("kakao", viewModel.uiState.value.pendingLinkProvider) + + viewModel.onIntent(ConnectedAccountsIntent.ConsumeLinkRequest("kakao")) + assertNull(viewModel.uiState.value.pendingLinkProvider) + + // 소비 뒤 같은 제공자를 다시 켜면 다시 요청한다 (A → null → A). + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "kakao", enabled = true)) + assertEquals("kakao", viewModel.uiState.value.pendingLinkProvider) + } + + @Test + fun `늦게 도착한 소비가 그사이 올라온 다른 제공자 요청을 지우지 않는다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "kakao", enabled = true)) + viewModel.onIntent(ConnectedAccountsIntent.ConsumeLinkRequest("google")) + + assertEquals("kakao", viewModel.uiState.value.pendingLinkProvider) + + viewModel.onIntent(ConnectedAccountsIntent.ConsumeLinkRequest("kakao")) + assertNull(viewModel.uiState.value.pendingLinkProvider) + } + + @Test + fun `연결이 연달아 실패해도 매번 요청하고 실패 문구를 남긴 뒤 성공하면 계정을 갱신한다`() { + var remainingFailures = 2 + repository.onLinkConnectedAccount = { provider, _ -> + if (remainingFailures > 0) { + remainingFailures-- + error("oauth rejected") + } + ACCOUNTS.copy(google = provider == "google", googleEmail = "linked@afternote.local") + } + val viewModel = ConnectedAccountsViewModel(repository) + + repeat(2) { + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "token-$it")) + assertEquals("계정 연결에 실패했습니다.", viewModel.uiState.value.errorMessage) + assertFalse( + viewModel.uiState.value.accounts + .first { it.provider == "google" } + .isConnected, + ) + } + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "token-2")) + + assertEquals( + List(3) { FakeSettingAccountRepository.ConnectedAccountLinkCall("google", "token-$it") }, + repository.connectedLinkCalls.toList(), + ) + val google = + viewModel.uiState.value.accounts + .first { it.provider == "google" } + assertTrue(google.isConnected) + assertEquals("linked@afternote.local", google.email) + } + + @Test + fun `조회 실패는 로딩을 끝내고 실패 문구를 남긴다`() { + repository.onGetConnectedAccounts = { error("unavailable") } + + val state = ConnectedAccountsViewModel(repository).uiState.value + + assertFalse(state.isLoading) + assertTrue(state.accounts.isEmpty()) + assertEquals("계정 정보를 불러올 수 없습니다.", state.errorMessage) + } + + private companion object { + val ACCOUNTS = + UserConnectedAccount( + local = false, + naver = true, + google = false, + kakao = false, + apple = false, + localEmail = null, + naverEmail = "naver@afternote.local", + googleEmail = null, + kakaoEmail = null, + appleEmail = null, + ) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt new file mode 100644 index 000000000..5b714026d --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt @@ -0,0 +1,46 @@ +package com.afternote.feature.setting.presentation.account + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 연결된 계정 화면이 연결 요청 신호를 받자마자 소비하고, 인증은 신호와 떨어진 화면 코루틴에서 + * 돌리는지 확인한다 (#1502). 플랫폼 인증은 이 환경에서 토큰을 주지 못하므로 연결 호출까지는 가지 않는다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ConnectedAccountsSignalTest { + @get:Rule + val composeRule = createComposeRule() + + private val repository = FakeSettingAccountRepository() + private val viewModel = ConnectedAccountsViewModel(repository) + + @Test + fun `화면은 연결 요청 신호를 받으면 곧바로 소비하고 다음 요청도 다시 받는다`() { + composeRule.setContent { + AfternoteTheme { + ConnectedAccountsScreen(onBack = {}, viewModel = viewModel) + } + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { !viewModel.uiState.value.isLoading } + + repeat(2) { + composeRule.runOnIdle { viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.pendingLinkProvider == null } + } + + assertTrue(repository.connectedLinkCalls.isEmpty()) + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt index 6d88a442f..b2091e81a 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt @@ -8,7 +8,6 @@ import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepos import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -17,6 +16,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -49,23 +49,21 @@ class MarketingConsentFailureTest { onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } } val viewModel = viewModel(repository, reporter) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } advanceUntilIdle() - runCurrent() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - viewModel.onSmsChecked(false) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.isSmsChecked) - viewModel.onEmailChecked(false) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.isEmailChecked) - viewModel.onPushChecked(false) - advanceUntilIdle() - runCurrent() - assertTrue(viewModel.uiState.value.isPushChecked) + var signals = 0 + MarketingConsent.entries.forEach { consent -> + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(consent, false)) + assertFalse(viewModel.uiState.value.checked(consent)) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.checked(consent)) + if (viewModel.uiState.value.isMarketingConsentSaveFailed) signals++ + viewModel.onIntent(PushNotificationIntent.ConsumeMarketingConsentSaveFailure) + } - assertEquals(List(3) { PushNotificationEvent.MarketingConsentSaveFailed }, events) + assertEquals(3, signals) assertEquals(listOf("sms_consent_update", "email_consent_update", "push_consent_update"), reporter.stages) assertEquals( listOf(Triple(false, null, null), Triple(null, false, null), Triple(null, null, false)), @@ -82,14 +80,12 @@ class MarketingConsentFailureTest { onUpdateMyMarketingConsents = { _, _, _ -> throw CancellationException("screen left") } } val viewModel = viewModel(repository, reporter) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } advanceUntilIdle() - runCurrent() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - viewModel.onSmsChecked(false) - viewModel.onEmailChecked(false) - viewModel.onPushChecked(false) + MarketingConsent.entries.forEach { consent -> + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(consent, false)) + } advanceUntilIdle() runCurrent() @@ -97,39 +93,83 @@ class MarketingConsentFailureTest { listOf(false, false, false), viewModel.uiState.value.let { listOf(it.isSmsChecked, it.isEmailChecked, it.isPushChecked) }, ) - assertTrue(events.isEmpty()) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) assertTrue(reporter.stages.isEmpty()) } @Test - fun `구독이 끝난 동안의 실패 안내는 다음 구독에 재생하지 않는다`() = + fun `STARTED 밖에서 난 실패 안내는 다음 진입에 재생하지 않는다`() = + runTest(dispatcher) { + val viewModel = failingViewModel() + advanceUntilIdle() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + advanceUntilIdle() + // 값 롤백은 화면 수명과 무관하게 일어난다. 안내만 막힌다. + assertTrue(viewModel.uiState.value.isSmsChecked) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, false)) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) + } + + @Test + fun `소비하기 전에 화면이 멈추면 남은 안내를 걷어 재진입에 띄우지 않는다`() = runTest(dispatcher) { + val viewModel = failingViewModel() + advanceUntilIdle() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.PUSH, true)) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + } + + @Test + fun `안내는 소비할 때까지 남고 소비 뒤 연속 실패는 다시 안내한다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() val viewModel = viewModel( - repository().apply { - onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } - }, - RecordingReporter(), + repository().apply { onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } }, + reporter, ) - val events = mutableListOf() - val firstCollector = backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } advanceUntilIdle() - runCurrent() - firstCollector.cancel() - runCurrent() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - viewModel.onSmsChecked(false) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) advanceUntilIdle() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } - runCurrent() - assertTrue(events.isEmpty()) + // 다른 입력이 지나가도 소비 전까지 신호는 남는다. + viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) - viewModel.onEmailChecked(false) + viewModel.onIntent(PushNotificationIntent.ConsumeMarketingConsentSaveFailure) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) advanceUntilIdle() - runCurrent() - assertEquals(listOf(PushNotificationEvent.MarketingConsentSaveFailed), events) + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) + assertTrue(viewModel.uiState.value.isSmsChecked) + assertEquals(listOf("sms_consent_update", "sms_consent_update"), reporter.stages) } + private fun failingViewModel() = + viewModel( + repository().apply { onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } }, + RecordingReporter(), + ) + private fun repository() = FakeSettingNotificationRepository.strict().apply { onGetMyPushSettings = { UserPushSetting(false, false, false) } @@ -141,6 +181,13 @@ class MarketingConsentFailureTest { reporter: ErrorReporter, ) = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, reporter) + private fun PushNotificationUiState.checked(consent: MarketingConsent): Boolean = + when (consent) { + MarketingConsent.SMS -> isSmsChecked + MarketingConsent.EMAIL -> isEmailChecked + MarketingConsent.PUSH -> isPushChecked + } + private class RecordingReporter : ErrorReporter { val stages = mutableListOf() diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt new file mode 100644 index 000000000..4ef2fdb83 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt @@ -0,0 +1,150 @@ +package com.afternote.feature.setting.presentation.notification + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.NoOpErrorReporter +import com.afternote.feature.setting.presentation.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 알림 설정 화면이 마케팅 동의 저장 실패 신호를 받아 안내하고 소비하는지, STARTED 밖의 실패를 + * 재진입에 재생하지 않는지 화면 배선까지 확인한다 (#1502, #558). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class NotificationSettingSignalTest { + @get:Rule + val composeRule = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + private val failedMessage = context.getString(R.string.setting_marketing_consent_save_failed) + private val repository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { UserPushSetting(timeLetter = false, mindRecord = false, afterNote = false) } + onGetMyMarketingConsents = { UserMarketingConsent(sms = true, email = true, push = true) } + onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } + } + private val viewModel = PushNotificationViewModel(context, repository, NoOpErrorReporter) + private val lifecycleOwner = ManualLifecycleOwner() + + @Test + fun `저장 실패 신호를 받으면 안내를 띄우고 신호를 소비한다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNodeWithText(failedMessage).assertExists() + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + assertTrue(viewModel.uiState.value.isSmsChecked) + } + + @Test + fun `STARTED 밖에서 난 저장 실패는 다시 시작해도 안내하지 않는다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.CREATED) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { !viewModel.uiState.value.isMarketingFeedbackActive } + + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + repository.marketingConsentUpdates.size == 1 && viewModel.uiState.value.isEmailChecked + } + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + composeRule.waitForIdle() + + assertTrue(composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isEmpty()) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + } + + @Test + fun `안내 중에 화면이 멈추면 떠 있던 안내와 밀린 안내를 거둬 재진입에 띄우지 않는다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + + // 첫 실패의 안내가 떠 있는 동안 둘째 실패가 나서 그 뒤에 줄을 선다. + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { isFailedMessageShown() } + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + repository.marketingConsentUpdates.size == 2 && + viewModel.uiState.value.isEmailChecked && + !viewModel.uiState.value.isMarketingConsentSaveFailed + } + + // 안내 시간이 흘러 저절로 닫히지 않게 시계를 세운다. 멈췄다 돌아오는 것만으로 걷히는지 본다. + composeRule.mainClock.autoAdvance = false + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.CREATED) } + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + composeRule.mainClock.advanceTimeBy(SNACKBAR_EXIT_MILLIS) + + assertFalse(isFailedMessageShown()) + } + + private fun isFailedMessageShown(): Boolean = composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isNotEmpty() + + private fun setScreen() { + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + AfternoteTheme { + NotificationSettingScreen( + onBack = {}, + onPushNotificationClick = {}, + viewModel = viewModel, + ) + } + } + } + } + + /** 화면이 보는 수명만 따로 움직인다. 호스트 액티비티는 RESUMED 로 둔 채 STARTED 밖을 흉내 낸다. */ + private class ManualLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + + override val lifecycle: Lifecycle get() = registry + + fun moveTo(state: Lifecycle.State) { + registry.currentState = state + } + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + + /** 스낵바가 사라지는 애니메이션이 끝나기에 넉넉하고, 짧은 안내 시간(4초)보다는 짧다. */ + const val SNACKBAR_EXIT_MILLIS = 1_000L + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt index df72f27a5..1068f0f47 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt @@ -56,19 +56,19 @@ class PushNotificationSaveReportingTest { val viewModel = viewModel(repository, reporter) runCurrent() - viewModel.onNewsletterToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) runCurrent() assertTrue(viewModel.uiState.value.isNewsletterOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() assertTrue(viewModel.uiState.value.isMindRecordOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) runCurrent() assertTrue(viewModel.uiState.value.isAfternoteOn) assertFalse(viewModel.uiState.value.isAfternoteUpdating) @@ -91,9 +91,9 @@ class PushNotificationSaveReportingTest { val viewModel = viewModel(FakeSettingNotificationRepository(pushSetting = ALL_ON), reporter) runCurrent() - viewModel.onNewsletterToggle(false) - viewModel.onMindRecordToggle(false) - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) runCurrent() assertFalse(viewModel.uiState.value.isNewsletterOn) @@ -119,7 +119,7 @@ class PushNotificationSaveReportingTest { val store = ViewModelStore().apply { put(STORE_KEY, viewModel) } runCurrent() - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() assertEquals(1, repository.pushUpdateCalls.size) @@ -166,12 +166,12 @@ class PushNotificationSaveReportingTest { val viewModel = viewModel(repository, reporter) runCurrent() - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() - viewModel.onSaveFailureRetry() + viewModel.onIntent(PushNotificationIntent.RetrySave) runCurrent() assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureRetry() + viewModel.onIntent(PushNotificationIntent.RetrySave) runCurrent() assertFalse(viewModel.uiState.value.isMindRecordOn) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt index 8a3276dba..dc8063ec7 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt @@ -47,7 +47,7 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls) runCurrent() - viewModel.onNewsletterToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) assertFalse(viewModel.uiState.value.isNewsletterOn) assertTrue(viewModel.uiState.value.isNewsletterUpdating) @@ -67,22 +67,22 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls, failUpdateAttempts = Int.MAX_VALUE) runCurrent() - viewModel.onNewsletterToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) assertFalse(viewModel.uiState.value.isNewsletterOn) runCurrent() assertTrue(viewModel.uiState.value.isNewsletterOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) assertNull(viewModel.uiState.value.saveFailure) - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) assertFalse(viewModel.uiState.value.isMindRecordOn) runCurrent() assertTrue(viewModel.uiState.value.isMindRecordOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) assertFalse(viewModel.uiState.value.isAfternoteOn) runCurrent() assertTrue(viewModel.uiState.value.isAfternoteOn) @@ -105,13 +105,13 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) runCurrent() - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() assertTrue(viewModel.uiState.value.isMindRecordOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureRetry() + viewModel.onIntent(PushNotificationIntent.RetrySave) assertNull(viewModel.uiState.value.saveFailure) assertFalse(viewModel.uiState.value.isMindRecordOn) @@ -139,7 +139,7 @@ class PushNotificationViewModelTest { ) runCurrent() - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) runCurrent() assertEquals(PushNotificationSaveFailure.NETWORK, viewModel.uiState.value.saveFailure) @@ -154,8 +154,8 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls) runCurrent() - viewModel.onNewsletterToggle(false) - viewModel.onNewsletterToggle(true) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, true)) assertFalse(viewModel.uiState.value.isNewsletterOn) assertTrue(viewModel.uiState.value.isNewsletterUpdating) @@ -167,6 +167,76 @@ class PushNotificationViewModelTest { assertFalse(viewModel.uiState.value.isNewsletterUpdating) } + @Test + fun `재시도를 연달아 보내도 실패한 변경을 한 번만 다시 저장한다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) + runCurrent() + + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) + runCurrent() + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + viewModel.onIntent(PushNotificationIntent.RetrySave) + viewModel.onIntent(PushNotificationIntent.RetrySave) + assertTrue(viewModel.uiState.value.isAfternoteUpdating) + runCurrent() + + assertEquals( + List(2) { PushUpdateCall(timeLetter = null, mindRecord = null, afterNote = false) }, + calls, + ) + assertFalse(viewModel.uiState.value.isAfternoteOn) + assertFalse(viewModel.uiState.value.isAfternoteUpdating) + assertNull(viewModel.uiState.value.failedUpdate) + } + + @Test + fun `실패 안내를 닫으면 재시도 대상도 비워 이후 재시도가 요청을 보내지 않는다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) + runCurrent() + + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + runCurrent() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) + + assertNull(viewModel.uiState.value.saveFailure) + assertNull(viewModel.uiState.value.failedUpdate) + + viewModel.onIntent(PushNotificationIntent.RetrySave) + runCurrent() + + assertEquals(listOf(PushUpdateCall(timeLetter = false, mindRecord = null, afterNote = null)), calls) + assertTrue(viewModel.uiState.value.isNewsletterOn) + } + + @Test + fun `서로 다른 토글이 연달아 실패하면 재시도 대상은 마지막 실패만 남는다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 2) + runCurrent() + + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) + runCurrent() + + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertTrue(viewModel.uiState.value.isMindRecordOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + viewModel.onIntent(PushNotificationIntent.RetrySave) + runCurrent() + + assertEquals(PushUpdateCall(timeLetter = null, mindRecord = false, afterNote = null), calls.last()) + assertEquals(3, calls.size) + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertFalse(viewModel.uiState.value.isMindRecordOn) + } + private fun viewModel( calls: MutableList, failUpdateAttempts: Int = 0, diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index ff907b9be..bc88ed964 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -17,7 +17,7 @@ import java.io.File * 정착했는데 그 아래 전이 경로는 화면마다 갈렸고, 진입점을 하나로 모은 화면은 51개 중 * 1개뿐이었다. 규약을 문서로만 두면 같은 일이 반복된다. * - * 규칙은 셋이다. + * 규칙은 넷이다. * - **A.** `MviViewModel` 상속체는 `MutableStateFlow`·`MutableSharedFlow`·`Channel` 을 직접 * 선언하지 않는다. 별도 상태 홀더를 두면 전이가 `reduce` 밖으로 새고 베이스를 도입한 의미가 * 사라진다. `Channel` 금지는 #1502 가 세우려는 규칙과 같은 방향이라 **두 규칙을 하나로 합친다** — @@ -26,6 +26,10 @@ import java.io.File * 것은 [PENDING_MVI_MIGRATION] 예외로 두고(가드 도입 시점 49개), 전환할 때마다 뺀다. * - **C.** `MviIntent`·`ReducerEvent` 를 직접 구현하는 화면 계약 타입은 `sealed interface` 다. * 열려 있으면 `when` 이 전수 분기를 보장하지 못해 진입점 단일화의 이득이 사라진다. + * - **D.** 아직 전환하지 않은 `feature/…/presentation` ViewModel 도 이벤트용 `Channel`·`MutableSharedFlow` 를 + * 새로 들이지 않는다 (#1502). 규칙 A 는 전환한 ViewModel 만 보므로, 이 규칙이 없으면 [PENDING_MVI_MIGRATION] + * 예외에 든 ViewModel 이 전환 전까지 이벤트 스트림을 늘릴 수 있다. 가드 도입 시점에 이미 있던 선언은 + * [LEGACY_EVENT_STREAMS] 기준선으로 인정한다. `MutableStateFlow` 는 전환 전 상태 홀더라 대상이 아니다. * * 기준 문서는 `docs/convention/mvi.md` 다. * @@ -93,6 +97,38 @@ class MviContractKonsistTest { ) } + @Test + fun `미전환 ViewModel 도 이벤트 스트림을 새로 들이지 않는다`() { + val violations = legacyEventStreams(AfternoteKonsistScope.productionFiles) - LEGACY_EVENT_STREAMS + + check(violations.isEmpty()) { + buildString { + appendLine("MVI 미전환 ViewModel 이 이벤트용 Channel·MutableSharedFlow 를 새로 선언한다 (${violations.size}건).") + appendLine("producer 가 consumer 보다 오래 살면 Channel·SharedFlow 는 전달을 보장하지 못한다 (#228).") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("일회성 신호는 UiState 의 nullable 필드로 흡수하고 소비는 Intent 로 받는다 (#1502).") + appendLine("기존 선언을 옮긴 것이면 ViewModel 을 MviViewModel 로 전환한다. 기준: docs/convention/mvi.md") + } + } + } + + @Test + fun `해소된 이벤트 스트림 기준선은 경고로 알린다`() { + val stale = LEGACY_EVENT_STREAMS - legacyEventStreams(AfternoteKonsistScope.productionFiles) + if (stale.isEmpty()) return + + println( + buildString { + appendLine("[경고] LEGACY_EVENT_STREAMS 에 지금 소스에 없는 항목이 남아 있다 (${stale.size}건).") + appendLine("전환이 끝났으면 지운다 — 남겨 두면 같은 이름으로 다시 들인 스트림이 기준선에 숨는다.") + appendLine() + stale.sorted().forEach { appendLine(" $it") } + }, + ) + } + @Test fun `Intent 와 ReducerEvent 는 sealed interface 다`() { val violations = openContractTypes(AfternoteKonsistScope.productionFiles) @@ -219,6 +255,60 @@ class MviContractKonsistTest { check(violations.any { it.endsWith("object Dismiss") }) { violations.toString() } } + @Test + fun `규칙 D - 미전환 ViewModel 의 Channel·MutableSharedFlow 만 잡는다`() { + val root = fixture.newFolder("rule-d") + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/LegacyViewModel.kt", + """ + package sample + + class LegacyViewModel : ViewModel() { + private val _uiState = MutableStateFlow(SampleUiState()) + private val _events = Channel(Channel.BUFFERED) + private val signals = MutableSharedFlow(replay = 0) + private val plain = 0 + } + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/MigratedViewModel.kt", + """ + package sample + + class MigratedViewModel : MviViewModel(SampleUiState()) { + private val signals = Channel() + } + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/SnackbarRelay.kt", + """ + package sample + + class SnackbarRelay { + private val messages = Channel() + } + """, + ) + root.writeKotlin( + "app/src/main/kotlin/app/AppViewModel.kt", + """ + package app + + class AppViewModel : ViewModel() { + private val _events = Channel() + } + """, + ) + + val violations = legacyEventStreams(fixtureFiles(root)) + + // 상태 홀더(MutableStateFlow)는 전환 전 ViewModel 의 정상 구조다. 전환한 ViewModel 은 규칙 A, + // ViewModel 이 아닌 타입과 app 은 이 규칙 밖이다(app 은 #1809 몫). + assertEquals(setOf("sample.LegacyViewModel._events", "sample.LegacyViewModel.signals"), violations) + } + @Test fun `규칙 A·B - 중간 추상 베이스를 낀 상속도 MviViewModel 로 본다`() { val root = fixture.newFolder("indirect-parents") @@ -267,6 +357,25 @@ class MviContractKonsistTest { }.toSet() } + /** 규칙 D 위반 후보 — `feature/…/presentation` main 소스의 미전환 ViewModel 이 든 이벤트 스트림(`FQN.프로퍼티`). */ + private fun legacyEventStreams(files: List): Set { + val extendsMvi = mviViewModelSubclassTest(files) + return files + .filter { FEATURE_PRESENTATION_MAIN.containsMatchIn(it.normalizedProjectPath()) } + .flatMap { file -> + file + .classes() + .filter { it.name.endsWith(VIEW_MODEL_SUFFIX) } + .filterNot(extendsMvi) + .flatMap { declaration -> + declaration + .properties() + .filter { property -> EVENT_STREAM.containsMatchIn(property.text) } + .map { property -> "${file.packagee?.name}.${declaration.name}.${property.name}" } + } + }.toSet() + } + /** 규칙 A 위반 — 베이스를 상속한 ViewModel 이 직접 든 상태 홀더. */ private fun stateHolderDeclarations(files: List): List { val extendsMvi = mviViewModelSubclassTest(files) @@ -363,6 +472,21 @@ class MviContractKonsistTest { */ val STATE_HOLDER = Regex("""\b(MutableStateFlow|MutableSharedFlow|Channel)\s*[(<]""") + /** 규칙 D 의 이벤트 스트림 선언. [STATE_HOLDER] 에서 전환 전 상태 홀더인 `MutableStateFlow` 만 뺐다. */ + val EVENT_STREAM = Regex("""\b(MutableSharedFlow|Channel)\s*[(<]""") + + /** + * 규칙 D 의 기준선 — 가드 도입(#1502) 때 이미 있던 미전환 ViewModel 의 이벤트 스트림. + * + * 새 항목은 넣지 않는다. 해당 ViewModel 이 전환되거나 지워지면 여기서도 뺀다(남으면 경고). + */ + val LEGACY_EVENT_STREAMS = + setOf( + // #944 가 카카오 초대(BE#289)로 교체한다. 폼 값과 이메일 등록 버튼이 PM 결정 대기라 + // #1502 전환 범위에서 뺐다. + "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel._events", + ) + /** #1804 가 뺀다. */ private val ISSUE_1804_AFTERNOTE = setOf( @@ -424,13 +548,14 @@ class MviContractKonsistTest { "com.afternote.feature.receiver.presentation.senderdetail.SenderDetailViewModel", ) - /** #1805 가 뺀다. `Channel` 5곳 흡수(#1502)가 선행이다. */ + /** + * #1805 가 뺀다. 이벤트 스트림을 쓰던 ConnectedAccounts·PushNotification 은 #1502 가 전환했다. + * ReceiverRegisterViewModel 은 #944 가 카카오 초대로 교체하며 뺀다([LEGACY_EVENT_STREAMS]). + */ private val ISSUE_1805_SETTING = setOf( - "com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel", "com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel", "com.afternote.feature.setting.presentation.home.SettingViewModel", - "com.afternote.feature.setting.presentation.notification.PushNotificationViewModel", "com.afternote.feature.setting.presentation.passkey.PassKeyViewModel", "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel", )