Skip to content

Commit ffe5424

Browse files
Add LiteRT ABI/runtime guards for clearer offline errors
1 parent 29a6212 commit ffe5424

8 files changed

Lines changed: 237 additions & 105 deletions

File tree

app/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ android {
2424
targetSdk = 35
2525
versionCode = 1
2626
versionName = "1.0"
27+
ndk {
28+
abiFilters += listOf("arm64-v8a", "x86_64")
29+
}
2730

2831
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2932
vectorDrawables {
@@ -47,6 +50,7 @@ android {
4750
}
4851
kotlinOptions {
4952
jvmTarget = "1.8"
53+
freeCompilerArgs += "-Xskip-metadata-version-check"
5054
}
5155
buildFeatures {
5256
compose = true
@@ -57,6 +61,12 @@ android {
5761
}
5862

5963
dependencies {
64+
constraints {
65+
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.20")
66+
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.20")
67+
implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.20")
68+
}
69+
6070
implementation("androidx.core:core-ktx:1.9.0")
6171
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
6272
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
@@ -100,6 +110,8 @@ dependencies {
100110

101111
// MediaPipe GenAI for offline inference (LLM)
102112
implementation("com.google.mediapipe:tasks-genai:0.10.32")
113+
// LiteRT-LM for newer offline .litertlm models (e.g. Gemma 4 E4B it)
114+
implementation("com.google.ai.edge.litertlm:litertlm-android:0.0.0-alpha06")
103115

104116
// Camera Core to potentially fix missing JNI lib issue
105117
implementation("androidx.camera:camera-core:1.4.0")

app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
android:supportsRtl="true"
3535
android:requestLegacyExternalStorage="true"
3636
tools:targetApi="31">
37+
<uses-native-library android:name="libvndksupport.so" android:required="false" />
38+
<uses-native-library android:name="libOpenCL.so" android:required="false" />
39+
3740
<activity
3841
android:name=".MainActivity"
3942
android:exported="true"

app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ enum class ModelOption(
2727
val apiProvider: ApiProvider = ApiProvider.GOOGLE,
2828
val downloadUrl: String? = null,
2929
val size: String? = null,
30-
val supportsScreenshot: Boolean = true
30+
val supportsScreenshot: Boolean = true,
31+
val isOfflineModel: Boolean = false,
32+
val offlineModelFilename: String? = null
3133
) {
3234
PUTER_GLM5("GLM-5 (Puter)", "z-ai/glm-5", ApiProvider.PUTER, supportsScreenshot = false),
3335
MISTRAL_LARGE_3("Mistral Large 3", "mistral-large-latest", ApiProvider.MISTRAL),
@@ -49,7 +51,17 @@ enum class ModelOption(
4951
"gemma-3n-e4b-it",
5052
ApiProvider.GOOGLE,
5153
"https://huggingface.co/na5h13/gemma-3n-E4B-it-litert-lm/resolve/main/gemma-3n-E4B-it-int4.litertlm?download=true",
52-
"4.92 GB"
54+
"4.92 GB",
55+
isOfflineModel = true,
56+
offlineModelFilename = "gemma-3n-e4b-it-int4.litertlm"
57+
),
58+
GEMMA_4_E4B_IT(
59+
"Gemma 4 E4B it (offline)",
60+
"gemma-4-e4b-it",
61+
ApiProvider.GOOGLE,
62+
"https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it.litertlm?download=true",
63+
isOfflineModel = true,
64+
offlineModelFilename = "gemma-4-E4B-it.litertlm"
5365
),
5466
HUMAN_EXPERT("Human Expert", "human-expert", ApiProvider.HUMAN_EXPERT);
5567

@@ -77,7 +89,7 @@ val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
7789

7890
// Get the API key from MainActivity
7991
val mainActivity = MainActivity.getInstance()
80-
val apiKey = if (currentModel == ModelOption.GEMMA_3N_E4B_IT || currentModel == ModelOption.HUMAN_EXPERT) {
92+
val apiKey = if (currentModel.isOfflineModel || currentModel == ModelOption.HUMAN_EXPERT) {
8193
"offline-no-key-needed" // Dummy key for offline/human expert models
8294
} else {
8395
mainActivity?.getCurrentApiKey(currentModel.apiProvider) ?: ""

app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,10 @@ fun MenuScreen(
210210
},
211211
onClick = {
212212
expanded = false
213-
val wasOfflineModel = selectedModel == ModelOption.GEMMA_3N_E4B_IT
213+
val wasOfflineModel = selectedModel.isOfflineModel
214214

215-
if (modelOption == ModelOption.GEMMA_3N_E4B_IT) {
216-
val isDownloaded = ModelDownloadManager.isModelDownloaded(context)
215+
if (modelOption.isOfflineModel) {
216+
val isDownloaded = ModelDownloadManager.isModelDownloaded(context, modelOption)
217217
if (!isDownloaded) {
218218
downloadDialogModel = modelOption
219219
showDownloadDialog = true
@@ -257,7 +257,7 @@ fun MenuScreen(
257257
}
258258

259259
// CPU/GPU Selection - only visible when offline model is selected
260-
if (selectedModel == ModelOption.GEMMA_3N_E4B_IT) {
260+
if (selectedModel.isOfflineModel) {
261261
item {
262262
val currentBackend = remember { mutableStateOf(GenerativeAiViewModelFactory.getBackend()) }
263263

@@ -444,10 +444,10 @@ fun MenuScreen(
444444
modifier = Modifier.fillMaxWidth().sliderFriendly()
445445
)
446446

447-
if (selectedModel == ModelOption.GEMMA_3N_E4B_IT) {
447+
if (selectedModel.isOfflineModel) {
448448
Spacer(modifier = Modifier.height(4.dp))
449449
Text(
450-
text = "Note: LlmInference (offline model) may not support all generation parameters.",
450+
text = "Note: Offline inference may not support all generation parameters.",
451451
style = MaterialTheme.typography.bodySmall,
452452
color = MaterialTheme.colorScheme.onSurfaceVariant
453453
)
@@ -487,15 +487,13 @@ fun MenuScreen(
487487
val mainActivity = context as? MainActivity
488488
val activeModel = GenerativeAiViewModelFactory.getCurrentModel()
489489
// Check API Key for online models
490-
if (activeModel.apiProvider != ApiProvider.GOOGLE || !activeModel.modelName.contains("litert")) { // Simple check, refine if needed. Actually offline model has specific Enum
491-
if (activeModel != ModelOption.GEMMA_3N_E4B_IT && activeModel != ModelOption.HUMAN_EXPERT) {
492-
val apiKey = mainActivity?.getCurrentApiKey(activeModel.apiProvider)
493-
if (apiKey.isNullOrEmpty()) {
494-
// Show API Key Dialog
495-
onApiKeyButtonClicked(activeModel.apiProvider) // Or a specific callback to show dialog
496-
return@TextButton
497-
}
498-
}
490+
if (!activeModel.isOfflineModel && activeModel != ModelOption.HUMAN_EXPERT) {
491+
val apiKey = mainActivity?.getCurrentApiKey(activeModel.apiProvider)
492+
if (apiKey.isNullOrEmpty()) {
493+
// Show API Key Dialog
494+
onApiKeyButtonClicked(activeModel.apiProvider) // Or a specific callback to show dialog
495+
return@TextButton
496+
}
499497
}
500498

501499
if (mainActivity != null) { // Ensure mainActivity is not null
@@ -689,12 +687,12 @@ fun MenuScreen(
689687
}
690688
// Don't dismiss while downloading/paused
691689
},
692-
title = { Text("Download Model (4.92 GB)") },
690+
title = { Text("Download Model (${downloadDialogModel?.size ?: "unknown size"})") },
693691
text = {
694692
Column {
695693
when (val state = dlState) {
696694
is ModelDownloadManager.DownloadState.Idle -> {
697-
Text("Should the Gemma 3n E4B be downloaded?\n\n$formattedGbAvailable GB of storage available.")
695+
Text("Should ${downloadDialogModel?.displayName ?: "this model"} be downloaded?\n\n$formattedGbAvailable GB of storage available.")
698696
}
699697
is ModelDownloadManager.DownloadState.Downloading -> {
700698
Text("Downloading...")
@@ -741,8 +739,10 @@ fun MenuScreen(
741739
is ModelDownloadManager.DownloadState.Idle -> {
742740
TextButton(
743741
onClick = {
744-
downloadDialogModel?.downloadUrl?.let { url ->
745-
ModelDownloadManager.downloadModel(context, url)
742+
downloadDialogModel?.let { model ->
743+
model.downloadUrl?.let { url ->
744+
ModelDownloadManager.downloadModel(context, model, url)
745+
}
746746
// Task 2: Request notification permission when download starts
747747
val mainActivity = context as? MainActivity
748748
if (mainActivity != null && !mainActivity.isNotificationPermissionGranted()) {
@@ -758,8 +758,10 @@ fun MenuScreen(
758758
is ModelDownloadManager.DownloadState.Paused -> {
759759
TextButton(
760760
onClick = {
761-
downloadDialogModel?.downloadUrl?.let { url ->
762-
ModelDownloadManager.resumeDownload(context, url)
761+
downloadDialogModel?.let { model ->
762+
model.downloadUrl?.let { url ->
763+
ModelDownloadManager.resumeDownload(context, model, url)
764+
}
763765
}
764766
}
765767
) { Text("Resume") }
@@ -777,8 +779,10 @@ fun MenuScreen(
777779
is ModelDownloadManager.DownloadState.Error -> {
778780
TextButton(
779781
onClick = {
780-
downloadDialogModel?.downloadUrl?.let { url ->
781-
ModelDownloadManager.downloadModel(context, url)
782+
downloadDialogModel?.let { model ->
783+
model.downloadUrl?.let { url ->
784+
ModelDownloadManager.downloadModel(context, model, url)
785+
}
782786
}
783787
}
784788
) { Text("Retry") }
@@ -794,7 +798,7 @@ fun MenuScreen(
794798
is ModelDownloadManager.DownloadState.Paused -> {
795799
TextButton(
796800
onClick = {
797-
ModelDownloadManager.cancelDownload(context)
801+
downloadDialogModel?.let { ModelDownloadManager.cancelDownload(context, it) }
798802
showDownloadDialog = false
799803
}
800804
) { Text("Cancel Download") }

app/src/main/kotlin/com/google/ai/sample/ScreenOperatorAccessibilityService.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,9 @@ class ScreenOperatorAccessibilityService : AccessibilityService() {
233233
true // Asynchronous
234234
}
235235
is Command.TakeScreenshot -> {
236-
val modelName = GenerativeAiViewModelFactory.getCurrentModel().modelName
237-
if (modelName == "gemma-3n-e4b-it") {
238-
Log.d(TAG, "Command.TakeScreenshot: Model is gemma-3n-e4b-it, capturing screen info only.")
236+
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()
237+
if (currentModel.isOfflineModel) {
238+
Log.d(TAG, "Command.TakeScreenshot: Model is offline, capturing screen info only.")
239239
this.showToast("Capturing screen info...", false)
240240
val screenInfo = captureScreenInformation()
241241
val mainActivity = MainActivity.getInstance()

app/src/main/kotlin/com/google/ai/sample/feature/multimodal/ModelDownloadManager.kt

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import android.os.Build
77
import android.util.Log
88
import android.widget.Toast
99
import androidx.core.app.NotificationCompat
10+
import com.google.ai.sample.GenerativeAiViewModelFactory
11+
import com.google.ai.sample.ModelOption
1012
import kotlin.coroutines.coroutineContext
1113
import kotlinx.coroutines.*
1214
import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,13 +21,12 @@ import java.net.HttpURLConnection
1921
import java.net.URL
2022

2123
/**
22-
* Custom download manager for the Gemma 3n model.
24+
* Custom download manager for offline LiteRT models.
2325
* Uses HttpURLConnection with Range-Request support for resume capability.
2426
* Point 18: Includes Android notification for download progress.
2527
*/
2628
object ModelDownloadManager {
2729
private const val TAG = "ModelDownloadManager"
28-
const val MODEL_FILENAME = "gemma-3n-e4b-it-int4.litertlm"
2930
private const val TEMP_SUFFIX = ".downloading"
3031
private const val BUFFER_SIZE = 8192
3132
private const val MAX_RETRIES = 3
@@ -57,25 +58,27 @@ object ModelDownloadManager {
5758
private var downloadJob: Job? = null
5859
private var isPaused = false
5960

60-
fun isModelDownloaded(context: Context): Boolean {
61-
val file = getModelFile(context)
61+
fun isModelDownloaded(context: Context, model: ModelOption = GenerativeAiViewModelFactory.getCurrentModel()): Boolean {
62+
val file = getModelFile(context, model)
6263
return file != null && file.exists() && file.length() > 0
6364
}
6465

65-
fun getModelFile(context: Context): File? {
66+
fun getModelFile(context: Context, model: ModelOption = GenerativeAiViewModelFactory.getCurrentModel()): File? {
67+
val modelFilename = model.offlineModelFilename ?: return null
6668
val externalFilesDir = context.getExternalFilesDir(null)
6769
return if (externalFilesDir != null) {
68-
File(externalFilesDir, MODEL_FILENAME)
70+
File(externalFilesDir, modelFilename)
6971
} else {
7072
Log.e(TAG, "External files directory is not available.")
7173
null
7274
}
7375
}
7476

75-
private fun getTempFile(context: Context): File? {
77+
private fun getTempFile(context: Context, model: ModelOption): File? {
78+
val modelFilename = model.offlineModelFilename ?: return null
7679
val externalFilesDir = context.getExternalFilesDir(null)
7780
return if (externalFilesDir != null) {
78-
File(externalFilesDir, MODEL_FILENAME + TEMP_SUFFIX)
81+
File(externalFilesDir, modelFilename + TEMP_SUFFIX)
7982
} else {
8083
null
8184
}
@@ -131,8 +134,8 @@ object ModelDownloadManager {
131134
notificationManager.cancel(DOWNLOAD_NOTIFICATION_ID)
132135
}
133136

134-
fun downloadModel(context: Context, url: String) {
135-
if (isModelDownloaded(context)) {
137+
fun downloadModel(context: Context, model: ModelOption, url: String) {
138+
if (isModelDownloaded(context, model)) {
136139
Toast.makeText(context, "Model already downloaded.", Toast.LENGTH_SHORT).show()
137140
return
138141
}
@@ -144,7 +147,7 @@ object ModelDownloadManager {
144147

145148
isPaused = false
146149
downloadJob = CoroutineScope(Dispatchers.IO).launch {
147-
downloadWithResume(context, url)
150+
downloadWithResume(context, model, url)
148151
}
149152
}
150153

@@ -153,26 +156,26 @@ object ModelDownloadManager {
153156
isPaused = true
154157
}
155158

156-
fun resumeDownload(context: Context, url: String) {
159+
fun resumeDownload(context: Context, model: ModelOption, url: String) {
157160
if (downloadJob?.isActive == true) {
158161
Log.d(TAG, "Download is still active, not resuming.")
159162
return
160163
}
161164

162165
isPaused = false
163166
downloadJob = CoroutineScope(Dispatchers.IO).launch {
164-
downloadWithResume(context, url)
167+
downloadWithResume(context, model, url)
165168
}
166169
}
167170

168-
fun cancelDownload(context: Context) {
171+
fun cancelDownload(context: Context, model: ModelOption) {
169172
Log.d(TAG, "Cancelling download...")
170173
isPaused = false
171174
downloadJob?.cancel()
172175
downloadJob = null
173176

174177
// Delete temp file
175-
val tempFile = getTempFile(context)
178+
val tempFile = getTempFile(context, model)
176179
if (tempFile != null && tempFile.exists()) {
177180
tempFile.delete()
178181
Log.d(TAG, "Temp file deleted.")
@@ -185,12 +188,12 @@ object ModelDownloadManager {
185188
}
186189
}
187190

188-
private suspend fun downloadWithResume(context: Context, url: String) {
189-
val tempFile = getTempFile(context) ?: run {
191+
private suspend fun downloadWithResume(context: Context, model: ModelOption, url: String) {
192+
val tempFile = getTempFile(context, model) ?: run {
190193
_downloadState.value = DownloadState.Error("Storage not available.")
191194
return
192195
}
193-
val finalFile = getModelFile(context) ?: run {
196+
val finalFile = getModelFile(context, model) ?: run {
194197
_downloadState.value = DownloadState.Error("Storage not available.")
195198
return
196199
}
@@ -348,4 +351,3 @@ object ModelDownloadManager {
348351
}
349352
}
350353
}
351-

app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningScreen.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ fun PhotoReasoningScreen(
370370
)
371371
}
372372

373-
val isGemma = modelName == "gemma-3n-e4b-it"
373+
val isGemma = com.google.ai.sample.GenerativeAiViewModelFactory.getCurrentModel().isOfflineModel
374374
val isLoading = uiState is PhotoReasoningUiState.Loading
375375
val showStopButton = isGenerationRunning || isLoading || isOfflineGpuModelLoaded || isGemma
376376
val stopButtonText = if (isGenerationRunning || isLoading) "Stop" else "Model Unload"
@@ -406,9 +406,9 @@ fun PhotoReasoningScreen(
406406
return@IconButton
407407
}
408408

409-
// Check MediaProjection for all models except gemma-3n-e4b-it and human-expert
409+
// Check MediaProjection for all models except offline and human-expert
410410
// Human Expert uses its own MediaProjection for WebRTC, not ScreenCaptureService
411-
if (!isMediaProjectionPermissionGranted && modelName != "gemma-3n-e4b-it" && modelName != "human-expert") {
411+
if (!isMediaProjectionPermissionGranted && !com.google.ai.sample.GenerativeAiViewModelFactory.getCurrentModel().isOfflineModel && modelName != "human-expert") {
412412
mainActivity?.requestMediaProjectionPermission {
413413
// This block will be executed after permission is granted
414414
if (userQuestion.isNotBlank()) {

0 commit comments

Comments
 (0)