diff --git a/.editorconfig b/.editorconfig index aa422545..b1a46d4a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,3 +8,6 @@ trim_trailing_whitespace = true [*.{kt,kts}] ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_annotation = disabled + +[*Test.kt] +ktlint_standard_function-naming = disabled diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 989d47a6..0dfa0a8c 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -33,4 +33,8 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + + testImplementation(libs.junit) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index 8d908e24..c0ab8838 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.core.content.pm.PackageInfoCompat import androidx.lifecycle.viewModelScope import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo import com.sseotdabwa.buyornot.domain.model.UpdateStrategy import com.sseotdabwa.buyornot.domain.model.UserType import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository @@ -20,9 +21,36 @@ import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException private const val SPLASH_TIMEOUT_MILLIS = 2300L -private const val SOFT_UPDATE_INTERVAL_MILLIS = 24 * 60 * 60 * 1000L +internal const val SOFT_UPDATE_INTERVAL_MILLIS = 24 * 60 * 60 * 1000L private const val TAG = "SplashUpdate" +internal fun resolveUpdateDialogType( + currentVersion: Int, + updateInfo: AppUpdateInfo?, + lastSoftUpdateShownTime: Long, + now: Long, +): UpdateDialogType { + if (updateInfo == null) return UpdateDialogType.None + + return when { + currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force + // currentVersion >= latestVersion이면 이미 최신 버전이므로 FORCE 팝업 표시 안 함 + updateInfo.updateStrategy == UpdateStrategy.FORCE && + currentVersion < updateInfo.latestVersion -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.SOFT && + currentVersion < updateInfo.latestVersion -> { + // lastSoftUpdateShownTime이 미래 값이면 시계 역행으로 판단, 표시된 적 없는 것으로 처리 + val effectiveLastShown = if (lastSoftUpdateShownTime > now) 0L else lastSoftUpdateShownTime + if (now - effectiveLastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { + UpdateDialogType.Soft + } else { + UpdateDialogType.None + } + } + else -> UpdateDialogType.None + } +} + /** * 스플래시 화면을 위한 ViewModel * @@ -93,30 +121,21 @@ class SplashViewModel @Inject constructor( private suspend fun determineDialogType( currentVersion: Int, - updateInfo: com.sseotdabwa.buyornot.domain.model.AppUpdateInfo?, + updateInfo: AppUpdateInfo?, ): UpdateDialogType { - if (updateInfo == null) return UpdateDialogType.None - - return when { - currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force - updateInfo.updateStrategy == UpdateStrategy.FORCE -> UpdateDialogType.Force - updateInfo.updateStrategy == UpdateStrategy.SOFT && - currentVersion < updateInfo.latestVersion -> { - val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() - if (System.currentTimeMillis() - lastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { - UpdateDialogType.Soft - } else { - UpdateDialogType.None - } - } - else -> UpdateDialogType.None - } + val now = System.currentTimeMillis() + val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() + return resolveUpdateDialogType(currentVersion, updateInfo, lastShown, now) } private fun dismissSoftUpdate() { viewModelScope.launch { try { appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to save soft update shown time", e) } finally { updateState { it.copy(updateDialogType = UpdateDialogType.None) } } diff --git a/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt b/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt new file mode 100644 index 00000000..32206277 --- /dev/null +++ b/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt @@ -0,0 +1,220 @@ +package com.sseotdabwa.buyornot.feature.auth.ui + +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo +import com.sseotdabwa.buyornot.domain.model.UpdateStrategy +import org.junit.Test +import kotlin.test.assertEquals + +class SplashUpdateLogicTest { + private val now = 1_000_000_000L + + @Test + fun updateInfo가_null이면_None을_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 10, + updateInfo = null, + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun currentVersion이_minimumVersion_미만이면_Force를_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 5, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 6, + updateStrategy = UpdateStrategy.NONE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.Force, result) + } + + @Test + fun FORCE_전략이고_currentVersion이_latestVersion_미만이면_Force를_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.FORCE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.Force, result) + } + + @Test + fun FORCE_전략이지만_currentVersion이_latestVersion_이상이면_None을_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 10, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.FORCE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun FORCE_전략이고_minimumVersion_이상_latestVersion_미만이면_Force를_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 7, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.FORCE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.Force, result) + } + + @Test + fun SOFT_전략이고_24시간_이상_지났으면_Soft를_반환한다() { + val lastShown = now - SOFT_UPDATE_INTERVAL_MILLIS + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = lastShown, + now = now, + ) + assertEquals(UpdateDialogType.Soft, result) + } + + @Test + fun SOFT_전략이지만_24시간_미만이면_None을_반환한다() { + val lastShown = now - SOFT_UPDATE_INTERVAL_MILLIS + 1 + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = lastShown, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun SOFT_전략이지만_currentVersion이_latestVersion_이상이면_None을_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 10, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun 시계가_역행했을때_lastShown이_무효화되어_Soft를_반환한다() { + // lastSoftUpdateShownTime이 now보다 미래인 경우 (시계 역행) → effectiveLastShown = 0 → 항상 Soft + val lastShownInFuture = now + 1_000L + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = lastShownInFuture, + now = now, + ) + assertEquals(UpdateDialogType.Soft, result) + } + + @Test + fun NONE_전략이면_None을_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.NONE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun minimumVersion_미달이면_전략_무관하게_Force를_반환한다() { + for (strategy in UpdateStrategy.entries) { + val result = + resolveUpdateDialogType( + currentVersion = 3, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = strategy, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals( + UpdateDialogType.Force, + result, + "strategy=$strategy 일 때 minimumVersion 미달은 Force여야 함", + ) + } + } + + @Test + fun 최초_설치시_소프트_업데이트를_표시한다() { + // DataStore 기본값(0L)일 때 now - 0 >= SOFT_UPDATE_INTERVAL_MILLIS 이므로 Soft 반환 + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = 0L, + now = SOFT_UPDATE_INTERVAL_MILLIS + 1L, + ) + assertEquals(UpdateDialogType.Soft, result) + } +}