Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down Expand Up @@ -71,7 +71,6 @@ android {
}

dependencies {

// Test
testImplementation(libs.junit)
androidTestImplementation(platform(libs.compose.bom))
Expand Down Expand Up @@ -100,6 +99,7 @@ dependencies {
// FireBase
implementation(platform(libs.firebase.bom))
implementation(libs.bundles.firebase)
implementation(libs.firebase.config.ktx)

// Amplitude
implementation(libs.amplitude)
Expand Down
Original file line number Diff line number Diff line change
@@ -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()

Comment on lines +13 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Consider optimizing Remote Config fetch frequency

Currently, the implementation calls fetch() every time getAppUpdateState() is called, which might be inefficient if called frequently.

Since Firebase RemoteConfig has a default caching mechanism, consider implementing a more strategic approach to fetching, such as only fetching if the cached data is stale.

Do you have a RemoteConfigDataSource implementation that manages caching efficiently? If not, consider adding a check for the last fetch time before making a new fetch request.


🏁 Script executed:

#!/bin/bash
# Checking if RemoteConfigDataSource has caching logic
rg -A 10 "class RemoteConfigDataSource" --type kotlin

Length of output: 1227


Optimize Remote Config Fetching Frequency

The current implementation in AppUpdateCheckerImpl.kt calls remoteConfigDataSource.fetch() on every invocation of getAppUpdateState(). Our investigation of RemoteConfigDataSource.kt confirms that it simply calls fetchAndActivate() without any additional caching logic or a check on the last fetch time. While Firebase RemoteConfig has a built-in caching mechanism, relying solely on it might not be sufficient if this method is hit frequently.

Suggestions:

  • Implement an additional caching strategy (e.g., store the last fetch timestamp) and skip fetching if the cached data is still fresh.
  • Revisit your caching requirements to determine if Firebase’s default behavior meets your needs or if more fine-grained control is necessary.

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
}
}
}
Comment on lines +13 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for Remote Config fetching

The implementation correctly determines update states based on version comparison, but lacks error handling for potential failures when fetching remote config.

Since fetching from Firebase might fail due to network issues, you should add try-catch blocks to handle exceptions and implement a fallback strategy.

override suspend fun getAppUpdateState(currentVersion: String): AppUpdateState {
-   remoteConfigDataSource.fetch()
+   try {
+       remoteConfigDataSource.fetch()
+   } catch (e: Exception) {
+       // If fetch fails, continue with potentially cached values
+       // Log the error for monitoring
+   }

    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
        }
    }
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
}
}
override suspend fun getAppUpdateState(currentVersion: String): AppUpdateState {
try {
remoteConfigDataSource.fetch()
} catch (e: Exception) {
// If fetch fails, continue with potentially cached values
// Log the error for monitoring
}
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
}
}
}

}
Original file line number Diff line number Diff line change
@@ -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"
}
}
27 changes: 27 additions & 0 deletions app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +17 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Firebase RemoteConfig provider should include configuration settings

While the provider correctly returns the Firebase RemoteConfig instance, it's missing the configuration for fetch settings like minimum fetch interval and fetch timeout.

According to the PR description, the fetch interval should be 12 hours, but this isn't set in the code.

@Provides
@Singleton
fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig =
-   FirebaseRemoteConfig.getInstance()
+   FirebaseRemoteConfig.getInstance().apply {
+       val configSettings = FirebaseRemoteConfigSettings.Builder()
+           .setMinimumFetchIntervalInSeconds(43200) // 12 hours in seconds
+           .build()
+       setConfigSettingsAsync(configSettings)
+   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Provides
@Singleton
fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig =
FirebaseRemoteConfig.getInstance()
@Provides
@Singleton
fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig =
FirebaseRemoteConfig.getInstance().apply {
val configSettings = FirebaseRemoteConfigSettings.Builder()
.setMinimumFetchIntervalInSeconds(43200) // 12 hours in seconds
.build()
setConfigSettingsAsync(configSettings)
}


@Provides
@Singleton
fun provideAppUpdateChecker(
remoteConfigDataSource: RemoteConfigDataSource,
): AppUpdateChecker = AppUpdateCheckerImpl(remoteConfigDataSource)
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions app/src/main/java/com/sopt/clody/domain/util/VersionComparator.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -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()
}

Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
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<Boolean?>(null)
val isUserLoggedIn: StateFlow<Boolean?> = _isUserLoggedIn

