diff --git a/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt b/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt index c7021069..4fff2a3f 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt @@ -36,6 +36,7 @@ class MainActivity : ComponentActivity() { BuyOrNotApp( authEventBus = authEventBus, onBackPressed = { finish() }, + onFinish = { finishAffinity() }, ) } } diff --git a/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt b/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt index b1c78c96..3180f675 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt @@ -42,6 +42,7 @@ import com.sseotdabwa.buyornot.feature.upload.navigation.uploadScreen fun BuyOrNotNavHost( navController: NavHostController, authEventBus: AuthEventBus, + onFinish: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarState = LocalSnackbarState.current @@ -74,6 +75,7 @@ fun BuyOrNotNavHost( }, ) }, + onFinish = onFinish, ) authScreen( diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt index 778acf35..be76d0ac 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt @@ -32,19 +32,19 @@ import com.sseotdabwa.buyornot.navigation.BuyOrNotNavHost * 네비게이션과 하단 네비게이션 바를 포함한 앱의 전체 구조를 정의합니다. * 스플래시 및 로그인 화면에서는 하단 바가 표시되지 않습니다. * - * * 전체 화면이 필요하면 → bottomBarPadding() 함수의 리스트에 라우트 추가 - * * 일반 화면이면 → 아무 것도 하지 않아도 자동으로 패딩 적용 * * @param authEventBus 인증 관련 이벤트 버스 * @param onBackPressed 홈 화면에서 뒤로가기 시 앱 종료를 위한 콜백 + * @param onFinish 앱 종료 콜백 (강제 업데이트 시 "종료" 버튼) * @param viewModel 앱 공통 ViewModel */ @Composable fun BuyOrNotApp( authEventBus: AuthEventBus, onBackPressed: () -> Unit = {}, + onFinish: () -> Unit = {}, viewModel: BuyOrNotViewModel = hiltViewModel(), ) { val navController = rememberNavController() @@ -79,6 +79,7 @@ fun BuyOrNotApp( BuyOrNotNavHost( navController = navController, authEventBus = authEventBus, + onFinish = onFinish, modifier = Modifier .consumeWindowInsets(innerPadding) diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 68b527a0..7b3919fc 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -17,6 +17,10 @@ dependencies { implementation(libs.okhttp) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.config) implementation(libs.hilt.android) ksp(libs.hilt.compiler) diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSource.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSource.kt new file mode 100644 index 00000000..7e2b7bcd --- /dev/null +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSource.kt @@ -0,0 +1,7 @@ +package com.sseotdabwa.buyornot.core.data.datasource + +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo + +interface RemoteConfigDataSource { + suspend fun fetchAppUpdateConfig(): AppUpdateInfo +} diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt new file mode 100644 index 00000000..7205fac0 --- /dev/null +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt @@ -0,0 +1,69 @@ +package com.sseotdabwa.buyornot.core.data.datasource + +import android.util.Log +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo +import com.sseotdabwa.buyornot.domain.model.UpdateStrategy +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RemoteConfigDataSourceImpl @Inject constructor() : RemoteConfigDataSource { + private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() + + override suspend fun fetchAppUpdateConfig(): AppUpdateInfo { + val settings = + FirebaseRemoteConfigSettings + .Builder() + .setMinimumFetchIntervalInSeconds(FETCH_INTERVAL_SECONDS) + .build() + + remoteConfig.setConfigSettingsAsync(settings).await() + remoteConfig + .setDefaultsAsync( + mapOf( + KEY_LATEST_VERSION to DEFAULT_VERSION, + KEY_MINIMUM_VERSION to DEFAULT_VERSION, + KEY_UPDATE_STRATEGY to UpdateStrategy.NONE.name, + ), + ).await() + + val activated = + runCatching { remoteConfig.fetchAndActivate().await() } + .onFailure { Log.w(TAG, "fetchAndActivate failed, using cached/default values", it) } + .getOrDefault(false) + Log.d(TAG, "fetchAndActivate: activated=$activated") + + val latestVersion = remoteConfig.getLong(KEY_LATEST_VERSION).toInt() + val minimumVersion = remoteConfig.getLong(KEY_MINIMUM_VERSION).toInt() + val strategyRaw = remoteConfig.getString(KEY_UPDATE_STRATEGY) + val updateStrategy = + runCatching { UpdateStrategy.valueOf(strategyRaw) }.getOrDefault(UpdateStrategy.NONE) + + Log.d( + TAG, + "Remote Config values — " + + "latestVersion=$latestVersion, " + + "minimumVersion=$minimumVersion, " + + "strategyRaw=$strategyRaw, " + + "resolvedStrategy=$updateStrategy", + ) + + return AppUpdateInfo( + latestVersion = latestVersion, + minimumVersion = minimumVersion, + updateStrategy = updateStrategy, + ) + } + + companion object { + private const val TAG = "RemoteConfig" + private const val KEY_LATEST_VERSION = "android_latest_version" + private const val KEY_MINIMUM_VERSION = "android_minimum_version" + private const val KEY_UPDATE_STRATEGY = "android_update_strategy" + private const val DEFAULT_VERSION = 1L + private const val FETCH_INTERVAL_SECONDS = 3600L + } +} diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt index f536bf09..c2a66c53 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt @@ -1,12 +1,14 @@ package com.sseotdabwa.buyornot.core.data.di import com.sseotdabwa.buyornot.core.data.repository.AppPreferencesRepositoryImpl +import com.sseotdabwa.buyornot.core.data.repository.AppUpdateRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.AuthRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.FeedRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.NotificationRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.UserPreferencesRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.UserRepositoryImpl import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository +import com.sseotdabwa.buyornot.domain.repository.AppUpdateRepository import com.sseotdabwa.buyornot.domain.repository.AuthRepository import com.sseotdabwa.buyornot.domain.repository.FeedRepository import com.sseotdabwa.buyornot.domain.repository.NotificationRepository @@ -37,4 +39,7 @@ internal abstract class DataModule { @Binds abstract fun bindAppPreferencesRepository(impl: AppPreferencesRepositoryImpl): AppPreferencesRepository + + @Binds + abstract fun bindAppUpdateRepository(impl: AppUpdateRepositoryImpl): AppUpdateRepository } diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/RemoteConfigModule.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/RemoteConfigModule.kt new file mode 100644 index 00000000..d9e380e1 --- /dev/null +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/RemoteConfigModule.kt @@ -0,0 +1,15 @@ +package com.sseotdabwa.buyornot.core.data.di + +import com.sseotdabwa.buyornot.core.data.datasource.RemoteConfigDataSource +import com.sseotdabwa.buyornot.core.data.datasource.RemoteConfigDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class RemoteConfigModule { + @Binds + abstract fun bindRemoteConfigDataSource(impl: RemoteConfigDataSourceImpl): RemoteConfigDataSource +} diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt index 3f2de7bb..e6fcdded 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt @@ -18,6 +18,9 @@ class AppPreferencesRepositoryImpl @Inject constructor( override val isFirstRun: Flow = appPreferencesDataSource.isFirstRun + override val lastSoftUpdateShownTime: Flow = + appPreferencesDataSource.lastSoftUpdateShownTime + override suspend fun updateNotificationPermissionRequested(requested: Boolean) { appPreferencesDataSource.updateNotificationPermissionRequested(requested) } @@ -25,4 +28,8 @@ class AppPreferencesRepositoryImpl @Inject constructor( override suspend fun updateIsFirstRun(isFirstRun: Boolean) { appPreferencesDataSource.updateIsFirstRun(isFirstRun) } + + override suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) { + appPreferencesDataSource.updateLastSoftUpdateShownTime(timeMillis) + } } diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt new file mode 100644 index 00000000..b3723344 --- /dev/null +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt @@ -0,0 +1,12 @@ +package com.sseotdabwa.buyornot.core.data.repository + +import com.sseotdabwa.buyornot.core.data.datasource.RemoteConfigDataSource +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo +import com.sseotdabwa.buyornot.domain.repository.AppUpdateRepository +import javax.inject.Inject + +class AppUpdateRepositoryImpl @Inject constructor( + private val remoteConfigDataSource: RemoteConfigDataSource, +) : AppUpdateRepository { + override suspend fun getAppUpdateInfo(): AppUpdateInfo = remoteConfigDataSource.fetchAppUpdateConfig() +} diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt index 71d72c13..f4b66df2 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt @@ -23,6 +23,11 @@ interface AppPreferencesDataSource { */ val isFirstRun: Flow + /** + * 소프트 업데이트 다이얼로그 마지막 노출 시각 (epoch millis)을 Flow로 제공 + */ + val lastSoftUpdateShownTime: Flow + /** * 알림 권한 요청 이력 업데이트 */ @@ -32,4 +37,9 @@ interface AppPreferencesDataSource { * 최초 실행 여부 업데이트 */ suspend fun updateIsFirstRun(isFirstRun: Boolean) + + /** + * 소프트 업데이트 다이얼로그 마지막 노출 시각 업데이트 + */ + suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) } diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt index 0b04c0d4..b9075d90 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt @@ -3,6 +3,7 @@ package com.sseotdabwa.buyornot.core.datastore import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -18,43 +19,53 @@ private val Context.appPreferencesDataStore by preferencesDataStore(name = "app_ * 사용자 정보와 무관한 앱 레벨의 설정(알림 권한 등)을 DataStore로 관리합니다. */ @Singleton -class AppPreferencesDataSourceImpl - @Inject - constructor( - @ApplicationContext private val context: Context, - ) : AppPreferencesDataSource { - private object Keys { - val HAS_REQUESTED_NOTIFICATION_PERMISSION = booleanPreferencesKey("has_requested_notification_permission") - val IS_FIRST_RUN = booleanPreferencesKey("is_first_run") +class AppPreferencesDataSourceImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : AppPreferencesDataSource { + private object Keys { + val HAS_REQUESTED_NOTIFICATION_PERMISSION = booleanPreferencesKey("has_requested_notification_permission") + val IS_FIRST_RUN = booleanPreferencesKey("is_first_run") + val LAST_SOFT_UPDATE_SHOWN_TIME = longPreferencesKey("last_soft_update_shown_time") + } + + override val preferences: Flow = + context.appPreferencesDataStore.data.map { prefs -> + AppPreferences( + hasRequestedNotificationPermission = prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false, + isFirstRun = prefs[Keys.IS_FIRST_RUN] ?: false, + ) + } + + override val hasRequestedNotificationPermission: Flow = + context.appPreferencesDataStore.data.map { prefs -> + prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false + } + + override val isFirstRun: Flow = + context.appPreferencesDataStore.data.map { prefs -> + prefs[Keys.IS_FIRST_RUN] ?: true } - override val preferences: Flow = - context.appPreferencesDataStore.data.map { prefs -> - AppPreferences( - hasRequestedNotificationPermission = prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false, - isFirstRun = prefs[Keys.IS_FIRST_RUN] ?: false, - ) - } - - override val hasRequestedNotificationPermission: Flow = - context.appPreferencesDataStore.data.map { prefs -> - prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false - } - - override val isFirstRun: Flow = - context.appPreferencesDataStore.data.map { prefs -> - prefs[Keys.IS_FIRST_RUN] ?: true - } - - override suspend fun updateNotificationPermissionRequested(requested: Boolean) { - context.appPreferencesDataStore.edit { prefs -> - prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] = requested - } + override val lastSoftUpdateShownTime: Flow = + context.appPreferencesDataStore.data.map { prefs -> + prefs[Keys.LAST_SOFT_UPDATE_SHOWN_TIME] ?: 0L } - override suspend fun updateIsFirstRun(isFirstRun: Boolean) { - context.appPreferencesDataStore.edit { prefs -> - prefs[Keys.IS_FIRST_RUN] = isFirstRun - } + override suspend fun updateNotificationPermissionRequested(requested: Boolean) { + context.appPreferencesDataStore.edit { prefs -> + prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] = requested + } + } + + override suspend fun updateIsFirstRun(isFirstRun: Boolean) { + context.appPreferencesDataStore.edit { prefs -> + prefs[Keys.IS_FIRST_RUN] = isFirstRun + } + } + + override suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) { + context.appPreferencesDataStore.edit { prefs -> + prefs[Keys.LAST_SOFT_UPDATE_SHOWN_TIME] = timeMillis } } +} diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt index 3aa9899f..0b909d40 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt @@ -18,85 +18,83 @@ private val Context.userPreferencesDataStore by preferencesDataStore(name = "use * 사용자 프로필 및 인증 토큰을 DataStore로 관리합니다. */ @Singleton -class UserPreferencesDataSourceImpl - @Inject - constructor( - @ApplicationContext private val context: Context, - ) : UserPreferencesDataSource { - private object Keys { - val DISPLAY_NAME = stringPreferencesKey("display_name") - val PROFILE_IMAGE_URL = stringPreferencesKey("profile_image_url") - val ACCESS_TOKEN = stringPreferencesKey("access_token") - val REFRESH_TOKEN = stringPreferencesKey("refresh_token") - val USER_TYPE = stringPreferencesKey("user_type") - } +class UserPreferencesDataSourceImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : UserPreferencesDataSource { + private object Keys { + val DISPLAY_NAME = stringPreferencesKey("display_name") + val PROFILE_IMAGE_URL = stringPreferencesKey("profile_image_url") + val ACCESS_TOKEN = stringPreferencesKey("access_token") + val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + val USER_TYPE = stringPreferencesKey("user_type") + } - override val preferences: Flow = - context.userPreferencesDataStore.data.map { prefs -> - UserPreferences( - displayName = prefs[Keys.DISPLAY_NAME] ?: UserPreferences().displayName, - profileImageUrl = prefs[Keys.PROFILE_IMAGE_URL] ?: UserPreferences().profileImageUrl, - accessToken = prefs[Keys.ACCESS_TOKEN] ?: UserPreferences().accessToken, - refreshToken = prefs[Keys.REFRESH_TOKEN] ?: UserPreferences().refreshToken, - userType = - prefs[Keys.USER_TYPE]?.let { - try { - UserType.valueOf(it) - } catch (e: IllegalArgumentException) { - UserPreferences().userType - } - } ?: UserPreferences().userType, - ) - } + override val preferences: Flow = + context.userPreferencesDataStore.data.map { prefs -> + UserPreferences( + displayName = prefs[Keys.DISPLAY_NAME] ?: UserPreferences().displayName, + profileImageUrl = prefs[Keys.PROFILE_IMAGE_URL] ?: UserPreferences().profileImageUrl, + accessToken = prefs[Keys.ACCESS_TOKEN] ?: UserPreferences().accessToken, + refreshToken = prefs[Keys.REFRESH_TOKEN] ?: UserPreferences().refreshToken, + userType = + prefs[Keys.USER_TYPE]?.let { + try { + UserType.valueOf(it) + } catch (e: IllegalArgumentException) { + UserPreferences().userType + } + } ?: UserPreferences().userType, + ) + } - override val accessToken: Flow = context.userPreferencesDataStore.data.map { it[Keys.ACCESS_TOKEN] ?: "" } + override val accessToken: Flow = context.userPreferencesDataStore.data.map { it[Keys.ACCESS_TOKEN] ?: "" } - override val userType: Flow = - context.userPreferencesDataStore.data.map { prefs -> - prefs[Keys.USER_TYPE]?.let { - try { - UserType.valueOf(it) - } catch (e: IllegalArgumentException) { - UserType.GUEST - } - } ?: UserType.GUEST - } + override val userType: Flow = + context.userPreferencesDataStore.data.map { prefs -> + prefs[Keys.USER_TYPE]?.let { + try { + UserType.valueOf(it) + } catch (e: IllegalArgumentException) { + UserType.GUEST + } + } ?: UserType.GUEST + } - override suspend fun updateDisplayName(newName: String) { - context.userPreferencesDataStore.edit { prefs -> - prefs[Keys.DISPLAY_NAME] = newName - } + override suspend fun updateDisplayName(newName: String) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.DISPLAY_NAME] = newName } + } - override suspend fun updateProfileImageUrl(newUrl: String) { - context.userPreferencesDataStore.edit { prefs -> - prefs[Keys.PROFILE_IMAGE_URL] = newUrl - } + override suspend fun updateProfileImageUrl(newUrl: String) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.PROFILE_IMAGE_URL] = newUrl } + } - override suspend fun updateTokens( - accessToken: String, - refreshToken: String, - ) { - context.userPreferencesDataStore.edit { prefs -> - prefs[Keys.ACCESS_TOKEN] = accessToken - prefs[Keys.REFRESH_TOKEN] = refreshToken - } + override suspend fun updateTokens( + accessToken: String, + refreshToken: String, + ) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.ACCESS_TOKEN] = accessToken + prefs[Keys.REFRESH_TOKEN] = refreshToken } + } - override suspend fun updateUserType(userType: UserType) { - context.userPreferencesDataStore.edit { prefs -> - prefs[Keys.USER_TYPE] = userType.name - } + override suspend fun updateUserType(userType: UserType) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.USER_TYPE] = userType.name } + } - override suspend fun clearUserInfo() { - context.userPreferencesDataStore.edit { prefs -> - prefs.remove(Keys.ACCESS_TOKEN) - prefs.remove(Keys.REFRESH_TOKEN) - prefs.remove(Keys.PROFILE_IMAGE_URL) - prefs.remove(Keys.DISPLAY_NAME) - prefs[Keys.USER_TYPE] = UserType.GUEST.name - } + override suspend fun clearUserInfo() { + context.userPreferencesDataStore.edit { prefs -> + prefs.remove(Keys.ACCESS_TOKEN) + prefs.remove(Keys.REFRESH_TOKEN) + prefs.remove(Keys.PROFILE_IMAGE_URL) + prefs.remove(Keys.DISPLAY_NAME) + prefs[Keys.USER_TYPE] = UserType.GUEST.name } } +} diff --git a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/AuthEventBus.kt b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/AuthEventBus.kt index 6b8477be..ebd8efe8 100644 --- a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/AuthEventBus.kt +++ b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/AuthEventBus.kt @@ -10,13 +10,11 @@ enum class AuthEvent { } @Singleton -class AuthEventBus - @Inject - constructor() { - private val _events = MutableSharedFlow() - val events = _events.asSharedFlow() +class AuthEventBus @Inject constructor() { + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() - suspend fun emit(event: AuthEvent) { - _events.emit(event) - } + suspend fun emit(event: AuthEvent) { + _events.emit(event) } +} diff --git a/docs/CommitConvention.md b/docs/CommitConvention.md deleted file mode 100644 index 91cf2eba..00000000 --- a/docs/CommitConvention.md +++ /dev/null @@ -1,39 +0,0 @@ -# Commit Convention - -## Commit Message Structure - -``` -/#: -``` - -### Type - -| Type | Description | -| ---------- | ------------------------------------------------------------ | -| `feat` | 새로운 기능 추가 | -| `fix` | 버그 수정 | -| `docs` | 문서 수정 | -| `style` | 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 | -| `refactor` | 코드 리팩토링 | -| `test` | 테스트 코드, 리팩토링 테스트 코드 추가 | -| `chore` | 빌드 업무 수정, 패키지 매니저 수정 | -| `add` | 에셋 추가 등 | -| `ci` | CI 관련 설정 | -| `build` | 빌드 관련 파일 수정 | -| `perf` | 성능 개선 | - - -### Subject - -- 제목은 50자를 넘기지 않도록 합니다. -- 첫 글자는 대문자로 작성합니다. -- 마침표 및 특수기호는 사용하지 않습니다. -- 과거시제가 아닌 현재시제로 명령문으로 작성합니다. - - - -### Example - -``` -feat/#10: 네트워크 에러 공통 Error View 컴포넌트 구현 -``` diff --git a/docs/Modularization.md b/docs/Modularization.md deleted file mode 100644 index 6bf158e0..00000000 --- a/docs/Modularization.md +++ /dev/null @@ -1,71 +0,0 @@ -프로젝트는 다음과 같은 모듈 구조를 따른다. - -```text -app -domain -core:data -core:network -core:datastore -core:ui -core:designsystem -feature:auth -feature:home -feature:upload -feature:mypage -build-logic -``` - -## 각 모듈의 역할 정의 - -### app 모듈 - -- 애플리케이션 진입점 -- Hilt 엔트리포인트 정의 -- 전역 Navigation 그래프 관리 (Navigation3) -- feature 모듈 바인딩 - -app 모듈은 **비즈니스 로직을 포함하지 않는다.** - -### feature 모듈 - -각 feature 모듈은 **화면 및 사용자 플로우 단위**로 분리된다. -모든 feature 모듈은 다음 요소를 포함한다. - -- Compose UI -- ViewModel -- Navigation Route 정의 - -### domain 모듈 - -- 순수 Kotlin 모듈 -- 비즈니스 모델 및 Repository 인터페이스 정의 -- Android 프레임워크 의존성 없음 - -#### 포함 요소 -- Domain Model -- Repository 인터페이스 -- 비즈니스 규칙 - -### core:data 모듈 - -- 외부 데이터 소스 접근 책임 -- Retrofit2 + OkHttp 기반 네트워크 통신 -- Repository 구현체 제공 - -domain 계층과 feature 계층에는 영향을 주지 않는다. - -### core:ui 모듈 - -- 공용 UI 컴포넌트 정의 -- 버튼, 리스트 아이템, 로딩 UI 등 재사용 가능한 Composable 포함 - -### core:designsystem 모듈 - -- 색상, 타이포그래피, spacing 등 디자인 토큰 정의 -- feature 모듈에서는 디자인 값을 직접 정의하지 않는다. - -## 의존성 관리 - -- 모든 모듈은 **build-logic 기반 커스텀 컨벤션 플러그인**을 사용하여 설정 -- 모듈별 의존성 중복 제거 -- Gradle 설정의 일관성 유지 diff --git a/docs/SnackbarStateManagement.md b/docs/SnackbarStateManagement.md deleted file mode 100644 index a29cc7f6..00000000 --- a/docs/SnackbarStateManagement.md +++ /dev/null @@ -1,51 +0,0 @@ -### 중앙 스낵바 관리 전략 (상태 호이스팅 방식) - -**1. 목표 (Goal)** -- `BuyOrNotApp.kt`의 최상단 `Scaffold`에서 스낵바를 중앙 관리한다. -- `core:common`과 `core:designsystem` 간의 부적절한 의존성 문제를 회피한다. -- Compose의 상태 호이스팅(State Hoisting)과 `CompositionLocal` 패턴을 활용한다. - -**2. 핵심 아이디어 (Core Idea)** -- 스낵바 표시에 필요한 상태(`SnackbarHostState`)와 로직(`showBuyOrNotSnackBar`)을 포함하는 `BuyOrNotSnackbarState` 클래스를 UI 계층(`app` 모듈)에 생성한다. -- 이 상태 객체를 `BuyOrNotApp`에서 `remember`로 생성하고, `CompositionLocal`을 통해 앱의 전체 UI 트리에 제공한다. -- 각 화면의 `ViewModel`은 이전처럼 `ShowSnackbar` `SideEffect`를 발생시킨다. -- 각 화면의 `...Route` 컴포저블은 `CompositionLocal`로부터 `BuyOrNotSnackbarState`를 얻고, `SideEffect`를 구독하여 `snackbarState.show(...)`를 호출한다. - -**3. 구현 단계 (Implementation Steps)** - -- **`BuyOrNotSnackbarState` 클래스 및 `CompositionLocal` 정의:** - - **경로**: `core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/BuyOrNotSnackbarState.kt` - - **`BuyOrNotSnackbarState` 내용**: - - `SnackbarHostState`와 `CoroutineScope`를 내부 프로퍼티로 갖는다. - - `showBuyOrNotSnackBar`를 호출하는 `fun show(message: String, icon: IconResource? = null, iconTint: SnackBarIconTint = SnackBarIconTint.Success)` 메서드를 노출한다. - - **`remember...` 함수 내용**: `rememberBuyOrNotSnackbarState` 라는 `@Composable` 팩토리 함수를 만들어 `remember` 로직을 캡슐화한다. - - **`CompositionLocal` 내용**: `val LocalSnackbarState = compositionLocalOf { error("SnackbarState not provided") }` 와 같이 `CompositionLocal`을 함께 정의한다. - -- **`BuyOrNotApp.kt` 수정:** - - `rememberBuyOrNotSnackbarState()`를 호출하여 `snackbarState` 인스턴스를 생성한다. - - `CompositionLocalProvider(LocalSnackbarState provides snackbarState)`를 사용하여 `BuyOrNotNavHost`를 감싼다. - - 최상단 `Scaffold`의 `snackbarHost` 파라미터에 `BuyOrNotSnackBarHost(snackbarState.snackbarHostState)`를 설정한다. - -- **`SideEffect` 수정:** - - 스낵바에 아이콘을 표시할 수 있도록 `ShowSnackbar` `SideEffect` 데이터 클래스에 `icon: IconResource? = null` 및 `iconTint: SnackBarIconTint = SnackBarIconTint.Success` 파라미터를 추가한다. (대상: `LoginSideEffect.kt`, `MyPageSideEffect.kt` 등) - -- **개별 화면 UI (`...Route`) 수정:** - - **대상 파일**: `LoginScreen.kt`, `MyPageScreen.kt`, `AccountSettingScreen.kt`, `WithdrawalScreen.kt` - - 각 파일의 `...Route` 컴포저블에서 로컬 `Scaffold`와 `SnackbarHostState`를 **삭제**한다. - - `LocalSnackbarState.current`를 통해 중앙 `snackbarState`에 접근한다. - - `LaunchedEffect`에서 `ViewModel`의 `ShowSnackbar` `SideEffect`를 `collect`하고, `snackbarState.show(sideEffect.message, sideEffect.icon, sideEffect.iconTint)`를 호출한다. - -- **ViewModel 수정:** - - `SnackbarManager`를 사용하는 대신, 기존처럼 `ShowSnackbar` `SideEffect`를 발생시키도록 유지하거나 복원한다. (이 전략에서는 ViewModel을 수정할 필요가 없음) - - -**4. 장단점 (Pros and Cons)** - -- **장점**: - - **모듈 의존성 해결**: `core:common`이 `core:designsystem`에 의존할 필요가 없어진다. 모든 UI 관련 로직이 UI 계층에 머무른다. - - **Compose 친화적**: `CompositionLocal`을 사용하는 것은 Compose UI 트리 내에서 상태를 공유하는 자연스러운 방법이다. - -- **단점**: - - **테스트 복잡성**: `...Route` 컴포저블을 UI 테스트하려면 `CompositionLocalProvider`를 통해 `BuyOrNotSnackbarState`의 Mock 객체를 제공해야 하는 등 테스트 설정이 더 복잡해진다. - - **보일러플레이트**: 각 `...Route` 컴포저블마다 `LocalSnackbarState.current`를 호출하고 `LaunchedEffect`를 설정하는 코드가 반복된다. (`SnackbarManager` 방식에서는 이 로직이 `BuyOrNotApp`에 단 한 번만 존재했다.) - - **낮은 유연성**: 스낵바가 아닌 다른 방식(예: 푸시 알림)으로 메시지를 보내고 싶을 경우, UI 상태에 의존적인 현재 구조는 확장이 더 어렵다. diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/AppUpdateInfo.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/AppUpdateInfo.kt new file mode 100644 index 00000000..51a5d112 --- /dev/null +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/AppUpdateInfo.kt @@ -0,0 +1,13 @@ +package com.sseotdabwa.buyornot.domain.model + +data class AppUpdateInfo( + val latestVersion: Int, + val minimumVersion: Int, + val updateStrategy: UpdateStrategy, +) + +enum class UpdateStrategy { + NONE, + SOFT, + FORCE, +} diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt index 2b0195b5..6b800ac8 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt @@ -18,6 +18,11 @@ interface AppPreferencesRepository { */ val isFirstRun: Flow + /** + * 소프트 업데이트 다이얼로그 마지막 노출 시각 (epoch millis)을 Flow로 제공 + */ + val lastSoftUpdateShownTime: Flow + /** * 알림 권한 요청 이력 업데이트 */ @@ -27,4 +32,9 @@ interface AppPreferencesRepository { * 최초 실행 여부 업데이트 */ suspend fun updateIsFirstRun(isFirstRun: Boolean) + + /** + * 소프트 업데이트 다이얼로그 마지막 노출 시각 업데이트 + */ + suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) } diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppUpdateRepository.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppUpdateRepository.kt new file mode 100644 index 00000000..a2386f1b --- /dev/null +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppUpdateRepository.kt @@ -0,0 +1,7 @@ +package com.sseotdabwa.buyornot.domain.repository + +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo + +interface AppUpdateRepository { + suspend fun getAppUpdateInfo(): AppUpdateInfo +} diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt index 298fccdf..455378a8 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt @@ -30,11 +30,13 @@ const val AUTH_ROUTE = "auth" fun NavGraphBuilder.splashScreen( onNavigateToLogin: () -> Unit, onNavigateToHome: () -> Unit, + onFinish: () -> Unit, ) { composable(route = SPLASH_ROUTE) { SplashRoute( onNavigateToLogin = onNavigateToLogin, onNavigateToHome = onNavigateToHome, + onFinish = onFinish, ) } } diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt index 8a66c366..a13a0a5c 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt @@ -2,33 +2,38 @@ package com.sseotdabwa.buyornot.feature.auth.ui import androidx.compose.runtime.Immutable +sealed interface UpdateDialogType { + data object None : UpdateDialogType + + data object Soft : UpdateDialogType + + data object Force : UpdateDialogType +} + /** * 스플래시 화면의 UI 상태 (MVI State) * * @property isLoading 초기 로딩 중 여부 + * @property updateDialogType 표시할 업데이트 다이얼로그 타입 */ @Immutable data class SplashUiState( val isLoading: Boolean = true, + val updateDialogType: UpdateDialogType = UpdateDialogType.None, ) /** * 스플래시 화면에서 발생하는 사용자 액션 (MVI Intent) - * 스플래시 화면은 자동으로 진행되므로 사용자 Intent는 없음 */ -sealed interface SplashIntent +sealed interface SplashIntent { + data object DismissSoftUpdate : SplashIntent +} /** * 스플래시 화면의 일회성 이벤트 (MVI SideEffect) */ sealed interface SplashSideEffect { - /** - * 로그인 화면으로 이동 - */ data object NavigateToLogin : SplashSideEffect - /** - * 홈 화면으로 이동 - */ data object NavigateToHome : SplashSideEffect } diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt index 3d075c9e..09bf7600 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt @@ -1,5 +1,9 @@ package com.sseotdabwa.buyornot.feature.auth.ui +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,12 +17,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.rememberLottieComposition +import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotAlertDialog import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotLotties import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector @@ -28,21 +35,24 @@ import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme * 스플래시 화면의 네비게이션 진입점 * * 앱 최초 진입 시 표시되는 스플래시 화면입니다. - * 지정된 시간(2.3초) 후 자동으로 로그인 상태를 확인하여: - * - 로그인 상태(토큰 있음) → 홈 화면으로 이동 - * - 비로그인 상태 → 로그인 화면으로 이동 + * 토큰 체크 + 업데이트 체크를 병렬로 실행하며, + * 업데이트 팝업이 표시 중이면 다른 화면으로 이동하지 않습니다. * * @param onNavigateToLogin 로그인 화면으로 이동하는 콜백 * @param onNavigateToHome 홈 화면으로 이동하는 콜백 + * @param onFinish 앱 종료 콜백 (강제 업데이트 시 "종료" 버튼) * @param viewModel SplashViewModel (Hilt 주입) */ @Composable fun SplashRoute( onNavigateToLogin: () -> Unit, onNavigateToHome: () -> Unit, + onFinish: () -> Unit, viewModel: SplashViewModel = hiltViewModel(), ) { - // SideEffect 처리 (ViewModel에서 토큰 체크 후 네비게이션 이벤트 방출) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + LaunchedEffect(Unit) { viewModel.sideEffect.collect { sideEffect -> when (sideEffect) { @@ -52,16 +62,24 @@ fun SplashRoute( } } - SplashScreen() + SplashScreen( + updateDialogType = uiState.updateDialogType, + onDismissSoftUpdate = { viewModel.handleIntent(SplashIntent.DismissSoftUpdate) }, + onUpdateClick = { openPlayStore(context) }, + onFinish = onFinish, + ) } /** * 스플래시 화면 UI - * - * 앱 로고를 중앙에 표시합니다. */ @Composable -private fun SplashScreen() { +private fun SplashScreen( + updateDialogType: UpdateDialogType = UpdateDialogType.None, + onDismissSoftUpdate: () -> Unit = {}, + onUpdateClick: () -> Unit = {}, + onFinish: () -> Unit = {}, +) { Box( modifier = Modifier @@ -89,11 +107,50 @@ private fun SplashScreen() { } } } + + when (updateDialogType) { + UpdateDialogType.Force -> { + BuyOrNotAlertDialog( + onDismissRequest = { }, + title = "필수 업데이트가 있어요", + subText = "서비스 이용을 위해 업데이트가 필요해요.", + confirmText = "업데이트", + dismissText = "종료", + onConfirm = onUpdateClick, + onDismiss = onFinish, + ) + } + UpdateDialogType.Soft -> { + BuyOrNotAlertDialog( + onDismissRequest = onDismissSoftUpdate, + title = "새 버전이 출시됐어요", + subText = "더 나은 경험을 위해 업데이트를 권장해요.", + confirmText = "업데이트", + dismissText = "나중에", + onConfirm = onUpdateClick, + onDismiss = onDismissSoftUpdate, + ) + } + UpdateDialogType.None -> Unit + } +} + +private fun openPlayStore(context: Context) { + val packageName = context.packageName + try { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")), + ) + } catch (e: ActivityNotFoundException) { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$packageName"), + ), + ) + } } -/** - * 스플래시 화면 프리뷰 - */ @Preview(name = "SplashScreen - Pixel 5", device = "id:pixel_5", showBackground = true) @Composable private fun SplashScreenPreview() { 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 d5aa65de..8d908e24 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 @@ -1,10 +1,18 @@ package com.sseotdabwa.buyornot.feature.auth.ui +import android.content.Context +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.UpdateStrategy import com.sseotdabwa.buyornot.domain.model.UserType +import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository +import com.sseotdabwa.buyornot.domain.repository.AppUpdateRepository import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -12,42 +20,67 @@ 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 +private const val TAG = "SplashUpdate" /** * 스플래시 화면을 위한 ViewModel * - * 토큰 존재 여부를 확인하고, 2.3초 후 자동으로 네비게이션 SideEffect를 방출합니다. + * 토큰 존재 여부와 앱 업데이트 필요 여부를 병렬로 확인하고, + * 업데이트 팝업이 표시 중이면 네비게이션을 차단합니다. */ @HiltViewModel class SplashViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val userPreferencesRepository: UserPreferencesRepository, + private val appUpdateRepository: AppUpdateRepository, + private val appPreferencesRepository: AppPreferencesRepository, ) : BaseViewModel(SplashUiState()) { init { checkTokenAndNavigate() } override fun handleIntent(intent: SplashIntent) { - // 스플래시 화면은 사용자 액션이 없으므로 비어있음 + when (intent) { + SplashIntent.DismissSoftUpdate -> dismissSoftUpdate() + } } - /** - * 토큰 존재 여부를 확인하고 적절한 화면으로 이동 - */ private fun checkTokenAndNavigate() { viewModelScope.launch { + // 토큰 체크 + 업데이트 체크 병렬 실행 + val updateInfoDeferred = + async { + runCatching { appUpdateRepository.getAppUpdateInfo() }.getOrNull() + } + val hasValidToken = try { - val userType = userPreferencesRepository.userType.first() - userType != UserType.GUEST + userPreferencesRepository.userType.first() != UserType.GUEST } catch (e: CancellationException) { throw e } catch (e: Exception) { - false // DataStore 오류 시 비로그인 화면으로 폴백 + false } delay(SPLASH_TIMEOUT_MILLIS) - // 토큰 유효성에 따라 SideEffect 방출 + // 업데이트 다이얼로그 타입 결정 + val updateInfo = updateInfoDeferred.await() + val currentVersion = + PackageInfoCompat + .getLongVersionCode(context.packageManager.getPackageInfo(context.packageName, 0)) + .toInt() + val dialogType = determineDialogType(currentVersion, updateInfo) + + Log.d(TAG, "currentVersion=$currentVersion, dialogType=$dialogType, updateInfo=$updateInfo") + + if (dialogType != UpdateDialogType.None) { + updateState { it.copy(updateDialogType = dialogType) } + // 팝업이 닫힐 때까지 네비게이션 차단 + uiState.first { it.updateDialogType == UpdateDialogType.None } + } + if (hasValidToken) { sendSideEffect(SplashSideEffect.NavigateToHome) } else { @@ -57,4 +90,36 @@ class SplashViewModel @Inject constructor( updateState { it.copy(isLoading = false) } } } + + private suspend fun determineDialogType( + currentVersion: Int, + updateInfo: com.sseotdabwa.buyornot.domain.model.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 + } + } + + private fun dismissSoftUpdate() { + viewModelScope.launch { + try { + appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) + } finally { + updateState { it.copy(updateDialogType = UpdateDialogType.None) } + } + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca320995..dfd88eb5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -96,6 +96,7 @@ googleid = { group = "com.google.android.libraries.identity.googleid", name = "g kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutines" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } # Lifecycle @@ -123,6 +124,7 @@ androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +firebase-config = { group = "com.google.firebase", name = "firebase-config" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" }