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.
7 changes: 4 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ android {
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "1.8"
jvmTarget = "21"
}
buildFeatures {
compose = true
Expand Down Expand Up @@ -89,6 +89,7 @@ dependencies {
debugImplementation("androidx.compose.ui:ui-test-manifest")

implementation("com.google.ai.client.generativeai:generativeai:0.9.0")
implementation("com.google.mediapipe:tasks-genai:0.10.27")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
}
Expand Down
14 changes: 6 additions & 8 deletions app/src/main/kotlin/com/google/ai/sample/ApiKeyDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ import androidx.compose.ui.window.Dialog
fun ApiKeyDialog(
apiKeyManager: ApiKeyManager,
isFirstLaunch: Boolean = false,
initialProvider: ApiProvider = ApiProvider.VERCEL,
onDismiss: () -> Unit
) {
var apiKeyInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
var selectedProvider by remember { mutableStateOf(ApiProvider.VERCEL) }
var selectedProvider by remember { mutableStateOf(initialProvider) }
val apiKeys = remember { mutableStateMapOf<ApiProvider, List<String>>() }
var selectedKeyIndex by remember { mutableStateOf(apiKeyManager.getCurrentKeyIndex(selectedProvider)) }
val context = LocalContext.current
Expand All @@ -44,9 +45,7 @@ fun ApiKeyDialog(
}

Dialog(onDismissRequest = {
if (!isFirstLaunch || (apiKeys[ApiProvider.GOOGLE]?.isNotEmpty() == true || apiKeys[ApiProvider.CEREBRAS]?.isNotEmpty() == true)) {
onDismiss()
}
onDismiss()
}) {
Surface(
modifier = Modifier
Expand Down Expand Up @@ -89,6 +88,7 @@ fun ApiKeyDialog(
ApiProvider.GOOGLE -> "https://makersuite.google.com/app/apikey"
ApiProvider.CEREBRAS -> "https://cloud.cerebras.ai/"
ApiProvider.VERCEL -> "https://vercel.com/ai-gateway"
else -> ""
}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
Expand Down Expand Up @@ -194,10 +194,8 @@ fun ApiKeyDialog(
.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
) {
if (!isFirstLaunch || (apiKeys[ApiProvider.VERCEL]?.isNotEmpty() == true || apiKeys[ApiProvider.GOOGLE]?.isNotEmpty() == true || apiKeys[ApiProvider.CEREBRAS]?.isNotEmpty() == true)) {
TextButton(onClick = onDismiss) {
Text("Close")
}
TextButton(onClick = onDismiss) {
Text("Close")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel
enum class ApiProvider {
VERCEL,
GOOGLE,
CEREBRAS
CEREBRAS,
OFFLINE_GEMMA
}

enum class ModelOption(val displayName: String, val modelName: String, val apiProvider: ApiProvider = ApiProvider.GOOGLE) {
Expand All @@ -30,7 +31,8 @@ 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 (online)", "gemma-3n-e4b-it"),
GEMMA_3N_E4B_IT_OFFLINE("Gemma 3n E4B it (offline GPU)", "gemma-3n-e4b-it-offline", ApiProvider.OFFLINE_GEMMA)
}

val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
Expand All @@ -48,18 +50,35 @@ val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
// Get the API key from MainActivity
val mainActivity = MainActivity.getInstance()
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()
val apiKey = mainActivity?.getCurrentApiKey(currentModel.apiProvider) ?: ""
val apiKey = if (currentModel.apiProvider == ApiProvider.OFFLINE_GEMMA) {
"OFFLINE" // Dummy key for offline model
} else {
mainActivity?.getCurrentApiKey(currentModel.apiProvider) ?: ""
}

if (apiKey.isEmpty()) {
if (apiKey.isEmpty() && currentModel.apiProvider != ApiProvider.OFFLINE_GEMMA) {
throw IllegalStateException("API key for ${currentModel.apiProvider} is not available. Please set an API key.")
}

return with(viewModelClass) {
when {
isAssignableFrom(PhotoReasoningViewModel::class.java) -> {
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()

if (currentModel.modelName.contains("live")) {

if (currentModel.apiProvider == ApiProvider.OFFLINE_GEMMA) {
// For offline models, we use a dummy GenerativeModel
// The actual inference is handled in ScreenCaptureService via MediaPipe
val dummyModel = GenerativeModel(
modelName = currentModel.modelName,
apiKey = "OFFLINE",
generationConfig = config
)
PhotoReasoningViewModel(
dummyModel,
currentModel.modelName,
null
)
} else if (currentModel.modelName.contains("live")) {
// Live API models
val liveApiManager = LiveApiManager(apiKey, currentModel.modelName)

Expand Down
14 changes: 8 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 @@ -103,6 +103,7 @@ class MainActivity : ComponentActivity() {
private var photoReasoningViewModel: PhotoReasoningViewModel? = null
private lateinit var apiKeyManager: ApiKeyManager
private var showApiKeyDialog by mutableStateOf(false)
private var initialApiKeyProvider by mutableStateOf(ApiProvider.VERCEL)

// Google Play Billing
private lateinit var billingClient: BillingClient
Expand Down Expand Up @@ -317,6 +318,11 @@ class MainActivity : ComponentActivity() {
}
}

fun showApiKeyDialogWithProvider(provider: ApiProvider) {
initialApiKeyProvider = provider
showApiKeyDialog = true
}

fun getCurrentApiKey(provider: ApiProvider): String? {
val key = if (::apiKeyManager.isInitialized) {
apiKeyManager.getCurrentApiKey(provider)
Expand Down Expand Up @@ -381,12 +387,7 @@ 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.")
}
// Removed auto-show of ApiKeyDialog on first launch

// Log.d(TAG, "onCreate: Calling checkAndRequestPermissions.") // Deleted
// checkAndRequestPermissions() // Deleted
Expand Down Expand Up @@ -548,6 +549,7 @@ class MainActivity : ComponentActivity() {
ApiKeyDialog(
apiKeyManager = apiKeyManager,
isFirstLaunch = apiKeyManager.getApiKeys(ApiProvider.GOOGLE).isEmpty() && apiKeyManager.getApiKeys(ApiProvider.CEREBRAS).isEmpty(),
initialProvider = initialApiKeyProvider,
onDismiss = {
Log.d(TAG, "ApiKeyDialog onDismiss called.")
showApiKeyDialog = false
Expand Down
97 changes: 96 additions & 1 deletion 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 com.google.ai.sample.util.ModelDownloadManager
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.material3.LinearProgressIndicator

data class MenuItem(
val routeId: String,
Expand All @@ -67,6 +71,10 @@ 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 isDownloading by remember { mutableStateOf(false) }
var downloadProgress by remember { mutableStateOf(0f) }
val scope = rememberCoroutineScope()

Column(
modifier = Modifier
Expand Down Expand Up @@ -154,6 +162,15 @@ fun MenuScreen(
DropdownMenuItem(
text = { Text(modelOption.displayName) },
onClick = {
if (modelOption.apiProvider == ApiProvider.OFFLINE_GEMMA) {
if (!ModelDownloadManager.isModelDownloaded(context)) {
selectedModel = modelOption
GenerativeAiViewModelFactory.setModel(modelOption)
showDownloadDialog = true
expanded = false
return@DropdownMenuItem
}
}
selectedModel = modelOption
GenerativeAiViewModelFactory.setModel(modelOption)
expanded = false
Expand Down Expand Up @@ -193,8 +210,25 @@ fun MenuScreen(
if (isTrialExpired) {
Toast.makeText(context, "Please subscribe to the app to continue.", Toast.LENGTH_LONG).show()
} else {
val currentModelOption = GenerativeAiViewModelFactory.getCurrentModel()
val mainActivity = context as? MainActivity

// Check for API key if not offline
if (currentModelOption.apiProvider != ApiProvider.OFFLINE_GEMMA) {
val apiKey = mainActivity?.getCurrentApiKey(currentModelOption.apiProvider)
if (apiKey.isNullOrEmpty()) {
mainActivity?.showApiKeyDialogWithProvider(currentModelOption.apiProvider)
return@TextButton
}
} else {
// Offline model - check if downloaded
if (!ModelDownloadManager.isModelDownloaded(context)) {
showDownloadDialog = true
return@TextButton
}
}

if (menuItem.routeId == "photo_reasoning") {
val mainActivity = context as? MainActivity
if (mainActivity != null) { // Ensure mainActivity is not null
if (!mainActivity.isNotificationPermissionGranted()) {
Log.d("MenuScreen", "Notification permission NOT granted.")
Expand Down Expand Up @@ -314,6 +348,67 @@ GPT-5 nano Input: $0.05/M Output: $0.40/M
}
}

if (showDownloadDialog) {
val availableMB = ModelDownloadManager.getAvailableExternalStorage(context) / (1024 * 1024)
val availableGB = availableMB / 1024.0

AlertDialog(
onDismissRequest = { if (!isDownloading) showDownloadDialog = false },
title = { Text("Download Gemma 3n E4B") },
text = {
Column {
Text("Should Gemma 3n E4B be downloaded? It requires 4.7 GB.")
Spacer(modifier = Modifier.height(8.dp))
Text("Available storage: ${String.format("%.2f", availableGB)} GB",
color = if (availableGB < 5.0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)

if (isDownloading) {
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = downloadProgress,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "${(downloadProgress * 100).toInt()}%",
modifier = Modifier.align(Alignment.End),
style = MaterialTheme.typography.bodySmall
)
}
}
},
confirmButton = {
if (!isDownloading) {
TextButton(
onClick = {
isDownloading = true
scope.launch {
val success = ModelDownloadManager.downloadModel(context) { progress ->
downloadProgress = progress
}
isDownloading = false
if (success) {
showDownloadDialog = false
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Download failed", Toast.LENGTH_LONG).show()
}
}
}
) {
Text("OK")
}
Comment on lines +381 to +399

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Logic Error: The download dialog doesn't check available storage before allowing download. Users can start downloading a 4.7GB file even when they have insufficient storage, leading to failed downloads and wasted bandwidth.

Suggested change
TextButton(
onClick = {
isDownloading = true
scope.launch {
val success = ModelDownloadManager.downloadModel(context) { progress ->
downloadProgress = progress
}
isDownloading = false
if (success) {
showDownloadDialog = false
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Download failed", Toast.LENGTH_LONG).show()
}
}
}
) {
Text("OK")
}
TextButton(
onClick = {
if (availableGB < 5.0) {
Toast.makeText(context, "Insufficient storage space. At least 5 GB required.", Toast.LENGTH_LONG).show()
return@TextButton
}
isDownloading = true
scope.launch {
val success = ModelDownloadManager.downloadModel(context) { progress ->
downloadProgress = progress
}
isDownloading = false
if (success) {
showDownloadDialog = false
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Download failed", Toast.LENGTH_LONG).show()
}
}
}
) {
Text("OK")
}

}
},
dismissButton = {
if (!isDownloading) {
TextButton(onClick = { showDownloadDialog = false }) {
Text("Abort")
}
}
}
)
}

if (showRationaleDialogForPhotoReasoning) {
val mainActivity = LocalContext.current as? MainActivity
AlertDialog(
Expand Down
Loading