Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
48 changes: 42 additions & 6 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,6 @@
<activity
android:name=".presentation.intro.IntroActivity"
android:exported="true">
<!-- 기본 런처 필터 -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!-- 딥링크 필터 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
Expand All @@ -173,6 +167,48 @@
android:scheme="eatssu" />
</intent-filter>
</activity>

<activity-alias
android:name=".alias.DefaultLauncherAlias"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".presentation.intro.IntroActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<activity-alias
android:name=".alias.ChristmasLauncherAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_christmas"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round_christmas"
android:targetActivity=".presentation.intro.IntroActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<activity-alias
android:name=".alias.SpringLauncherAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_spring"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_spring_round"
android:targetActivity=".presentation.intro.IntroActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".presentation.cafeteria.review.ReviewComposeActivity"
android:exported="true">
Expand Down
Binary file added app/src/main/ic_launcher_blossom-playstore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.eatssu.android.data.local

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.eatssu.android.domain.model.AppTheme
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
import javax.inject.Singleton

private val Context.appThemeDataStore: DataStore<Preferences> by preferencesDataStore(name = "app_theme")

@Singleton
class AppThemeDataStore @Inject constructor(
@ApplicationContext private val context: Context,
) {

companion object {
private val APP_THEME_KEY = stringPreferencesKey("app_theme")
}

val appTheme: Flow<AppTheme> = context.appThemeDataStore.data
.map { preferences ->
AppTheme.fromStringOrDefault(preferences[APP_THEME_KEY].orEmpty())
}
.distinctUntilChanged()

val cachedAppTheme: AppTheme by lazy(LazyThreadSafetyMode.NONE) {
runBlocking { appTheme.first() }
}
Comment thread
PeraSite marked this conversation as resolved.
Outdated
Comment thread
PeraSite marked this conversation as resolved.
Outdated

suspend fun setAppTheme(theme: AppTheme) {
context.appThemeDataStore.edit { preferences ->
preferences[APP_THEME_KEY] = theme.remoteValue
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.eatssu.android.data.remote.repository

import com.eatssu.android.R
import com.eatssu.android.domain.model.AppTheme
import com.eatssu.android.domain.model.RestaurantInfo
import com.eatssu.android.domain.repository.FirebaseRemoteConfigRepository
import com.eatssu.common.enums.Restaurant
Expand Down Expand Up @@ -29,25 +30,27 @@ class FirebaseRemoteConfigRepositoryImpl @Inject constructor(
}

override suspend fun getMinimumVersionCode(): Long {
// 값을 가져오기 전에 fetchAndActivate 호출
// min fetch interval이 지나지 않았으면 로컬 캐시를 사용하고, 지났으면 서버에서 가져옵니다.
try {
instance.fetchAndActivate().await()
} catch (e: Exception) {
Timber.e(e, "RemoteConfig fetchAndActivate 실패")
}
fetchAndActivateSafely()
return instance.getLong("android_version_code")
}

override suspend fun getAppTheme(): AppTheme {
fetchAndActivateSafely()
return AppTheme.fromStringOrDefault(instance.getString("app_theme"))
}

override suspend fun getRestaurantInfo(restaurant: Restaurant): RestaurantInfo? {
// 값을 가져오기 전에 fetchAndActivate 호출
fetchAndActivateSafely()
return getCafeteriaInfo().find { it.enum == restaurant }
Comment on lines 32 to +44
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

getMinimumVersionCode()/getAppTheme()/getRestaurantInfo()가 모두 fetchAndActivateSafely()를 호출하고 있어, 앱 시작 시 여러 값을 연속으로 읽는 경우 fetchAndActivate()가 중복 호출됩니다. Remote Config가 내부적으로 캐시를 쓰더라도 불필요한 작업/지연이 발생할 수 있으니, fetch를 1회만 수행하도록 (예: fetch 작업을 memoize하거나, 외부에서 한 번 fetch 후 여러 getter가 값을 읽도록) 구조를 조정하는 게 좋습니다.

Copilot uses AI. Check for mistakes.
}

private suspend fun fetchAndActivateSafely() {
// min fetch interval이 지나지 않았으면 로컬 캐시를 사용하고, 지났으면 서버에서 가져옵니다.
try {
instance.fetchAndActivate().await()
} catch (e: Exception) {
Timber.e(e, "RemoteConfig fetchAndActivate 실패")
}
return getCafeteriaInfo().find { it.enum == restaurant }
}

private fun getCafeteriaInfo(): List<RestaurantInfo> {
Expand Down
37 changes: 37 additions & 0 deletions app/src/main/java/com/eatssu/android/domain/model/AppTheme.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.eatssu.android.domain.model

import androidx.annotation.AnyRes
import androidx.annotation.DrawableRes
import com.eatssu.android.R

enum class AppTheme(
val remoteValue: String,
@AnyRes val splashBackgroundResId: Int,
@DrawableRes val splashLogoResId: Int,
val launcherAliasSuffix: String,
) {
DEFAULT(
remoteValue = "default",
splashBackgroundResId = R.color.primary,
splashLogoResId = R.drawable.img_logo,
launcherAliasSuffix = ".alias.DefaultLauncherAlias",
),
CHRISTMAS(
remoteValue = "christmas",
splashBackgroundResId = R.drawable.img_background_snow,
splashLogoResId = R.drawable.img_logo_snow,
launcherAliasSuffix = ".alias.ChristmasLauncherAlias",
),
SPRING(
remoteValue = "spring",
splashBackgroundResId = R.drawable.img_background_spring,
splashLogoResId = R.drawable.img_logo,
launcherAliasSuffix = ".alias.SpringLauncherAlias",
);

companion object {
fun fromStringOrDefault(value: String): AppTheme {
return entries.find { it.remoteValue.equals(value, ignoreCase = true) } ?: DEFAULT
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.eatssu.android.domain.repository

import com.eatssu.android.domain.model.AppTheme
import com.eatssu.android.domain.model.RestaurantInfo
import com.eatssu.common.enums.Restaurant

Expand All @@ -11,6 +12,11 @@ interface FirebaseRemoteConfigRepository {
*/
suspend fun getMinimumVersionCode(): Long

/**
* 앱 테마를 Remote Config에서 가져옵니다.
*/
suspend fun getAppTheme(): AppTheme

/**
* 특정 식당 정보를 Remote Config에서 가져옴
* 값을 가져오기 전에 fetchAndActivate를 호출하여 최신 값을 가져옵니다.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.eatssu.android.presentation.intro

import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.eatssu.android.BuildConfig
import com.eatssu.android.databinding.ActivityIntroBinding
import com.eatssu.android.domain.model.AppTheme
import com.eatssu.android.presentation.MainActivity
import com.eatssu.android.presentation.common.ForceUpdateDialogActivity
import com.eatssu.android.presentation.login.LoginActivity
Expand All @@ -23,6 +27,7 @@ import com.eatssu.common.enums.ScreenId
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject

@AndroidEntryPoint
Expand All @@ -41,6 +46,10 @@ class IntroActivity : AppCompatActivity() {
setContentView(binding.root)
log()

// 로컬에 저장된 테마 동기로 불러오기
applySplashTheme(introViewModel.appTheme.value)
observeTheme()

observeState()
observeEvents()
observeNetworkError()
Expand All @@ -63,6 +72,15 @@ class IntroActivity : AppCompatActivity() {
}
}

private fun observeTheme() {
lifecycleScope.launch {
introViewModel.appTheme.collectLatest { theme ->
applySplashTheme(theme)
syncLauncherIcon(theme)
}
}
}

private fun observeState() {
lifecycleScope.launch {
introViewModel.uiState.collectLatest { state ->
Expand All @@ -84,6 +102,35 @@ class IntroActivity : AppCompatActivity() {
}
}

private fun applySplashTheme(theme: AppTheme) {
binding.root.setBackgroundResource(theme.splashBackgroundResId)
binding.ivLogo.setImageResource(theme.splashLogoResId)
}

private fun syncLauncherIcon(theme: AppTheme) {
if (BuildConfig.DEBUG) return

val manifestPackageName = BuildConfig::class.java.packageName
val enabledAlias = ComponentName(packageName, manifestPackageName + theme.launcherAliasSuffix)

AppTheme.entries.forEach { appTheme ->
val componentName = ComponentName(packageName, manifestPackageName + appTheme.launcherAliasSuffix)
val newState = if (componentName == enabledAlias) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}

packageManager.setComponentEnabledSetting(
componentName,
newState,
PackageManager.DONT_KILL_APP,
)
}

Timber.d("런처 아이콘 테마 적용: %s", theme.remoteValue)
}
Comment thread
PeraSite marked this conversation as resolved.

private fun observeEvents() {
lifecycleScope.launch {
introViewModel.uiEvent.collectLatest { event ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.eatssu.android.R
import com.eatssu.android.BuildConfig.VERSION_CODE
import com.eatssu.android.data.local.AppThemeDataStore
import com.eatssu.android.domain.model.AppTheme
import com.eatssu.android.domain.repository.FirebaseRemoteConfigRepository
import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase
import com.eatssu.android.domain.usecase.health.HealthCheckUseCase
Expand All @@ -14,9 +16,11 @@ import com.eatssu.common.enums.ToastType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
Expand All @@ -25,7 +29,8 @@ import javax.inject.Inject
class IntroViewModel @Inject constructor(
private val healthCheckUseCase: HealthCheckUseCase,
private val getAccessTokenUseCase: GetAccessTokenUseCase,
private val firebaseRemoteConfigRepository: FirebaseRemoteConfigRepository
private val firebaseRemoteConfigRepository: FirebaseRemoteConfigRepository,
private val appThemeDataStore: AppThemeDataStore,
) : ViewModel() {

private val _uiState: MutableStateFlow<UiState<IntroState>> = MutableStateFlow(UiState.Init)
Expand All @@ -37,6 +42,13 @@ class IntroViewModel @Inject constructor(
private val _versionCheckResult = MutableStateFlow<VersionCheckResult?>(null)
val versionCheckResult: StateFlow<VersionCheckResult?> = _versionCheckResult.asStateFlow()

val appTheme: StateFlow<AppTheme> = appThemeDataStore.appTheme
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = appThemeDataStore.cachedAppTheme,
)
Comment thread
PeraSite marked this conversation as resolved.
Outdated

init {
initializeApp()
}
Expand All @@ -46,6 +58,8 @@ class IntroViewModel @Inject constructor(
_uiState.value = UiState.Loading

try {
syncAppTheme()

// 1. 버전 체크 (Firebase Remote Config는 자동으로 초기화됨)
checkVersionUpdate()

Expand All @@ -60,6 +74,16 @@ class IntroViewModel @Inject constructor(
}
}

private suspend fun syncAppTheme() {
try {
val remoteTheme = firebaseRemoteConfigRepository.getAppTheme()
appThemeDataStore.setAppTheme(remoteTheme)
Timber.i("theme loaded $remoteTheme")
} catch (e: Exception) {
Timber.e(e, "앱 테마 로드 중 예외 발생")
}
}

private suspend fun checkVersionUpdate() {
try {
val minimumVersionCode = firebaseRemoteConfigRepository.getMinimumVersionCode()
Expand Down
Binary file removed app/src/main/res/drawable/img_backgroud_snow.png
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion app/src/main/res/layout/activity_intro.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
tools:context=".presentation.intro.IntroActivity">

<ImageView
android:id="@+id/ivLogo"
android:layout_width="250dp"
android:layout_height="wrap_content"
android:scaleType="centerInside"
Expand All @@ -19,4 +20,4 @@
app:layout_constraintTop_toTopOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
5 changes: 5 additions & 0 deletions app/src/main/res/mipmap-anydpi-v26/ic_launcher_spring.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@mipmap/ic_launcher_spring_foreground"/>
</adaptive-icon>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@mipmap/ic_launcher_spring_foreground"/>
</adaptive-icon>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading