Skip to content
Open
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
Binary file modified app-release-signed.apk
Binary file not shown.
8 changes: 6 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@


plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
Expand Down Expand Up @@ -91,5 +90,10 @@ dependencies {
implementation("com.google.ai.client.generativeai:generativeai:0.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
}

// MediaPipe GenAI for offline inference (LLM)
implementation("com.google.mediapipe:tasks-genai:0.10.20")

// Camera Core to potentially fix missing JNI lib issue
implementation("androidx.camera:camera-core:1.4.0")
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ enum class ApiProvider {
CEREBRAS
}

enum class ModelOption(val displayName: String, val modelName: String, val apiProvider: ApiProvider = ApiProvider.GOOGLE) {
enum class ModelOption(
val displayName: String,
val modelName: String,
val apiProvider: ApiProvider = ApiProvider.GOOGLE,
val downloadUrl: String? = null,
val size: String? = null
) {
GPT_5_1_CODEX_MAX("GPT-5.1 Codex Max (Vercel)", "openai/gpt-5.1-codex-max", ApiProvider.VERCEL),
GPT_5_1_CODEX_MINI("GPT-5.1 Codex Mini (Vercel)", "openai/gpt-5.1-codex-mini", ApiProvider.VERCEL),
GPT_5_NANO("GPT-5 Nano (Vercel)", "openai/gpt-5-nano", ApiProvider.VERCEL),
Expand All @@ -30,7 +36,13 @@ enum class ModelOption(val displayName: String, val modelName: String, val apiPr
GEMINI_FLASH("Gemini 2.0 Flash", "gemini-2.0-flash"),
GEMINI_FLASH_LITE("Gemini 2.0 Flash Lite", "gemini-2.0-flash-lite"),
GEMMA_3_27B_IT("Gemma 3 27B IT", "gemma-3-27b-it"),
GEMMA_3N_E4B_IT("Gemma 3n E4B it (online)", "gemma-3n-e4b-it")
GEMMA_3N_E4B_IT(
"Gemma 3n E4B it (offline)",
"gemma-3n-e4b-it",
ApiProvider.GOOGLE,
"https://huggingface.co/na5h13/gemma-3n-E4B-it-litert-lm/resolve/main/gemma-3n-E4B-it-int4.litertlm?download=true",
"4.92 GB"
)
}

val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
Expand Down Expand Up @@ -73,6 +85,7 @@ val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
)

val viewModel = PhotoReasoningViewModel(
application,
fallbackModel,
currentModel.modelName,
liveApiManager
Expand All @@ -88,6 +101,7 @@ val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
generationConfig = config
)
PhotoReasoningViewModel(
application,
generativeModel,
currentModel.modelName,
null // No LiveApiManager for regular models
Expand Down
8 changes: 2 additions & 6 deletions app/src/main/kotlin/com/google/ai/sample/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -381,12 +381,8 @@ class MainActivity : ComponentActivity() {

apiKeyManager = ApiKeyManager.getInstance(this)
Log.d(TAG, "onCreate: ApiKeyManager initialized.")
if (apiKeyManager.getApiKeys(ApiProvider.GOOGLE).isEmpty() && apiKeyManager.getApiKeys(ApiProvider.CEREBRAS).isEmpty()) {
showApiKeyDialog = true
Log.d(TAG, "onCreate: No API key found, showApiKeyDialog set to true.")
} else {
Log.d(TAG, "onCreate: API key found.")
}
// API key dialog logic removed from onCreate as requested.
// It will be triggered when needed (e.g., when the user tries to use an online model).

// Log.d(TAG, "onCreate: Calling checkAndRequestPermissions.") // Deleted
// checkAndRequestPermissions() // Deleted
Expand Down
73 changes: 70 additions & 3 deletions app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ import android.Manifest // For Manifest.permission.POST_NOTIFICATIONS
import androidx.compose.material3.AlertDialog // For the rationale dialog
import androidx.compose.runtime.saveable.rememberSaveable
import android.util.Log
import android.os.Environment
import android.os.StatFs
import com.google.ai.sample.feature.multimodal.ModelDownloadManager
import java.io.File

data class MenuItem(
val routeId: String,
Expand All @@ -67,6 +71,8 @@ fun MenuScreen(
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()
var selectedModel by remember { mutableStateOf(currentModel) }
var expanded by remember { mutableStateOf(false) }
var showDownloadDialog by remember { mutableStateOf(false) }
var downloadDialogModel by remember { mutableStateOf<ModelOption?>(null) }

Column(
modifier = Modifier
Expand Down Expand Up @@ -152,11 +158,24 @@ fun MenuScreen(

orderedModels.forEach { modelOption ->
DropdownMenuItem(
text = { Text(modelOption.displayName) },
text = {
Text(modelOption.displayName + (modelOption.size?.let { " - $it" } ?: ""))
},
onClick = {
selectedModel = modelOption
GenerativeAiViewModelFactory.setModel(modelOption)
expanded = false
if (modelOption == ModelOption.GEMMA_3N_E4B_IT) {
val isDownloaded = ModelDownloadManager.isModelDownloaded(context)
if (!isDownloaded) {
downloadDialogModel = modelOption
showDownloadDialog = true
} else {
selectedModel = modelOption
GenerativeAiViewModelFactory.setModel(modelOption)
}
} else {
selectedModel = modelOption
GenerativeAiViewModelFactory.setModel(modelOption)
}
},
enabled = true // Always enabled
)
Expand Down Expand Up @@ -195,6 +214,19 @@ fun MenuScreen(
} else {
if (menuItem.routeId == "photo_reasoning") {
val mainActivity = context as? MainActivity
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()
// Check API Key for online models
if (currentModel.apiProvider != ApiProvider.GOOGLE || !currentModel.modelName.contains("litert")) { // Simple check, refine if needed. Actually offline model has specific Enum
if (currentModel != ModelOption.GEMMA_3N_E4B_IT) {
val apiKey = mainActivity?.getCurrentApiKey(currentModel.apiProvider)
if (apiKey.isNullOrEmpty()) {
// Show API Key Dialog
onApiKeyButtonClicked() // Or a specific callback to show dialog
return@TextButton
}
}
}

if (mainActivity != null) { // Ensure mainActivity is not null
if (!mainActivity.isNotificationPermissionGranted()) {
Log.d("MenuScreen", "Notification permission NOT granted.")
Expand Down Expand Up @@ -351,6 +383,41 @@ GPT-5 nano Input: $0.05/M Output: $0.40/M
}
)
}

if (showDownloadDialog && downloadDialogModel != null) {
val context = LocalContext.current
val statFs = StatFs(Environment.getExternalStorageDirectory().path)
val bytesAvailable = statFs.availableBlocksLong * statFs.blockSizeLong
val gbAvailable = bytesAvailable.toDouble() / (1024 * 1024 * 1024)
val formattedGbAvailable = String.format("%.2f", gbAvailable)

AlertDialog(
onDismissRequest = { showDownloadDialog = false },
title = { Text("Download Model? (4.92 GB)") },
text = { Text("Should the Gemma 3n E4B be downloaded?\n\n$formattedGbAvailable GB of storage available.") },
confirmButton = {
TextButton(
onClick = {
showDownloadDialog = false
downloadDialogModel?.downloadUrl?.let { url ->
ModelDownloadManager.downloadModel(context, url)
// We set the model, but the user will have to wait for download
selectedModel = downloadDialogModel!!
GenerativeAiViewModelFactory.setModel(downloadDialogModel!!)
}
}
) { Text("OK") }
},
dismissButton = {
TextButton(
onClick = {
showDownloadDialog = false
// Do not change model
}
) { Text("ABORT") }
}
)
}
}

@Preview(showSystemUi = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.google.ai.sample.feature.multimodal

import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.Cursor
import android.net.Uri
import android.util.Log
import android.widget.Toast
import java.io.File

object ModelDownloadManager {
private const val TAG = "ModelDownloadManager"
const val MODEL_FILENAME = "gemma-3n-e4b-it-int4.litertlm"
private var downloadId: Long = -1

fun isModelDownloaded(context: Context): Boolean {
val file = getModelFile(context)
return file != null && file.exists() && file.length() > 0
}

fun getModelFile(context: Context): File? {
val externalFilesDir = context.getExternalFilesDir(null)
return if (externalFilesDir != null) {
File(externalFilesDir, MODEL_FILENAME)
} else {
Log.e(TAG, "External files directory is not available.")
null
}
}

fun downloadModel(context: Context, url: String) {
if (isModelDownloaded(context)) {
Toast.makeText(context, "Model already downloaded.", Toast.LENGTH_SHORT).show()
return
}

val file = getModelFile(context)
if (file != null && file.exists()) {
file.delete() // Clean up partial or old file
}

try {
val request = DownloadManager.Request(Uri.parse(url))
.setTitle("Downloading Gemma Model")
.setDescription("Downloading offline AI model (4.92 GB)...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalFilesDir(context, null, MODEL_FILENAME)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)

val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager

if (downloadManager != null) {
downloadId = downloadManager.enqueue(request)
Toast.makeText(context, "Download started. Please do not pause the download, as it cannot be resumed.", Toast.LENGTH_LONG).show()
Log.d(TAG, "Download started with ID: $downloadId")
} else {
Log.e(TAG, "DownloadManager service not available.")
Toast.makeText(context, "Download service unavailable.", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.e(TAG, "Error starting download: ${e.message}")
Toast.makeText(context, "Failed to start download.", Toast.LENGTH_SHORT).show()
}
}

fun cancelDownload(context: Context) {
if (downloadId != -1L) {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
if (downloadManager != null) {
downloadManager.remove(downloadId)
downloadId = -1
Toast.makeText(context, "Download cancelled.", Toast.LENGTH_SHORT).show()
} else {
Log.e(TAG, "DownloadManager service not available for cancellation.")
}
}
}
}
Loading