Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 85 additions & 19 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ on:
push:
branches: [develop]
workflow_dispatch:
inputs:
deploy_firebase:
description: "Firebase App Distribution 배포 여부 (수동 실행 전용 — push 트리거는 항상 배포)"
type: boolean
default: true

concurrency:
group: cd-${{ github.workflow }}-${{ github.ref }}
Expand All @@ -13,7 +18,14 @@ permissions:
contents: read

jobs:
distribute:
# QA/기능 테스트용. `internal` buildType 은 debug 서명 + 온디바이스 모델을 APK 에 그대로 번들해서
# (PAD 미사용) 테스터가 설치하자마자 모든 기능을 바로 쓸 수 있다 — data/build.gradle.kts,
# fastlane/Fastfile 의 `distribute` lane 참고. release 서명 키스토어는 필요 없다.
#
# push(develop)는 항상 배포하고, workflow_dispatch(수동 실행)만 deploy_firebase 입력으로 건너뛸 수
# 있다 — 예: release AAB만 다시 뽑아서 확인하고 싶을 때 테스터에게 알림이 가는 걸 막는 용도.
firebase-distribution:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.deploy_firebase }}
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -39,7 +51,7 @@ jobs:
bundler-cache: true

- name: Prepare secret directories
run: mkdir -p fastlane/config keystore
run: mkdir -p fastlane/config

- name: Write google-services.json
run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 -d > app/google-services.json
Expand All @@ -58,40 +70,94 @@ jobs:
- name: Write Firebase service account key
run: echo "${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}" | base64 -d > fastlane/config/gamss-key.json

- name: Write release keystore
run: echo "${{ secrets.RELEASE_KEYSTORE }}" | base64 -d > keystore/release.jks

- name: Compute CI version
run: |
echo "VERSION_CODE=$((100000 + GITHUB_RUN_NUMBER))" >> "$GITHUB_ENV"

- name: Distribute to Firebase App Distribution
env:
RELEASE_KEYSTORE_PATH: ${{ github.workspace }}/keystore/release.jks
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
FIREBASE_SERVICE_CREDENTIALS_FILE: ${{ github.workspace }}/fastlane/config/gamss-key.json
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
FIREBASE_TESTER_GROUPS: inner-tester
run: bundle exec fastlane android distribute

