diff --git a/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmInteractionActivityReceiver.kt b/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmInteractionActivityReceiver.kt index 029072a4..c3faf948 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmInteractionActivityReceiver.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmInteractionActivityReceiver.kt @@ -53,7 +53,9 @@ class AlarmInteractionActivityReceiver(private val activity: ComponentActivity) } when (fortuneCreateStatus) { - is FortuneCreateStatus.Creating -> { + is FortuneCreateStatus.Creating, + is FortuneCreateStatus.Failure, + -> { context?.let { ctx -> val uri = "orbitapp://fortune".toUri() val fortuneIntent = Intent(Intent.ACTION_VIEW, uri).apply { @@ -78,7 +80,7 @@ class AlarmInteractionActivityReceiver(private val activity: ComponentActivity) } } - FortuneCreateStatus.Failure, FortuneCreateStatus.Idle -> { } + FortuneCreateStatus.Idle -> { } } } else { context?.let { ctx -> diff --git a/core/datastore/src/main/java/com/yapp/datastore/FortunePreferences.kt b/core/datastore/src/main/java/com/yapp/datastore/FortunePreferences.kt index 98ebc7c5..fde9d5b7 100644 --- a/core/datastore/src/main/java/com/yapp/datastore/FortunePreferences.kt +++ b/core/datastore/src/main/java/com/yapp/datastore/FortunePreferences.kt @@ -35,6 +35,7 @@ class FortunePreferences @Inject constructor( val CREATING = booleanPreferencesKey("fortune_creating") val FAILED = booleanPreferencesKey("fortune_failed") + val FAILED_DATE = longPreferencesKey("fortune_failed_date_epoch") val ATTEMPT_ID = stringPreferencesKey("fortune_attempt_id") val STARTED_AT = longPreferencesKey("fortune_started_at") @@ -104,6 +105,7 @@ class FortunePreferences @Inject constructor( dataStore.edit { pref -> pref[Keys.CREATING] = false pref[Keys.FAILED] = true + pref[Keys.FAILED_DATE] = todayEpoch() } emit(false) return@transformLatest @@ -115,7 +117,25 @@ class FortunePreferences @Inject constructor( val isFortuneFailedFlow: Flow = dataStore.data .catch { emit(emptyPreferences()) } - .map { it[Keys.FAILED] ?: false } + .map { pref -> + val failed = pref[Keys.FAILED] ?: false + val failedDate = pref[Keys.FAILED_DATE] + failed to failedDate + } + .transformLatest { (failed, failedDate) -> + if (failed) { + val isToday = failedDate == todayEpoch() + if (!isToday) { + dataStore.edit { pref -> + pref[Keys.FAILED] = false + pref.remove(Keys.FAILED_DATE) + } + emit(false) + return@transformLatest + } + } + emit(failed) + } .distinctUntilChanged() val isFirstAlarmDismissedTodayFlow: Flow = dataStore.data @@ -134,7 +154,6 @@ class FortunePreferences @Inject constructor( val now = nowMillis() dataStore.edit { pref -> pref[Keys.CREATING] = true - pref[Keys.FAILED] = false pref[Keys.ATTEMPT_ID] = attemptId pref[Keys.STARTED_AT] = now pref[Keys.EXPIRES_AT] = now + lease @@ -169,6 +188,7 @@ class FortunePreferences @Inject constructor( pref[Keys.DATE] = today pref[Keys.CREATING] = false pref[Keys.FAILED] = false + pref.remove(Keys.FAILED_DATE) pref.remove(Keys.ATTEMPT_ID) pref.remove(Keys.STARTED_AT) pref.remove(Keys.EXPIRES_AT) @@ -186,6 +206,7 @@ class FortunePreferences @Inject constructor( if (pref[Keys.ATTEMPT_ID] == attemptId) { pref[Keys.CREATING] = false pref[Keys.FAILED] = true + pref[Keys.FAILED_DATE] = todayEpoch() pref.remove(Keys.ATTEMPT_ID) pref.remove(Keys.STARTED_AT) pref.remove(Keys.EXPIRES_AT) @@ -226,6 +247,7 @@ class FortunePreferences @Inject constructor( pref.remove(Keys.TOOLTIP_SHOWN) pref.remove(Keys.CREATING) pref.remove(Keys.FAILED) + pref.remove(Keys.FAILED_DATE) } } } diff --git a/core/datastore/src/test/kotlin/com/yapp/datastore/FortunePreferencesTest.kt b/core/datastore/src/test/kotlin/com/yapp/datastore/FortunePreferencesTest.kt index b9410965..af669911 100644 --- a/core/datastore/src/test/kotlin/com/yapp/datastore/FortunePreferencesTest.kt +++ b/core/datastore/src/test/kotlin/com/yapp/datastore/FortunePreferencesTest.kt @@ -148,6 +148,54 @@ class FortunePreferencesTest { assertEquals(true, failed) } + @Test + fun `운세_생성_실패_후_재시도해도_성공하기_전까지_Failure가_유지된다`() = runTest { + // given: 첫 번째 시도에서 실패 상태로 전환 + val dataStore = createNewDataStoreWithFile("prefs_fail_retry.preferences_pb") + val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) + val firstAttemptId = "ATTEMPT_FIRST" + val retryAttemptId = "ATTEMPT_RETRY" + val fortuneId = 101L + + preferences.markFortuneCreating(attemptId = firstAttemptId, lease = 60_000L) + preferences.markFortuneFailedIfAttemptMatches(firstAttemptId) + + // when: 새로운 attemptId로 재시도 + preferences.markFortuneCreating(attemptId = retryAttemptId, lease = 60_000L) + + // then: 성공 전까지는 Failure 상태 유지 + val failedDuringRetry = preferences.isFortuneFailedFlow.first() + assertEquals(true, failedDuringRetry) + + // when: 재시도가 성공적으로 완료되면 + preferences.markFortuneCreatedIfAttemptMatches( + attemptId = retryAttemptId, + fortuneId = fortuneId, + ) + + // then: Failure 플래그가 해제된다 + val failedAfterSuccess = preferences.isFortuneFailedFlow.first() + assertEquals(false, failedAfterSuccess) + } + + @Test + fun `이전_날짜의_운세_실패_상태는_자동_초기화된다`() = runTest { + // given: 기준일에 실패 상태 기록 + val dataStore = createNewDataStoreWithFile("prefs_fail_old.preferences_pb") + val preferencesToday = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) + val attemptId = "ATTEMPT_OLD_FAIL" + preferencesToday.markFortuneCreating(attemptId = attemptId, lease = 60_000L) + preferencesToday.markFortuneFailedIfAttemptMatches(attemptId) + + // when: 다음 날 시점에서 상태 확인 + val nextDayClock = Clock.fixed(referenceInstantForAnyDay.plusSeconds(86_400), fixedZoneOffsetUtc) + val preferencesNextDay = createFortunePreferencesWithClock(dataStore, nextDayClock) + + // then: 실패 상태가 자동으로 해제된다 + val failedNextDay = preferencesNextDay.isFortuneFailedFlow.first() + assertEquals(false, failedNextDay) + } + @Test fun `운세_생성_상태_Creating_만료_시_Success_처리는_거부되고_Failure로_교정된다`() = runTest { // given: t0에서 Creating(lease 1초) 설정 diff --git a/feature/fortune/src/main/java/com/yapp/fortune/FortuneContract.kt b/feature/fortune/src/main/java/com/yapp/fortune/FortuneContract.kt index 6c7528e0..e900b6b7 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneContract.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneContract.kt @@ -15,6 +15,7 @@ sealed class FortuneContract { val avgFortuneScore: Int = 0, val fortunePages: List = emptyList(), val fortuneImageId: Int? = null, + val isCreateFailureDialogVisible: Boolean = false, ) : com.yapp.ui.base.UiState sealed class Action { diff --git a/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt b/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt index 1eb97abc..9417e908 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -36,7 +37,9 @@ import com.yapp.designsystem.theme.OrbitTheme import com.yapp.fortune.component.FortuneTopAppBar import com.yapp.fortune.component.SlidingIndicator import com.yapp.fortune.page.FortunePager +import com.yapp.ui.component.dialog.OrbitDialog import com.yapp.ui.component.lottie.LottieAnimation +import feature.fortune.R import kotlinx.coroutines.delay import java.math.BigDecimal import java.math.RoundingMode @@ -184,6 +187,15 @@ fun FortuneScreen( } } } + + if (state.isCreateFailureDialogVisible) { + OrbitDialog( + title = stringResource(id = R.string.fortune_failure_dialog_title), + message = stringResource(id = R.string.fortune_failure_dialog_message), + confirmText = stringResource(id = R.string.fortune_failure_dialog_confirm), + onConfirm = onNavigateToHome, + ) + } } @Composable diff --git a/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt b/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt index 4a83a561..0bc19414 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt @@ -64,8 +64,19 @@ class FortuneViewModel @Inject constructor( ) } - is FortuneCreateStatus.Failure, FortuneCreateStatus.Idle -> { - postSideEffect(FortuneContract.SideEffect.NavigateToHome) + is FortuneCreateStatus.Failure -> { + reduce { + state.copy( + isLoading = false, + isCreateFailureDialogVisible = true, + ) + } + } + + is FortuneCreateStatus.Idle -> { + if (!state.isCreateFailureDialogVisible) { + postSideEffect(FortuneContract.SideEffect.NavigateToHome) + } } } } @@ -94,6 +105,7 @@ class FortuneViewModel @Inject constructor( fortunePages = fortune.toFortunePages(), fortuneImageId = imageId, hasReward = isFirstAlarmDismissedToday, + isCreateFailureDialogVisible = false, ) } }.onFailure { error -> @@ -122,6 +134,10 @@ class FortuneViewModel @Inject constructor( } private fun navigateToHome() = intent { + if (state.isCreateFailureDialogVisible) { + reduce { state.copy(isCreateFailureDialogVisible = false) } + } + postSideEffect(FortuneContract.SideEffect.NavigateToHome) } diff --git a/feature/fortune/src/main/java/com/yapp/fortune/scheduler/WorkManagerPostFortuneTaskScheduler.kt b/feature/fortune/src/main/java/com/yapp/fortune/scheduler/WorkManagerPostFortuneTaskScheduler.kt index 36e49c4f..2aadbe87 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/scheduler/WorkManagerPostFortuneTaskScheduler.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/scheduler/WorkManagerPostFortuneTaskScheduler.kt @@ -2,9 +2,7 @@ package com.yapp.fortune.scheduler import android.content.Context import androidx.work.BackoffPolicy -import androidx.work.Constraints import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.yapp.alarm.scheduler.PostFortuneTaskScheduler @@ -19,11 +17,7 @@ class WorkManagerPostFortuneTaskScheduler @Inject constructor( ) : PostFortuneTaskScheduler { override fun enqueueOnceForToday() { val name = "post_fortune_${LocalDate.now()}" - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() val req = OneTimeWorkRequestBuilder() - .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS) .build() WorkManager.getInstance(context) diff --git a/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt b/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt index d5feb56c..45f31753 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt @@ -28,9 +28,8 @@ class PostFortuneWorker @AssistedInject constructor( is FortuneCreateStatus.Creating, is FortuneCreateStatus.Success, -> return Result.success() - FortuneCreateStatus.Failure, - FortuneCreateStatus.Idle, - -> { /* 계속 진행 */ } + is FortuneCreateStatus.Failure -> return Result.failure() + FortuneCreateStatus.Idle -> { /* 계속 진행 */ } } val userId = userInfoRepository.userIdFlow.firstOrNull() @@ -57,6 +56,7 @@ class PostFortuneWorker @AssistedInject constructor( }, ) } catch (ce: CancellationException) { + fortuneRepository.markFortuneAsFailed(attemptId) throw ce } catch (_: Throwable) { fortuneRepository.markFortuneAsFailed(attemptId) diff --git a/feature/fortune/src/main/res/values/strings.xml b/feature/fortune/src/main/res/values/strings.xml new file mode 100644 index 00000000..a4f7b162 --- /dev/null +++ b/feature/fortune/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + 운세 생성 실패 + 운세 생성에 실패했어요.\n네트워크 연결을 확인해주세요. + 확인 + diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt index b5e1c45d..f26f893c 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt @@ -142,10 +142,13 @@ class MissionViewModel @Inject constructor( val fortuneCreateStatus = fortuneRepository.fortuneCreateStatusFlow.first() val hasUnseenFortune = fortuneRepository.hasUnseenFortuneFlow.first() - val shouldOpenFortune = ( - fortuneCreateStatus is FortuneCreateStatus.Creating || - fortuneCreateStatus is FortuneCreateStatus.Success && hasUnseenFortune - ) + val shouldOpenFortune = when (fortuneCreateStatus) { + is FortuneCreateStatus.Creating, + is FortuneCreateStatus.Failure, + -> true + is FortuneCreateStatus.Success -> hasUnseenFortune + FortuneCreateStatus.Idle -> false + } postSideEffect( if (shouldOpenFortune) {