diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 742620eb..6ae513ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,8 +64,6 @@ dependencies { implementation(libs.compose.material) implementation(libs.kotlin.reflect) - implementation(libs.hilt.worker) - implementation(libs.androidx.work.runtime) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e7abd75a..59cbd0b8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -80,14 +80,5 @@ android:name="com.yapp.alarm.services.AlarmService" android:foregroundServiceType="mediaPlayback" /> - - - diff --git a/app/src/main/java/com/yapp/orbit/OrbitApplication.kt b/app/src/main/java/com/yapp/orbit/OrbitApplication.kt index b06538f8..61f01f70 100644 --- a/app/src/main/java/com/yapp/orbit/OrbitApplication.kt +++ b/app/src/main/java/com/yapp/orbit/OrbitApplication.kt @@ -1,24 +1,13 @@ package com.yapp.orbit import android.app.Application -import androidx.hilt.work.HiltWorkerFactory -import androidx.work.Configuration import com.google.android.gms.ads.MobileAds import dagger.hilt.android.HiltAndroidApp -import javax.inject.Inject @HiltAndroidApp -class OrbitApplication() : Application(), Configuration.Provider { - - @Inject lateinit var workerFactory: HiltWorkerFactory - +class OrbitApplication() : Application() { override fun onCreate() { super.onCreate() MobileAds.initialize(this) } - - override val workManagerConfiguration: Configuration - get() = Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() } diff --git a/build-logic/src/main/java/com/yapp/convention/HiltAndroid.kt b/build-logic/src/main/java/com/yapp/convention/HiltAndroid.kt index dec5fc7a..36727c95 100644 --- a/build-logic/src/main/java/com/yapp/convention/HiltAndroid.kt +++ b/build-logic/src/main/java/com/yapp/convention/HiltAndroid.kt @@ -13,9 +13,7 @@ internal fun Project.configureHiltAndroid() { dependencies { "implementation"(libs.findLibrary("hilt.android").get()) "ksp"(libs.findLibrary("hilt.android.compiler").get()) - "ksp"(libs.findLibrary("androidx-hilt-compiler").get()) "implementation"(libs.findLibrary("hilt-navigation-compose").get()) - "implementation"(libs.findLibrary("hilt-worker").get()) } } 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 c3faf948..030027be 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 @@ -6,9 +6,10 @@ import android.content.Intent import androidx.activity.ComponentActivity import androidx.core.net.toUri import com.yapp.alarm.AlarmConstants -import com.yapp.domain.model.FortuneCreateStatus +import com.yapp.domain.model.FortuneCreationState import com.yapp.domain.model.MissionType import com.yapp.domain.repository.FortuneRepository +import com.yapp.domain.tracker.FortuneCreationTracker import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -23,6 +24,9 @@ class AlarmInteractionActivityReceiver(private val activity: ComponentActivity) @Inject lateinit var fortuneRepository: FortuneRepository + @Inject + lateinit var fortuneCreationTracker: FortuneCreationTracker + override fun onReceive(context: Context?, intent: Intent?) { val isSnoozed = intent?.getBooleanExtra(AlarmConstants.EXTRA_IS_SNOOZED, false) ?: false @@ -46,16 +50,15 @@ class AlarmInteractionActivityReceiver(private val activity: ComponentActivity) CoroutineScope(Dispatchers.Main).launch { try { if (!hasValidMissionData) { - val (fortuneCreateStatus, hasUnseenFortune) = withContext(Dispatchers.IO) { - val status = fortuneRepository.fortuneCreateStatusFlow.first() + val (fortuneCreationState, hasUnseenFortune) = withContext(Dispatchers.IO) { + val todayFortune = fortuneCreationTracker.state.first() val unseen = fortuneRepository.hasUnseenFortuneFlow.first() - status to unseen + + todayFortune to unseen } - when (fortuneCreateStatus) { - is FortuneCreateStatus.Creating, - is FortuneCreateStatus.Failure, - -> { + when (fortuneCreationState) { + is FortuneCreationState.Start -> { context?.let { ctx -> val uri = "orbitapp://fortune".toUri() val fortuneIntent = Intent(Intent.ACTION_VIEW, uri).apply { @@ -65,8 +68,7 @@ class AlarmInteractionActivityReceiver(private val activity: ComponentActivity) ctx.startActivity(fortuneIntent) } } - - is FortuneCreateStatus.Success -> { + is FortuneCreationState.Success -> { if (hasUnseenFortune) { context?.let { ctx -> val uri = "orbitapp://fortune".toUri() @@ -79,8 +81,7 @@ class AlarmInteractionActivityReceiver(private val activity: ComponentActivity) } } } - - FortuneCreateStatus.Idle -> { } + is FortuneCreationState.Failure -> { } } } else { context?.let { ctx -> diff --git a/core/alarm/src/main/java/com/yapp/alarm/scheduler/PostFortuneTaskScheduler.kt b/core/alarm/src/main/java/com/yapp/alarm/scheduler/PostFortuneTaskScheduler.kt deleted file mode 100644 index e16e8dd4..00000000 --- a/core/alarm/src/main/java/com/yapp/alarm/scheduler/PostFortuneTaskScheduler.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.yapp.alarm.scheduler - -interface PostFortuneTaskScheduler { - fun enqueueOnceForToday() -} diff --git a/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt b/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt index abdc8692..0b7e42e2 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt @@ -23,17 +23,20 @@ import com.yapp.alarm.pendingIntent.interaction.createAlarmAlertPendingIntent import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissPendingIntent import com.yapp.alarm.pendingIntent.interaction.createAlarmSnoozePendingIntent import com.yapp.alarm.pendingIntent.interaction.createNavigateToMissionPendingIntent -import com.yapp.alarm.scheduler.PostFortuneTaskScheduler import com.yapp.domain.model.Alarm import com.yapp.domain.model.AlarmDay import com.yapp.domain.model.MissionType import com.yapp.domain.repository.AlarmRepository +import com.yapp.domain.repository.FortuneRepository +import com.yapp.domain.repository.UserInfoRepository +import com.yapp.domain.tracker.FortuneCreationTracker import com.yapp.media.sound.SoundPlayer import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -52,7 +55,13 @@ class AlarmService : Service() { lateinit var androidAlarmScheduler: AndroidAlarmScheduler @Inject - lateinit var postFortuneTaskScheduler: PostFortuneTaskScheduler + lateinit var fortuneRepository: FortuneRepository + + @Inject + lateinit var userInfoRepository: UserInfoRepository + + @Inject + lateinit var fortuneCreationTracker: FortuneCreationTracker private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -79,7 +88,7 @@ class AlarmService : Service() { super.onDestroy() } - private fun handleIntent(intent: Intent) { + private suspend fun handleIntent(intent: Intent) { val alarm: Alarm? = intent.getStringExtra(AlarmConstants.EXTRA_ALARM)?.let(Alarm::fromJson) if (alarm == null) { @@ -117,7 +126,19 @@ class AlarmService : Service() { turnOffAlarm(alarmId = notificationId) } - postFortuneTaskScheduler.enqueueOnceForToday() + val shouldPostFortune = !fortuneRepository.hasTodayFortune() + if (shouldPostFortune) { + val userId = userInfoRepository.userIdFlow.first() + userId?.let { + fortuneCreationTracker.start() + fortuneRepository.postFortune(userId).onSuccess { fortune -> + fortuneCreationTracker.succeed(fortune.id) + fortuneRepository.markFortuneAsCreated(fortune.id) + }.onFailure { + fortuneCreationTracker.fail() + } + } + } } private fun shouldNavigateToMission( diff --git a/core/common/src/main/java/com/yapp/common/di/FortuneTrackerModule.kt b/core/common/src/main/java/com/yapp/common/di/FortuneTrackerModule.kt new file mode 100644 index 00000000..025180b6 --- /dev/null +++ b/core/common/src/main/java/com/yapp/common/di/FortuneTrackerModule.kt @@ -0,0 +1,19 @@ +package com.yapp.common.di + +import com.yapp.common.tracker.InMemoryFortuneCreationTracker +import com.yapp.domain.tracker.FortuneCreationTracker +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class FortuneTrackerModule { + @Binds + @Singleton + abstract fun bindFortuneCreationTracker( + impl: InMemoryFortuneCreationTracker, + ): FortuneCreationTracker +} diff --git a/core/common/src/main/java/com/yapp/common/tracker/InMemoryFortuneCreationTracker.kt b/core/common/src/main/java/com/yapp/common/tracker/InMemoryFortuneCreationTracker.kt new file mode 100644 index 00000000..49b315bd --- /dev/null +++ b/core/common/src/main/java/com/yapp/common/tracker/InMemoryFortuneCreationTracker.kt @@ -0,0 +1,27 @@ +package com.yapp.common.tracker + +import com.yapp.domain.model.FortuneCreationState +import com.yapp.domain.tracker.FortuneCreationTracker +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class InMemoryFortuneCreationTracker @Inject constructor() : FortuneCreationTracker { + private val _state = MutableStateFlow(FortuneCreationState.Start) + override val state: StateFlow = _state.asStateFlow() + + override fun start() { + _state.value = FortuneCreationState.Start + } + + override fun succeed(fortuneId: Long) { + _state.value = FortuneCreationState.Success(fortuneId) + } + + override fun fail() { + _state.value = FortuneCreationState.Failure + } +} 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 fde9d5b7..1079067a 100644 --- a/core/datastore/src/main/java/com/yapp/datastore/FortunePreferences.kt +++ b/core/datastore/src/main/java/com/yapp/datastore/FortunePreferences.kt @@ -7,15 +7,12 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.transformLatest import java.time.Clock -import java.time.Instant import java.time.LocalDate import javax.inject.Inject import javax.inject.Singleton @@ -33,20 +30,11 @@ class FortunePreferences @Inject constructor( val SEEN = booleanPreferencesKey("fortune_seen") val TOOLTIP_SHOWN = booleanPreferencesKey("fortune_tooltip_shown") - 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") - val EXPIRES_AT = longPreferencesKey("fortune_expires_at") - val FIRST_ALARM_DISMISSED_TODAY = booleanPreferencesKey("first_alarm_dismissed_today") val FIRST_ALARM_DISMISSED_DATE_EPOCH = longPreferencesKey("first_alarm_dismissed_date_epoch") } private fun todayEpoch(): Long = LocalDate.now(clock).toEpochDay() - private fun nowMillis(): Long = Instant.now(clock).toEpochMilli() val fortuneIdFlow: Flow = dataStore.data .catch { emit(emptyPreferences()) } @@ -85,59 +73,6 @@ class FortunePreferences @Inject constructor( } .distinctUntilChanged() - @OptIn(ExperimentalCoroutinesApi::class) - val isFortuneCreatingFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .map { pref -> - Triple( - pref[Keys.CREATING] ?: false, - pref[Keys.EXPIRES_AT] ?: 0L, - pref[Keys.ATTEMPT_ID], - ) - } - .transformLatest { (creating, expiresAt, attemptId) -> - if (creating) { - val legacy = (expiresAt <= 0L) || attemptId.isNullOrEmpty() - val expired = (!legacy && nowMillis() > expiresAt) - - if (legacy || expired) { - // 레거시(만료정보 없음) 또는 만료 → 실패로 교정 - dataStore.edit { pref -> - pref[Keys.CREATING] = false - pref[Keys.FAILED] = true - pref[Keys.FAILED_DATE] = todayEpoch() - } - emit(false) - return@transformLatest - } - } - emit(creating) - } - .distinctUntilChanged() - - val isFortuneFailedFlow: Flow = dataStore.data - .catch { emit(emptyPreferences()) } - .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 .catch { emit(emptyPreferences()) } .map { pref -> @@ -147,69 +82,18 @@ class FortunePreferences @Inject constructor( } .distinctUntilChanged() - suspend fun markFortuneCreating( - attemptId: String, - lease: Long, - ) { - val now = nowMillis() + suspend fun markFortuneCreated(fortuneId: Long) { dataStore.edit { pref -> - pref[Keys.CREATING] = true - pref[Keys.ATTEMPT_ID] = attemptId - pref[Keys.STARTED_AT] = now - pref[Keys.EXPIRES_AT] = now + lease - } - } + val today = todayEpoch() + val prevDate = pref[Keys.DATE] + val isNewForToday = (pref[Keys.ID] != fortuneId) || (prevDate != today) - suspend fun markFortuneCreatedIfAttemptMatches( - attemptId: String, - fortuneId: Long, - ) { - dataStore.edit { pref -> - val currentAttempt = pref[Keys.ATTEMPT_ID] - val isCreating = pref[Keys.CREATING] ?: false - val expiresAt = pref[Keys.EXPIRES_AT] ?: 0L - - if (isCreating) { - val legacy = (expiresAt <= 0L) || currentAttempt.isNullOrEmpty() - val expired = (!legacy && nowMillis() > expiresAt) - - if (legacy || expired) { - // 만료된 상태라면 성공 처리 거부 - return@edit - } - } + pref[Keys.ID] = fortuneId + pref[Keys.DATE] = today - if (isCreating && currentAttempt == attemptId) { - val today = todayEpoch() - val prevDate = pref[Keys.DATE] - val isNewForToday = (pref[Keys.ID] != fortuneId) || (prevDate != today) - - pref[Keys.ID] = fortuneId - 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) - - if (isNewForToday) { - pref[Keys.SEEN] = false - pref[Keys.TOOLTIP_SHOWN] = false - } - } - } - } - - suspend fun markFortuneFailedIfAttemptMatches(attemptId: String) { - dataStore.edit { pref -> - 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) + if (isNewForToday) { + pref[Keys.SEEN] = false + pref[Keys.TOOLTIP_SHOWN] = false } } } @@ -245,9 +129,11 @@ class FortunePreferences @Inject constructor( pref.remove(Keys.SCORE) pref.remove(Keys.SEEN) pref.remove(Keys.TOOLTIP_SHOWN) - pref.remove(Keys.CREATING) - pref.remove(Keys.FAILED) - pref.remove(Keys.FAILED_DATE) } } + + suspend fun hasTodayFortune(): Boolean { + val prefs = dataStore.data.first() + return prefs[Keys.ID] != null && prefs[Keys.DATE] == todayEpoch() + } } 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 af669911..a80abb32 100644 --- a/core/datastore/src/test/kotlin/com/yapp/datastore/FortunePreferencesTest.kt +++ b/core/datastore/src/test/kotlin/com/yapp/datastore/FortunePreferencesTest.kt @@ -3,19 +3,15 @@ package com.yapp.datastore import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import kotlinx.coroutines.flow.first +import junit.framework.TestCase.assertFalse import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.time.Clock import java.time.Instant -import java.time.LocalDate import java.time.ZoneOffset -import java.util.UUID class FortunePreferencesTest { @@ -25,15 +21,10 @@ class FortunePreferencesTest { private val fixedZoneOffsetUtc = ZoneOffset.UTC private val baseInstantAtT0: Instant = Instant.parse("2025-09-17T00:00:00Z") - private val baseInstantAtT0Plus2Seconds: Instant = Instant.parse("2025-09-17T00:00:02Z") + private val nextDayInstant: Instant = Instant.parse("2025-09-18T00:00:00Z") private val fixedClockAtT0: Clock = Clock.fixed(baseInstantAtT0, fixedZoneOffsetUtc) - private val fixedClockAtT0Plus2Seconds: Clock = - Clock.fixed(baseInstantAtT0Plus2Seconds, fixedZoneOffsetUtc) - - private val referenceInstantForAnyDay: Instant = Instant.parse("2025-09-17T00:00:00Z") - private val fixedClockForReferenceDay: Clock = - Clock.fixed(referenceInstantForAnyDay, fixedZoneOffsetUtc) + private val fixedClockNextDay: Clock = Clock.fixed(nextDayInstant, fixedZoneOffsetUtc) private fun createNewDataStoreWithFile(fileName: String): DataStore = PreferenceDataStoreFactory.create { @@ -46,254 +37,31 @@ class FortunePreferencesTest { ): FortunePreferences = FortunePreferences(dataStore, fixedClock) @Test - fun `운세_생성_상태_Creating_만료_시_Failure로_교정된다`() = runTest { - // given: t0 시점에서 Creating(lease 1초) 설정 - val dataStoreAtT0 = createNewDataStoreWithFile("prefs_expire.preferences_pb") - val preferencesAtT0 = - createFortunePreferencesWithClock(dataStoreAtT0, fixedClockAtT0) - - val generatedAttemptId = UUID.randomUUID().toString() - preferencesAtT0.markFortuneCreating(attemptId = generatedAttemptId, lease = 1_000L) - - // when: t0 + 2초 경과 후 같은 DataStore를 새로운 Clock으로 읽음 - val preferencesAtT0Plus2Seconds = - createFortunePreferencesWithClock(dataStoreAtT0, fixedClockAtT0Plus2Seconds) - - // then: Creating → false, Failed → true - val creating = preferencesAtT0Plus2Seconds.isFortuneCreatingFlow.first() - assertEquals(false, creating) - val failed = preferencesAtT0Plus2Seconds.isFortuneFailedFlow.first() - assertEquals(true, failed) - } - - @Test - fun `만료_정보_없는_운세_생성_상태_Creating은_즉시_Failure로_교정된다`() = runTest { - // given: 과거 버전 데이터 (Creating=true만 존재) - val dataStoreWithLegacyCreating = createNewDataStoreWithFile("prefs_legacy.preferences_pb") - val keyCreatingOnly = booleanPreferencesKey("fortune_creating") - dataStoreWithLegacyCreating.edit { it[keyCreatingOnly] = true } - - val preferencesFromLegacyData = - createFortunePreferencesWithClock(dataStoreWithLegacyCreating, fixedClockForReferenceDay) - - // when: Failure로 교정 로직이 표시된 Flow 구독 시작 - - // then: 즉시 Creating → false, Failed → true - val creating = preferencesFromLegacyData.isFortuneCreatingFlow.first() - assertEquals(false, creating) - val failed = preferencesFromLegacyData.isFortuneFailedFlow.first() - assertEquals(true, failed) - } - - @Test - fun `생성_성공_시_attemptId가_일치할_때만_운세_생성_상태가_Creating에서_Success로_전환된다`() = runTest { - // given: 운세 Creating 상태 - val dataStore = createNewDataStoreWithFile("prefs_success.preferences_pb") - val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) - val validAttemptId = "ATTEMPT_VALID" - val invalidAttemptId = "ATTEMPT_INVALID" - val createdFortuneId = 99L - preferences.markFortuneCreating(attemptId = validAttemptId, lease = 60_000L) - - // when: 잘못된 attemptId로 생성 성공 처리 시도 - preferences.markFortuneCreatedIfAttemptMatches( - attemptId = invalidAttemptId, - fortuneId = createdFortuneId - ) - - // then: 여전히 Creating 상태 (무시됨) - val stillCreating = preferences.isFortuneCreatingFlow.first() - assertEquals(true, stillCreating) - - // when: 올바른 attemptId로 생성 성공 처리 시도 - preferences.markFortuneCreatedIfAttemptMatches( - attemptId = validAttemptId, - fortuneId = createdFortuneId - ) - - // then: Creating → false, Failed → false, fortuneId 및 날짜 설정 - val creatingAfterSuccess = preferences.isFortuneCreatingFlow.first() - assertEquals(false, creatingAfterSuccess) - val failedAfterSuccess = preferences.isFortuneFailedFlow.first() - assertEquals(false, failedAfterSuccess) - val savedId = preferences.fortuneIdFlow.first() - assertEquals(createdFortuneId, savedId) - val savedEpoch = preferences.fortuneDateEpochFlow.first() - assertEquals(LocalDate.now(fixedClockForReferenceDay).toEpochDay(), savedEpoch) - } - - @Test - fun `운세_생성_실패_시_attemptId가_일치할_때만_Failure로_전환된다`() = runTest { - // given: 운세 Creating 상태 - val dataStore = createNewDataStoreWithFile("prefs_fail.preferences_pb") - val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) - val validAttemptId = "ATTEMPT_VALID" - val invalidAttemptId = "ATTEMPT_INVALID" - preferences.markFortuneCreating(attemptId = validAttemptId, lease = 60_000L) - - // when: 잘못된 attemptId로 실패 처리 시도 - preferences.markFortuneFailedIfAttemptMatches(invalidAttemptId) - - // then: 아직 Creating 상태 (무시됨) - val stillCreating = preferences.isFortuneCreatingFlow.first() - assertEquals(true, stillCreating) - - // when: 올바른 attemptId로 실패 처리 시도 - preferences.markFortuneFailedIfAttemptMatches(validAttemptId) - - // then: Creating → false, Failed → true - val creatingAfterFail = preferences.isFortuneCreatingFlow.first() - assertEquals(false, creatingAfterFail) - val failed = preferences.isFortuneFailedFlow.first() - 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초) 설정 - val dataStore = createNewDataStoreWithFile("prefs_expired_success_guard.preferences_pb") - val prefsAtT0 = createFortunePreferencesWithClock(dataStore, fixedClockAtT0) - - val attemptId = UUID.randomUUID().toString() - val fortuneId = 999L - prefsAtT0.markFortuneCreating(attemptId = attemptId, lease = 1_000L) - - // when: t0+2초(만료 이후)로 시계를 바꾸고, 같은 DataStore로 성공 처리 시도 - val prefsAtT0Plus2 = createFortunePreferencesWithClock(dataStore, fixedClockAtT0Plus2Seconds) - // 만료된 상태이므로, 아래 호출은 내부에서 return@edit 되어 성공 반영이 되면 안 된다. - prefsAtT0Plus2.markFortuneCreatedIfAttemptMatches( - attemptId = attemptId, - fortuneId = fortuneId - ) - - // then: 성공 반영이 거부되었으므로 fortuneId는 여전히 null이어야 한다 - val savedId = prefsAtT0Plus2.fortuneIdFlow.first() - assertEquals(null, savedId) - - // 그리고 isFortuneCreatingFlow 구독 시 만료 교정 로직이 작동하여 - // CREATING → false, FAILED → true 로 자동 교정되어야 한다. - val creatingAfter = prefsAtT0Plus2.isFortuneCreatingFlow.first() - assertEquals(false, creatingAfter) - - val failedAfter = prefsAtT0Plus2.isFortuneFailedFlow.first() - assertEquals(true, failedAfter) - } - - @Test - fun `오늘_운세가_있고_확인한_경우_hasUnseenFortune가_false`() = runTest { - // given: 오늘 운세가 생성되어 있고(미확인) - val dataStore = createNewDataStoreWithFile("prefs_seen.preferences_pb") - val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) - val attemptId = "ATTEMPT_FOR_SEEN" - val fortuneId = 777L - preferences.markFortuneCreating(attemptId = attemptId, lease = 60_000L) - preferences.markFortuneCreatedIfAttemptMatches(attemptId = attemptId, fortuneId = fortuneId) - - // when: 사용자가 오늘 운세를 확인 - preferences.markFortuneSeen() - - // then: hasUnseenFortune → false - val unseen = preferences.hasUnseenFortuneFlow.first() - assertEquals(false, unseen) - } - - @Test - fun `오늘_운세가_있고_아직_확인하지_않은_경우_hasUnseenFortune가_true`() = runTest { - // given: 오늘 운세가 생성되어 있는 상태(미확인) - val dataStore = createNewDataStoreWithFile("prefs_unseen.preferences_pb") - val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) - val attemptId = "ATTEMPT_FOR_UNSEEN" - val fortuneId = 123L - preferences.markFortuneCreating(attemptId = attemptId, lease = 60_000L) - preferences.markFortuneCreatedIfAttemptMatches(attemptId = attemptId, fortuneId = fortuneId) + fun `오늘_운세를_생성했다면_hasTodayFortune이_참이다`() = runTest { + // given + val dataStore = createNewDataStoreWithFile("today.preferences_pb") + val preferences = createFortunePreferencesWithClock(dataStore, fixedClockAtT0) - // when: hasUnseenFortuneFlow 구독 + // when + preferences.markFortuneCreated(fortuneId = 1L) - // then: 오늘 운세 존재 + 아직 읽지 않음 = hasUnseenFortune → true - val unseen = preferences.hasUnseenFortuneFlow.first() - assertEquals(true, unseen) + // then + val result = preferences.hasTodayFortune() + assertTrue(result) } @Test - fun `오늘_운세가_있고_Tooltip을_보여주었다면_shouldShowFortuneToolTip이_false`() = runTest { - // given: 오늘 운세가 생성되어 있는 상태(툴팁 미표시) - val dataStore = createNewDataStoreWithFile("prefs_tooltip_true.preferences_pb") - val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) - val attemptId = "ATTEMPT_FOR_TOOLTIP_TRUE" - val fortuneId = 888L - preferences.markFortuneCreating(attemptId = attemptId, lease = 60_000L) - preferences.markFortuneCreatedIfAttemptMatches(attemptId = attemptId, fortuneId = fortuneId) - - // when: ToolTip을 보여줌 - preferences.markFortuneTooltipShown() - - // then: shouldShowFortuneToolTip → false - val showTooltip = preferences.shouldShowFortuneToolTipFlow.first() - assertEquals(false, showTooltip) - } - - @Test - fun `오늘_운세가_있고_Tooltip을_아직_보여주지_않았다면_shouldShowFortuneToolTip이_true`() = runTest { - // given: 오늘 운세가 생성되어 있는 상태(툴팁 미표시) - val dataStore = createNewDataStoreWithFile("prefs_tooltip.preferences_pb") - val preferences = createFortunePreferencesWithClock(dataStore, fixedClockForReferenceDay) - val attemptId = "ATTEMPT_FOR_TOOLTIP" - val fortuneId = 456L - preferences.markFortuneCreating(attemptId = attemptId, lease = 60_000L) - preferences.markFortuneCreatedIfAttemptMatches(attemptId = attemptId, fortuneId = fortuneId) - - // when: shouldShowFortuneToolTipFlow 구독 - - // then: 오늘 운세 존재 + 툴팁 미표시 = shouldShowFortuneToolTip → true - val showTooltip = preferences.shouldShowFortuneToolTipFlow.first() - assertEquals(true, showTooltip) + fun `운세_생성일과_현재_날짜가_다르면_hasTodayFortune이_거짓이다`() = runTest { + // given + val dataStore = createNewDataStoreWithFile("yesterday.preferences_pb") + val preferencesAtT0 = createFortunePreferencesWithClock(dataStore, fixedClockAtT0) + val preferencesNextDay = createFortunePreferencesWithClock(dataStore, fixedClockNextDay) + + // when + preferencesAtT0.markFortuneCreated(fortuneId = 1L) + + // then + val result = preferencesNextDay.hasTodayFortune() + assertFalse(result) } } diff --git a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt index 2cd0f60d..693176a3 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt @@ -1,6 +1,5 @@ package com.yapp.data.local.datasource -import com.yapp.domain.model.FortuneCreateStatus import kotlinx.coroutines.flow.Flow interface FortuneLocalDataSource { @@ -12,11 +11,7 @@ interface FortuneLocalDataSource { val shouldShowFortuneToolTipFlow: Flow val isFirstAlarmDismissedTodayFlow: Flow - val fortuneCreateStatusFlow: Flow - - suspend fun markFortuneCreating(attemptId: String, leaseMillis: Long) - suspend fun markFortuneCreated(attemptId: String, fortuneId: Long) - suspend fun markFortuneFailed(attemptId: String) + suspend fun markFortuneCreated(fortuneId: Long) suspend fun markFortuneSeen() suspend fun markFortuneTooltipShown() suspend fun saveFortuneImageId(imageResId: Int) @@ -24,4 +19,6 @@ interface FortuneLocalDataSource { suspend fun markFirstAlarmDismissedToday() suspend fun clearFortuneData() + + suspend fun hasTodayFortune(): Boolean } diff --git a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt index a3178e11..1066166c 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt @@ -1,10 +1,6 @@ package com.yapp.data.local.datasource import com.yapp.datastore.FortunePreferences -import com.yapp.domain.model.FortuneCreateStatus -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import java.time.LocalDate import javax.inject.Inject class FortuneLocalDataSourceImpl @Inject constructor( @@ -19,32 +15,8 @@ class FortuneLocalDataSourceImpl @Inject constructor( override val shouldShowFortuneToolTipFlow = fortunePreferences.shouldShowFortuneToolTipFlow override val isFirstAlarmDismissedTodayFlow = fortunePreferences.isFirstAlarmDismissedTodayFlow - override val fortuneCreateStatusFlow = combine( - fortunePreferences.fortuneIdFlow, - fortunePreferences.fortuneDateEpochFlow, - fortunePreferences.isFortuneCreatingFlow, - fortunePreferences.isFortuneFailedFlow, - ) { fortuneId, fortuneDate, isCreating, isFailed -> - when { - isFailed -> FortuneCreateStatus.Failure - isCreating -> FortuneCreateStatus.Creating - fortuneId != null && fortuneDate == todayEpoch() -> FortuneCreateStatus.Success(fortuneId) - else -> FortuneCreateStatus.Idle - } - }.distinctUntilChanged() - - private fun todayEpoch(): Long = LocalDate.now().toEpochDay() - - override suspend fun markFortuneCreating(attemptId: String, leaseMillis: Long) { - fortunePreferences.markFortuneCreating(attemptId, leaseMillis) - } - - override suspend fun markFortuneCreated(attemptId: String, fortuneId: Long) { - fortunePreferences.markFortuneCreatedIfAttemptMatches(attemptId, fortuneId) - } - - override suspend fun markFortuneFailed(attemptId: String) { - fortunePreferences.markFortuneFailedIfAttemptMatches(attemptId) + override suspend fun markFortuneCreated(fortuneId: Long) { + fortunePreferences.markFortuneCreated(fortuneId) } override suspend fun markFortuneSeen() { @@ -70,4 +42,8 @@ class FortuneLocalDataSourceImpl @Inject constructor( override suspend fun clearFortuneData() { fortunePreferences.clearFortuneData() } + + override suspend fun hasTodayFortune(): Boolean { + return fortunePreferences.hasTodayFortune() + } } diff --git a/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt b/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt index 8849da81..b4271611 100644 --- a/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt +++ b/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt @@ -4,7 +4,6 @@ import com.yapp.data.local.datasource.FortuneLocalDataSource import com.yapp.data.remote.datasource.FortuneDataSource import com.yapp.data.remote.dto.response.toDomain import com.yapp.domain.model.Fortune -import com.yapp.domain.model.FortuneCreateStatus import com.yapp.domain.repository.FortuneRepository import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -22,11 +21,7 @@ class FortuneRepositoryImpl @Inject constructor( override val shouldShowFortuneToolTipFlow: Flow = fortuneLocalDataSource.shouldShowFortuneToolTipFlow override val isFirstAlarmDismissedTodayFlow: Flow = fortuneLocalDataSource.isFirstAlarmDismissedTodayFlow - override val fortuneCreateStatusFlow: Flow = fortuneLocalDataSource.fortuneCreateStatusFlow - - override suspend fun markFortuneAsCreating(attemptId: String, leaseMillis: Long) = fortuneLocalDataSource.markFortuneCreating(attemptId, leaseMillis) - override suspend fun markFortuneAsCreated(attemptId: String, fortuneId: Long) = fortuneLocalDataSource.markFortuneCreated(attemptId, fortuneId) - override suspend fun markFortuneAsFailed(attemptId: String) = fortuneLocalDataSource.markFortuneFailed(attemptId) + override suspend fun markFortuneAsCreated(fortuneId: Long) = fortuneLocalDataSource.markFortuneCreated(fortuneId) override suspend fun markFortuneSeen() = fortuneLocalDataSource.markFortuneSeen() override suspend fun markFortuneTooltipShown() = fortuneLocalDataSource.markFortuneTooltipShown() override suspend fun saveFortuneImageId(imageResId: Int) = fortuneLocalDataSource.saveFortuneImageId(imageResId) @@ -35,6 +30,8 @@ class FortuneRepositoryImpl @Inject constructor( override suspend fun clearFortuneData() = fortuneLocalDataSource.clearFortuneData() + override suspend fun hasTodayFortune() = fortuneLocalDataSource.hasTodayFortune() + override suspend fun postFortune(userId: Long): Result { return fortuneRemoteDataSource.postFortune(userId) .mapCatching { it.toDomain() } diff --git a/domain/src/main/java/com/yapp/domain/model/FortuneCreateStatus.kt b/domain/src/main/java/com/yapp/domain/model/FortuneCreateStatus.kt deleted file mode 100644 index 27ae9ad0..00000000 --- a/domain/src/main/java/com/yapp/domain/model/FortuneCreateStatus.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.yapp.domain.model - -sealed class FortuneCreateStatus { - data object Idle : FortuneCreateStatus() - data object Creating : FortuneCreateStatus() - data class Success(val fortuneId: Long) : FortuneCreateStatus() - data object Failure : FortuneCreateStatus() -} diff --git a/domain/src/main/java/com/yapp/domain/model/FortuneCreationState.kt b/domain/src/main/java/com/yapp/domain/model/FortuneCreationState.kt new file mode 100644 index 00000000..bf53a30b --- /dev/null +++ b/domain/src/main/java/com/yapp/domain/model/FortuneCreationState.kt @@ -0,0 +1,7 @@ +package com.yapp.domain.model + +sealed class FortuneCreationState { + data object Start : FortuneCreationState() + data class Success(val fortuneId: Long) : FortuneCreationState() + data object Failure : FortuneCreationState() +} diff --git a/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt b/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt index 7edc5396..937556d8 100644 --- a/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt +++ b/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt @@ -1,7 +1,6 @@ package com.yapp.domain.repository import com.yapp.domain.model.Fortune -import com.yapp.domain.model.FortuneCreateStatus import kotlinx.coroutines.flow.Flow interface FortuneRepository { @@ -13,11 +12,7 @@ interface FortuneRepository { val shouldShowFortuneToolTipFlow: Flow val isFirstAlarmDismissedTodayFlow: Flow - val fortuneCreateStatusFlow: Flow - - suspend fun markFortuneAsCreating(attemptId: String, leaseMillis: Long = 2 * 60_000L) - suspend fun markFortuneAsCreated(attemptId: String, fortuneId: Long) - suspend fun markFortuneAsFailed(attemptId: String) + suspend fun markFortuneAsCreated(fortuneId: Long) suspend fun markFortuneSeen() suspend fun markFortuneTooltipShown() suspend fun saveFortuneImageId(imageResId: Int) @@ -26,6 +21,8 @@ interface FortuneRepository { suspend fun clearFortuneData() + suspend fun hasTodayFortune(): Boolean + suspend fun postFortune(userId: Long): Result suspend fun getFortune(fortuneId: Long): Result } diff --git a/domain/src/main/java/com/yapp/domain/tracker/FortuneCreationTracker.kt b/domain/src/main/java/com/yapp/domain/tracker/FortuneCreationTracker.kt new file mode 100644 index 00000000..72708316 --- /dev/null +++ b/domain/src/main/java/com/yapp/domain/tracker/FortuneCreationTracker.kt @@ -0,0 +1,11 @@ +package com.yapp.domain.tracker + +import com.yapp.domain.model.FortuneCreationState +import kotlinx.coroutines.flow.StateFlow + +interface FortuneCreationTracker { + val state: StateFlow + fun start() + fun succeed(fortuneId: Long) + fun fail() +} diff --git a/feature/fortune/build.gradle.kts b/feature/fortune/build.gradle.kts index 543510e8..b161f68f 100644 --- a/feature/fortune/build.gradle.kts +++ b/feature/fortune/build.gradle.kts @@ -17,9 +17,6 @@ dependencies { implementation(libs.orbit.compose) implementation(libs.orbit.viewmodel) implementation(libs.coil.compose) - implementation(libs.androidx.work.runtime) - testImplementation(libs.androidx.work.testing) - androidTestImplementation(libs.androidx.work.testing) implementation(projects.domain) implementation(projects.core.media) } 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 0bc19414..c267c811 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt @@ -4,8 +4,9 @@ import android.app.Application import android.util.Log import androidx.annotation.DrawableRes import androidx.lifecycle.ViewModel -import com.yapp.domain.model.FortuneCreateStatus +import com.yapp.domain.model.FortuneCreationState import com.yapp.domain.repository.FortuneRepository +import com.yapp.domain.tracker.FortuneCreationTracker import com.yapp.fortune.page.toFortunePages import com.yapp.media.decoder.ImageUtils import com.yapp.media.storage.ImageSaver @@ -25,6 +26,7 @@ class FortuneViewModel @Inject constructor( private val application: Application, private val fortuneRepository: FortuneRepository, private val imageSaver: ImageSaver, + private val fortuneCreationTracker: FortuneCreationTracker, ) : ViewModel(), ContainerHost { override val container: Container = container( @@ -51,20 +53,20 @@ class FortuneViewModel @Inject constructor( } private fun observeFortune() = intent { - fortuneRepository.fortuneCreateStatusFlow.collect { status -> + fortuneCreationTracker.state.collect { status -> when (status) { - is FortuneCreateStatus.Creating -> { + is FortuneCreationState.Start -> { reduce { state.copy(isLoading = true) } } - is FortuneCreateStatus.Success -> { + is FortuneCreationState.Success -> { fetchAndUpdateFortune( fortuneId = status.fortuneId, isFirstAlarmDismissedToday = fortuneRepository.isFirstAlarmDismissedTodayFlow.first(), ) } - is FortuneCreateStatus.Failure -> { + FortuneCreationState.Failure -> { reduce { state.copy( isLoading = false, @@ -72,12 +74,6 @@ class FortuneViewModel @Inject constructor( ) } } - - is FortuneCreateStatus.Idle -> { - if (!state.isCreateFailureDialogVisible) { - postSideEffect(FortuneContract.SideEffect.NavigateToHome) - } - } } } } diff --git a/feature/fortune/src/main/java/com/yapp/fortune/di/SchedulerModule.kt b/feature/fortune/src/main/java/com/yapp/fortune/di/SchedulerModule.kt deleted file mode 100644 index 47fbd0b0..00000000 --- a/feature/fortune/src/main/java/com/yapp/fortune/di/SchedulerModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.yapp.fortune.di - -import com.yapp.alarm.scheduler.PostFortuneTaskScheduler -import com.yapp.fortune.scheduler.WorkManagerPostFortuneTaskScheduler -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -abstract class SchedulerModule { - @Binds - @Singleton - abstract fun bindsPostFortuneTaskScheduler( - postFortuneTaskScheduler: WorkManagerPostFortuneTaskScheduler, - ): PostFortuneTaskScheduler -} 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 deleted file mode 100644 index 2aadbe87..00000000 --- a/feature/fortune/src/main/java/com/yapp/fortune/scheduler/WorkManagerPostFortuneTaskScheduler.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.yapp.fortune.scheduler - -import android.content.Context -import androidx.work.BackoffPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import com.yapp.alarm.scheduler.PostFortuneTaskScheduler -import com.yapp.fortune.worker.PostFortuneWorker -import dagger.hilt.android.qualifiers.ApplicationContext -import java.time.LocalDate -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -class WorkManagerPostFortuneTaskScheduler @Inject constructor( - @ApplicationContext private val context: Context, -) : PostFortuneTaskScheduler { - override fun enqueueOnceForToday() { - val name = "post_fortune_${LocalDate.now()}" - val req = OneTimeWorkRequestBuilder() - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS) - .build() - WorkManager.getInstance(context) - .enqueueUniqueWork(name, ExistingWorkPolicy.KEEP, req) - } -} 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 deleted file mode 100644 index 45f31753..00000000 --- a/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.yapp.fortune.worker - -import android.content.Context -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import com.yapp.domain.model.FortuneCreateStatus -import com.yapp.domain.repository.FortuneRepository -import com.yapp.domain.repository.UserInfoRepository -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import java.util.UUID - -@HiltWorker -class PostFortuneWorker @AssistedInject constructor( - @Assisted appContext: Context, - @Assisted params: WorkerParameters, - private val fortuneRepository: FortuneRepository, - private val userInfoRepository: UserInfoRepository, -) : CoroutineWorker(appContext, params) { - - override suspend fun doWork(): Result { - // 이미 진행 중이거나(다른 워커) 오늘 운세가 성공 상태면 중복 실행 방지 - when (fortuneRepository.fortuneCreateStatusFlow.first()) { - is FortuneCreateStatus.Creating, - is FortuneCreateStatus.Success, - -> return Result.success() - is FortuneCreateStatus.Failure -> return Result.failure() - FortuneCreateStatus.Idle -> { /* 계속 진행 */ } - } - - val userId = userInfoRepository.userIdFlow.firstOrNull() - ?: run { - return Result.failure() - } - - val attemptId = UUID.randomUUID().toString() - - return try { - fortuneRepository.markFortuneAsCreating(attemptId) - - val result = fortuneRepository.postFortune(userId) - - result.fold( - onSuccess = { fortune -> - fortuneRepository.markFortuneAsCreated(attemptId, fortune.id) - fortuneRepository.saveFortuneScore(fortune.avgFortuneScore) - Result.success() - }, - onFailure = { - fortuneRepository.markFortuneAsFailed(attemptId) - Result.retry() - }, - ) - } catch (ce: CancellationException) { - fortuneRepository.markFortuneAsFailed(attemptId) - throw ce - } catch (_: Throwable) { - fortuneRepository.markFortuneAsFailed(attemptId) - Result.retry() - } - } -} 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 f88d9321..d4a4c364 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.ViewModel import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissIntent import com.yapp.analytics.AnalyticsEvent import com.yapp.analytics.AnalyticsHelper -import com.yapp.domain.model.FortuneCreateStatus import com.yapp.domain.model.MissionMode import com.yapp.domain.model.MissionType import com.yapp.domain.repository.FortuneRepository @@ -139,16 +138,10 @@ class MissionViewModel @Inject constructor( return@intent } - val fortuneCreateStatus = fortuneRepository.fortuneCreateStatusFlow.first() + val hasTodayFortune = fortuneRepository.hasTodayFortune() val hasUnseenFortune = fortuneRepository.hasUnseenFortuneFlow.first() - val shouldOpenFortune = when (fortuneCreateStatus) { - is FortuneCreateStatus.Creating, - is FortuneCreateStatus.Failure, - -> true - is FortuneCreateStatus.Success -> hasUnseenFortune - FortuneCreateStatus.Idle -> false - } + val shouldOpenFortune = !hasTodayFortune || hasUnseenFortune postSideEffect( if (shouldOpenFortune) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea5df3af..2afbecdc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,23 +29,21 @@ androidx-app-compat = "1.7.0" androidx-core = "1.15.0" androidx-datastore = "1.1.1" androidx-room = "2.7.2" -androidx-work = "2.10.3" androidx-lifecycle = "2.9.4" annotation = "1.9.1" ## Compose -compose-bom = "2024.11.00" -compose-navigation = "2.8.4" -compose-material3 = "1.3.1" -compose-ui = "1.7.6" +compose-bom = "2025.02.00" +compose-navigation = "2.8.5" +compose-material3 = "1.4.0" +compose-ui = "1.8.3" activity-compose = "1.9.3" ## Hilt hilt = "2.57.2" hilt-navigation-compose = "1.3.0" -hilt-work = "1.3.0" ## Third Party okhttp = "4.12.0" @@ -109,9 +107,6 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "androidx-room" } androidx-room-paging = { group = "androidx.room", name = "room-paging", version.ref = "androidx-room" } androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" } -androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hilt-work" } -androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidx-work" } -androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidx-work" } ## Compose Libraries activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } @@ -139,7 +134,6 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" } -hilt-worker = { group = "androidx.hilt", name = "hilt-work", version.ref = "hilt-work" } # Orbit orbit-core = { group = "org.orbit-mvi", name = "orbit-core", version.ref = "orbit" }