Skip to content
Open
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
18 changes: 15 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"] ?: ""}\"")

Expand All @@ -67,9 +77,6 @@ android {
buildConfig = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
Expand Down Expand Up @@ -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)
Expand Down
81 changes: 81 additions & 0 deletions app/src/main/java/com/umc/edison/EdisonApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
36 changes: 36 additions & 0 deletions app/src/main/java/com/umc/edison/common/logging/AppLogger.kt
Original file line number Diff line number Diff line change
@@ -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) }
}
}
39 changes: 39 additions & 0 deletions app/src/main/java/com/umc/edison/common/logging/UserContext.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<User> {
override fun toDomain(): User {
return User(
id = id,
nickname = nickname,
profileImage = profileImage,
email = email
Expand All @@ -19,6 +21,7 @@ data class UserEntity(

fun User.toData(): UserEntity {
return UserEntity(
id = id,
nickname = nickname,
profileImage = profileImage,
email = email
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -77,7 +77,7 @@ class BubbleRepositoryImpl @Inject constructor(
)
} catch (e: Exception) {
// 로컬에 없는 버블은 무시
Log.d("BubbleRepositoryImpl", "getAllClusteredBubbles: ${e.message}")
AppLogger.d("BubbleRepositoryImpl", "getAllClusteredBubbles: ${e.message}")
}
}

Expand All @@ -99,7 +99,7 @@ class BubbleRepositoryImpl @Inject constructor(
)
} catch (e: Exception) {
// 로컬에 없는 버블은 무시
Log.d("BubbleRepositoryImpl", "getKeywordBubbles: ${e.message}")
AppLogger.d("BubbleRepositoryImpl", "getKeywordBubbles: ${e.message}")
null
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,15 +15,15 @@ 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 ->
val syncedLabel = labelRemoteDataSource.syncLabel(label)
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}")
}
}

Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
}
suspend fun clearCachedTokens()
suspend fun setCachedTokens(accessToken: String, refreshToken: String?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,15 +13,20 @@ class DefaultTokenRetryHandler @Inject constructor(
override suspend fun <T> 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)

dataAction()
}
}

private fun isUnauthorized(e: Throwable): Boolean {
return (e is ApiException && e.message == "LOGIN4004") ||
(e is HttpException && e.code() == 401)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.umc.edison.data.token

class NoRefreshTokenException : IllegalStateException("No refresh token")
class RefreshFailedException(message: String) : IllegalStateException(message)
Loading