Skip to content

Commit 185335e

Browse files
feat: all 7 features implemented - MediaProjection fix, custom download manager, UI init feedback, 4096 context window, generation settings, real-time commands, offline model screen elements
1 parent b8aa15f commit 185335e

8 files changed

Lines changed: 651 additions & 95 deletions

File tree

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.google.ai.client.generativeai.GenerativeModel
99
import com.google.ai.client.generativeai.type.generationConfig
1010
import com.google.ai.sample.feature.live.LiveApiManager
1111
import com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel
12+
import com.google.ai.sample.util.GenerationSettingsPreferences
1213

1314
// Model options
1415
enum class ApiProvider {
@@ -44,24 +45,32 @@ enum class ModelOption(
4445
"https://huggingface.co/na5h13/gemma-3n-E4B-it-litert-lm/resolve/main/gemma-3n-E4B-it-int4.litertlm?download=true",
4546
"4.92 GB"
4647
),
47-
HUMAN_EXPERT("Human Expert", "human-expert", ApiProvider.HUMAN_EXPERT)
48+
HUMAN_EXPERT("Human Expert", "human-expert", ApiProvider.HUMAN_EXPERT);
49+
50+
/** Whether this model supports TopK/TopP/Temperature settings */
51+
val supportsGenerationSettings: Boolean
52+
get() = this != HUMAN_EXPERT
4853
}
4954

5055
val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
5156
override fun <T : ViewModel> create(
5257
viewModelClass: Class<T>,
5358
extras: CreationExtras
5459
): T {
55-
val config = generationConfig {
56-
temperature = 0.0f
57-
}
58-
5960
// Get the application context from extras
6061
val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY])
62+
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()
63+
64+
// Load per-model generation settings
65+
val genSettings = GenerationSettingsPreferences.loadSettings(application.applicationContext, currentModel.modelName)
66+
val config = generationConfig {
67+
temperature = genSettings.temperature
68+
topP = genSettings.topP
69+
topK = genSettings.topK
70+
}
6171