private val _updateState = MutableStateFlow<AppUpdateState?>(null)
val updateState: StateFlow<AppUpdateState?> = _updateState

init {
attemptAutoLogin()
checkVersion()
}

private fun attemptAutoLogin() {
val accessToken = tokenRepository.getAccessToken()
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
}
}
Original file line number Diff line number Diff line change
@@ -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
*/
Comment on lines +10 to +13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[3] 요즘 코드 작성 간에 함수 위에 이렇게 기능과 param에 대한 comment를 남겨놓으시더라구요! 이때까지 솝트에서 프로젝트를 하다보면 리뷰 간에 주석, 로그 제거에 좀 집착(?)하던 경향이 있었는데 어떻게 생각하시나요? 지금정도의 주석은 필요한 주석이다?!

사실 리뷰를 하는 입장에서 제 개인적으로는 좋다고 생각하기는한데 주변에 현직자들의 의견이나 문수님 의견 들은 바가 있다면 공유받고 싶습니다😀

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 질문입니다!

주석 관련 생각?

사실 이 부분은 회사마다 다소 지향하는 바가 다를 수 있는데, 공식적인 KDoc 형식의 주석은 실무에서도 많이 사용하고 권장하는 경우가 많아요. 특히 SDK, Library, Utility Class처럼 다른 모듈이나 팀에서 재사용될 가능성이 있는 클래스나 함수에는 기능과 파라미터 설명을 남기는 게 좋습니다.

BUT, 너무 obvious한 주석(// 클릭 시 이동)이나 개발 도중 찍은 Log는 커밋 전에 정리하는 게 보통 맞습니다.

저도 개인적인 생각으로는 이런 정도의 KDoc 주석은 남기는 게 좋다고 생각하고, 특히 유틸리티성 함수들은 이런 주석이 없으면 오히려 의도가 잘 안 보일 때가 있어서 코드 리뷰할 때도 도움 되는 것 같습니다.

ps: 카카오뱅크에 재직중인 익명의 시니어 분에게 여쭈어 봤는데 이런 형태의 주석을 많이 사용한다고 해요~
주석관련 아티클

그리고 왜 로그를 정리하라고 할까요?

코드리뷰를 받을 때 항상 "불필요한 로그 지워주세요~" 라는 걸 많이 받잖아요? 크게 2가지 이유가 있어요.

첫 번째 - 성능 문제

로그 출력은 단순한 텍스트 출력이 아니라 내부적으로 I/O가 발생해요.
특히 애니메이션, 스크롤, 게임, 실시간 처리가 많은 구간에서 디버그 로그가 남아있으면 프레임 드랍, 렌더링 지연의 원인이 될 수 있답니다?

두 번째 - 불필요한 메모리 할당 및 GC(Garbage Collection) 유발

로그를 위해 생성된 문자열, 포맷팅된 파라미터 등이 내부적으로 객체 할당을 발생시켜요ㅠ
이로 인해서 GC의 빈도가 늘어나고, 결국 앱의 퍼포먼스 저하로 이어질 수 있답니다!

※ 물론 Timber와 같은 로깅 라이브러리를 활용하면
릴리즈 빌드에서 로그를 비활성화할 수 있지만,
디버그 빌드에서도 의미 없는 로그는 노이즈가 될 수 있기 때문에
정리하는 습관을 들이는 것이 좋습니다~!!

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()
}
}
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }
Expand Down