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
21 changes: 2 additions & 19 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ android {
applicationId = "com.gamss.android"
// CD에서 fastlane이 -PversionCode= 로 CI 빌드 번호(GITHUB_RUN_NUMBER 기반)를 주입한다.
versionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
versionName = "0.1.0"
versionName = "1.0.0"

ndk {
abiFilters += "arm64-v8a"
Expand All @@ -33,24 +33,6 @@ android {
// 애셋팩은 다운로드 후 로컬 파일로 추출되므로(APK zip 엔트리가 아님) noCompress 설정이 필요 없다.
assetPacks += setOf(":models:emotion-pack", ":models:summary-pack")

packaging {
resources {
excludes += setOf(
"native/lib/win-x86_64/**",
"native/lib/osx-aarch64/**",
"native/lib/osx-x86_64/**",
"native/lib/linux-x86_64/**",
"com/sun/jna/aix-ppc/**",
"com/sun/jna/aix-ppc64/**",
"com/sun/jna/win32-x86/**",
"com/sun/jna/win32-x86-64/**",
"com/sun/jna/darwin-aarch64/**",
"com/sun/jna/darwin-x86-64/**",
"META-INF/INDEX.LIST",
)
}
}

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 @@ -136,4 +118,5 @@ dependencies {

androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.gamss.android

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.summary.AndroidDiarySummarizer
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.Test
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
* ```
*/
@RunWith(AndroidJUnit4::class)
class OnDeviceModelEvalTest {

private val diaries = listOf(
"오늘 회사에서 부장님한테 사람들 앞에서 크게 혼났다. 딱히 내 잘못도 아니었는데 억울하고 " +
"하루종일 기분이 안 좋았다. 집에 와서도 계속 그 생각만 났다.",
"드디어 몇 달간 준비하던 자격증 시험에 합격했다. 밤새 공부한 게 헛되지 않은 것 같아 너무 " +
"뿌듯하고 기뻤다. 엄마한테 전화했더니 엄마도 같이 좋아서 울었다.",
"15년을 함께한 강아지가 오늘 무지개다리를 건넜다. 이제 곁에 없다는 게 아직 실감이 안 난다. " +
"빈 밥그릇을 치우다가 한참을 울었다.",
"내일 중요한 발표가 있는데 준비를 제대로 못 한 것 같아 너무 불안하다. 자려고 누워도 자꾸 " +
"실수하는 장면만 떠올라서 잠이 오지 않는다.",
)

@Test
fun summarizeOnDevice(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val summarizer = AndroidDiarySummarizer(context)
Log.i(TAG, "===SUMMARY_START=== total=${diaries.size}")
diaries.forEachIndexed { index, diary ->
val started = System.nanoTime()
val summary = summarizer.summarize(diary)
val ms = (System.nanoTime() - started) / 1_000_000
Log.i(TAG, "SUMMARY|$index|${ms}ms|입력=$diary")
Log.i(TAG, "SUMMARY|$index|출력=$summary")
// 품질까지는 로그로 눈으로 본다.
assertTrue("요약이 비었습니다(index=$index)", summary.isNotBlank())
}
Log.i(TAG, "===SUMMARY_END===")
}

@Test
fun classifyOnDevice(): Unit = runBlocking {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val testSet = JSONObject(
instrumentation.context.assets.open(TEST_SET).bufferedReader().use { it.readText() },
)
val samples = testSet.getJSONArray("samples")
// 모델은 한국어 라벨, 테스트셋은 영문 라벨을 쓴다.
val labelKo = testSet.getJSONObject("label_ko")
val koToEn = labelKo.keys().asSequence().associateBy { labelKo.getString(it) }

val classifier = AndroidEmotionClassifier(instrumentation.targetContext)
val perLabel = HashMap<String, IntArray>() // 라벨 → [정답, 전체]
var correct = 0
Log.i(TAG, "===EMOTION_START=== total=${samples.length()}")
for (index in 0 until samples.length()) {
val sample = samples.getJSONObject(index)
val text = sample.getString("text")
val gold = sample.getString("label")
val result = classifier.classify(text)
// 폴백을 두면 매핑 파손이 정확도 저하로 위장된다.
val predicted = koToEn[result.topLabel]
?: error("테스트셋 label_ko 에 없는 모델 라벨: ${result.topLabel}")

val bucket = perLabel.getOrPut(gold) { IntArray(2) }
bucket[1]++
if (predicted == gold) {
correct++
bucket[0]++
}
val confidence = String.format(Locale.ROOT, "%.4f", result.confidence)
Log.i(TAG, "EMOTION|$index|$gold|$predicted|$confidence|$text")
}

val total = samples.length()
val percent = String.format(Locale.ROOT, "%.1f", correct * PERCENT / total)
Log.i(TAG, "===EMOTION_END=== accuracy=$correct/$total ($percent%)")
perLabel.toSortedMap().forEach { (label, bucket) ->
Log.i(TAG, "PERLABEL|$label|${bucket[0]}/${bucket[1]}")
}

assertTrue("정확도 회귀: $correct/$total (하한 $ACCURACY_FLOOR)", correct >= ACCURACY_FLOOR)
}

private companion object {
const val TAG = "ONDEVICE_EVAL"
const val TEST_SET = "emotion_testset.json"
const val PERCENT = 100.0

/** 교체 시점 실측 44/60. 흔들리면 모델이나 추론 런타임이 바뀐 것이다. */
const val ACCURACY_FLOOR = 40
}
}
8 changes: 8 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@

<application>

<!--
미출시 앱이라 Play 로는 애셋팩을 못 받는다. 이 값이 있으면 Play Core 가 로컬에 넣어둔
팩 APK 를 읽는다. 절차는 OnDeviceModelEvalTest 참고.
-->
<meta-data
android:name="local_testing_dir"
android:value="local_testing" />

<!-- 디버그/검증 전용 화면. adb 로만 실행. -->
<activity
android:name=".debug.CardDebugActivity"
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
tools:node="remove"/>

<!-- firebase-analytics 가 머지하는 광고 ID 권한. 캠페인 전환 추적을 도입하면 되돌려야 한다. -->
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove"/>
<uses-permission
android:name="android.permission.ACCESS_ADSERVICES_AD_ID"
tools:node="remove"/>
<uses-permission
android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION"
tools:node="remove"/>

<queries>
<intent>
<action android:name="android.intent.action.DIAL" />
Expand Down
7 changes: 2 additions & 5 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,15 @@ dependencies {
implementation(libs.firebase.config)
implementation(libs.kotlinx.coroutines.play.services)

// 온디바이스 감정 분류(KoELECTRA INT8)LiteRT 추론 + DJL WordPiece 토크나이저
// 온디바이스 감정 분류(KoELECTRA INT8). LiteRT 추론, 토크나이저는 순수 Kotlin.
implementation(libs.litert) {
// 모델은 Play Asset Delivery(범용 on-demand 애셋팩)로 내려받는다.
// LiteRT 자체 모델 배포 API(ai-delivery)는 쓰지 않는다 — 콜드스타트마다 WorkManager 를
// 초기화시켜서 제외하고, 대신 asset-delivery 로 직접 다운로드 상태를 제어한다.
exclude(group = "com.google.android.play", module = "ai-delivery")
}
implementation(platform(libs.djl.bom))
implementation(libs.djl.huggingface.tokenizers)
runtimeOnly(libs.djl.android.tokenizer.native)

// 온디바이스 원문 요약(kobart INT8)ONNX Runtime Mobile (토크나이저는 DJL 재사용)
// 온디바이스 원문 요약(kobart INT8). ONNX Runtime Mobile, 토크나이저는 순수 Kotlin.
implementation(libs.onnxruntime.android)

// 감정/요약 모델(.tflite, .onnx)을 담은 on-demand 애셋팩(:models:emotion-pack, :models:summary-pack)을
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.gamss.android.data

import ai.onnxruntime.OrtEnvironment
import android.os.Build
import android.system.Os
import android.system.OsConstants
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.tensorflow.lite.TensorFlowLite

/**
* 4KB 정렬된 .so 는 16KB 페이지 기기에서 dlopen 자체가 실패한다. APK 정적 검사의 최종 확인용.
* 16KB 환경은 `google_apis_playstore_ps16k` 시스템 이미지 AVD 로 만든다.
*/
@RunWith(AndroidJUnit4::class)
class NativeLibraryLoadTest {

@Test
fun nativeLibrariesLoadOn16kPageDevice() {
val pageSize = Os.sysconf(OsConstants._SC_PAGESIZE)
Log.i(TAG, "pageSize=$pageSize model=${Build.MODEL} api=${Build.VERSION.SDK_INT}")
assumeTrue("16KB 페이지 기기가 아니라 건너뜁니다(pageSize=$pageSize)", pageSize == PAGE_SIZE_16KB)

assertNotNull(OrtEnvironment.getEnvironment())
assertTrue("LiteRT 런타임 버전이 비어 있습니다", TensorFlowLite.runtimeVersion().isNotEmpty())
}

private companion object {
const val TAG = "NativeLibraryLoadTest"
const val PAGE_SIZE_16KB = 16384L
}
}

This file was deleted.

This file was deleted.

Loading
Loading