Skip to content

Commit 74384f1

Browse files
authored
Merge pull request #247 from Team-Clody/feat/#238-version-control
[Feat/#238] FireBase RemoteConfig를 통한 앱 버전 관리를 구현합니다.
2 parents 68038e4 + d775bac commit 74384f1

File tree

11 files changed

+249
-4
lines changed

11 files changed

+249
-4
lines changed

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ android {
2626
minSdk = 28
2727
targetSdk = 35
2828
versionCode = 18
29-
versionName = "1.0.6"
29+
versionName = "1.0.7"
3030
val kakaoApiKey: String = properties.getProperty("kakao.api.key")
3131
val amplitudeApiKey: String = properties.getProperty("amplitude.api.key")
3232
val googleAdmobAppId: String = properties.getProperty("GOOGLE_ADMOB_APP_ID", "")
@@ -71,7 +71,6 @@ android {
7171
}
7272

7373
dependencies {
74-
7574
// Test
7675
testImplementation(libs.junit)
7776
androidTestImplementation(platform(libs.compose.bom))
@@ -100,6 +99,7 @@ dependencies {
10099
// FireBase
101100
implementation(platform(libs.firebase.bom))
102101
implementation(libs.bundles.firebase)
102+
implementation(libs.firebase.config.ktx)
103103

104104
// Amplitude
105105
implementation(libs.amplitude)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.sopt.clody.data.remote.appupdate
2+
3+
import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource
4+
import com.sopt.clody.domain.appupdate.AppUpdateChecker
5+
import com.sopt.clody.domain.model.AppUpdateState
6+
import com.sopt.clody.domain.util.VersionComparator
7+
import javax.inject.Inject
8+
9+
class AppUpdateCheckerImpl @Inject constructor(
10+
private val remoteConfigDataSource: RemoteConfigDataSource,
11+
) : AppUpdateChecker {
12+
13+
override suspend fun getAppUpdateState(currentVersion: String): AppUpdateState {
14+
remoteConfigDataSource.fetch()
15+
16+
val latestVersion = remoteConfigDataSource.getLatestVersion()
17+
val minimumVersion = remoteConfigDataSource.getMinimumVersion()
18+
19+
return when {
20+
VersionComparator.compare(currentVersion, minimumVersion) < 0 -> {
21+
AppUpdateState.HardUpdate(latestVersion)
22+
}
23+
24+
VersionComparator.compare(currentVersion, latestVersion) < 0 -> {
25+
AppUpdateState.SoftUpdate(latestVersion)
26+
}
27+
28+
else -> {
29+
AppUpdateState.Latest
30+
}
31+
}
32+
}
33+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.sopt.clody.data.remote.datasource
2+
3+
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
4+
import com.sopt.clody.BuildConfig
5+
import kotlinx.coroutines.tasks.await
6+
import javax.inject.Inject
7+
import javax.inject.Singleton
8+
9+
@Singleton
10+
class RemoteConfigDataSource @Inject constructor(
11+
private val remoteConfig: FirebaseRemoteConfig,
12+
) {
13+
suspend fun fetch() {
14+
remoteConfig.fetchAndActivate().await()
15+
}
16+
17+
fun getLatestVersion(): String =
18+
remoteConfig.getString(KEY_LATEST_VERSION).ifEmpty { BuildConfig.VERSION_NAME }
19+
20+
fun getMinimumVersion(): String =
21+
remoteConfig.getString(KEY_MINIMUM_VERSION).ifEmpty { BuildConfig.VERSION_NAME }
22+
23+
companion object {
24+
private const val KEY_LATEST_VERSION = "latest_version"
25+
private const val KEY_MINIMUM_VERSION = "min_required_version"
26+
}
27+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.sopt.clody.di
2+
3+
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
4+
import com.sopt.clody.data.remote.appupdate.AppUpdateCheckerImpl
5+
import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource
6+
import com.sopt.clody.domain.appupdate.AppUpdateChecker
7+
import dagger.Module
8+
import dagger.Provides
9+
import dagger.hilt.InstallIn
10+
import dagger.hilt.components.SingletonComponent
11+
import javax.inject.Singleton
12+
13+
@Module
14+
@InstallIn(SingletonComponent::class)
15+
object AppUpdateModule {
16+
17+
@Provides
18+
@Singleton
19+
fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig =
20+
FirebaseRemoteConfig.getInstance()
21+
22+
@Provides
23+
@Singleton
24+
fun provideAppUpdateChecker(
25+
remoteConfigDataSource: RemoteConfigDataSource,
26+
): AppUpdateChecker = AppUpdateCheckerImpl(remoteConfigDataSource)
27+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.sopt.clody.domain.appupdate
2+
3+
import com.sopt.clody.domain.model.AppUpdateState
4+
5+
interface AppUpdateChecker {
6+
suspend fun getAppUpdateState(currentVersion: String): AppUpdateState
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.sopt.clody.domain.model
2+
3+
sealed interface AppUpdateState {
4+
data object Latest : AppUpdateState
5+
data class SoftUpdate(val latestVersion: String) : AppUpdateState
6+
data class HardUpdate(val latestVersion: String) : AppUpdateState
7+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.sopt.clody.domain.util
2+
3+
object VersionComparator {
4+
fun compare(current: String, latest: String): Int {
5+
val currentParts = current.split(".").map { it.toIntOrNull() ?: 0 }
6+
val latestParts = latest.split(".").map { it.toIntOrNull() ?: 0 }
7+
8+
for (i in 0 until maxOf(currentParts.size, latestParts.size)) {
9+
val c = currentParts.getOrNull(i) ?: 0
10+
val l = latestParts.getOrNull(i) ?: 0
11+
if (c < l) return -1
12+
if (c > l) return 1
13+
}
14+
return 0
15+
}
16+
}

app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
package com.sopt.clody.presentation.ui.splash
22

3+
import android.app.Activity
34
import androidx.compose.foundation.Image
45
import androidx.compose.foundation.background
56
import androidx.compose.foundation.layout.Box
67
import androidx.compose.foundation.layout.fillMaxSize
78
import androidx.compose.foundation.layout.size
9+
import androidx.compose.material3.AlertDialog
10+
import androidx.compose.material3.Text
11+
import androidx.compose.material3.TextButton
812
import androidx.compose.runtime.Composable
913
import androidx.compose.runtime.LaunchedEffect
1014
import androidx.compose.runtime.getValue
1115
import androidx.compose.ui.Alignment
1216
import androidx.compose.ui.Modifier
17+
import androidx.compose.ui.platform.LocalContext
1318
import androidx.compose.ui.res.painterResource
19+
import androidx.compose.ui.text.style.TextAlign
1420
import androidx.compose.ui.tooling.preview.Preview
1521
import androidx.compose.ui.unit.dp
1622
import androidx.hilt.navigation.compose.hiltViewModel
1723
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1824
import com.sopt.clody.R
25+
import com.sopt.clody.domain.model.AppUpdateState
1926
import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator
27+
import com.sopt.clody.presentation.utils.appupdate.AppUpdateUtils
2028
import com.sopt.clody.ui.theme.ClodyTheme
2129
import kotlinx.coroutines.delay
2230
import java.time.LocalDate
@@ -27,9 +35,12 @@ fun SplashRoute(
2735
viewModel: SplashViewModel = hiltViewModel(),
2836
) {
2937
val isUserLoggedIn by viewModel.isUserLoggedIn.collectAsStateWithLifecycle()
38+
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
39+
val context = LocalContext.current
40+
val activity = context as Activity
3041

31-
LaunchedEffect(isUserLoggedIn) {
32-
if (isUserLoggedIn != null) {
42+
LaunchedEffect(isUserLoggedIn, updateState) {
43+
if (isUserLoggedIn != null && updateState == AppUpdateState.Latest) {
3344
delay(1000)
3445
navigator.navController.navigate(
3546
if (isUserLoggedIn == true) {
@@ -43,6 +54,26 @@ fun SplashRoute(
4354
}
4455
}
4556

57+
when (val state = updateState) {
58+
is AppUpdateState.SoftUpdate -> {
59+
SoftUpdateDialog(
60+
latestVersion = state.latestVersion,
61+
onDismiss = { viewModel.clearUpdateState() },
62+
onConfirm = { AppUpdateUtils.navigateToMarket(context) },
63+
)
64+
}
65+
66+
is AppUpdateState.HardUpdate -> {
67+
HardUpdateDialog(
68+
latestVersion = state.latestVersion,
69+
onConfirm = { AppUpdateUtils.navigateToMarketAndFinish(activity) },
70+
onExit = { activity.finishAffinity() },
71+
)
72+
}
73+
74+
else -> {}
75+
}
76+
4677
SplashScreen()
4778
}
4879

@@ -63,6 +94,41 @@ fun SplashScreen() {
6394
}
6495
}
6596

97+
@Composable
98+
fun SoftUpdateDialog(
99+
latestVersion: String,
100+
onDismiss: () -> Unit,
101+
onConfirm: () -> Unit,
102+
) {
103+
AlertDialog(
104+
onDismissRequest = onDismiss,
105+
title = { Text("업데이트 필요") },
106+
text = {
107+
Text(
108+
text = "새로운 버전 ${latestVersion}을 사용할 수 있습니다.\n지금 업데이트하시겠습니까?",
109+
textAlign = TextAlign.Center,
110+
)
111+
},
112+
confirmButton = { TextButton(onClick = onConfirm) { Text("업데이트") } },
113+
dismissButton = { TextButton(onClick = onDismiss) { Text("나중에") } },
114+
)
115+
}
116+
117+
@Composable
118+
fun HardUpdateDialog(
119+
latestVersion: String,
120+
onConfirm: () -> Unit,
121+
onExit: () -> Unit,
122+
) {
123+
AlertDialog(
124+
onDismissRequest = {},
125+
title = { Text("필수 업데이트") },
126+
text = { Text("버전 ${latestVersion}으로 업데이트가 필요합니다.") },
127+
confirmButton = { TextButton(onClick = onConfirm) { Text("업데이트") } },
128+
dismissButton = { TextButton(onClick = onExit) { Text("앱 종료") } },
129+
)
130+
}
131+
66132
@Preview(showBackground = true)
67133
@Composable
68134
fun SplashScreenPreview() {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,48 @@
11
package com.sopt.clody.presentation.ui.splash
22

33
import androidx.lifecycle.ViewModel
4+
import androidx.lifecycle.viewModelScope
5+
import com.sopt.clody.BuildConfig
6+
import com.sopt.clody.domain.appupdate.AppUpdateChecker
7+
import com.sopt.clody.domain.model.AppUpdateState
48
import com.sopt.clody.domain.repository.TokenRepository
59
import dagger.hilt.android.lifecycle.HiltViewModel
610
import kotlinx.coroutines.flow.MutableStateFlow
711
import kotlinx.coroutines.flow.StateFlow
12+
import kotlinx.coroutines.launch
813
import javax.inject.Inject
914

1015
@HiltViewModel
1116
class SplashViewModel @Inject constructor(
1217
private val tokenRepository: TokenRepository,
18+
private val appUpdateChecker: AppUpdateChecker,
1319
) : ViewModel() {
1420

1521
private val _isUserLoggedIn = MutableStateFlow<Boolean?>(null)
1622
val isUserLoggedIn: StateFlow<Boolean?> = _isUserLoggedIn
1723

24+
private val _updateState = MutableStateFlow<AppUpdateState?>(null)
25+
val updateState: StateFlow<AppUpdateState?> = _updateState
26+
1827
init {
1928
attemptAutoLogin()
29+
checkVersion()
2030
}
2131

2232
private fun attemptAutoLogin() {
2333
val accessToken = tokenRepository.getAccessToken()
2434
val refreshToken = tokenRepository.getRefreshToken()
2535
_isUserLoggedIn.value = accessToken.isNotBlank() && refreshToken.isNotBlank()
2636
}
37+
38+
private fun checkVersion() {
39+
viewModelScope.launch {
40+
val state = appUpdateChecker.getAppUpdateState(BuildConfig.VERSION_NAME)
41+
_updateState.value = state
42+
}
43+
}
44+
45+
fun clearUpdateState() {
46+
_updateState.value = AppUpdateState.Latest
47+
}
2748
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.sopt.clody.presentation.utils.appupdate
2+
3+
import android.app.Activity
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.net.Uri
7+
8+
object AppUpdateUtils {
9+
10+
/**
11+
* 마켓 이동
12+
* @param context Context
13+
*/
14+
fun navigateToMarket(context: Context) {
15+
val packageName = context.packageName
16+
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).apply {
17+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
18+
}
19+
20+
if (marketIntent.resolveActivity(context.packageManager) != null) {
21+
context.startActivity(marketIntent)
22+
} else {
23+
val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$packageName")).apply {
24+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
25+
}
26+
context.startActivity(webIntent)
27+
}
28+
}
29+
30+
/**
31+
* 마켓 이동 후 앱 종료 (Hard Update 용)
32+
* @param activity Activity
33+
*/
34+
fun navigateToMarketAndFinish(activity: Activity) {
35+
navigateToMarket(activity)
36+
activity.finishAffinity()
37+
}
38+
}

0 commit comments

Comments
 (0)