diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 127369b4..1bdb732f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,7 +26,7 @@ android { minSdk = 28 targetSdk = 35 versionCode = 18 - versionName = "1.0.6" + versionName = "1.0.7" val kakaoApiKey: String = properties.getProperty("kakao.api.key") val amplitudeApiKey: String = properties.getProperty("amplitude.api.key") val googleAdmobAppId: String = properties.getProperty("GOOGLE_ADMOB_APP_ID", "") @@ -71,7 +71,6 @@ android { } dependencies { - // Test testImplementation(libs.junit) androidTestImplementation(platform(libs.compose.bom)) @@ -100,6 +99,7 @@ dependencies { // FireBase implementation(platform(libs.firebase.bom)) implementation(libs.bundles.firebase) + implementation(libs.firebase.config.ktx) // Amplitude implementation(libs.amplitude) diff --git a/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt b/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt new file mode 100644 index 00000000..ef137767 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt @@ -0,0 +1,33 @@ +package com.sopt.clody.data.remote.appupdate + +import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource +import com.sopt.clody.domain.appupdate.AppUpdateChecker +import com.sopt.clody.domain.model.AppUpdateState +import com.sopt.clody.domain.util.VersionComparator +import javax.inject.Inject + +class AppUpdateCheckerImpl @Inject constructor( + private val remoteConfigDataSource: RemoteConfigDataSource, +) : AppUpdateChecker { + + override suspend fun getAppUpdateState(currentVersion: String): AppUpdateState { + remoteConfigDataSource.fetch() + + val latestVersion = remoteConfigDataSource.getLatestVersion() + val minimumVersion = remoteConfigDataSource.getMinimumVersion() + + return when { + VersionComparator.compare(currentVersion, minimumVersion) < 0 -> { + AppUpdateState.HardUpdate(latestVersion) + } + + VersionComparator.compare(currentVersion, latestVersion) < 0 -> { + AppUpdateState.SoftUpdate(latestVersion) + } + + else -> { + AppUpdateState.Latest + } + } + } +} diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt b/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt new file mode 100644 index 00000000..8561623a --- /dev/null +++ b/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt @@ -0,0 +1,27 @@ +package com.sopt.clody.data.remote.datasource + +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.sopt.clody.BuildConfig +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RemoteConfigDataSource @Inject constructor( + private val remoteConfig: FirebaseRemoteConfig, +) { + suspend fun fetch() { + remoteConfig.fetchAndActivate().await() + } + + fun getLatestVersion(): String = + remoteConfig.getString(KEY_LATEST_VERSION).ifEmpty { BuildConfig.VERSION_NAME } + + fun getMinimumVersion(): String = + remoteConfig.getString(KEY_MINIMUM_VERSION).ifEmpty { BuildConfig.VERSION_NAME } + + companion object { + private const val KEY_LATEST_VERSION = "latest_version" + private const val KEY_MINIMUM_VERSION = "min_required_version" + } +} diff --git a/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt b/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt new file mode 100644 index 00000000..f584941f --- /dev/null +++ b/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt @@ -0,0 +1,27 @@ +package com.sopt.clody.di + +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.sopt.clody.data.remote.appupdate.AppUpdateCheckerImpl +import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource +import com.sopt.clody.domain.appupdate.AppUpdateChecker +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppUpdateModule { + + @Provides + @Singleton + fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig = + FirebaseRemoteConfig.getInstance() + + @Provides + @Singleton + fun provideAppUpdateChecker( + remoteConfigDataSource: RemoteConfigDataSource, + ): AppUpdateChecker = AppUpdateCheckerImpl(remoteConfigDataSource) +} diff --git a/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt b/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt new file mode 100644 index 00000000..e8f3e7d6 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt @@ -0,0 +1,7 @@ +package com.sopt.clody.domain.appupdate + +import com.sopt.clody.domain.model.AppUpdateState + +interface AppUpdateChecker { + suspend fun getAppUpdateState(currentVersion: String): AppUpdateState +} diff --git a/app/src/main/java/com/sopt/clody/domain/model/AppUpdateState.kt b/app/src/main/java/com/sopt/clody/domain/model/AppUpdateState.kt new file mode 100644 index 00000000..0d55c8e5 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/domain/model/AppUpdateState.kt @@ -0,0 +1,7 @@ +package com.sopt.clody.domain.model + +sealed interface AppUpdateState { + data object Latest : AppUpdateState + data class SoftUpdate(val latestVersion: String) : AppUpdateState + data class HardUpdate(val latestVersion: String) : AppUpdateState +} diff --git a/app/src/main/java/com/sopt/clody/domain/util/VersionComparator.kt b/app/src/main/java/com/sopt/clody/domain/util/VersionComparator.kt new file mode 100644 index 00000000..0fb081bd --- /dev/null +++ b/app/src/main/java/com/sopt/clody/domain/util/VersionComparator.kt @@ -0,0 +1,16 @@ +package com.sopt.clody.domain.util + +object VersionComparator { + fun compare(current: String, latest: String): Int { + val currentParts = current.split(".").map { it.toIntOrNull() ?: 0 } + val latestParts = latest.split(".").map { it.toIntOrNull() ?: 0 } + + for (i in 0 until maxOf(currentParts.size, latestParts.size)) { + val c = currentParts.getOrNull(i) ?: 0 + val l = latestParts.getOrNull(i) ?: 0 + if (c < l) return -1 + if (c > l) return 1 + } + return 0 + } +} diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt index 5193e719..10505c0b 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt @@ -1,22 +1,30 @@ package com.sopt.clody.presentation.ui.splash +import android.app.Activity import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable 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.res.painterResource +import androidx.compose.ui.text.style.TextAlign 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.sopt.clody.R +import com.sopt.clody.domain.model.AppUpdateState import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator +import com.sopt.clody.presentation.utils.appupdate.AppUpdateUtils import com.sopt.clody.ui.theme.ClodyTheme import kotlinx.coroutines.delay import java.time.LocalDate @@ -27,9 +35,12 @@ fun SplashRoute( viewModel: SplashViewModel = hiltViewModel(), ) { val isUserLoggedIn by viewModel.isUserLoggedIn.collectAsStateWithLifecycle() + val updateState by viewModel.updateState.collectAsStateWithLifecycle() + val context = LocalContext.current + val activity = context as Activity - LaunchedEffect(isUserLoggedIn) { - if (isUserLoggedIn != null) { + LaunchedEffect(isUserLoggedIn, updateState) { + if (isUserLoggedIn != null && updateState == AppUpdateState.Latest) { delay(1000) navigator.navController.navigate( if (isUserLoggedIn == true) { @@ -43,6 +54,26 @@ fun SplashRoute( } } + when (val state = updateState) { + is AppUpdateState.SoftUpdate -> { + SoftUpdateDialog( + latestVersion = state.latestVersion, + onDismiss = { viewModel.clearUpdateState() }, + onConfirm = { AppUpdateUtils.navigateToMarket(context) }, + ) + } + + is AppUpdateState.HardUpdate -> { + HardUpdateDialog( + latestVersion = state.latestVersion, + onConfirm = { AppUpdateUtils.navigateToMarketAndFinish(activity) }, + onExit = { activity.finishAffinity() }, + ) + } + + else -> {} + } + SplashScreen() } @@ -63,6 +94,41 @@ fun SplashScreen() { } } +@Composable +fun SoftUpdateDialog( + latestVersion: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("업데이트 필요") }, + text = { + Text( + text = "새로운 버전 ${latestVersion}을 사용할 수 있습니다.\n지금 업데이트하시겠습니까?", + textAlign = TextAlign.Center, + ) + }, + confirmButton = { TextButton(onClick = onConfirm) { Text("업데이트") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("나중에") } }, + ) +} + +@Composable +fun HardUpdateDialog( + latestVersion: String, + onConfirm: () -> Unit, + onExit: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { Text("필수 업데이트") }, + text = { Text("버전 ${latestVersion}으로 업데이트가 필요합니다.") }, + confirmButton = { TextButton(onClick = onConfirm) { Text("업데이트") } }, + dismissButton = { TextButton(onClick = onExit) { Text("앱 종료") } }, + ) +} + @Preview(showBackground = true) @Composable fun SplashScreenPreview() { diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt index bfb3b6db..b52627bb 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt @@ -1,22 +1,32 @@ package com.sopt.clody.presentation.ui.splash import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sopt.clody.BuildConfig +import com.sopt.clody.domain.appupdate.AppUpdateChecker +import com.sopt.clody.domain.model.AppUpdateState import com.sopt.clody.domain.repository.TokenRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SplashViewModel @Inject constructor( private val tokenRepository: TokenRepository, + private val appUpdateChecker: AppUpdateChecker, ) : ViewModel() { private val _isUserLoggedIn = MutableStateFlow(null) val isUserLoggedIn: StateFlow = _isUserLoggedIn + private val _updateState = MutableStateFlow(null) + val updateState: StateFlow = _updateState + init { attemptAutoLogin() + checkVersion() } private fun attemptAutoLogin() { @@ -24,4 +34,15 @@ class SplashViewModel @Inject constructor( val refreshToken = tokenRepository.getRefreshToken() _isUserLoggedIn.value = accessToken.isNotBlank() && refreshToken.isNotBlank() } + + private fun checkVersion() { + viewModelScope.launch { + val state = appUpdateChecker.getAppUpdateState(BuildConfig.VERSION_NAME) + _updateState.value = state + } + } + + fun clearUpdateState() { + _updateState.value = AppUpdateState.Latest + } } diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/appupdate/AppUpdateUtils.kt b/app/src/main/java/com/sopt/clody/presentation/utils/appupdate/AppUpdateUtils.kt new file mode 100644 index 00000000..1295fcc9 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/presentation/utils/appupdate/AppUpdateUtils.kt @@ -0,0 +1,38 @@ +package com.sopt.clody.presentation.utils.appupdate + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri + +object AppUpdateUtils { + + /** + * 마켓 이동 + * @param context Context + */ + fun navigateToMarket(context: Context) { + val packageName = context.packageName + val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + if (marketIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(marketIntent) + } else { + val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$packageName")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(webIntent) + } + } + + /** + * 마켓 이동 후 앱 종료 (Hard Update 용) + * @param activity Activity + */ + fun navigateToMarketAndFinish(activity: Activity) { + navigateToMarket(activity) + activity.finishAffinity() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8c64485c..134ae3ba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,8 @@ kotlinx-datetime = "0.4.0" accompanist = "0.25.1" accompanist-insets = "0.28.0" +firebase-config-ktx = "22.1.0" + [libraries] # AndroidX Core core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -97,6 +99,7 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx", version.ref = "firebase-messaging" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx", version.ref = "firebase-analytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebase-crashlytics" } +firebase-config-ktx = { group = "com.google.firebase", name = "firebase-config-ktx", version.ref = "firebase-config-ktx" } # Amplitude amplitude = { group = "com.amplitude", name = "analytics-android", version.ref = "amplitude" }