6272
// Get the API key from MainActivity
6373
val mainActivity = MainActivity.getInstance()
64-
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()
6574
val apiKey = if (currentModel == ModelOption.GEMMA_3N_E4B_IT || currentModel == ModelOption.HUMAN_EXPERT) {
6675
"offline-no-key-needed" // Dummy key for offline/human expert models
6776
} else {
@@ -75,8 +84,6 @@ val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
7584
return with(viewModelClass) {
7685
when {
7786
isAssignableFrom(PhotoReasoningViewModel::class.java) -> {
78-
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()
79-
8087
if (currentModel.modelName.contains("live")) {
8188
// Live API models
8289
val liveApiManager = LiveApiManager(apiKey, currentModel.modelName)

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

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,14 @@ class MainActivity : ComponentActivity() {
120120
// MediaProjection
121121
private lateinit var mediaProjectionManager: MediaProjectionManager
122122
private lateinit var mediaProjectionLauncher: ActivityResultLauncher<Intent>
123+
private lateinit var webRtcMediaProjectionLauncher: ActivityResultLauncher<Intent>
123124

124125
private var currentScreenInfoForScreenshot: String? = null
125126

126127
private lateinit var navController: NavHostController
127128
private var isProcessingExplicitScreenshotRequest: Boolean = false
128129
private var onMediaProjectionPermissionGranted: (() -> Unit)? = null
130+
private var onWebRtcMediaProjectionResult: ((Int, Intent) -> Unit)? = null
129131

130132
private val screenshotRequestHandler = object : BroadcastReceiver() {
131133
override fun onReceive(context: Context?, intent: Intent?) {
@@ -187,15 +189,28 @@ class MainActivity : ComponentActivity() {
187189
// This should be guaranteed by its placement in onCreate.
188190
if (!::mediaProjectionManager.isInitialized) {
189191
Log.e(TAG, "requestMediaProjectionPermission: mediaProjectionManager not initialized!")
190-
// Optionally, initialize it here as a fallback, though it indicates an issue with onCreate ordering
191-
// mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
192-
// Toast.makeText(this, "Error: Projection manager not ready. Please try again.", Toast.LENGTH_SHORT).show()
193192
return
194193
}
195194
val intent = mediaProjectionManager.createScreenCaptureIntent()
196195
mediaProjectionLauncher.launch(intent)
197196
}
198197

198+
/**
199+
* Request a fresh MediaProjection permission specifically for WebRTC (Human Expert).
200+
* This does NOT start ScreenCaptureService - the result is passed directly to the callback.
201+
*/
202+
fun requestMediaProjectionForWebRTC(onResult: (Int, Intent) -> Unit) {
203+
Log.d(TAG, "Requesting MediaProjection permission for WebRTC")
204+
onWebRtcMediaProjectionResult = onResult
205+
206+
if (!::mediaProjectionManager.isInitialized) {
207+
Log.e(TAG, "requestMediaProjectionForWebRTC: mediaProjectionManager not initialized!")
208+
return
209+
}
210+
val intent = mediaProjectionManager.createScreenCaptureIntent()
211+
webRtcMediaProjectionLauncher.launch(intent)
212+
}
213+
199214
fun takeAdditionalScreenshot() {
200215
if (ScreenCaptureService.isRunning()) {
201216
Log.d(TAG, "MainActivity: Instructing ScreenCaptureService to take an additional screenshot.")
@@ -491,6 +506,21 @@ class MainActivity : ComponentActivity() {
491506
}
492507
}
493508

509+
// Separate WebRTC MediaProjection launcher - does NOT start ScreenCaptureService
510+
webRtcMediaProjectionLauncher = registerForActivityResult(
511+
ActivityResultContracts.StartActivityForResult()
512+
) { result ->
513+
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
514+
Log.i(TAG, "WebRTC MediaProjection permission granted.")
515+
onWebRtcMediaProjectionResult?.invoke(result.resultCode, result.data!!)
516+
onWebRtcMediaProjectionResult = null
517+
} else {
518+
Log.w(TAG, "WebRTC MediaProjection permission denied.")
519+
Toast.makeText(this, "Screen capture permission denied", Toast.LENGTH_SHORT).show()
520+
onWebRtcMediaProjectionResult = null
521+
}
522+
}
523+
494524
// Keyboard visibility listener
495525
val rootView = findViewById<View>(android.R.id.content)
496526
onGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {

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

Lines changed: 214 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import android.util.Log
4848
import android.os.Environment
4949
import android.os.StatFs
5050
import com.google.ai.sample.feature.multimodal.ModelDownloadManager
51+
import androidx.compose.runtime.collectAsState
5152
import java.io.File
5253

5354
data class MenuItem(
@@ -285,6 +286,111 @@ fun MenuScreen(
285286
}
286287
}
287288

289+
// Generation Settings (TopK, TopP, Temperature) for current model
290+
if (selectedModel.supportsGenerationSettings) {
291+
item {
292+
val genSettings = remember(selectedModel) {
293+
mutableStateOf(
294+
com.google.ai.sample.util.GenerationSettingsPreferences.loadSettings(
295+
context, selectedModel.modelName
296+
)
297+
)
298+
}
299+
300+
Card(
301+
modifier = Modifier
302+
.fillMaxWidth()
303+
.padding(horizontal = 16.dp, vertical = 8.dp)
304+
) {
305+
Column(
306+
modifier = Modifier
307+
.padding(all = 16.dp)
308+
.fillMaxWidth()
309+
) {
310+
Text(
311+
text = "Generation Settings (${selectedModel.displayName})",
312+
style = MaterialTheme.typography.titleMedium
313+
)
314+
315+
Spacer(modifier = Modifier.height(12.dp))
316+
317+
// Temperature Slider (0.0 - 2.0)
318+
Text(
319+
text = "Temperature: ${"%.2f".format(genSettings.value.temperature)}",
320+
style = MaterialTheme.typography.bodyMedium
321+
)
322+
androidx.compose.material3.Slider(
323+
value = genSettings.value.temperature,
324+
onValueChange = { newVal ->
325+
genSettings.value = genSettings.value.copy(temperature = newVal)
326+
},
327+
onValueChangeFinished = {
328+
com.google.ai.sample.util.GenerationSettingsPreferences.saveSettings(
329+
context, selectedModel.modelName, genSettings.value
330+
)
331+
},
332+
valueRange = 0f..2f,
333+
steps = 39,
334+
modifier = Modifier.fillMaxWidth()
335+
)
336+
337+
Spacer(modifier = Modifier.height(8.dp))
338+
339+
// TopP Slider (0.0 - 1.0)
340+
Text(
341+
text = "Top P: ${"%.2f".format(genSettings.value.topP)}",
342+
style = MaterialTheme.typography.bodyMedium
343+
)
344+
androidx.compose.material3.Slider(
345+
value = genSettings.value.topP,
346+
onValueChange = { newVal ->
347+
genSettings.value = genSettings.value.copy(topP = newVal)
348+
},
349+
onValueChangeFinished = {
350+
com.google.ai.sample.util.GenerationSettingsPreferences.saveSettings(
351+
context, selectedModel.modelName, genSettings.value
352+
)
353+
},
354+
valueRange = 0f..1f,
355+
steps = 19,
356+
modifier = Modifier.fillMaxWidth()
357+
)
358+
359+
Spacer(modifier = Modifier.height(8.dp))
360+
361+
// TopK Slider (1 - 100)
362+
Text(
363+
text = "Top K: ${genSettings.value.topK}",
364+
style = MaterialTheme.typography.bodyMedium
365+
)
366+
androidx.compose.material3.Slider(
367+
value = genSettings.value.topK.toFloat(),
368+
onValueChange = { newVal ->
369+
genSettings.value = genSettings.value.copy(topK = newVal.toInt())
370+
},
371+
onValueChangeFinished = {
372+
com.google.ai.sample.util.GenerationSettingsPreferences.saveSettings(
373+
context, selectedModel.modelName, genSettings.value
374+
)
375+
},
376+
valueRange = 1f..100f,
377+
steps = 98,
378+
modifier = Modifier.fillMaxWidth()
379+
)
380+
381+
if (selectedModel == ModelOption.GEMMA_3N_E4B_IT) {
382+
Spacer(modifier = Modifier.height(4.dp))
383+
Text(
384+
text = "Note: LlmInference (offline model) may not support all generation parameters.",
385+
style = MaterialTheme.typography.bodySmall,
386+
color = MaterialTheme.colorScheme.onSurfaceVariant
387+
)
388+
}
389+
}
390+
}
391+
}
392+
}
393+
288394
// Menu Items
289395
items(menuItems) { menuItem ->
290396
Card(
@@ -489,31 +595,121 @@ GPT-5 nano Input: $0.05/M Output: $0.40/M
489595
val bytesAvailable = statFs.availableBlocksLong * statFs.blockSizeLong
490596
val gbAvailable = bytesAvailable.toDouble() / (1024 * 1024 * 1024)
491597
val formattedGbAvailable = String.format("%.2f", gbAvailable)
598+
599+
val dlState by ModelDownloadManager.downloadState.collectAsState()
492600

493601
AlertDialog(
494-
onDismissRequest = { showDownloadDialog = false },
495-
title = { Text("Download Model? (4.92 GB)") },
496-
text = { Text("Should the Gemma 3n E4B be downloaded?\n\n$formattedGbAvailable GB of storage available.") },
497-
confirmButton = {
498-
TextButton(
499-
onClick = {
500-
showDownloadDialog = false
501-
downloadDialogModel?.downloadUrl?.let { url ->
502-
ModelDownloadManager.downloadModel(context, url)
503-
// We set the model, but the user will have to wait for download
504-
selectedModel = downloadDialogModel!!
505-
GenerativeAiViewModelFactory.setModel(downloadDialogModel!!)
602+
onDismissRequest = {
603+
if (dlState is ModelDownloadManager.DownloadState.Idle || dlState is ModelDownloadManager.DownloadState.Completed || dlState is ModelDownloadManager.DownloadState.Error) {
604+
showDownloadDialog = false
605+
}
606+
// Don't dismiss while downloading/paused
607+
},
608+
title = { Text("Download Model (4.92 GB)") },
609+
text = {
610+
Column {
611+
when (val state = dlState) {
612+
is ModelDownloadManager.DownloadState.Idle -> {
613+
Text("Should the Gemma 3n E4B be downloaded?\n\n$formattedGbAvailable GB of storage available.")
614+
}
615+
is ModelDownloadManager.DownloadState.Downloading -> {
616+
Text("Downloading...")
617+
Spacer(modifier = Modifier.height(8.dp))
618+
androidx.compose.material3.LinearProgressIndicator(
619+
progress = { state.progress },
620+
modifier = Modifier.fillMaxWidth()
621+
)
622+
Spacer(modifier = Modifier.height(4.dp))
623+
Text(
624+
text = "${ModelDownloadManager.formatBytes(state.bytesDownloaded)} / ${if (state.totalBytes > 0) ModelDownloadManager.formatBytes(state.totalBytes) else "?"}",
625+
style = MaterialTheme.typography.bodySmall
626+
)
627+
Text(
628+
text = "${"%.1f".format(state.progress * 100)}%",
629+
style = MaterialTheme.typography.bodySmall
630+
)
631+
}
632+
is ModelDownloadManager.DownloadState.Paused -> {
633+
Text("Download paused.")
634+
Spacer(modifier = Modifier.height(8.dp))
635+
val progress = if (state.totalBytes > 0) state.bytesDownloaded.toFloat() / state.totalBytes else 0f
636+
androidx.compose.material3.LinearProgressIndicator(
637+
progress = { progress },
638+
modifier = Modifier.fillMaxWidth()
639+
)
640+
Spacer(modifier = Modifier.height(4.dp))
641+
Text(
642+
text = "${ModelDownloadManager.formatBytes(state.bytesDownloaded)} / ${if (state.totalBytes > 0) ModelDownloadManager.formatBytes(state.totalBytes) else "?"}",
643+
style = MaterialTheme.typography.bodySmall
644+
)
645+
}
646+
is ModelDownloadManager.DownloadState.Completed -> {
647+
Text("Download complete! ✅")
648+
}
649+
is ModelDownloadManager.DownloadState.Error -> {
650+
Text("Error: ${state.message}")
506651
}
507652
}
508-
) { Text("OK") }
653+
}
654+
},
655+
confirmButton = {
656+
when (dlState) {
657+
is ModelDownloadManager.DownloadState.Idle -> {
658+
TextButton(
659+
onClick = {
660+
downloadDialogModel?.downloadUrl?.let { url ->
661+
ModelDownloadManager.downloadModel(context, url)
662+
selectedModel = downloadDialogModel!!
663+
GenerativeAiViewModelFactory.setModel(downloadDialogModel!!)
664+
}
665+
}
666+
) { Text("Download") }
667+
}
668+
is ModelDownloadManager.DownloadState.Downloading -> {
669+
TextButton(onClick = { ModelDownloadManager.pauseDownload() }) { Text("Pause") }
670+
}
671+
is ModelDownloadManager.DownloadState.Paused -> {
672+
TextButton(
673+
onClick = {
674+
downloadDialogModel?.downloadUrl?.let { url ->
675+
ModelDownloadManager.resumeDownload(context, url)
676+
}
677+
}
678+
) { Text("Resume") }
679+
}
680+
is ModelDownloadManager.DownloadState.Completed -> {
681+
TextButton(onClick = { showDownloadDialog = false }) { Text("Close") }
682+
}
683+
is ModelDownloadManager.DownloadState.Error -> {
684+
TextButton(
685+
onClick = {
686+
downloadDialogModel?.downloadUrl?.let { url ->
687+
ModelDownloadManager.downloadModel(context, url)
688+
}
689+
}
690+
) { Text("Retry") }
691+
}
692+
}
509693
},
510694
dismissButton = {
511-
TextButton(
512-
onClick = {
513-
showDownloadDialog = false
514-
// Do not change model
695+
when (dlState) {
696+
is ModelDownloadManager.DownloadState.Idle -> {
697+
TextButton(onClick = { showDownloadDialog = false }) { Text("Cancel") }
515698
}
516-
) { Text("ABORT") }
699+
is ModelDownloadManager.DownloadState.Downloading,
700+
is ModelDownloadManager.DownloadState.Paused -> {
701+
TextButton(
702+
onClick = {
703+
ModelDownloadManager.cancelDownload(context)
704+
showDownloadDialog = false
705+
}
706+
) { Text("Cancel Download") }
707+
}
708+
is ModelDownloadManager.DownloadState.Completed -> { /* No dismiss button */ }
709+
is ModelDownloadManager.DownloadState.Error -> {
710+
TextButton(onClick = { showDownloadDialog = false }) { Text("Close") }
711+
}
712+
}
517713
}
518714
)
519715
}

0 commit comments

Comments
 (0)