diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1a45798f9..645d10a55 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,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"] ?: ""}\"") } release { @@ -67,9 +78,6 @@ android { buildConfig = true viewBinding = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.1" - } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -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) diff --git a/app/src/main/java/com/umc/edison/EdisonApplication.kt b/app/src/main/java/com/umc/edison/EdisonApplication.kt index 419f0d357..01e2b32c9 100644 --- a/app/src/main/java/com/umc/edison/EdisonApplication.kt +++ b/app/src/main/java/com/umc/edison/EdisonApplication.kt @@ -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) @@ -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) + } + } + } + } + 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/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..4bb5dbe79 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,15 @@ package com.umc.edison.data.token -import javax.inject.Inject -import javax.inject.Singleton +import com.umc.edison.common.logging.AppLogger 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.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @Singleton class TokenManager @Inject constructor( @@ -13,72 +17,100 @@ class TokenManager @Inject constructor( @ApplicationScope private val applicationScope: CoroutineScope ) : AccessTokenProvider { - init { - applicationScope.launch { - loadAccessToken() - loadRefreshToken() - } - } + private val mutex = Mutex() + private val loadJob = applicationScope.launch { preloadTokens() } 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.") - } + ensureLoaded() return cachedAccessToken } override fun getRefreshToken(): String? { - if (cachedRefreshToken == null) { - println("⚠️ Warning: refresh token not cached. Consider calling loadRefreshToken() at app startup.") - } + ensureLoaded() return cachedRefreshToken } override fun clearCachedTokens() { - cachedAccessToken = null - cachedRefreshToken = null + runBlocking { + mutex.withLock { + cachedAccessToken = null + cachedRefreshToken = null + } + } } override fun setCachedTokens(accessToken: String, refreshToken: String?) { - cachedAccessToken = accessToken - cachedRefreshToken = refreshToken + runBlocking { + 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) + } } companion object { private const val ACCESS_TOKEN_KEY = "access_token" private const val REFRESH_TOKEN_KEY = "refresh_token" + + private const val TAG = "TokenManager" + } + + private suspend fun preloadTokens() { + mutex.withLock { + cachedAccessToken = prefDataSource.get(ACCESS_TOKEN_KEY, "").ifEmpty { null } + cachedRefreshToken = prefDataSource.get(REFRESH_TOKEN_KEY, "").ifEmpty { null } + } + } + + private fun ensureLoaded() { + if (cachedAccessToken != null || cachedRefreshToken != null) return + if (!loadJob.isCompleted) { + runBlocking { + try { + loadJob.join() + } catch (e: Exception) { + AppLogger.w(TAG, "Failed to preload tokens: ${e.message}", e) + } + } + } } } 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..b453d104e 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 @@ -9,8 +8,12 @@ import androidx.credentials.GetCredentialResponse import androidx.credentials.exceptions.GetCredentialException import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.firebase.crashlytics.ktx.crashlytics import com.umc.edison.BuildConfig import com.umc.edison.R +import com.google.firebase.ktx.Firebase +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 +31,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() @@ -57,8 +61,17 @@ class GoogleLoginHelper @Inject constructor( is androidx.credentials.exceptions.GetCredentialCancellationException -> GoogleLoginState.ERROR_MESSAGE_CANCELLED - else -> + else -> { + AppLogger.e( + "Google SignIn", + "GetCredentialException: ${e.javaClass.simpleName} - ${e.message}", + e + ) + Firebase.crashlytics.setCustomKey("google_signin_phase", "get_credential") + Firebase.crashlytics.setCustomKey("google_signin_error_type", e.javaClass.simpleName) + Firebase.crashlytics.setCustomKey("google_signin_error_message", e.message ?: "") GoogleLoginState.ERROR_MESSAGE_UNKNOWN + } } onResult(GoogleLoginState.Failure(errorMessage)) } @@ -75,7 +88,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,19 +101,21 @@ 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 { + AppLogger.w("Google SignIn", "CustomCredential type mismatch: ${credential.type}") onResult(GoogleLoginState.Failure()) } } else -> { + AppLogger.w("Google SignIn", "Unknown credential type: ${credential.javaClass.simpleName}") onResult(GoogleLoginState.Failure()) } } @@ -115,32 +130,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/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/app/src/main/java/com/umc/edison/ui/navigation/NavRoute.kt b/app/src/main/java/com/umc/edison/ui/navigation/NavRoute.kt index 91c63f22f..6fa8d7e56 100644 --- a/app/src/main/java/com/umc/edison/ui/navigation/NavRoute.kt +++ b/app/src/main/java/com/umc/edison/ui/navigation/NavRoute.kt @@ -69,7 +69,7 @@ sealed class NavRoute(val route: String) { data object BubbleEdit : NavRoute("$MY_EDISON_ROUTE/edit") { fun createRoute(bubbleId: String?): String { - return if (bubbleId != null) { + return if (!bubbleId.isNullOrEmpty()) { "$route?bubbleId=$bubbleId" } else { route diff --git a/bitrise.yml b/bitrise.yml new file mode 100644 index 000000000..6240ab68c --- /dev/null +++ b/bitrise.yml @@ -0,0 +1,413 @@ +format_version: '11' +default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git + +workflows: + build-qa: + steps: + - activate-ssh-key@4: + run_if: '{{getenv "SSH_RSA_PRIVATE_KEY" | ne ""}}' + + - git-clone@8.4.0: + inputs: + - clone_depth: 1 + + - install-missing-android-tools@3: + inputs: + - gradlew_path: "$PROJECT_LOCATION/gradlew" + + - restore-gradle-cache@2.2.4: {} + + - script@1: + title: Setup google-services.json files + inputs: + - content: | + #!/bin/bash + set -e + + if [ -z "$BUILD_TYPE" ]; then + if [ "$BITRISE_GIT_BRANCH" = "main" ] || [ "$BITRISE_GIT_BRANCH" = "master" ]; then + BUILD_TYPE="release" + else + BUILD_TYPE="qa" + fi + fi + + if [ -n "$GOOGLE_SERVICES_JSON_DEBUG" ]; then + mkdir -p app/src/debug + echo "$GOOGLE_SERVICES_JSON_DEBUG" | base64 -d > app/src/debug/google-services.json + echo "✓ Created app/src/debug/google-services.json" + else + echo "⚠ Warning: GOOGLE_SERVICES_JSON_DEBUG not set" + fi + + if [ -n "$GOOGLE_SERVICES_JSON_QA" ]; then + mkdir -p app/src/qa + echo "$GOOGLE_SERVICES_JSON_QA" | base64 -d > app/src/qa/google-services.json + echo "✓ Created app/src/qa/google-services.json" + else + echo "⚠ Warning: GOOGLE_SERVICES_JSON_QA not set" + fi + + if [ -n "$GOOGLE_SERVICES_JSON_RELEASE" ]; then + mkdir -p app/src/release + echo "$GOOGLE_SERVICES_JSON_RELEASE" | base64 -d > app/src/release/google-services.json + echo "✓ Created app/src/release/google-services.json" + else + echo "⚠ Warning: GOOGLE_SERVICES_JSON_RELEASE not set" + fi + + echo "Google Services JSON files setup completed for build type: $BUILD_TYPE" + + - script@1: + title: Set environment variables + inputs: + - content: | + #!/bin/bash + set -e + + if [ -z "$ENVIRONMENT" ] || [ "$ENVIRONMENT" = "auto" ]; then + if [ "$BITRISE_GIT_BRANCH" = "main" ] || [ "$BITRISE_GIT_BRANCH" = "master" ]; then + ENVIRONMENT="production" + else + ENVIRONMENT="development" + fi + fi + + if [ -z "$BUILD_TYPE" ]; then + if [ "$ENVIRONMENT" = "production" ]; then + BUILD_TYPE="release" + else + BUILD_TYPE="qa" + fi + fi + + if [ "$ENVIRONMENT" = "production" ]; then + export BASE_URL="$PRODUCTION_BASE_URL" + export BRANCH_KEY="$PRODUCTION_BRANCH_KEY" + else + export BASE_URL="$DEVELOPMENT_BASE_URL" + export BRANCH_KEY="$DEVELOPMENT_BRANCH_KEY" + fi + + envman add --key BUILD_TYPE --value "$BUILD_TYPE" + + echo "Environment: $ENVIRONMENT" + echo "Build Type: $BUILD_TYPE" + if [ -n "$BASE_URL" ]; then + MASKED_URL=$(echo "$BASE_URL" | sed 's/\(https\?:\/\/[^\/]*\).*/\1\/***/') + echo "BASE_URL: $MASKED_URL (masked)" + else + echo "BASE_URL: (not set)" + fi + + echo "sdk.dir=$ANDROID_HOME" > local.properties + echo "base_url=$BASE_URL" >> local.properties + echo "branch_key=$BRANCH_KEY" >> local.properties + + - script@1: + title: Build APK with correct build type + inputs: + - content: | + #!/bin/bash + set -e + + case "$BUILD_TYPE" in + debug) + GRADLE_TASK="assembleDebug" + ;; + qa) + GRADLE_TASK="assembleQa" + ;; + release) + GRADLE_TASK="assembleRelease" + ;; + *) + echo "Unknown BUILD_TYPE: $BUILD_TYPE, defaulting to debug" + GRADLE_TASK="assembleDebug" + ;; + esac + + echo "Gradle Task: $GRADLE_TASK" + envman add --key GRADLE_TASK --value "$GRADLE_TASK" + + - gradle-runner@5.0.0: + inputs: + - gradle_file: "$PROJECT_LOCATION/build.gradle.kts" + - gradle_task: $GRADLE_TASK + - gradle_options: "--stacktrace" + + - sign-apk@2.0.1: + run_if: '{{getenv "BITRISEIO_ANDROID_KEYSTORE_URL" | ne ""}}' + inputs: + - use_apk_signer: "true" + + - save-gradle-cache@1: {} + + - deploy-to-bitrise-io@2: + inputs: + - deploy_path: "$BITRISE_APK_PATH" + - notify_user_groups: none + - is_enable_public_page: "true" + + - script@1: + title: Send Discord notification + run_if: '{{getenv "DISCORD_WEBHOOK_URL" | ne ""}}' + inputs: + - content: | + #!/bin/bash + set -e + + if [ "$BITRISE_BUILD_STATUS" = "0" ]; then + EMOJI="✅" + STATUS="Build Success" + COLOR=3066993 + else + EMOJI="❌" + STATUS="Build Failed" + COLOR=15158332 + fi + + jq -n \ + --arg username "Android Build Bot" \ + --arg avatar_url "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" \ + --arg content "${EMOJI} **${STATUS}**" \ + --arg branch "$BITRISE_GIT_BRANCH" \ + --arg build_type "$BUILD_TYPE" \ + --arg version "$BITRISE_APP_VERSION" \ + --arg download_url "$BITRISE_APP_URL" \ + --argjson color "$COLOR" \ + '{ + username: $username, + avatar_url: $avatar_url, + content: $content, + embeds: [{ + title: "📱 Edison Android Build Success", + color: $color, + fields: [ + { + name: "🌿 Branch", + value: $branch, + inline: true + }, + { + name: "🔨 Build Type", + value: $build_type, + inline: true + }, + { + name: "📦 Version", + value: $version, + inline: true + }, + { + name: "📥 Download", + value: $download_url, + inline: false + } + ], + url: $download_url, + footer: { + text: "Bitrise" + }, + timestamp: (now | todateiso8601) + }] + }' > payload.json + + curl -H "Content-Type: application/json" \ + -X POST \ + -d @payload.json \ + "$DISCORD_WEBHOOK_URL" || echo "Discord notification failed" + + build-release: + steps: + - activate-ssh-key@4: + run_if: '{{getenv "SSH_RSA_PRIVATE_KEY" | ne ""}}' + + - git-clone@8.4.0: + inputs: + - clone_depth: 1 + + - install-missing-android-tools@3: + inputs: + - gradlew_path: "$PROJECT_LOCATION/gradlew" + + - restore-gradle-cache@2.2.4: {} + + - script@1: + title: Setup google-services.json files + inputs: + - content: | + #!/bin/bash + set -e + + if [ -n "$GOOGLE_SERVICES_JSON_DEBUG" ]; then + mkdir -p app/src/debug + echo "$GOOGLE_SERVICES_JSON_DEBUG" | base64 -d > app/src/debug/google-services.json + echo "✓ Created app/src/debug/google-services.json" + else + echo "⚠ Warning: GOOGLE_SERVICES_JSON_DEBUG not set" + fi + + if [ -n "$GOOGLE_SERVICES_JSON_QA" ]; then + mkdir -p app/src/qa + echo "$GOOGLE_SERVICES_JSON_QA" | base64 -d > app/src/qa/google-services.json + echo "✓ Created app/src/qa/google-services.json" + else + echo "⚠ Warning: GOOGLE_SERVICES_JSON_QA not set" + fi + + if [ -n "$GOOGLE_SERVICES_JSON_RELEASE" ]; then + mkdir -p app/src/release + echo "$GOOGLE_SERVICES_JSON_RELEASE" | base64 -d > app/src/release/google-services.json + echo "✓ Created app/src/release/google-services.json" + else + echo "⚠ Warning: GOOGLE_SERVICES_JSON_RELEASE not set" + fi + + echo "Google Services JSON files setup completed" + + - script@1: + title: Set production environment variables + inputs: + - content: | + #!/bin/bash + set -e + + BUILD_TYPE="${BUILD_TYPE:-release}" + ENVIRONMENT="production" + + export BASE_URL="$PRODUCTION_BASE_URL" + export BRANCH_KEY="$PRODUCTION_BRANCH_KEY" + export ENVIRONMENT + + envman add --key BUILD_TYPE --value "$BUILD_TYPE" + + echo "Environment: Production" + echo "Build Type: $BUILD_TYPE" + if [ -n "$BASE_URL" ]; then + MASKED_URL=$(echo "$BASE_URL" | sed 's/\(https\?:\/\/[^\/]*\).*/\1\/***/') + echo "BASE_URL: $MASKED_URL (masked)" + else + echo "BASE_URL: (not set)" + fi + + echo "sdk.dir=$ANDROID_HOME" > local.properties + echo "base_url=$BASE_URL" >> local.properties + echo "branch_key=$BRANCH_KEY" >> local.properties + + - script@1: + title: Build APK with correct build type + inputs: + - content: | + #!/bin/bash + set -e + + case "$BUILD_TYPE" in + debug) + GRADLE_TASK="assembleDebug" + ;; + qa) + GRADLE_TASK="assembleQa" + ;; + release) + GRADLE_TASK="assembleRelease" + ;; + *) + echo "Unknown BUILD_TYPE: $BUILD_TYPE, defaulting to release" + GRADLE_TASK="assembleRelease" + ;; + esac + + echo "Gradle Task: $GRADLE_TASK" + envman add --key GRADLE_TASK --value "$GRADLE_TASK" + + - gradle-runner@5.0.0: + inputs: + - gradle_file: "$PROJECT_LOCATION/build.gradle.kts" + - gradle_task: $GRADLE_TASK + - gradle_options: "--stacktrace" + + - sign-apk@2.0.1: + run_if: '{{getenv "BITRISEIO_ANDROID_KEYSTORE_URL" | ne ""}}' + inputs: + - use_apk_signer: "true" + + - save-gradle-cache@1: {} + + - deploy-to-bitrise-io@2: + inputs: + - deploy_path: "$BITRISE_APK_PATH" + - notify_user_groups: none + - is_enable_public_page: "true" + + - script@1: + title: Send Discord notification + run_if: '{{getenv "DISCORD_WEBHOOK_URL" | ne ""}}' + inputs: + - content: | + #!/bin/bash + set -e + + if [ "$BITRISE_BUILD_STATUS" = "0" ]; then + EMOJI="✅" + STATUS="Build Success" + COLOR=3066993 + else + EMOJI="❌" + STATUS="Build Failed" + COLOR=15158332 + fi + + jq -n \ + --arg username "Android Build Bot" \ + --arg content "${EMOJI} **${STATUS}**" \ + --arg branch "$BITRISE_GIT_BRANCH" \ + --arg build_type "release" \ + --arg version "$BITRISE_APP_VERSION" \ + --arg download_url "$BITRISE_APP_URL" \ + --argjson color "$COLOR" \ + '{ + username: $username, + content: $content, + embeds: [{ + title: "📱 Edison Android Release Build Success", + color: $color, + fields: [ + { + name: "🌿 Branch", + value: $branch, + inline: true + }, + { + name: "🔨 Build Type", + value: $build_type, + inline: true + }, + { + name: "📦 Version", + value: $version, + inline: true + }, + { + name: "📥 Download", + value: $download_url, + inline: false + } + ], + url: $download_url, + footer: { + text: "Bitrise" + }, + timestamp: (now | todateiso8601) + }] + }' > payload.json + + curl -H "Content-Type: application/json" \ + -X POST \ + -d @payload.json \ + "$DISCORD_WEBHOOK_URL" || echo "Discord notification failed" + +app: + envs: + - PROJECT_LOCATION: "." + - MODULE: "app" 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