Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
760055d
:sparkles: Feature(#135): 호스트 주소 Remote Config 기반으로 변경
SuHyeon00 Jan 24, 2026
c4c25d8
:sparkles: Feature(#135): 앱로거 추가
SuHyeon00 Jan 24, 2026
7aa1ee4
:sparkles: Feature: Add Bitrise CI configuration for Android builds
SuHyeon00 Jan 25, 2026
3e98b0b
:sparkles: Feature: Upgrade Bitrise step versions for git-clone and g…
SuHyeon00 Jan 25, 2026
8d5ae68
:sparkles: Feature: Update Bitrise workflows to rename primary and re…
SuHyeon00 Jan 25, 2026
9374703
:sparkles: Feature: Replace echo commands with envman for setting env…
SuHyeon00 Jan 25, 2026
8f57091
:sparkles: Feature: Update cache steps
SuHyeon00 Jan 25, 2026
836ba60
:sparkles: Feature: Add script to setup google-services.json files fo…
SuHyeon00 Jan 25, 2026
bca79b0
:sparkles: Feature: Upgrade restore and save gradle cache steps to ve…
SuHyeon00 Jan 25, 2026
e2b2f5a
:sparkles: Feature: Downgrade save-gradle-cache step to version 1 in …
SuHyeon00 Jan 25, 2026
fbe09ec
:sparkles: Feature: Add conditional android-sign step in Bitrise work…
SuHyeon00 Feb 8, 2026
c712274
:sparkles: Feature: Rename android-sign step to sign-apk in Bitrise w…
SuHyeon00 Feb 8, 2026
4d6507f
:sparkles: Feature: Enable APK signing in Bitrise workflows
SuHyeon00 Feb 8, 2026
465484f
:sparkles: Feature: Enhance GoogleLoginHelper with error logging
SuHyeon00 Feb 8, 2026
f934edf
:sparkles: Feature: Upgrade gradle-runner and sign-apk steps to lates…
SuHyeon00 Feb 8, 2026
c194f42
:sparkles: Refactor: Remove debug checks from logging methods in AppL…
SuHyeon00 Feb 8, 2026
dd37659
Revert ":sparkles: Refactor: Remove debug checks from logging methods…
SuHyeon00 Feb 8, 2026
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
19 changes: 16 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,6 +45,15 @@ 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")
buildConfigField("String", "BASE_URL", "\"${localProperties["base_url"] ?: ""}\"")
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

qa 빌드 유형에서 buildConfigField를 다시 정의하고 있습니다. initWith(getByName("debug"))를 통해 debug 빌드 유형의 설정을 이미 상속받았기 때문에 이 부분은 중복됩니다. 코드의 간결성과 유지보수성을 위해 이 라인을 제거하는 것이 좋습니다.

}
release {
Expand All @@ -67,9 +78,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 +133,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
69 changes: 69 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,42 @@ 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.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

@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 +48,51 @@ 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() {
remoteConfig.getString("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("base_url")
.takeIf { it.isNotBlank() }
?.let(domainProvider::setDomain)
return@launch
}

if (attempt < maxAttempts - 1) {
delay(backoff)
backoff = (backoff * 2).coerceAtMost(8_000L)
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

8_000L이라는 매직 넘버가 사용되었습니다. 이 값을 클래스 수준의 상수로 추출하면 코드의 가독성과 유지보수성이 향상됩니다. 예를 들어, 클래스 상단에 private const val MAX_BACKOFF_MS = 8_000L와 같이 정의하고, 이 라인에서는 backoff = (backoff * 2).coerceAtMost(MAX_BACKOFF_MS)와 같이 사용하는 것을 권장합니다.

}
}
}
}

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")
}
Comment on lines +15 to +18
Copy link
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The AppLogger.d implementation sends all log messages to Firebase Crashlytics regardless of the build type. While Log.d is conditionally called based on isDebug, Firebase.crashlytics.log is always executed. This can lead to sensitive information being sent to a third-party service even in release builds.

Specifically, BaseViewModel.kt (line 42) logs the entire dataResource.data object on success, which often contains PII such as user email addresses (see User and UserEntity models). Additionally, GoogleLoginHelper.kt (lines 91 and 104) logs Google ID tokens in debug builds, which are then transmitted to Crashlytics via this function.


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,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