diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml
index 5e95f6a9..2681e424 100644
--- a/.github/workflows/android-ci.yml
+++ b/.github/workflows/android-ci.yml
@@ -12,6 +12,8 @@ permissions:
env:
# PR CI는 compile/build 체크만 하므로, fork PR에서는 dummy 값으로도 통과 가능하게 둔다.
KAKAO_NATIVE_APP_KEY_DEV: ${{ secrets.KAKAO_NATIVE_APP_KEY_DEV || 'dummy-ci-kakao-key' }}
+ KAKAO_NATIVE_APP_KEY_STAGING: ${{ secrets.KAKAO_NATIVE_APP_KEY_STAGING || 'dummy-ci-kakao-key' }}
+ KAKAO_NATIVE_APP_KEY_PROD: ${{ secrets.KAKAO_NATIVE_APP_KEY_PROD || 'dummy-ci-kakao-key' }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -74,5 +76,21 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- - name: Build (assembleDebug, only compile check)
- run: ./gradlew :app:assembleDebug --no-daemon --stacktrace
+ - name: Build dev debug
+ run: ./gradlew :app:assembleDevDebug --no-daemon --stacktrace
+
+ - name: Build staging debug
+ if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
+ run: ./gradlew :app:assembleStagingDebug --no-daemon --stacktrace
+
+ - name: Build staging release
+ if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
+ run: ./gradlew :app:assembleStagingRelease --no-daemon --stacktrace
+
+ - name: Build prod debug
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ run: ./gradlew :app:assembleProdDebug --no-daemon --stacktrace
+
+ - name: Build prod release
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ run: ./gradlew :app:assembleProdRelease --no-daemon --stacktrace
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5b6ddf98..ab86de5e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,5 +1,5 @@
-import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
@@ -19,18 +19,62 @@ val localProperties = Properties().apply {
}
}
-// 로컬은 local.properties를 우선 사용하고, CI는 env로 폴백하되 둘 다 없으면 즉시 실패한다.
-val kakaoNativeAppKey = localProperties
- .getProperty("kakao.native.app.key.dev")
- .orEmpty()
- .ifBlank { System.getenv("KAKAO_NATIVE_APP_KEY_DEV").orEmpty() }
- .ifBlank {
+fun requestedTasksMentionFlavor(flavorName: String): Boolean {
+ val flavorVariantPattern = Regex(".*$flavorName(debug|release|$).*", RegexOption.IGNORE_CASE)
+
+ return gradle.startParameter.taskNames.any { taskName ->
+ flavorVariantPattern.matches(taskName.substringAfterLast(":"))
+ }
+}
+
+fun requestedTasksIncludeAggregateBuild(): Boolean {
+ val flavors = listOf("dev", "staging", "prod")
+ val aggregateTaskNames = setOf("assemble", "build", "check", "test", "bundle", "install")
+ val aggregateVariantTaskPattern = Regex(
+ pattern = "(assemble|bundle|install)(debug|release)?|" +
+ "test(debug|release)?(unittest)?|" +
+ "connected(debug|release)?androidtest",
+ option = RegexOption.IGNORE_CASE
+ )
+
+ return gradle.startParameter.taskNames.any { taskName ->
+ val simpleTaskName = taskName.substringAfterLast(":")
+ val hasFlavor = flavors.any { flavor ->
+ Regex(".*$flavor(debug|release|$).*", RegexOption.IGNORE_CASE).matches(simpleTaskName)
+ }
+
+ !hasFlavor &&
+ (
+ simpleTaskName.lowercase() in aggregateTaskNames ||
+ aggregateVariantTaskPattern.matches(simpleTaskName)
+ )
+ }
+}
+
+fun shouldRequireKakaoNativeAppKey(flavorName: String): Boolean {
+ return requestedTasksMentionFlavor(flavorName) || requestedTasksIncludeAggregateBuild()
+}
+
+fun kakaoNativeAppKey(
+ flavorName: String,
+ propertyName: String,
+ environmentName: String
+): String {
+ val key = localProperties
+ .getProperty(propertyName)
+ .orEmpty()
+ .ifBlank { System.getenv(environmentName).orEmpty() }
+
+ if (key.isBlank() && shouldRequireKakaoNativeAppKey(flavorName)) {
error(
- "Missing Kakao native app key. Set 'kakao.native.app.key.dev' in local.properties " +
- "or KAKAO_NATIVE_APP_KEY_DEV in the environment."
+ "Missing Kakao native app key for $flavorName. " +
+ "Set '$propertyName' in local.properties or $environmentName in the environment."
)
}
+ return key
+}
+
android {
namespace = "com.lyrics.feelin"
compileSdk = 36
@@ -43,12 +87,49 @@ android {
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ flavorDimensions += "environment"
+
+ productFlavors {
+ create("dev") {
+ dimension = "environment"
+ applicationId = "com.lyrics.feelin.dev"
+ val kakaoKey = kakaoNativeAppKey(
+ flavorName = name,
+ propertyName = "kakao.native.app.key.dev",
+ environmentName = "KAKAO_NATIVE_APP_KEY_DEV"
+ )
+ buildConfigField("String", "BASE_URL", "\"http://dev.feelinapp.com/\"")
+ buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoKey\"")
+ manifestPlaceholders["kakaoNativeAppKey"] = kakaoKey
+ }
- // Application 클래스에서 사용할 BuildConfig 생성
- buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoNativeAppKey\"")
+ create("staging") {
+ dimension = "environment"
+ applicationId = "com.lyrics.feelin.qa"
+ val kakaoKey = kakaoNativeAppKey(
+ flavorName = name,
+ propertyName = "kakao.native.app.key.staging",
+ environmentName = "KAKAO_NATIVE_APP_KEY_STAGING"
+ )
+ buildConfigField("String", "BASE_URL", "\"http://dev.feelinapp.com/\"")
+ buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoKey\"")
+ manifestPlaceholders["kakaoNativeAppKey"] = kakaoKey
+ }
- // AndroidManifest.xml에서 사용할 placeholder
- manifestPlaceholders["kakaoNativeAppKey"] = kakaoNativeAppKey
+ // MARK(@이대근): prod용 사이닝 키를 추후 생성 필요 2026.06.11.
+ create("prod") {
+ dimension = "environment"
+ val kakaoKey = kakaoNativeAppKey(
+ flavorName = name,
+ propertyName = "kakao.native.app.key.prod",
+ environmentName = "KAKAO_NATIVE_APP_KEY_PROD"
+ )
+ buildConfigField("String", "BASE_URL", "\"http://api.feelinapp.com/\"")
+ buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoKey\"")
+ manifestPlaceholders["kakaoNativeAppKey"] = kakaoKey
+ }
}
buildTypes {
diff --git a/app/src/androidTest/java/com/lyrics/feelin/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lyrics/feelin/ExampleInstrumentedTest.kt
index ef918c58..11e0dc79 100644
--- a/app/src/androidTest/java/com/lyrics/feelin/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/lyrics/feelin/ExampleInstrumentedTest.kt
@@ -1,13 +1,11 @@
package com.lyrics.feelin
-import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
-
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
-import org.junit.Assert.*
-
/**
* Instrumented test, which will execute on an Android device.
*
@@ -19,6 +17,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.lyrics.feelin", appContext.packageName)
+ assertEquals(BuildConfig.APPLICATION_ID, appContext.packageName)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/dev/res/values/strings.xml b/app/src/dev/res/values/strings.xml
new file mode 100644
index 00000000..c2ce5659
--- /dev/null
+++ b/app/src/dev/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ [DEV] Feelin
+
diff --git a/app/src/main/java/com/lyrics/feelin/core/data/di/NetworkModule.kt b/app/src/main/java/com/lyrics/feelin/core/data/di/NetworkModule.kt
index 95c45a3e..52b95a1d 100644
--- a/app/src/main/java/com/lyrics/feelin/core/data/di/NetworkModule.kt
+++ b/app/src/main/java/com/lyrics/feelin/core/data/di/NetworkModule.kt
@@ -58,7 +58,7 @@ object NetworkModule {
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
- .baseUrl("http://dev.feelinapp.com/") // TODO(@이대근): productFlavor 변수화 필요 2025.10.05.
+ .baseUrl(BuildConfig.BASE_URL)
.addConverterFactory(
Json.asConverterFactory(
"application/json; charset=UTF-8".toMediaType()
diff --git a/app/src/prod/res/values/strings.xml b/app/src/prod/res/values/strings.xml
new file mode 100644
index 00000000..0ff5f724
--- /dev/null
+++ b/app/src/prod/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Feelin
+
diff --git a/app/src/staging/res/values/strings.xml b/app/src/staging/res/values/strings.xml
new file mode 100644
index 00000000..fe245228
--- /dev/null
+++ b/app/src/staging/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ [QA] Feelin
+
diff --git a/app/src/test/java/com/lyrics/feelin/FlavorBuildConfigTest.kt b/app/src/test/java/com/lyrics/feelin/FlavorBuildConfigTest.kt
new file mode 100644
index 00000000..d65e3e10
--- /dev/null
+++ b/app/src/test/java/com/lyrics/feelin/FlavorBuildConfigTest.kt
@@ -0,0 +1,49 @@
+package com.lyrics.feelin
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FlavorBuildConfigTest {
+
+ @Test
+ fun flavorBuildConfig_matchesExpectedEnvironment() {
+ val expected = when (BuildConfig.FLAVOR) {
+ DEV_FLAVOR -> ExpectedFlavorConfig(
+ applicationId = "com.lyrics.feelin.dev",
+ baseUrl = "http://dev.feelinapp.com/"
+ )
+ STAGING_FLAVOR -> ExpectedFlavorConfig(
+ applicationId = "com.lyrics.feelin.qa",
+ baseUrl = "http://dev.feelinapp.com/"
+ )
+ PROD_FLAVOR -> ExpectedFlavorConfig(
+ applicationId = "com.lyrics.feelin",
+ baseUrl = "http://api.feelinapp.com/"
+ )
+ else -> error("Unexpected flavor: ${BuildConfig.FLAVOR}")
+ }
+
+ assertEquals(expected.applicationId, BuildConfig.APPLICATION_ID)
+ assertEquals(expected.baseUrl, BuildConfig.BASE_URL)
+ }
+
+ @Test
+ fun kakaoNativeAppKey_isInjectedForRequestedVariant() {
+ assertTrue(
+ "Kakao native app key must be injected for ${BuildConfig.FLAVOR}",
+ BuildConfig.KAKAO_NATIVE_APP_KEY.isNotBlank()
+ )
+ }
+
+ private data class ExpectedFlavorConfig(
+ val applicationId: String,
+ val baseUrl: String
+ )
+
+ private companion object {
+ const val DEV_FLAVOR = "dev"
+ const val STAGING_FLAVOR = "staging"
+ const val PROD_FLAVOR = "prod"
+ }
+}
diff --git a/local.properties.example b/local.properties.example
new file mode 100644
index 00000000..c9f097ba
--- /dev/null
+++ b/local.properties.example
@@ -0,0 +1,4 @@
+sdk.dir=YOUR_ANDROID_SDK_DIR_WILL_BE_AUTOMATICALLY_CREATED
+kakao.native.app.key.dev=KAKAO_DEV_NATIVE_APP_KEY_HERE
+kakao.native.app.key.staging=KAKAO_QA_NATIVE_APP_KEY_HERE
+kakao.native.app.key.prod=KAKAO_PROD_NATIVE_APP_KEY_HERE