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