diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1a45798f9..f85ddba42 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,6 +12,8 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.hilt.android) alias(libs.plugins.compose.compiler) + alias(libs.plugins.google.services) + alias(libs.plugins.firebase.crashlytics) } android { @@ -43,8 +45,16 @@ android { buildTypes { debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" buildConfigField("String", "BASE_URL", "\"${localProperties["base_url"] ?: ""}\"") } + create("qa") { + initWith(getByName("debug")) + applicationIdSuffix = ".qa" + versionNameSuffix = "-qa" + matchingFallbacks += listOf("debug") + } release { buildConfigField("String", "BASE_URL", "\"${localProperties["base_url"] ?: ""}\"") @@ -67,9 +77,6 @@ android { buildConfig = true viewBinding = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.1" - } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -125,6 +132,11 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.config.ktx) + implementation(libs.firebase.analytics.ktx) + implementation(libs.firebase.crashlytics.ktx) + implementation(libs.play.services.auth) implementation(libs.androidx.credentials) implementation(libs.androidx.credentials.play.services.auth) diff --git a/app/src/main/java/com/umc/edison/EdisonApplication.kt b/app/src/main/java/com/umc/edison/EdisonApplication.kt index 419f0d357..06f579acc 100644 --- a/app/src/main/java/com/umc/edison/EdisonApplication.kt +++ b/app/src/main/java/com/umc/edison/EdisonApplication.kt @@ -3,18 +3,44 @@ package com.umc.edison import android.app.Application import android.util.Log import androidx.work.Configuration +import com.google.firebase.ktx.Firebase +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings +import com.google.firebase.remoteconfig.ktx.remoteConfig +import com.umc.edison.common.logging.AppLogger import com.umc.edison.data.di.EntryPointModule import com.umc.edison.data.sync.SyncDataWorkerFactory import com.umc.edison.presentation.sync.SyncTrigger +import com.umc.edison.remote.config.DomainProvider import dagger.hilt.EntryPoints import dagger.hilt.android.HiltAndroidApp import io.branch.referral.Branch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import com.umc.edison.common.logging.UserContext +import com.umc.edison.remote.config.RemoteConfigKeys @HiltAndroidApp class EdisonApplication : Application(), Configuration.Provider { + @Inject + lateinit var domainProvider: DomainProvider + + @Inject + lateinit var userContext: UserContext + + private val remoteConfigScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val remoteConfig by lazy { Firebase.remoteConfig } + private val maxAttempts = 4 + private val initialBackoffMs = 1_000L override fun onCreate() { super.onCreate() + initCrashlyticsContext() + initRemoteConfig() // Branch SDK 초기화 Branch.getAutoInstance(this) @@ -24,6 +50,61 @@ class EdisonApplication : Application(), Configuration.Provider { syncTrigger.setupSync() } + private fun initCrashlyticsContext() { + remoteConfigScope.launch { + userContext.ensureInstallId() + userContext.setBuildInfo( + BuildConfig.BUILD_TYPE, + BuildConfig.APPLICATION_ID, + BuildConfig.VERSION_NAME + ) + } + } + + private fun initRemoteConfig() { + + val settings = FirebaseRemoteConfigSettings.Builder() + .setMinimumFetchIntervalInSeconds(if (BuildConfig.DEBUG) 0 else 60 * 60 * 12) + .build() + remoteConfig.setConfigSettingsAsync(settings) + + remoteConfig.setDefaultsAsync( + mapOf(RemoteConfigKeys.BASE_URL to BuildConfig.BASE_URL) + ) + + remoteConfig.getString(RemoteConfigKeys.BASE_URL) + .takeIf { it.isNotBlank() } + ?.let(domainProvider::setDomain) + + remoteConfigScope.launch { + var backoff = initialBackoffMs + repeat(maxAttempts) { attempt -> + val activated = try { + remoteConfig.fetchAndActivate().await() + } catch (e: Exception) { + AppLogger.w( + "EdisonApplication", + "Remote config fetch failed on attempt ${attempt + 1}", + e + ) + false + } + + if (activated) { + remoteConfig.getString(RemoteConfigKeys.BASE_URL) + .takeIf { it.isNotBlank() } + ?.let(domainProvider::setDomain) + return@launch + } + + if (attempt < maxAttempts - 1) { + delay(backoff) + backoff = (backoff * 2).coerceAtMost(8_000L) + } + } + } + } + override val workManagerConfiguration: Configuration get() { val syncDataWorkerFactory: SyncDataWorkerFactory = EntryPoints.get( diff --git a/app/src/main/java/com/umc/edison/common/logging/AppLogger.kt b/app/src/main/java/com/umc/edison/common/logging/AppLogger.kt new file mode 100644 index 000000000..560c59239 --- /dev/null +++ b/app/src/main/java/com/umc/edison/common/logging/AppLogger.kt @@ -0,0 +1,36 @@ +package com.umc.edison.common.logging + +import android.util.Log +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import com.umc.edison.BuildConfig + +object AppLogger { + private const val PREFIX_DEBUG = "D/" + private const val PREFIX_INFO = "I/" + private const val PREFIX_WARN = "W/" + private const val PREFIX_ERROR = "E/" + private val isDebug = BuildConfig.DEBUG + + fun d(tag: String, message: String) { + if (isDebug) Log.d(tag, message) + Firebase.crashlytics.log("$PREFIX_DEBUG$tag: $message") + } + + fun i(tag: String, message: String) { + if (isDebug) Log.i(tag, message) + Firebase.crashlytics.log("$PREFIX_INFO$tag: $message") + } + + fun w(tag: String, message: String, throwable: Throwable? = null) { + if (isDebug) Log.w(tag, message, throwable) + Firebase.crashlytics.log("$PREFIX_WARN$tag: $message") + throwable?.let { Firebase.crashlytics.recordException(it) } + } + + fun e(tag: String, message: String, throwable: Throwable? = null) { + if (isDebug) Log.e(tag, message, throwable) + Firebase.crashlytics.log("$PREFIX_ERROR$tag: $message") + throwable?.let { Firebase.crashlytics.recordException(it) } + } +} diff --git a/app/src/main/java/com/umc/edison/common/logging/UserContext.kt b/app/src/main/java/com/umc/edison/common/logging/UserContext.kt new file mode 100644 index 000000000..8990fcd2d --- /dev/null +++ b/app/src/main/java/com/umc/edison/common/logging/UserContext.kt @@ -0,0 +1,39 @@ +package com.umc.edison.common.logging + +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import com.umc.edison.data.datasources.PrefDataSource +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UserContext @Inject constructor( + private val prefDataSource: PrefDataSource +) { + companion object { + private const val KEY_INSTALL_ID = "install_id" + } + + suspend fun ensureInstallId(): String { + val existing: String = prefDataSource.get(KEY_INSTALL_ID, "") + if (existing.isNotBlank()) { + Firebase.crashlytics.setCustomKey(KEY_INSTALL_ID, existing) + return existing + } + val newId = UUID.randomUUID().toString() + prefDataSource.set(KEY_INSTALL_ID, newId) + Firebase.crashlytics.setCustomKey(KEY_INSTALL_ID, newId) + return newId + } + + fun setAccountId(accountId: String) { + Firebase.crashlytics.setUserId(accountId) + } + + fun setBuildInfo(buildType: String, applicationId: String, versionName: String) { + Firebase.crashlytics.setCustomKey("build_type", buildType) + Firebase.crashlytics.setCustomKey("application_id", applicationId) + Firebase.crashlytics.setCustomKey("version_name", versionName) + } +} diff --git a/app/src/main/java/com/umc/edison/data/model/user/UserEntity.kt b/app/src/main/java/com/umc/edison/data/model/user/UserEntity.kt index 5b6251268..b57379283 100644 --- a/app/src/main/java/com/umc/edison/data/model/user/UserEntity.kt +++ b/app/src/main/java/com/umc/edison/data/model/user/UserEntity.kt @@ -4,12 +4,14 @@ import com.umc.edison.data.model.DataMapper import com.umc.edison.domain.model.user.User data class UserEntity( + val id: Long? = null, val nickname: String?, val profileImage: String?, val email: String ) : DataMapper { override fun toDomain(): User { return User( + id = id, nickname = nickname, profileImage = profileImage, email = email @@ -19,6 +21,7 @@ data class UserEntity( fun User.toData(): UserEntity { return UserEntity( + id = id, nickname = nickname, profileImage = profileImage, email = email diff --git a/app/src/main/java/com/umc/edison/data/repository/BubbleRepositoryImpl.kt b/app/src/main/java/com/umc/edison/data/repository/BubbleRepositoryImpl.kt index 615de050b..f2ff3203b 100644 --- a/app/src/main/java/com/umc/edison/data/repository/BubbleRepositoryImpl.kt +++ b/app/src/main/java/com/umc/edison/data/repository/BubbleRepositoryImpl.kt @@ -1,12 +1,12 @@ package com.umc.edison.data.repository -import android.util.Log import com.umc.edison.data.bound.FlowBoundResourceFactory import com.umc.edison.data.datasources.BubbleLocalDataSource import com.umc.edison.data.datasources.BubbleRemoteDataSource import com.umc.edison.data.model.bubble.ClusteredBubbleEntity import com.umc.edison.data.model.bubble.KeywordBubbleEntity import com.umc.edison.data.model.bubble.toData +import com.umc.edison.common.logging.AppLogger import com.umc.edison.domain.DataResource import com.umc.edison.domain.model.bubble.Bubble import com.umc.edison.domain.model.bubble.ClusteredBubble @@ -77,7 +77,7 @@ class BubbleRepositoryImpl @Inject constructor( ) } catch (e: Exception) { // 로컬에 없는 버블은 무시 - Log.d("BubbleRepositoryImpl", "getAllClusteredBubbles: ${e.message}") + AppLogger.d("BubbleRepositoryImpl", "getAllClusteredBubbles: ${e.message}") } } @@ -99,7 +99,7 @@ class BubbleRepositoryImpl @Inject constructor( ) } catch (e: Exception) { // 로컬에 없는 버블은 무시 - Log.d("BubbleRepositoryImpl", "getKeywordBubbles: ${e.message}") + AppLogger.d("BubbleRepositoryImpl", "getKeywordBubbles: ${e.message}") null } } diff --git a/app/src/main/java/com/umc/edison/data/repository/SyncRepositoryImpl.kt b/app/src/main/java/com/umc/edison/data/repository/SyncRepositoryImpl.kt index fc61668b2..6575b2143 100644 --- a/app/src/main/java/com/umc/edison/data/repository/SyncRepositoryImpl.kt +++ b/app/src/main/java/com/umc/edison/data/repository/SyncRepositoryImpl.kt @@ -1,6 +1,6 @@ package com.umc.edison.data.repository -import android.util.Log +import com.umc.edison.common.logging.AppLogger import com.umc.edison.data.datasources.BubbleLocalDataSource import com.umc.edison.data.datasources.BubbleRemoteDataSource import com.umc.edison.data.datasources.LabelLocalDataSource @@ -15,7 +15,7 @@ class SyncRepositoryImpl @Inject constructor( private val labelLocalDataSource: LabelLocalDataSource, ) : SyncRepository { override suspend fun syncLocalDataToServer() { - Log.i("syncLocalDataToServer", "syncLocalDataToServer is started") + AppLogger.i("syncLocalDataToServer", "syncLocalDataToServer is started") val unSyncedLabels = labelLocalDataSource.getUnSyncedLabels() unSyncedLabels.forEach { label -> @@ -23,7 +23,7 @@ class SyncRepositoryImpl @Inject constructor( if (syncedLabel.same(label)) { labelLocalDataSource.markAsSynced(syncedLabel) } else { - Log.e("syncLocalDataToServer", "Failed to sync label: ${label.id}") + AppLogger.e("syncLocalDataToServer", "Failed to sync label: ${label.id}") } } @@ -33,13 +33,13 @@ class SyncRepositoryImpl @Inject constructor( if (syncedBubble.same(bubble)) { bubbleLocalDataSource.markAsSynced(bubble) } else { - Log.e("syncLocalDataToServer", "Failed to sync bubble: ${bubble.id}") + AppLogger.e("syncLocalDataToServer", "Failed to sync bubble: ${bubble.id}") } } } override suspend fun syncServerDataToLocal() { - Log.i("syncServerDataToLocal", "syncServerDataToLocal is started") + AppLogger.i("syncServerDataToLocal", "syncServerDataToLocal is started") val remoteLabels = labelRemoteDataSource.getAllLabels() labelLocalDataSource.syncLabels(remoteLabels) diff --git a/app/src/main/java/com/umc/edison/data/token/AccessTokenProvider.kt b/app/src/main/java/com/umc/edison/data/token/AccessTokenProvider.kt index 3d86a8878..f795277fa 100644 --- a/app/src/main/java/com/umc/edison/data/token/AccessTokenProvider.kt +++ b/app/src/main/java/com/umc/edison/data/token/AccessTokenProvider.kt @@ -3,6 +3,6 @@ package com.umc.edison.data.token interface AccessTokenProvider { fun getAccessToken(): String? fun getRefreshToken(): String? - fun clearCachedTokens() - fun setCachedTokens(accessToken: String, refreshToken: String?) -} \ No newline at end of file + suspend fun clearCachedTokens() + suspend fun setCachedTokens(accessToken: String, refreshToken: String?) +} diff --git a/app/src/main/java/com/umc/edison/data/token/DefaultTokenRetryHandler.kt b/app/src/main/java/com/umc/edison/data/token/DefaultTokenRetryHandler.kt index 16c1cdcc3..270e6f203 100644 --- a/app/src/main/java/com/umc/edison/data/token/DefaultTokenRetryHandler.kt +++ b/app/src/main/java/com/umc/edison/data/token/DefaultTokenRetryHandler.kt @@ -3,6 +3,7 @@ package com.umc.edison.data.token import com.google.android.gms.common.api.ApiException import com.umc.edison.data.datasources.UserRemoteDataSource import javax.inject.Inject +import retrofit2.HttpException class DefaultTokenRetryHandler @Inject constructor( private val userRemoteDataSource: UserRemoteDataSource, @@ -12,10 +13,10 @@ class DefaultTokenRetryHandler @Inject constructor( override suspend fun runWithTokenRetry(dataAction: suspend () -> T): T { return try { dataAction() - } catch (e: ApiException) { - if (e.message != "LOGIN4004") throw e + } catch (e: Throwable) { + if (!isUnauthorized(e)) throw e - val refreshToken = tokenManager.loadRefreshToken() ?: throw IllegalStateException("No refresh token") + val refreshToken = tokenManager.loadRefreshToken() ?: throw NoRefreshTokenException() val newAccessToken = userRemoteDataSource.refreshAccessToken(refreshToken) tokenManager.setToken(newAccessToken, refreshToken) @@ -23,4 +24,9 @@ class DefaultTokenRetryHandler @Inject constructor( dataAction() } } + + private fun isUnauthorized(e: Throwable): Boolean { + return (e is ApiException && e.message == "LOGIN4004") || + (e is HttpException && e.code() == 401) + } } diff --git a/app/src/main/java/com/umc/edison/data/token/TokenExceptions.kt b/app/src/main/java/com/umc/edison/data/token/TokenExceptions.kt new file mode 100644 index 000000000..21a73e02c --- /dev/null +++ b/app/src/main/java/com/umc/edison/data/token/TokenExceptions.kt @@ -0,0 +1,4 @@ +package com.umc.edison.data.token + +class NoRefreshTokenException : IllegalStateException("No refresh token") +class RefreshFailedException(message: String) : IllegalStateException(message) diff --git a/app/src/main/java/com/umc/edison/data/token/TokenManager.kt b/app/src/main/java/com/umc/edison/data/token/TokenManager.kt index 7c8987ca6..2f5f0a186 100644 --- a/app/src/main/java/com/umc/edison/data/token/TokenManager.kt +++ b/app/src/main/java/com/umc/edison/data/token/TokenManager.kt @@ -1,11 +1,13 @@ package com.umc.edison.data.token -import javax.inject.Inject -import javax.inject.Singleton import com.umc.edison.data.datasources.PrefDataSource import com.umc.edison.data.di.ApplicationScope +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @Singleton class TokenManager @Inject constructor( @@ -13,68 +15,76 @@ class TokenManager @Inject constructor( @ApplicationScope private val applicationScope: CoroutineScope ) : AccessTokenProvider { - init { - applicationScope.launch { - loadAccessToken() - loadRefreshToken() - } - } + private val mutex = Mutex() private var cachedAccessToken: String? = null private var cachedRefreshToken: String? = null - override fun getAccessToken(): String? { - if (cachedAccessToken == null) { - println("⚠️ Warning: access token not cached. Consider calling loadAccessToken() at app startup.") + init { + applicationScope.launch { + preloadTokens() } - return cachedAccessToken } - override fun getRefreshToken(): String? { - if (cachedRefreshToken == null) { - println("⚠️ Warning: refresh token not cached. Consider calling loadRefreshToken() at app startup.") - } - return cachedRefreshToken - } + override fun getAccessToken(): String? = cachedAccessToken - override fun clearCachedTokens() { - cachedAccessToken = null - cachedRefreshToken = null + override fun getRefreshToken(): String? = cachedRefreshToken + + override suspend fun clearCachedTokens() { + mutex.withLock { + cachedAccessToken = null + cachedRefreshToken = null + } } - override fun setCachedTokens(accessToken: String, refreshToken: String?) { - cachedAccessToken = accessToken - cachedRefreshToken = refreshToken + override suspend fun setCachedTokens(accessToken: String, refreshToken: String?) { + mutex.withLock { + cachedAccessToken = accessToken + cachedRefreshToken = refreshToken + } } suspend fun loadAccessToken(): String? { - val token = prefDataSource.get(ACCESS_TOKEN_KEY, "") - cachedAccessToken = token.ifEmpty { null } - - return token + return mutex.withLock { + val token = prefDataSource.get(ACCESS_TOKEN_KEY, "") + cachedAccessToken = token.ifEmpty { null } + token + } } suspend fun loadRefreshToken(): String? { - val token = prefDataSource.get(REFRESH_TOKEN_KEY, "") - cachedRefreshToken = token.ifEmpty { null } - - return token + return mutex.withLock { + val token = prefDataSource.get(REFRESH_TOKEN_KEY, "") + cachedRefreshToken = token.ifEmpty { null } + token + } } suspend fun setToken(accessToken: String, refreshToken: String? = null) { - cachedAccessToken = accessToken - prefDataSource.set(ACCESS_TOKEN_KEY, accessToken) - refreshToken?.let { - prefDataSource.set(REFRESH_TOKEN_KEY, it) - cachedRefreshToken = it + mutex.withLock { + cachedAccessToken = accessToken + prefDataSource.set(ACCESS_TOKEN_KEY, accessToken) + refreshToken?.let { + prefDataSource.set(REFRESH_TOKEN_KEY, it) + cachedRefreshToken = it + } } } suspend fun deleteToken() { - cachedAccessToken = null - cachedRefreshToken = null - prefDataSource.remove(ACCESS_TOKEN_KEY) - prefDataSource.remove(REFRESH_TOKEN_KEY) + mutex.withLock { + cachedAccessToken = null + cachedRefreshToken = null + prefDataSource.remove(ACCESS_TOKEN_KEY) + prefDataSource.remove(REFRESH_TOKEN_KEY) + } + } + + private suspend fun preloadTokens() { + mutex.withLock { + cachedAccessToken = prefDataSource.get(ACCESS_TOKEN_KEY, "").ifEmpty { null } + cachedRefreshToken = prefDataSource.get(REFRESH_TOKEN_KEY, "").ifEmpty { null } + } } companion object { diff --git a/app/src/main/java/com/umc/edison/domain/model/user/User.kt b/app/src/main/java/com/umc/edison/domain/model/user/User.kt index f3a76c53b..057674cf9 100644 --- a/app/src/main/java/com/umc/edison/domain/model/user/User.kt +++ b/app/src/main/java/com/umc/edison/domain/model/user/User.kt @@ -1,6 +1,7 @@ package com.umc.edison.domain.model.user data class User( + val id: Long?, val nickname: String?, val profileImage: String?, val email: String diff --git a/app/src/main/java/com/umc/edison/presentation/base/BaseViewModel.kt b/app/src/main/java/com/umc/edison/presentation/base/BaseViewModel.kt index a0dabf193..4279645e8 100644 --- a/app/src/main/java/com/umc/edison/presentation/base/BaseViewModel.kt +++ b/app/src/main/java/com/umc/edison/presentation/base/BaseViewModel.kt @@ -1,8 +1,8 @@ package com.umc.edison.presentation.base -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.umc.edison.common.logging.AppLogger import com.umc.edison.domain.DataResource import com.umc.edison.presentation.ToastManager import kotlinx.coroutines.flow.Flow @@ -31,7 +31,7 @@ open class BaseViewModel @Inject constructor( ) { viewModelScope.launch { flow.onCompletion { - Log.d("collectDataResource", "onComplete") + AppLogger.d("collectDataResource", "onComplete") _baseState.update { it.copy(isLoading = false) } @@ -39,12 +39,12 @@ open class BaseViewModel @Inject constructor( }.collect { dataResource -> when (dataResource) { is DataResource.Success -> { - Log.d("collectDataResource", "onSuccess: ${dataResource.data}") + AppLogger.d("collectDataResource", "onSuccess: ${dataResource.data}") onSuccess(dataResource.data) } is DataResource.Error -> { - Log.e("collectDataResource", "onError: ${dataResource.throwable}") + AppLogger.e("collectDataResource", "onError: ${dataResource.throwable}", dataResource.throwable) _baseState.update { it.copy(error = dataResource.throwable) } @@ -52,7 +52,7 @@ open class BaseViewModel @Inject constructor( } is DataResource.Loading -> { - Log.d("collectDataResource", "onLoading") + AppLogger.d("collectDataResource", "onLoading") _baseState.update { it.copy(isLoading = true) } diff --git a/app/src/main/java/com/umc/edison/presentation/login/GoogleLoginHelper.kt b/app/src/main/java/com/umc/edison/presentation/login/GoogleLoginHelper.kt index 02245bf7e..6ff6ed07e 100644 --- a/app/src/main/java/com/umc/edison/presentation/login/GoogleLoginHelper.kt +++ b/app/src/main/java/com/umc/edison/presentation/login/GoogleLoginHelper.kt @@ -1,7 +1,6 @@ package com.umc.edison.presentation.login import android.content.Context -import android.util.Log import androidx.credentials.CredentialManager import androidx.credentials.CustomCredential import androidx.credentials.GetCredentialRequest @@ -11,6 +10,8 @@ import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.umc.edison.BuildConfig import com.umc.edison.R +import com.umc.edison.common.logging.AppLogger +import com.umc.edison.common.logging.UserContext import com.umc.edison.domain.DataResource import com.umc.edison.domain.usecase.user.GoogleLoginUseCase import com.umc.edison.domain.usecase.sync.SyncServerDataToLocalUseCase @@ -28,6 +29,7 @@ class GoogleLoginHelper @Inject constructor( private val googleLoginUseCase: GoogleLoginUseCase, private val syncLocalDataToServerUseCase: SyncLocalDataToServerUseCase, private val syncServerDataToLocalUseCase: SyncServerDataToLocalUseCase, + private val userContext: UserContext, ) { private val coroutineScope = MainScope() @@ -75,7 +77,7 @@ class GoogleLoginHelper @Inject constructor( is GoogleIdTokenCredential -> { val idToken = credential.idToken if (BuildConfig.DEBUG){ - Log.d("Google SignIn", "ID Token: $idToken") + AppLogger.d("Google SignIn", "ID Token: $idToken") } sendIdTokenToServer(idToken, onResult) @@ -88,11 +90,11 @@ class GoogleLoginHelper @Inject constructor( GoogleIdTokenCredential.createFrom(credential.data) val idToken = googleIdTokenCredential.idToken if (BuildConfig.DEBUG){ - Log.d("Google SignIn", "ID Token (CustomCredential): $idToken") + AppLogger.d("Google SignIn", "ID Token (CustomCredential): $idToken") } sendIdTokenToServer(idToken, onResult) } catch (e: Exception) { - Log.e("Google SignIn", "Received an invalid Google ID token response", e) + AppLogger.e("Google SignIn", "Received an invalid Google ID token response", e) onResult(GoogleLoginState.Failure(GoogleLoginState.ERROR_MESSAGE_INVALID_TOKEN)) } } else { @@ -115,32 +117,34 @@ class GoogleLoginHelper @Inject constructor( when (result) { is DataResource.Success -> { onResult(GoogleLoginState.Success(result.data.toPresentation())) + // Crashlytics user 식별자 설정 + result.data.id?.let { userContext.setAccountId(it.toString()) } try { syncLocalDataToServerUseCase() } catch (e: Throwable) { - Log.e("Init sync local to server data", "Failed to sync data", e) + AppLogger.e("Init sync local to server data", "Failed to sync data", e) } try { syncServerDataToLocalUseCase() } catch (e: Throwable) { - Log.e("Init sync server to local data", "Failed to sync data", e) + AppLogger.e("Init sync server to local data", "Failed to sync data", e) } } is DataResource.Error -> { val t = result.throwable - Log.e("Google SignIn", "로그인 실패", t) + AppLogger.e("Google SignIn", "로그인 실패", t) val errorCode = (t as? HttpException)?.let { exception -> exception.response()?.errorBody()?.string()?.also { errorBody -> - Log.e("Google SignIn", "errorBody: $errorBody") + AppLogger.e("Google SignIn", "errorBody: $errorBody") }?.let { errorBody -> runCatching { JSONObject(errorBody).optString("code") .takeIf { it.isNotEmpty() } } - .onFailure { Log.e("Google SignIn", "에러 바디 파싱 실패", it) } + .onFailure { AppLogger.e("Google SignIn", "에러 바디 파싱 실패", it) } .getOrNull() } } diff --git a/app/src/main/java/com/umc/edison/presentation/model/UserModel.kt b/app/src/main/java/com/umc/edison/presentation/model/UserModel.kt index 6586bc3bf..c082c2926 100644 --- a/app/src/main/java/com/umc/edison/presentation/model/UserModel.kt +++ b/app/src/main/java/com/umc/edison/presentation/model/UserModel.kt @@ -17,6 +17,7 @@ data class UserModel( fun toDomain(): User { return User( + id = null, nickname = nickname, profileImage = profileImage, email = email, diff --git a/app/src/main/java/com/umc/edison/remote/api/RefreshTokenApiService.kt b/app/src/main/java/com/umc/edison/remote/api/RefreshTokenApiService.kt index 1fd10ff24..2f7f88b29 100644 --- a/app/src/main/java/com/umc/edison/remote/api/RefreshTokenApiService.kt +++ b/app/src/main/java/com/umc/edison/remote/api/RefreshTokenApiService.kt @@ -7,5 +7,5 @@ import retrofit2.http.POST interface RefreshTokenApiService { @POST("members/refresh") - fun refreshToken(@Header("Refresh-Token") refreshToken: String): ResponseWithData + suspend fun refreshToken(@Header("Refresh-Token") refreshToken: String): ResponseWithData } \ No newline at end of file diff --git a/app/src/main/java/com/umc/edison/remote/config/DomainProvider.kt b/app/src/main/java/com/umc/edison/remote/config/DomainProvider.kt new file mode 100644 index 000000000..aa1a57d99 --- /dev/null +++ b/app/src/main/java/com/umc/edison/remote/config/DomainProvider.kt @@ -0,0 +1,18 @@ +package com.umc.edison.remote.config + +import com.umc.edison.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.Volatile + +@Singleton +class DomainProvider @Inject constructor() { + @Volatile private var current: String = BuildConfig.BASE_URL + + fun getDomain(): String = current + fun setDomain(domain: String) { + if (domain.isNotBlank() && domain != current) { + current = domain + } + } +} diff --git a/app/src/main/java/com/umc/edison/remote/config/RemoteConfigKeys.kt b/app/src/main/java/com/umc/edison/remote/config/RemoteConfigKeys.kt new file mode 100644 index 000000000..ceb97d787 --- /dev/null +++ b/app/src/main/java/com/umc/edison/remote/config/RemoteConfigKeys.kt @@ -0,0 +1,13 @@ +package com.umc.edison.remote.config + +object RemoteConfigKeys { + /** + * API Base URL + */ + const val BASE_URL = "base_url" + + /** + * Branch SDK Key + */ + const val BRANCH_KEY = "branch_key" +} diff --git a/app/src/main/java/com/umc/edison/remote/datasources/UserRemoteDataSourceImpl.kt b/app/src/main/java/com/umc/edison/remote/datasources/UserRemoteDataSourceImpl.kt index 0006e1026..e4f109248 100644 --- a/app/src/main/java/com/umc/edison/remote/datasources/UserRemoteDataSourceImpl.kt +++ b/app/src/main/java/com/umc/edison/remote/datasources/UserRemoteDataSourceImpl.kt @@ -14,6 +14,7 @@ import com.umc.edison.remote.model.login.toSetIdentityKeywordRequest import com.umc.edison.remote.model.mypage.toUpdateTestRequest import com.umc.edison.remote.model.mypage.toUpdateProfileRequest import com.umc.edison.remote.api.RefreshTokenApiService +import com.umc.edison.data.token.RefreshFailedException import com.umc.edison.remote.model.login.SignUpRequest import javax.inject.Inject @@ -59,7 +60,11 @@ class UserRemoteDataSourceImpl @Inject constructor( } override suspend fun refreshAccessToken(refreshToken: String): String { - return refreshTokenApiService.refreshToken(refreshToken).data.accessToken + val response = refreshTokenApiService.refreshToken(refreshToken) + if (!response.isSuccess) { + throw RefreshFailedException("Refresh token failed: ${response.code}") + } + return response.data.accessToken } // READ diff --git a/app/src/main/java/com/umc/edison/remote/di/NetworkModule.kt b/app/src/main/java/com/umc/edison/remote/di/NetworkModule.kt index 2ae54265d..13453adc3 100644 --- a/app/src/main/java/com/umc/edison/remote/di/NetworkModule.kt +++ b/app/src/main/java/com/umc/edison/remote/di/NetworkModule.kt @@ -3,6 +3,7 @@ package com.umc.edison.remote.di import com.umc.edison.BuildConfig import com.umc.edison.remote.token.AccessTokenInterceptor import com.umc.edison.data.token.TokenManager +import com.umc.edison.remote.interceptor.HostSelectionInterceptor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -50,10 +51,12 @@ object NetworkModule { fun provideOkHttpClient( httpLoggingInterceptor: HttpLoggingInterceptor, accessTokenInterceptor: AccessTokenInterceptor, + hostSelectionInterceptor: HostSelectionInterceptor, ) : OkHttpClient = OkHttpClient.Builder() .connectTimeout(TIME_OUT.toLong(), TimeUnit.SECONDS) .readTimeout(TIME_OUT.toLong(), TimeUnit.SECONDS) .writeTimeout(TIME_OUT.toLong(), TimeUnit.SECONDS) + .addInterceptor(hostSelectionInterceptor) .addInterceptor(httpLoggingInterceptor) .addInterceptor(accessTokenInterceptor) .build() diff --git a/app/src/main/java/com/umc/edison/remote/interceptor/HostSelectionInterceptor.kt b/app/src/main/java/com/umc/edison/remote/interceptor/HostSelectionInterceptor.kt new file mode 100644 index 000000000..dd1eea777 --- /dev/null +++ b/app/src/main/java/com/umc/edison/remote/interceptor/HostSelectionInterceptor.kt @@ -0,0 +1,28 @@ +package com.umc.edison.remote.interceptor + +import com.umc.edison.remote.config.DomainProvider +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class HostSelectionInterceptor @Inject constructor( + private val domainProvider: DomainProvider +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val base = domainProvider.getDomain().toHttpUrlOrNull() + val newReq = base?.let { + val newUrl = originalRequest.url.newBuilder() + .scheme(it.scheme) + .host(it.host) + .port(it.port) + .build() + + originalRequest.newBuilder() + .url(newUrl) + .build() + } ?: originalRequest + return chain.proceed(newReq) + } +} diff --git a/app/src/main/java/com/umc/edison/remote/model/login/LoginResponse.kt b/app/src/main/java/com/umc/edison/remote/model/login/LoginResponse.kt index 19286e565..ac33d93db 100644 --- a/app/src/main/java/com/umc/edison/remote/model/login/LoginResponse.kt +++ b/app/src/main/java/com/umc/edison/remote/model/login/LoginResponse.kt @@ -26,6 +26,7 @@ data class LoginResponse( fun toUserEntity(): UserEntity = UserEntity( + id = memberId, nickname = nickname, profileImage = null, email = email diff --git a/app/src/main/java/com/umc/edison/remote/model/login/SignUpResponse.kt b/app/src/main/java/com/umc/edison/remote/model/login/SignUpResponse.kt index 46eb1957b..9fb1e09b2 100644 --- a/app/src/main/java/com/umc/edison/remote/model/login/SignUpResponse.kt +++ b/app/src/main/java/com/umc/edison/remote/model/login/SignUpResponse.kt @@ -27,6 +27,7 @@ data class SignUpResponse( fun toUserEntity(): UserEntity = UserEntity( + id = memberId, nickname = nickname, profileImage = null, email = email diff --git a/app/src/main/java/com/umc/edison/remote/token/AccessTokenInterceptor.kt b/app/src/main/java/com/umc/edison/remote/token/AccessTokenInterceptor.kt index 438fa2824..1f4497cea 100644 --- a/app/src/main/java/com/umc/edison/remote/token/AccessTokenInterceptor.kt +++ b/app/src/main/java/com/umc/edison/remote/token/AccessTokenInterceptor.kt @@ -1,5 +1,6 @@ package com.umc.edison.remote.token +import com.umc.edison.common.logging.AppLogger import com.umc.edison.data.token.AccessTokenProvider import okhttp3.Interceptor import okhttp3.Response @@ -19,6 +20,8 @@ class AccessTokenInterceptor @Inject constructor( val requestBuilder = chain.request().newBuilder() if (!token.isNullOrEmpty()) { requestBuilder.addHeader(HEADER_AUTHORIZATION, "$TOKEN_TYPE $token") + } else { + AppLogger.w("AccessTokenInterceptor", "Access token missing, sending request without Authorization header") } return chain.proceed(requestBuilder.build()) diff --git a/app/src/main/java/com/umc/edison/ui/artboard/ArtLetterDetailScreen.kt b/app/src/main/java/com/umc/edison/ui/artboard/ArtLetterDetailScreen.kt index a962bea69..83795e366 100644 --- a/app/src/main/java/com/umc/edison/ui/artboard/ArtLetterDetailScreen.kt +++ b/app/src/main/java/com/umc/edison/ui/artboard/ArtLetterDetailScreen.kt @@ -1,7 +1,6 @@ package com.umc.edison.ui.artboard import android.content.Intent -import android.util.Log import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable @@ -68,6 +67,7 @@ import com.umc.edison.ui.theme.Gray300 import com.umc.edison.ui.theme.Gray500 import com.umc.edison.ui.theme.Gray600 import com.umc.edison.ui.theme.Gray800 +import com.umc.edison.common.logging.AppLogger import io.branch.indexing.BranchUniversalObject import io.branch.referral.util.ContentMetadata import io.branch.referral.util.LinkProperties @@ -247,7 +247,7 @@ fun ArtLetterDetailScreen( val shareIntent = Intent.createChooser(sendIntent, null) context.startActivity(shareIntent) } else { - Log.e("BranchShare", "Branch error: ${error.message}") + AppLogger.e("BranchShare", "Branch error: ${error.message}") } } }, diff --git a/app/src/main/java/com/umc/edison/ui/components/ImageGallery.kt b/app/src/main/java/com/umc/edison/ui/components/ImageGallery.kt index a962af40c..072604e15 100644 --- a/app/src/main/java/com/umc/edison/ui/components/ImageGallery.kt +++ b/app/src/main/java/com/umc/edison/ui/components/ImageGallery.kt @@ -5,7 +5,7 @@ import android.content.Context import android.net.Uri import android.os.Build import android.provider.MediaStore -import android.util.Log +import com.umc.edison.common.logging.AppLogger import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -249,7 +249,7 @@ fun loadGalleryImages(context: Context, folder: String): List { val name = it.getString(nameColumn) val contentUri = ContentUris.withAppendedId(uriExternal, id) - Log.d("GalleryImage", "Image: $name, URI: $contentUri") + AppLogger.d("GalleryImage", "Image: $name, URI: $contentUri") images.add(contentUri) } } diff --git a/build.gradle.kts b/build.gradle.kts index 03cd1a9dc..6b63fa7c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,6 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt.android) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 832feea83..fee3e716a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,12 +26,16 @@ okhttp = "4.12.0" retrofit = "2.10.0" gson = "2.10.1" coil = "3.0.1" -hilt = "2.51.1" +hilt = "2.57.1" hiltCommon = "1.2.0" work = "2.10.2" foundationLayoutAndroid = "1.8.3" foundationAndroid = "1.8.3" hiltWork = "1.2.0" +firebaseBom = "33.1.0" +googleServices = "4.4.4" +crashlytics = "18.6.2" +crashlyticsPlugin = "3.0.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -83,10 +87,16 @@ androidx-foundation-layout-android = { group = "androidx.compose.foundation", na androidx-foundation-android = { group = "androidx.compose.foundation", name = "foundation-android", version.ref = "foundationAndroid" } richeditor-compose = { module = "com.mohamedrejeb.richeditor:richeditor-compose", version.ref = "richeditorCompose" } androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-config-ktx = { module = "com.google.firebase:firebase-config-ktx" } +firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics-ktx" } +firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics-ktx", version.ref = "crashlytics" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } -compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "crashlyticsPlugin" } \ No newline at end of file