# 위 Firebase 배포는 installTimeModels 로 모델을 박아 넣은 전용 AAB(~400MB)를 쓴다. 이 스텝은
# 그거와 별개로, 실제 운영 배포 형태(on-demand/fast-follow 유지, 작은 크기)의 AAB 를 워크플로
# 아티팩트로 남긴다 — Play Console 내부 테스트 트랙에 수동으로 올리거나 bundletool
# --local-testing 으로 로컬 설치해 PAD 동작을 검증할 때 쓴다. 실패해도 위 Firebase 배포는 이미
# 끝났으므로 파이프라인 전체를 실패시키지 않는다.
- name: Build release AAB
continue-on-error: true
- name: Upload reports
if: failure()
uses: actions/upload-artifact@v4
with:
name: firebase-distribution-reports
path: |
**/build/reports/**
if-no-files-found: ignore
retention-days: 7

# Play Console 에 올릴 서명된 운영용 AAB 를 만들어 워크플로 아티팩트로 남긴다. `release` buildType
# 그대로라 기존 PAD 정책(emotion=fast-follow, summary=on-demand)이 유지되고, internal 배포용 설정이
# 여기 섞이지 않는다.
#
# Play Console 로 자동 업로드(upload_to_play_store)는 아직 안 한다 — 서비스 계정 JSON secret
# (PLAY_STORE_SERVICE_ACCOUNT_JSON)이 리포지토리에 아직 없어서 지금 붙이면 이 job 이 매번 실패한다.
# 그래서 지금은 서명된 AAB 를 여기까지만 만들고, Actions 탭에서 아티팩트를 받아 Play Console에
# 수동으로 올린다. secret 이 준비되면 fastlane/Fastfile 의 build_play_release_aab lane 주석대로
# upload_to_play_store 호출을 다시 붙이면 된다.
play-release-aab:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true

- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true

- name: Prepare secret directories
run: mkdir -p keystore

- name: Write google-services.json
run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 -d > app/google-services.json

- name: Write local.properties
shell: bash
env:
DEV_BASE_URL: ${{ secrets.DEV_BASE_URL }}
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
run: |
: "${DEV_BASE_URL:?DEV_BASE_URL secret is not configured}"
: "${PROD_BASE_URL:?PROD_BASE_URL secret is not configured}"
printf 'DEV_BASE_URL=%s\n' "$DEV_BASE_URL" > local.properties
printf 'PROD_BASE_URL=%s\n' "$PROD_BASE_URL" >> local.properties

- name: Write release keystore
run: echo "${{ secrets.RELEASE_KEYSTORE }}" | base64 -d > keystore/release.jks

- name: Compute CI version
run: |
echo "VERSION_CODE=$((100000 + GITHUB_RUN_NUMBER))" >> "$GITHUB_ENV"

- name: Build signed release AAB
env:
RELEASE_KEYSTORE_PATH: ${{ github.workspace }}/keystore/release.jks
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: ./gradlew :app:bundleRelease -PversionCode="$VERSION_CODE" --stacktrace
run: bundle exec fastlane android build_play_release_aab

- name: Upload release AAB artifact
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: app-release-aab
Expand All @@ -103,7 +169,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@v4
with:
name: cd-reports
name: play-release-aab-reports
path: |
**/build/reports/**
if-no-files-found: ignore
Expand Down
18 changes: 15 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,18 @@ android {
}
}

// 온디바이스 모델(.tflite/.onnx)은 더 이상 base 앱에 번들되지 않는다.
// Play Asset Delivery(on-demand) 애셋팩으로 분리되어 필요 시점에만 기기로 내려받힌다.
// 애셋팩은 다운로드 후 로컬 파일로 추출되므로(APK zip 엔트리가 아님) noCompress 설정이 필요 없다.
// release(Play Console) buildType 은 온디바이스 모델(.tflite/.onnx)base 앱에 번들하지 않는다
// Play Asset Delivery(on-demand) 애셋팩으로 분리되어 필요 시점에만 기기로 내려받힌다(다운로드 후
// 로컬 파일로 추출되므로 APK zip 엔트리가 아니라 noCompress 설정이 필요 없다).
assetPacks += setOf(":models:emotion-pack", ":models:summary-pack")

// debug/internal buildType 은 위 애셋팩 대신 같은 파일을 assets 로 직접 번들한다(data 모듈의
// debug/internal sourceSet 참고) — 이 경우엔 APK zip 엔트리이므로 noCompress 가 필요하다. .onnx 는
// 명시하지 않으면 압축돼 mmap(assets.openFd)이 실패한다. .tflite 는 AGP 가 기본으로 비압축 처리한다.
androidResources {
noCompress += listOf("tflite", "onnx")
}

val releaseKeystorePath = providers.environmentVariable("RELEASE_KEYSTORE_PATH").orNull
val releaseKeystorePassword = providers.environmentVariable("RELEASE_KEYSTORE_PASSWORD").orNull
val releaseKeyAlias = providers.environmentVariable("RELEASE_KEY_ALIAS").orNull
Expand Down Expand Up @@ -71,6 +78,10 @@ android {
signingConfig = signingConfigs.getByName("release")
}
}

// Firebase App Distribution 전용 buildType. Play Store 를 거치지 않는 설치 경로라 온디바이스
// 모델을 PAD 대신 APK 에 그대로 번들한다 — data/build.gradle.kts 의 `internal` buildType/
// sourceSet 참고.
create("internal") {
initWith(getByName("release"))
isDebuggable = true
Expand All @@ -90,6 +101,7 @@ dependencies {
implementation(projects.feature.home)
implementation(projects.feature.chat)
implementation(projects.feature.calendar)
implementation(projects.feature.emotion)
implementation(projects.feature.login)
implementation(projects.feature.setting)
implementation(projects.feature.webview)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.gamss.android.data.emotion.AndroidEmotionClassifier
import com.gamss.android.data.model.LocalAssetsModelSource
import com.gamss.android.data.summary.AndroidDiarySummarizer
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
Expand All @@ -13,31 +14,11 @@ import org.junit.runner.RunWith
import java.util.Locale

/**
* 온디바이스 감정 분류·원문 요약 검증. 애셋팩 패키지와 일치해야 Play Core 가 로컬 팩을 받아주므로
* :data 가 아니라 :app 에 둔다. :app 은 abiFilters 가 arm64-v8a 하나라 arm64 기기/AVD 에서만 돈다.
*
* ```
* ./gradlew :app:bundleDebug :app:assembleDebug :app:assembleDebugAndroidTest
* bundletool build-apks --bundle=app/build/outputs/bundle/debug/app-debug.aab \
* --output=/tmp/app.apks --local-testing --connected-device \
* --ks=~/.android/debug.keystore --ks-pass=pass:android \
* --ks-key-alias=androiddebugkey --key-pass=pass:android
* unzip -o /tmp/app.apks -d /tmp/apks
* adb install -r app/build/outputs/apk/debug/app-debug.apk
* adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
*
* # files 디렉터리는 앱이 먼저 만들게 둔다. shell 이 만들면 앱이 못 읽는 기기가 있다.
* adb shell am instrument -w com.gamss.android.dev.test/androidx.test.runner.AndroidJUnitRunner
* DIR=/sdcard/Android/data/com.gamss.android.dev/files/local_testing
* adb shell mkdir -p $DIR
* adb push /tmp/apks/asset-slices/emotion_pack-master.apk $DIR/
* adb push /tmp/apks/asset-slices/summary_pack-master.apk $DIR/
*
* # connectedAndroidTest 는 재설치하며 위 디렉터리를 지우므로 쓰지 않는다.
* adb shell am instrument -w -e class com.gamss.android.OnDeviceModelEvalTest \
* com.gamss.android.dev.test/androidx.test.runner.AndroidJUnitRunner
* adb logcat -d -s ONDEVICE_EVAL
* ```
* 온디바이스 감정 분류·원문 요약 검증. :data 가 아니라 :app 에 두는 이유는 예전 애셋팩(PAD) 경로일 때
* 애셋팩 패키지가 앱과 일치해야 했기 때문인데, 지금은 [LocalAssetsModelSource]로 debug variant 의
* APK 에 번들된 assets(:models:emotion-pack/:models:summary-pack 원본을 data 모듈 debug sourceSet 이
* 참조)를 직접 읽으므로 PAD/bundletool 준비가 전혀 필요 없다 — `:app:connectedDebugAndroidTest`
* 하나로 바로 돈다. :app 은 abiFilters 가 arm64-v8a 하나라 arm64 기기/AVD 에서만 돈다.
*/
@RunWith(AndroidJUnit4::class)
class OnDeviceModelEvalTest {
Expand All @@ -56,7 +37,7 @@ class OnDeviceModelEvalTest {
@Test
fun summarizeOnDevice(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val summarizer = AndroidDiarySummarizer(context)
val summarizer = AndroidDiarySummarizer(LocalAssetsModelSource(context))
Log.i(TAG, "===SUMMARY_START=== total=${diaries.size}")
diaries.forEachIndexed { index, diary ->
val started = System.nanoTime()
Expand All @@ -81,7 +62,7 @@ class OnDeviceModelEvalTest {
val labelKo = testSet.getJSONObject("label_ko")
val koToEn = labelKo.keys().asSequence().associateBy { labelKo.getString(it) }

val classifier = AndroidEmotionClassifier(instrumentation.targetContext)
val classifier = AndroidEmotionClassifier(LocalAssetsModelSource(instrumentation.targetContext))
val perLabel = HashMap<String, IntArray>() // 라벨 → [정답, 전체]
var correct = 0
Log.i(TAG, "===EMOTION_START=== total=${samples.length()}")
Expand Down
27 changes: 27 additions & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,35 @@ android {
release {
buildConfigField("String", "BASE_URL", "\"$prodBaseUrl\"")
}
// app 의 `internal` buildType(Firebase App Distribution 배포)과 짝을 맞춘다. app 은 이
// buildType 에 matchingFallbacks("release")를 두지만, 그건 internal variant 가 아예 없는
// 라이브러리에만 적용된다 — 이 모듈처럼 진짜 internal variant 를 선언하면 그게 우선한다.
// BASE_URL 은 release 와 동일(운영 서버 대상 QA 배포).
create("internal") {
initWith(getByName("release"))
}
}

// 온디바이스 모델(.tflite/.onnx)은 release(Play Console)에서는 Play Asset Delivery 로 내려받지만,
// debug/internal(Play Store 를 거치지 않는 설치 경로)에서는 AssetPackManager 가 동작하지 않아
// APK 에 그대로 번들한다. 파일을 복사하지 않고 애셋팩 모듈의 assets 를 그대로 srcDir 로 참조해
// 단일 소스를 유지한다 — data/model/LocalAssetsModelSource, di/ModelAssetSourceModule 참고.
sourceSets {
listOf("debug", "internal").forEach { buildTypeName ->
getByName(buildTypeName) {
assets.srcDirs(
"../models/emotion-pack/src/main/assets",
"../models/summary-pack/src/main/assets",
)
}
}
}

androidResources {
// assets.openFd() + mmap 으로 로드하려면(LocalAssetsModelSource) APK 안에 비압축으로
// 들어있어야 한다. release 는 이 assets 를 안 쓰지만(애셋팩으로 분리) 무해하다.
noCompress += listOf("tflite", "onnx")
}
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.gamss.android.data.di

import android.content.Context
import com.gamss.android.data.model.LocalAssetsModelSource
import com.gamss.android.data.model.ModelAssetSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
* `debug` buildType(Android Studio Run) 전용 바인딩 — Play Store 를 거치지 않는 설치 경로라
* AssetPackManager 가 동작하지 않는다. 모델은 APK 에 그대로 번들된 assets 에서 읽는다
* (`data/build.gradle.kts` 의 `debug` sourceSet assets.srcDirs 참고). `internal`/`release` 는 동명의 다른
* 모듈(`OnDemandModelAssets` 바인딩)로 대체된다.
*/
@Module
@InstallIn(SingletonComponent::class)
internal object DebugModelAssetSourceModule {
@Provides
@Singleton
fun provideModelAssetSource(@ApplicationContext context: Context): ModelAssetSource =
LocalAssetsModelSource(context)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.gamss.android.data.di

import android.content.Context
import com.gamss.android.data.model.LocalAssetsModelSource
import com.gamss.android.data.model.ModelAssetSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
* `internal` buildType(Firebase App Distribution QA 배포) 전용 바인딩 — Play Store 를 거치지 않는
* 설치 경로라 AssetPackManager 가 동작하지 않는다. 모델은 APK 에 그대로 번들된 assets 에서 읽는다
* (`data/build.gradle.kts` 의 `internal` sourceSet assets.srcDirs 참고). `release` 는 동명의 다른
* 모듈(`OnDemandModelAssets` 바인딩)로 대체된다.
*/
@Module
@InstallIn(SingletonComponent::class)
internal object InternalModelAssetSourceModule {
@Provides
@Singleton
fun provideModelAssetSource(@ApplicationContext context: Context): ModelAssetSource =
LocalAssetsModelSource(context)
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
package com.gamss.android.data.emotion

import android.content.Context
import com.gamss.android.data.model.OnDemandModelAssets
import com.gamss.android.data.model.ModelAssetSource
import com.gamss.android.domain.emotion.ClassificationResult
import com.gamss.android.domain.emotion.EmotionClassifier
import com.gamss.android.domain.model.ModelDownloadStatus
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
Expand All @@ -20,15 +18,16 @@ import javax.inject.Singleton
*/
@Singleton
class AndroidEmotionClassifier @Inject constructor(
@ApplicationContext private val context: Context,
private val modelAssetSource: ModelAssetSource,
) : EmotionClassifier {

private val mutex = Mutex()
private var classifier: LiteRtClassifier? = null

override suspend fun classify(text: String): ClassificationResult = mutex.withLock {
withContext(Dispatchers.Default) {
val ready = classifier ?: LiteRtClassifier.load(context, EmotionModelSpec.SPEC).also { classifier = it }
val ready = classifier
?: LiteRtClassifier.load(modelAssetSource, EmotionModelSpec.SPEC).also { classifier = it }
ready.classify(text)
}
}
Expand All @@ -38,10 +37,10 @@ class AndroidEmotionClassifier @Inject constructor(
* mutex 를 타면 아직 안 끝난 prefetch 가 실제 분류 요청을 불필요하게 막게 된다.
*/
override suspend fun prefetch() {
OnDemandModelAssets(context).prefetch(EmotionModelSpec.PACK_NAME)
modelAssetSource.prefetch(EmotionModelSpec.PACK_NAME)
}

/** UI(app 루트)가 셀룰러/크기 확인 배너를 띄울지 판단하는 데 쓴다. */
override val downloadStatus: Flow<ModelDownloadStatus>
get() = OnDemandModelAssets(context).statusFlow(EmotionModelSpec.PACK_NAME)
get() = modelAssetSource.statusFlow(EmotionModelSpec.PACK_NAME)
}
Loading
Loading