diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 3b1c5e07..f7683d01 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -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 }} @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f8a45a23..13426e14 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 @@ -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 @@ -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) diff --git a/app/src/androidTest/kotlin/com/gamss/android/OnDeviceModelEvalTest.kt b/app/src/androidTest/kotlin/com/gamss/android/OnDeviceModelEvalTest.kt index 2d7db29f..c1505470 100644 --- a/app/src/androidTest/kotlin/com/gamss/android/OnDeviceModelEvalTest.kt +++ b/app/src/androidTest/kotlin/com/gamss/android/OnDeviceModelEvalTest.kt @@ -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 @@ -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 { @@ -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() @@ -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() // 라벨 → [정답, 전체] var correct = 0 Log.i(TAG, "===EMOTION_START=== total=${samples.length()}") diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 6d5db2ef..de90f690 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -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 { diff --git a/data/src/debug/java/com/gamss/android/data/di/DebugModelAssetSourceModule.kt b/data/src/debug/java/com/gamss/android/data/di/DebugModelAssetSourceModule.kt new file mode 100644 index 00000000..5db542b7 --- /dev/null +++ b/data/src/debug/java/com/gamss/android/data/di/DebugModelAssetSourceModule.kt @@ -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) +} diff --git a/data/src/internal/java/com/gamss/android/data/di/InternalModelAssetSourceModule.kt b/data/src/internal/java/com/gamss/android/data/di/InternalModelAssetSourceModule.kt new file mode 100644 index 00000000..5bbe09d3 --- /dev/null +++ b/data/src/internal/java/com/gamss/android/data/di/InternalModelAssetSourceModule.kt @@ -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) +} diff --git a/data/src/main/java/com/gamss/android/data/emotion/AndroidEmotionClassifier.kt b/data/src/main/java/com/gamss/android/data/emotion/AndroidEmotionClassifier.kt index 9f1f8385..ba715c8d 100644 --- a/data/src/main/java/com/gamss/android/data/emotion/AndroidEmotionClassifier.kt +++ b/data/src/main/java/com/gamss/android/data/emotion/AndroidEmotionClassifier.kt @@ -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 @@ -20,7 +18,7 @@ import javax.inject.Singleton */ @Singleton class AndroidEmotionClassifier @Inject constructor( - @ApplicationContext private val context: Context, + private val modelAssetSource: ModelAssetSource, ) : EmotionClassifier { private val mutex = Mutex() @@ -28,7 +26,8 @@ class AndroidEmotionClassifier @Inject constructor( 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) } } @@ -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 - get() = OnDemandModelAssets(context).statusFlow(EmotionModelSpec.PACK_NAME) + get() = modelAssetSource.statusFlow(EmotionModelSpec.PACK_NAME) } diff --git a/data/src/main/java/com/gamss/android/data/emotion/LiteRtClassifier.kt b/data/src/main/java/com/gamss/android/data/emotion/LiteRtClassifier.kt index b710c155..4f8fc1d7 100644 --- a/data/src/main/java/com/gamss/android/data/emotion/LiteRtClassifier.kt +++ b/data/src/main/java/com/gamss/android/data/emotion/LiteRtClassifier.kt @@ -1,6 +1,6 @@ package com.gamss.android.data.emotion -import android.content.Context +import com.gamss.android.data.model.ModelAssetSource import com.gamss.android.domain.emotion.ClassificationResult import org.tensorflow.lite.DataType import org.tensorflow.lite.Interpreter @@ -86,9 +86,9 @@ internal class LiteRtClassifier private constructor( private const val NAME_ATTENTION_MASK = "attention_mask" private const val NAME_TOKEN_TYPE = "token_type" - suspend fun load(context: Context, spec: ClassifierSpec): LiteRtClassifier = LiteRtClassifier( - model = LiteRtModel.load(context, spec.packName, spec.modelAsset), - tokenizer = WordPieceTokenizer.load(context, spec.packName, spec.tokenizerAsset, spec.seqLen), + suspend fun load(modelAssetSource: ModelAssetSource, spec: ClassifierSpec): LiteRtClassifier = LiteRtClassifier( + model = LiteRtModel.load(modelAssetSource, spec.packName, spec.modelAsset), + tokenizer = WordPieceTokenizer.load(modelAssetSource, spec.packName, spec.tokenizerAsset, spec.seqLen), labels = spec.labels, ) diff --git a/data/src/main/java/com/gamss/android/data/emotion/LiteRtModel.kt b/data/src/main/java/com/gamss/android/data/emotion/LiteRtModel.kt index 695d5fb3..50614a0b 100644 --- a/data/src/main/java/com/gamss/android/data/emotion/LiteRtModel.kt +++ b/data/src/main/java/com/gamss/android/data/emotion/LiteRtModel.kt @@ -1,14 +1,13 @@ package com.gamss.android.data.emotion -import android.content.Context -import com.gamss.android.data.model.OnDemandModelAssets -import com.gamss.android.data.model.mmap +import com.gamss.android.data.model.ModelAssetSource import org.tensorflow.lite.Interpreter import java.io.Closeable import java.nio.MappedByteBuffer /** - * on-demand 애셋팩에서 내려받은 .tflite 를 메모리 매핑(mmap)으로 로드해 스레드 고정 Interpreter 를 소유하는 홀더. + * .tflite 를 메모리 매핑(mmap)으로 로드해 스레드 고정 Interpreter 를 소유하는 홀더. 실제 바이트가 + * PAD 애셋팩에서 오는지 로컬 assets 에서 오는지는 [ModelAssetSource] 뒤에 숨는다. */ internal class LiteRtModel private constructor( val interpreter: Interpreter, @@ -19,14 +18,17 @@ internal class LiteRtModel private constructor( companion object { private const val DEFAULT_THREADS = 4 - suspend fun load(context: Context, packName: String, assetPath: String): LiteRtModel { - val buffer = mapModelFile(context, packName, assetPath) + suspend fun load(modelAssetSource: ModelAssetSource, packName: String, assetPath: String): LiteRtModel { + val buffer = mapModelFile(modelAssetSource, packName, assetPath) val interpreter = Interpreter(buffer, Interpreter.Options().setNumThreads(DEFAULT_THREADS)) return LiteRtModel(interpreter) } - /** 애셋팩이 내려받은 로컬 .tflite 파일을 mmap. 팩 미다운로드 시 여기서 내려받길 기다린다. */ - private suspend fun mapModelFile(context: Context, packName: String, assetPath: String): MappedByteBuffer = - OnDemandModelAssets(context).resolve(packName, assetPath).mmap() + /** .tflite 파일을 mmap. PAD 구현이면 팩 미다운로드 시 여기서 내려받길 기다린다. */ + private suspend fun mapModelFile( + modelAssetSource: ModelAssetSource, + packName: String, + assetPath: String, + ): MappedByteBuffer = modelAssetSource.mmap(packName, assetPath) } } diff --git a/data/src/main/java/com/gamss/android/data/emotion/WordPieceTokenizer.kt b/data/src/main/java/com/gamss/android/data/emotion/WordPieceTokenizer.kt index 8b43c4a4..3dc01f6d 100644 --- a/data/src/main/java/com/gamss/android/data/emotion/WordPieceTokenizer.kt +++ b/data/src/main/java/com/gamss/android/data/emotion/WordPieceTokenizer.kt @@ -1,8 +1,6 @@ package com.gamss.android.data.emotion -import android.content.Context -import com.gamss.android.data.model.OnDemandModelAssets -import com.gamss.android.data.model.readBytes +import com.gamss.android.data.model.ModelAssetSource import com.gamss.android.data.tokenizer.AddedVocabulary import com.gamss.android.data.tokenizer.REPLACEMENT_CODE_POINT import com.gamss.android.data.tokenizer.forEachCodePoint @@ -184,12 +182,12 @@ internal class WordPieceTokenizer private constructor( private val TOKENIZER_JSON = Json { ignoreUnknownKeys = true } suspend fun load( - context: Context, + modelAssetSource: ModelAssetSource, packName: String, tokenizerAsset: String, seqLen: Int, ): WordPieceTokenizer { - val bytes = OnDemandModelAssets(context).resolve(packName, tokenizerAsset).readBytes() + val bytes = modelAssetSource.readBytes(packName, tokenizerAsset) return fromJson(bytes, seqLen) } diff --git a/data/src/main/java/com/gamss/android/data/model/LocalAssetsModelSource.kt b/data/src/main/java/com/gamss/android/data/model/LocalAssetsModelSource.kt new file mode 100644 index 00000000..8c64639a --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/model/LocalAssetsModelSource.kt @@ -0,0 +1,40 @@ +package com.gamss.android.data.model + +import android.content.Context +import com.gamss.android.domain.model.ModelDownloadStatus +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import java.io.FileInputStream +import java.nio.MappedByteBuffer +import java.nio.channels.FileChannel + +/** + * `debug`/`internal` buildType 전용 [ModelAssetSource] 구현 — APK 에 번들된 assets 를 읽는다(PAD 미사용). + * Play Store 를 거치지 않는 설치 경로라 AssetPackManager 가 동작하지 않는다. + * + * `packName` 은 무시한다. 로컬 assets 는 단일 네임스페이스라 `relativeAssetPath` 만으로 충분하다. + * + * mmap 하려면 해당 확장자가 APK 안에 비압축으로 들어있어야 하므로 `androidResources.noCompress` + * ("tflite", "onnx")가 함께 필요하다. + * + * `internal` 이 아니라 public 이다 — [com.gamss.android.OnDeviceModelEvalTest]가 Hilt 없이 이 클래스를 + * 직접 생성해야 해서 모듈 경계 밖에서도 보여야 한다. + */ +class LocalAssetsModelSource(private val context: Context) : ModelAssetSource { + + override suspend fun mmap(packName: String, relativeAssetPath: String): MappedByteBuffer = + context.assets.openFd(relativeAssetPath).use { afd -> + FileInputStream(afd.fileDescriptor).use { fis -> + fis.channel.map(FileChannel.MapMode.READ_ONLY, afd.startOffset, afd.declaredLength) + } + } + + override suspend fun readBytes(packName: String, relativeAssetPath: String): ByteArray = + context.assets.open(relativeAssetPath).use { it.readBytes() } + + /** 로컬 assets 는 이미 APK 안에 있으므로 내려받을 게 없다. */ + override suspend fun prefetch(packName: String) = Unit + + /** 로컬 assets 는 항상 준비돼 있다 — UI 가 다운로드 확인 배너를 띄울 일이 없다. */ + override fun statusFlow(packName: String): Flow = flowOf(ModelDownloadStatus.COMPLETED) +} diff --git a/data/src/main/java/com/gamss/android/data/model/ModelAssetSource.kt b/data/src/main/java/com/gamss/android/data/model/ModelAssetSource.kt new file mode 100644 index 00000000..cc84bfb9 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/model/ModelAssetSource.kt @@ -0,0 +1,31 @@ +package com.gamss.android.data.model + +import com.gamss.android.domain.model.ModelDownloadStatus +import kotlinx.coroutines.flow.Flow +import java.nio.MappedByteBuffer + +/** + * 온디바이스 모델(.tflite/.onnx/tokenizer.json) 바이트에 접근하는 공용 경로. 실제 공급처는 + * buildType별로 갈아 끼워진다(Hilt, `di/ModelAssetSourceModule.kt` 각 소스셋 참고) — 추론 + * 코드(LiteRtModel, OnnxKobartSummarizer, *Tokenizer 계열)는 이 인터페이스만 알면 되고 PAD/로컬 + * 여부를 몰라도 된다. + * + * - `release`: [OnDemandModelAssets] — Play Asset Delivery(on-demand/fast-follow)로 내려받는다. + * - `debug`/`internal`: `LocalAssetsModelSource` — APK 에 그대로 번들된 assets 를 읽는다(PAD 미사용). + * + * `internal` 이 아니라 public 이다 — Hilt(Dagger KSP)가 `@Inject constructor`/`@Provides` 반환 + * 타입으로 쓰는 타입에 `internal` 가시성을 주면 컴파일이 깨진다(KSP 가 타입을 못 찾는다). + */ +interface ModelAssetSource { + /** [packName]/[relativeAssetPath] 를 mmap. 팩 미다운로드 시(PAD 구현) 내려받길 기다린다. */ + suspend fun mmap(packName: String, relativeAssetPath: String): MappedByteBuffer + + /** [packName]/[relativeAssetPath] 를 통째로 읽는다. 작은 tokenizer.json 로드용. */ + suspend fun readBytes(packName: String, relativeAssetPath: String): ByteArray + + /** [packName] 다운로드를 미리 걸어둔다. 로컬 구현은 이미 준비돼 있으므로 no-op. */ + suspend fun prefetch(packName: String) + + /** [packName] 의 현재/이후 다운로드 상태. 로컬 구현은 항상 [ModelDownloadStatus.COMPLETED]. */ + fun statusFlow(packName: String): Flow +} diff --git a/data/src/main/java/com/gamss/android/data/model/OnDemandModelAssets.kt b/data/src/main/java/com/gamss/android/data/model/OnDemandModelAssets.kt index c161abf9..3948f579 100644 --- a/data/src/main/java/com/gamss/android/data/model/OnDemandModelAssets.kt +++ b/data/src/main/java/com/gamss/android/data/model/OnDemandModelAssets.kt @@ -33,13 +33,23 @@ internal class ModelPackUnavailableException(message: String) : Exception(messag * (data 모듈은 Activity 를 안 씀). 그래서 여기선 실패로만 처리하고, 실제 확인 다이얼로그를 띄우는 * 책임은 [statusFlow] 를 구독해 Activity 가 있는 app 루트로 넘긴다 — [ModelDownloadConfirmationGateway] * 참고. + * + * `release` buildType 전용 [ModelAssetSource] 구현(`di/ModelAssetSourceModule.kt` 참고) — Play + * Store 밖 설치 경로(debug/internal)에서는 [com.gamss.android.data.model.LocalAssetsModelSource] + * 가 대신 쓰인다. */ -internal class OnDemandModelAssets(context: Context) { +internal class OnDemandModelAssets(context: Context) : ModelAssetSource { private val manager: AssetPackManager = AssetPackManagerFactory.getInstance(context.applicationContext) + override suspend fun mmap(packName: String, relativeAssetPath: String): MappedByteBuffer = + resolve(packName, relativeAssetPath).mmap() + + override suspend fun readBytes(packName: String, relativeAssetPath: String): ByteArray = + resolve(packName, relativeAssetPath).readBytes() + /** [packName] 팩이 아직 없으면 내려받아 완료될 때까지 대기한 뒤, [relativeAssetPath] 의 위치를 돌려준다. */ - suspend fun resolve(packName: String, relativeAssetPath: String): AssetLocation { + private suspend fun resolve(packName: String, relativeAssetPath: String): AssetLocation { ensureInstalled(packName) return manager.getAssetLocation(packName, relativeAssetPath) ?: throw ModelPackUnavailableException( @@ -56,7 +66,7 @@ internal class OnDemandModelAssets(context: Context) { * 취소는 삼키지 않는다 — 그냥 runCatching 만 쓰면 [kotlinx.coroutines.CancellationException] 도 * 잡혀서 취소가 조용히 성공한 것처럼 보인다. */ - suspend fun prefetch(packName: String) { + override suspend fun prefetch(packName: String) { runCatching { ensureInstalled(packName) } currentCoroutineContext().ensureActive() } @@ -112,7 +122,7 @@ internal class OnDemandModelAssets(context: Context) { * 직접 요청하지 않는다 — 순수 관찰용이라 UI 가 구독해도 다운로드가 새로 시작되진 않는다 * (이미 fetch 가 걸려 있어야 진행 상태가 바뀐다). */ - fun statusFlow(packName: String): Flow = callbackFlow { + override fun statusFlow(packName: String): Flow = callbackFlow { val listener = AssetPackStateUpdateListener { state -> if (state.name() == packName) { trySend(state.status().toDomainStatus()) diff --git a/data/src/main/java/com/gamss/android/data/summary/AndroidDiarySummarizer.kt b/data/src/main/java/com/gamss/android/data/summary/AndroidDiarySummarizer.kt index bc7955d9..8c7d365f 100644 --- a/data/src/main/java/com/gamss/android/data/summary/AndroidDiarySummarizer.kt +++ b/data/src/main/java/com/gamss/android/data/summary/AndroidDiarySummarizer.kt @@ -1,10 +1,8 @@ package com.gamss.android.data.summary -import android.content.Context -import com.gamss.android.data.model.OnDemandModelAssets +import com.gamss.android.data.model.ModelAssetSource import com.gamss.android.domain.model.ModelDownloadStatus import com.gamss.android.domain.summary.DiarySummarizer -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex @@ -19,7 +17,7 @@ import javax.inject.Singleton */ @Singleton class AndroidDiarySummarizer @Inject constructor( - @ApplicationContext private val context: Context, + private val modelAssetSource: ModelAssetSource, ) : DiarySummarizer { private val mutex = Mutex() @@ -27,7 +25,7 @@ class AndroidDiarySummarizer @Inject constructor( override suspend fun summarize(text: String): String = mutex.withLock { withContext(Dispatchers.Default) { - val ready = summarizer ?: OnnxKobartSummarizer.load(context).also { summarizer = it } + val ready = summarizer ?: OnnxKobartSummarizer.load(modelAssetSource).also { summarizer = it } ready.summarize(text) } } @@ -37,10 +35,10 @@ class AndroidDiarySummarizer @Inject constructor( * mutex 를 타면 아직 안 끝난 prefetch 가 실제 요약 요청을 불필요하게 막게 된다. */ override suspend fun prefetch() { - OnDemandModelAssets(context).prefetch(KobartSummarySpec.PACK_NAME) + modelAssetSource.prefetch(KobartSummarySpec.PACK_NAME) } /** UI(app 루트)가 셀룰러/크기 확인 배너를 띄울지 판단하는 데 쓴다. */ override val downloadStatus: Flow - get() = OnDemandModelAssets(context).statusFlow(KobartSummarySpec.PACK_NAME) + get() = modelAssetSource.statusFlow(KobartSummarySpec.PACK_NAME) } diff --git a/data/src/main/java/com/gamss/android/data/summary/KobartTokenCounter.kt b/data/src/main/java/com/gamss/android/data/summary/KobartTokenCounter.kt index 6620cceb..969dc3f9 100644 --- a/data/src/main/java/com/gamss/android/data/summary/KobartTokenCounter.kt +++ b/data/src/main/java/com/gamss/android/data/summary/KobartTokenCounter.kt @@ -1,8 +1,7 @@ package com.gamss.android.data.summary -import android.content.Context +import com.gamss.android.data.model.ModelAssetSource import com.gamss.android.domain.summary.UtteranceTokenCounter -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -12,7 +11,7 @@ import javax.inject.Singleton @Singleton internal class KobartTokenCounter @Inject constructor( - @ApplicationContext private val context: Context, + private val modelAssetSource: ModelAssetSource, ) : UtteranceTokenCounter { private val mutex = Mutex() @@ -24,7 +23,7 @@ internal class KobartTokenCounter @Inject constructor( private suspend fun loadTokenizer(): KobartTokenizer = mutex.withLock { tokenizer ?: KobartTokenizer.loadWithoutTruncation( - context = context, + modelAssetSource = modelAssetSource, packName = KobartSummarySpec.PACK_NAME, tokenizerAsset = KobartSummarySpec.TOKENIZER_ASSET, ).also { tokenizer = it } diff --git a/data/src/main/java/com/gamss/android/data/summary/KobartTokenizer.kt b/data/src/main/java/com/gamss/android/data/summary/KobartTokenizer.kt index 0c8c4309..5c74a914 100644 --- a/data/src/main/java/com/gamss/android/data/summary/KobartTokenizer.kt +++ b/data/src/main/java/com/gamss/android/data/summary/KobartTokenizer.kt @@ -1,8 +1,6 @@ package com.gamss.android.data.summary -import android.content.Context -import com.gamss.android.data.model.OnDemandModelAssets -import com.gamss.android.data.model.readBytes +import com.gamss.android.data.model.ModelAssetSource import com.gamss.android.data.tokenizer.AddedVocabulary import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -170,24 +168,24 @@ internal class KobartTokenizer private constructor( private val TOKENIZER_JSON = Json { ignoreUnknownKeys = true } suspend fun load( - context: Context, + modelAssetSource: ModelAssetSource, packName: String, tokenizerAsset: String, maxInput: Int, - ): KobartTokenizer = fromJson(readTokenizer(context, packName, tokenizerAsset), maxInput) + ): KobartTokenizer = fromJson(readTokenizer(modelAssetSource, packName, tokenizerAsset), maxInput) /** 토큰 수를 재려면 절단이 없어야 한다. 절단하면 한계 이상은 전부 같은 값으로 보인다. */ suspend fun loadWithoutTruncation( - context: Context, + modelAssetSource: ModelAssetSource, packName: String, tokenizerAsset: String, - ): KobartTokenizer = fromJson(readTokenizer(context, packName, tokenizerAsset), maxInput = null) + ): KobartTokenizer = fromJson(readTokenizer(modelAssetSource, packName, tokenizerAsset), maxInput = null) private suspend fun readTokenizer( - context: Context, + modelAssetSource: ModelAssetSource, packName: String, tokenizerAsset: String, - ): ByteArray = OnDemandModelAssets(context).resolve(packName, tokenizerAsset).readBytes() + ): ByteArray = modelAssetSource.readBytes(packName, tokenizerAsset) /** Context 없이 tokenizer.json 을 직접 먹이는 경로. 골든 대조 단위 테스트가 쓴다. */ fun fromJson(bytes: ByteArray, maxInput: Int?): KobartTokenizer { diff --git a/data/src/main/java/com/gamss/android/data/summary/OnnxKobartSummarizer.kt b/data/src/main/java/com/gamss/android/data/summary/OnnxKobartSummarizer.kt index d376c1da..78d2da64 100644 --- a/data/src/main/java/com/gamss/android/data/summary/OnnxKobartSummarizer.kt +++ b/data/src/main/java/com/gamss/android/data/summary/OnnxKobartSummarizer.kt @@ -3,9 +3,7 @@ package com.gamss.android.data.summary import ai.onnxruntime.OnnxTensor import ai.onnxruntime.OrtEnvironment import ai.onnxruntime.OrtSession -import android.content.Context -import com.gamss.android.data.model.OnDemandModelAssets -import com.gamss.android.data.model.mmap +import com.gamss.android.data.model.ModelAssetSource import java.io.Closeable import java.nio.LongBuffer import java.nio.MappedByteBuffer @@ -136,12 +134,12 @@ internal class OnnxKobartSummarizer private constructor( private const val OUT_LAST_HIDDEN = "last_hidden_state" private const val OUT_LOGITS = "logits" - suspend fun load(context: Context): OnnxKobartSummarizer { + suspend fun load(modelAssetSource: ModelAssetSource): OnnxKobartSummarizer { val env = OrtEnvironment.getEnvironment() - val encoder = env.createSession(mapModelFile(context, KobartSummarySpec.ENCODER_ASSET)) - val decoder = env.createSession(mapModelFile(context, KobartSummarySpec.DECODER_ASSET)) + val encoder = env.createSession(mapModelFile(modelAssetSource, KobartSummarySpec.ENCODER_ASSET)) + val decoder = env.createSession(mapModelFile(modelAssetSource, KobartSummarySpec.DECODER_ASSET)) val tokenizer = KobartTokenizer.load( - context, + modelAssetSource, KobartSummarySpec.PACK_NAME, KobartSummarySpec.TOKENIZER_ASSET, KobartSummarySpec.MAX_INPUT_TOKENS, @@ -150,11 +148,11 @@ internal class OnnxKobartSummarizer private constructor( } /** - * :models:summary-pack 이 내려받은 로컬 .onnx 를 mmap 해 힙에 통째로 올리지 않고 로드한다 - * (99MB 연속 힙 할당·2배 복사 회피). 파일 핸들을 닫아도 매핑은 유지되며 createSession 이 - * 그래프를 네이티브로 역직렬화한다. 팩 미다운로드 시 여기서 내려받길 기다린다. + * .onnx 를 mmap 해 힙에 통째로 올리지 않고 로드한다(99MB 연속 힙 할당·2배 복사 회피). 파일 + * 핸들을 닫아도 매핑은 유지되며 createSession 이 그래프를 네이티브로 역직렬화한다. PAD + * 구현이면 팩 미다운로드 시 여기서 내려받길 기다린다. */ - private suspend fun mapModelFile(context: Context, assetPath: String): MappedByteBuffer = - OnDemandModelAssets(context).resolve(KobartSummarySpec.PACK_NAME, assetPath).mmap() + private suspend fun mapModelFile(modelAssetSource: ModelAssetSource, assetPath: String): MappedByteBuffer = + modelAssetSource.mmap(KobartSummarySpec.PACK_NAME, assetPath) } } diff --git a/data/src/release/java/com/gamss/android/data/di/ReleaseModelAssetSourceModule.kt b/data/src/release/java/com/gamss/android/data/di/ReleaseModelAssetSourceModule.kt new file mode 100644 index 00000000..6d3eea28 --- /dev/null +++ b/data/src/release/java/com/gamss/android/data/di/ReleaseModelAssetSourceModule.kt @@ -0,0 +1,23 @@ +package com.gamss.android.data.di + +import android.content.Context +import com.gamss.android.data.model.ModelAssetSource +import com.gamss.android.data.model.OnDemandModelAssets +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 + +/** + * `release` buildType(Play Console 배포) 전용 바인딩 — 실제 Play Asset Delivery 를 쓴다. + * `debug`/`internal` 는 각 소스셋의 동명 모듈(`LocalAssetsModelSource` 바인딩)로 대체된다. + */ +@Module +@InstallIn(SingletonComponent::class) +internal object ReleaseModelAssetSourceModule { + @Provides + @Singleton + fun provideModelAssetSource(@ApplicationContext context: Context): ModelAssetSource = OnDemandModelAssets(context) +} diff --git a/fastlane/Appfile b/fastlane/Appfile index e3ca7d47..bda27644 100644 --- a/fastlane/Appfile +++ b/fastlane/Appfile @@ -1,2 +1 @@ -json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one -package_name("com.gamss.android") # e.g. com.krausefx.app +package_name("com.gamss.android") \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 832acaed..1493bdf6 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,20 +15,20 @@ platform :android do UI.user_error!("FIREBASE_APP_ID environment variable is not set") unless firebase_app_id # Firebase App Distribution 은 Play Store 설치 경로가 아니라서 Play Asset Delivery(on-demand/ - # fast-follow)로 배포되는 온디바이스 모델(emotion/summary pack)을 못 받아온다. 그래서 이 배포용 - # 빌드만 installTimeModels 로 두 팩을 install-time 전환해 AAB 안에 그대로 박는다(~400MB) — - # 테스터가 설치하자마자 모든 기능을 바로 쓸 수 있다. Play Console 배포용(작은 크기, on-demand/ - # fast-follow 유지)은 이 플래그 없이 cd.yml 의 "Build release AAB" 스텝이 따로 만든다. + # fast-follow)로 배포되는 온디바이스 모델(emotion/summary pack)을 못 받아온다. `internal` + # buildType 은 이 모델들을 애초에 PAD 가 아니라 APK 에 그대로 번들된 assets 로 공급하므로 + # (data/build.gradle.kts, ModelAssetSource 참고) 별도 플래그 없이 테스터가 설치하자마자 모든 + # 기능을 바로 쓸 수 있다. 결과물은 AAB 가 아니라 plain APK. gradle( - task: "clean :app:bundleRelease", - properties: { "versionCode" => version_code, "installTimeModels" => true } + task: "clean :app:assembleInternal", + properties: { "versionCode" => version_code } ) firebase_app_distribution( app: firebase_app_id, service_credentials_file: ENV["FIREBASE_SERVICE_CREDENTIALS_FILE"], - android_artifact_type: "AAB", - android_artifact_path: File.expand_path("../app/build/outputs/bundle/release/app-release.aab", __dir__), + android_artifact_type: "APK", + android_artifact_path: File.expand_path("../app/build/outputs/apk/internal/app-internal.apk", __dir__), groups: ENV["FIREBASE_TESTER_GROUPS"], release_notes: changelog_from_git_commits( commits_count: 1, @@ -36,4 +36,23 @@ platform :android do ) ) end + + desc "Play Console 배포용 서명된 운영 AAB 빌드 (업로드는 아직 안 함 — 아래 참고)" + lane :build_play_release_aab do + version_code = ENV["VERSION_CODE"] + UI.user_error!("VERSION_CODE environment variable is not set") unless version_code + + # release buildType 은 기존 PAD 정책(emotion=fast-follow, summary=on-demand) 그대로 — 이 빌드에는 + # internal 배포용 install-time 전환 같은 게 절대 섞이지 않는다. + # + # Play Console 자동 업로드(upload_to_play_store)는 아직 붙이지 않았다 — 서비스 계정 JSON secret + # (PLAY_STORE_SERVICE_ACCOUNT_JSON)이 리포지토리에 아직 없다. 지금은 서명된 AAB 를 CI 아티팩트로만 + # 만들어 수동으로 Play Console 내부 테스트 트랙에 올린다. secret 이 준비되면 이 lane 뒤에 + # upload_to_play_store(track: "internal", aab: ..., json_key: ENV["PLAY_STORE_JSON_KEY_FILE"], + # package_name: "com.gamss.android") 를 다시 추가하면 된다. + gradle( + task: "clean :app:bundleRelease", + properties: { "versionCode" => version_code } + ) + end end \ No newline at end of file diff --git a/models/emotion-pack/build.gradle.kts b/models/emotion-pack/build.gradle.kts index 71ea10ce..dd4c2fbb 100644 --- a/models/emotion-pack/build.gradle.kts +++ b/models/emotion-pack/build.gradle.kts @@ -8,13 +8,13 @@ plugins { // 시점(사용자가 채팅을 시작하기 전)부터 리드타임을 번다. // 실제 파일: src/main/assets/models/emotion_int8.tflite, emotion_tokenizer.json // -// -PinstallTimeModels=true 를 주면 install-time 으로 강제 전환된다. Play Asset Delivery(on-demand/ -// fast-follow)는 Play Store 설치 경로에서만 채워지므로, Play Store를 거치지 않는 배포(Firebase App -// Distribution 등)용 빌드에서 이 모델이 필요할 때 CD 에서 이 플래그로 켠다 — AAB 안에 모델이 그대로 -// 박혀 용량은 커지지만(전체 ~400MB) 어떤 설치 경로에서도 바로 동작한다. +// 이 애셋팩은 Play Console(release buildType) 배포에서만 쓰인다. Play Store 를 거치지 않는 배포 +// (debug/internal buildType)는 이 모델을 아예 여기서 가져가지 않고, data 모듈이 이 assets 디렉터리를 +// 자기 debug/internal sourceSet 에 직접 srcDir 로 얹어 APK 에 번들한다 — data/build.gradle.kts, +// data/model/LocalAssetsModelSource 참고. assetPack { packName.set("emotion_pack") dynamicDelivery { - deliveryType.set(if (project.hasProperty("installTimeModels")) "install-time" else "fast-follow") + deliveryType.set("fast-follow") } } diff --git a/models/summary-pack/build.gradle.kts b/models/summary-pack/build.gradle.kts index d8f983b8..e2373c86 100644 --- a/models/summary-pack/build.gradle.kts +++ b/models/summary-pack/build.gradle.kts @@ -5,11 +5,12 @@ plugins { // 원문 요약(kobart INT8) 인코더/디코더 모델 + tokenizer. on-demand 로 배포되어 base 앱 크기에 잡히지 않는다. // 실제 파일: src/main/assets/models/kobart_encoder_int8.onnx, kobart_decoder_int8.onnx, kobart_tokenizer.json // -// -PinstallTimeModels=true 를 주면 install-time 으로 강제 전환된다. models/emotion-pack/build.gradle.kts -// 상단 주석 참고 — Play Store를 거치지 않는 배포(Firebase App Distribution 등)용 빌드를 위한 스위치다. +// 이 애셋팩은 Play Console(release buildType) 배포에서만 쓰인다. models/emotion-pack/build.gradle.kts +// 상단 주석 참고 — Play Store를 거치지 않는 배포(debug/internal buildType)는 data 모듈이 이 assets +// 디렉터리를 직접 srcDir 로 얹어 APK 에 번들한다. assetPack { packName.set("summary_pack") dynamicDelivery { - deliveryType.set(if (project.hasProperty("installTimeModels")) "install-time" else "on-demand") + deliveryType.set("on-demand") } }