From 3d78920b12f5f1be9a77c92aaa165f72363446ee Mon Sep 17 00:00:00 2001 From: gdaegeun539 Date: Thu, 11 Jun 2026 10:53:20 +0900 Subject: [PATCH 1/3] build: Configure product flavors Introduce 'dev', 'staging', and 'prod' product flavors to separate development, QA, and production environments. Dynamic configuration includes custom application IDs, base URLs, and Kakao API keys. - Define 'environment' flavor dimension with dev, staging, prod flavors - Set up conditional Kakao key verification depending on Gradle tasks - Update CI workflow to build flavor-specific outputs - Inject BASE_URL dynamically and replace hardcoded value in NetworkModule - Add tests to verify build configurations across flavors --- .github/workflows/android-ci.yml | 13 +++- app/build.gradle.kts | 75 ++++++++++++++++--- .../lyrics/feelin/ExampleInstrumentedTest.kt | 10 +-- app/src/dev/res/values/strings.xml | 3 + .../feelin/core/data/di/NetworkModule.kt | 2 +- app/src/prod/res/values/strings.xml | 3 + app/src/staging/res/values/strings.xml | 3 + .../lyrics/feelin/FlavorBuildConfigTest.kt | 49 ++++++++++++ local.properties.example | 4 + 9 files changed, 141 insertions(+), 21 deletions(-) create mode 100644 app/src/dev/res/values/strings.xml create mode 100644 app/src/prod/res/values/strings.xml create mode 100644 app/src/staging/res/values/strings.xml create mode 100644 app/src/test/java/com/lyrics/feelin/FlavorBuildConfigTest.kt create mode 100644 local.properties.example diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 5e95f6a9..aeb6f12c 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -12,6 +12,7 @@ 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 }} concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -74,5 +75,13 @@ 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5b6ddf98..305b03f2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,18 +19,32 @@ 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 requestedTasksContainFlavor(flavorName: String): Boolean { + return gradle.startParameter.taskNames.any { taskName -> + taskName.contains(flavorName, ignoreCase = true) + } +} + +fun kakaoNativeAppKey( + flavorName: String, + propertyName: String, + environmentName: String +): String { + val key = localProperties + .getProperty(propertyName) + .orEmpty() + .ifBlank { System.getenv(environmentName).orEmpty() } + + if (key.isBlank() && requestedTasksContainFlavor(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 +57,49 @@ android { versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + flavorDimensions += "environment" - // Application 클래스에서 사용할 BuildConfig 생성 - buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoNativeAppKey\"") + 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 + } - // AndroidManifest.xml에서 사용할 placeholder - manifestPlaceholders["kakaoNativeAppKey"] = 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 + } + + // 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 From 7bff7601ad4183458e158a61e8510c88c62afe3d Mon Sep 17 00:00:00 2001 From: gdaegeun539 Date: Thu, 11 Jun 2026 12:23:05 +0900 Subject: [PATCH 2/3] ci: Add prod build steps and API key to workflow Configure the CI pipeline to run production debug and release builds when pushing to the main branch. Also define the KAKAO_NATIVE_APP_KEY_PROD environment variable with a fallback dummy key for CI compile checks. - Add KAKAO_NATIVE_APP_KEY_PROD to workflow env block - Create 'Build prod debug' step triggered on push to main - Create 'Build prod release' step triggered on push to main --- .github/workflows/android-ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index aeb6f12c..2681e424 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -12,7 +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 }} + 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 }} @@ -85,3 +86,11 @@ jobs: - 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 From 2d17f37ad4f72ba26d06a06800a325a6b41f92b7 Mon Sep 17 00:00:00 2001 From: gdaegeun539 Date: Thu, 11 Jun 2026 14:08:19 +0900 Subject: [PATCH 3/3] build: Expand Kakao key check for aggregate tasks Update app/build.gradle.kts to verify the Kakao native app key not only for specific flavor tasks, but also for aggregate Gradle tasks (like assemble, build, check) that compile those flavors. - Refactor task verification to use regex for precise matching - Add shouldRequireKakaoNativeAppKey to detect aggregate builds --- app/build.gradle.kts | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 305b03f2..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,12 +19,42 @@ val localProperties = Properties().apply { } } -fun requestedTasksContainFlavor(flavorName: String): Boolean { +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 -> - taskName.contains(flavorName, ignoreCase = true) + 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, @@ -35,7 +65,7 @@ fun kakaoNativeAppKey( .orEmpty() .ifBlank { System.getenv(environmentName).orEmpty() } - if (key.isBlank() && requestedTasksContainFlavor(flavorName)) { + if (key.isBlank() && shouldRequireKakaoNativeAppKey(flavorName)) { error( "Missing Kakao native app key for $flavorName. " + "Set '$propertyName' in local.properties or $environmentName in the environment."