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
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,13 @@ dependencies {
implementation(project(":samples:genai-summarization"))
implementation(project(":samples:genai-image-description"))
implementation(project(":samples:genai-writing-assistance"))
implementation(project(":samples:imagen"))
implementation(project(":samples:imagen-editing"))
implementation(project(":samples:nanobanana"))
implementation(project(":samples:magic-selfie"))
implementation(project(":samples:gemini-video-summarization"))
implementation(project(":samples:gemini-live-todo"))
implementation(project(":samples:gemini-video-metadata-creation"))
implementation(project(":samples:gemini-image-chat"))
implementation(project(":samples:gemini-hybrid"))

testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
Expand Down
38 changes: 20 additions & 18 deletions app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,25 @@ import com.android.ai.samples.geminivideosummary.ui.VideoSummarizationScreen
import com.android.ai.samples.genai_image_description.GenAIImageDescriptionScreen
import com.android.ai.samples.genai_summarization.GenAISummarizationScreen
import com.android.ai.samples.genai_writing_assistance.GenAIWritingAssistanceScreen
import com.android.ai.samples.imagen.ui.ImagenScreen
import com.android.ai.samples.imagenediting.ui.ImagenEditingScreen
import com.android.ai.samples.geminihybrid.GeminiHybridScreen
import com.android.ai.samples.nanobanana.ui.NanobananaScreen
import com.android.ai.samples.magicselfie.ui.MagicSelfieScreen
import com.android.ai.theme.extendedColorScheme
import com.google.firebase.ai.type.PublicPreviewAPI

@OptIn(PublicPreviewAPI::class)
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
val sampleCatalog = listOf(
SampleCatalogItem(
title = R.string.gemini_hybrid_sample_list_title,
description = R.string.gemini_hybrid_sample_list_description,
route = "GeminiHybridScreen",
sampleEntryScreen = { GeminiHybridScreen() },
tags = listOf(SampleTags.GEMINI_NANO, SampleTags.GEMINI_FLASH, SampleTags.ML_KIT, SampleTags.FIREBASE),
needsFirebase = true,
keyArt = R.drawable.img_keyart_text,
isFeatured = true,
),
SampleCatalogItem(
title = R.string.gemini_image_chat_list_title,
description = R.string.gemini_image_chat_list_description,
Expand All @@ -48,16 +60,6 @@ val sampleCatalog = listOf(
needsFirebase = true,
isFeatured = true,
),
SampleCatalogItem(
title = R.string.imagen_editing_sample_list_title,
description = R.string.imagen_editing_sample_list_description,
route = "ImagenMaskEditing",
sampleEntryScreen = { ImagenEditingScreen() },
tags = listOf(SampleTags.IMAGEN, SampleTags.FIREBASE),
needsFirebase = true,
keyArt = R.drawable.img_keyart_imagen,
isFeatured = true,
),
SampleCatalogItem(
title = R.string.gemini_multimodal_sample_list_title,
description = R.string.gemini_multimodal_sample_list_description,
Expand Down Expand Up @@ -102,11 +104,11 @@ val sampleCatalog = listOf(
keyArt = R.drawable.img_keyart_text,
),
SampleCatalogItem(
title = R.string.imagen_sample_list_title,
description = R.string.imagen_sample_list_description,
route = "ImagenImageGenerationScreen",
sampleEntryScreen = { ImagenScreen() },
tags = listOf(SampleTags.IMAGEN, SampleTags.FIREBASE),
title = R.string.nanobanana_sample_list_title,
description = R.string.nanobanana_sample_list_description,
route = "NanobananaImageGenerationScreen",
sampleEntryScreen = { NanobananaScreen() },
tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE),
needsFirebase = true,
keyArt = R.drawable.img_keyart_imagen,
),
Expand All @@ -115,7 +117,7 @@ val sampleCatalog = listOf(
description = R.string.magic_selfie_sample_list_description,
route = "MagicSelfieScreen",
sampleEntryScreen = { MagicSelfieScreen() },
tags = listOf(SampleTags.IMAGEN, SampleTags.FIREBASE, SampleTags.ML_KIT),
tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE),
needsFirebase = true,
keyArt = R.drawable.img_keyart_magic_selfie,
),
Expand Down
14 changes: 7 additions & 7 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[versions]
agp = "9.2.0"
coilCompose = "3.1.0"
firebaseAiOndevice = "16.0.0-beta01"
firebaseBom = "34.11.0"
lifecycleRuntimeCompose = "2.9.1"
mlkitGenAi = "1.0.0-beta1"
Expand All @@ -25,10 +26,10 @@ mlkitSegmentation = "16.0.0-beta1"
runtimeLivedata = "1.7.6"
media3 = "1.8.0"
firebaseCommonKtx = "21.0.0"
uiToolingPreviewAndroid = "1.11.0-rc01"
uiToolingPreviewAndroid = "1.8.1"
spotless = "7.0.4"
uiToolingPreview = "1.11.0-rc01"
uiTooling = "1.11.0-rc01"
uiToolingPreview = "1.8.3"
uiTooling = "1.8.3"
lifecycleViewmodelAndroid = "2.8.7"
material3 = "1.5.0-alpha01"
uiTextGoogleFonts = "1.8.1"
Expand All @@ -38,13 +39,13 @@ richtext = "1.0.0-alpha02"
glimmer = "1.0.0-alpha12"
projected = "1.0.0-alpha07"


[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
firebase-ai-ondevice = { module = "com.google.firebase:firebase-ai-ondevice", version.ref = "firebaseAiOndevice" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
firebase-ai = { group = "com.google.firebase", name = "firebase-ai" }
firebase-ai = { module = "com.google.firebase:firebase-ai"}
firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" }
genai-image-description = { module = "com.google.mlkit:genai-image-description", version.ref = "mlkitGenAi" }
genai-proofreading = { module = "com.google.mlkit:genai-proofreading", version.ref = "mlkitGenAi" }
Expand Down Expand Up @@ -82,7 +83,6 @@ androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "medi
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "media3"}
androidx-media3-transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" }
androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" }
mlkit-segmentation = { module = "com.google.android.gms:play-services-mlkit-subject-segmentation", version.ref = "mlkitSegmentation" }
ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "uiToolingPreview" }
ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" }
androidx-lifecycle-viewmodel-android = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-android", version.ref = "lifecycleViewmodelAndroid" }
Expand All @@ -99,4 +99,4 @@ google-gms-google-services = { id = "com.google.gms.google-services", version.re
hilt-plugin = { id = "com.google.dagger.hilt.android", version.ref = "hilt"}
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
32 changes: 5 additions & 27 deletions samples/gemini-live-todo/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# Gemini Live Todo AI Glasses Sample
# Gemini Live Todo Sample

This sample is part of the [AI Sample Catalog](../../). To build and run this sample, you should clone the entire repository.

This sample was built with Android Studio Quail 1 Canary 3. Get the latest [Android Studio Canary](https://developer.android.com/studio/preview) to access the AI glasses emulator and its latest features.

## Description

This sample demonstrates how to use the Gemini Live API for real-time, voice-based interactions in a simple ToDo application. Users can add, remove, and update tasks by speaking to the app, showcasing a hands-free, conversational user experience powered by the Gemini API.
This sample demonstrates how to use the Gemini Live API for real-time, voice-based interactions in a simple to-do application. Users can add, remove, and update tasks by speaking to the app, showcasing a hands-free, conversational user experience powered by the Gemini API.

<div style="text-align: center;">
<img width="320" alt="Gemini Live Todo in action" src="ai_glasses_todo.png" />
<img width="320" alt="Gemini Live API to-do in action" src="gemini_live_todo.png" />
</div>

## How it works
Expand All @@ -20,7 +20,7 @@ Here is the key snippet of code that initializes the model and connects to a liv

```kotlin
val generativeModel = Firebase.ai(backend = GenerativeBackend.vertexAI()).liveModel(
"gemini-2.0-flash-live-preview-04-09",
"gemini-2.5-flash-native-audio-preview-12-2025",
Comment on lines 22 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

According to the repository style guide (Line 73: 'When a view model or the business logic is modified in the code of a sample, verify that these changes are properly reflected in the README.md of this sample.'), the README should match the actual implementation in TodoScreenViewModel.kt.

In TodoScreenViewModel.kt, the backend was changed to GenerativeBackend.googleAI(), but the README still shows GenerativeBackend.vertexAI().

Suggested change
val generativeModel = Firebase.ai(backend = GenerativeBackend.vertexAI()).liveModel(
"gemini-2.0-flash-live-preview-04-09",
"gemini-2.5-flash-native-audio-preview-12-2025",
val generativeModel = Firebase.ai(backend = GenerativeBackend.googleAI()).liveModel(
"gemini-2.5-flash-native-audio-preview-12-2025",
References
  1. When a view model or the business logic is modified in the code of a sample, verify that these changes are properly reflected in the README.md of this sample. (link)

generationConfig = liveGenerationConfig,
systemInstruction = systemInstruction,
tools = listOf(
Expand All @@ -37,27 +37,5 @@ try {
liveSessionState.value = LiveSessionState.Error
}
```
# Google AI Glasses Support
This prototype sample demonstrates how to extend the Gemini Live experience to Google AI Glasses.

The prototype illustrates how to leverage the glasses' form factor for a heads-up display (HUD) experience while maintaining the core application logic on the host device.

<div style="text-align: center;">
<img width="320" alt="AI Glasses List in action" src="ai_glasses_list1.png" />
</div>

<div style="text-align: center;">
<img width="320" alt="AI Glasses List in action scrolled" src="ai_glasses_list2.png" />
</div>

## Tech Stack
The glasses integration is built using the following libraries:

### Jetpack Projected:
Used to manage the connection and service lifecycle between the host application (phone) and the client display (glasses). This allows the application to "project" its content onto the glasses.

### Jetpack Compose Glimmer:
The UI for the glasses is constructed using Glimmer, an Android UI toolkit optimized for transparent, wearable displays.

Read more about the Gemini Live API in the Android Documentation.
Read more about the [Gemini Live API](https://developer.android.com/ai/gemini/live) in the Android Documentation.
Read more about the [Gemini Live API](https://developer.android.com/ai/gemini/live) in the Android Documentation.
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package com.android.ai.samples.geminilivetodo

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.viewModels
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
Expand All @@ -15,19 +18,30 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.xr.glimmer.GlimmerTheme
import com.android.ai.samples.geminilivetodo.ui.AudioExperience
import com.android.ai.samples.geminilivetodo.ui.GlimmerTodoScreen
import com.android.ai.samples.geminilivetodo.ui.TodoScreenViewModel
import androidx.xr.projected.ProjectedDeviceController
import androidx.xr.projected.ProjectedDeviceController.Capability
import androidx.xr.projected.ProjectedDisplayController
import androidx.xr.projected.ProjectedDisplayController.PresentationMode
import androidx.xr.projected.experimental.ExperimentalProjectedApi
import androidx.xr.projected.permissions.ProjectedPermissionsRequestParams
import androidx.xr.projected.permissions.ProjectedPermissionsResultContract
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch

private const val TAG = "GlassesActivity"

@AndroidEntryPoint
class GlassesActivity : ComponentActivity() {

private val viewModel: TodoScreenViewModel by viewModels()
private var isPermissionsGranted by mutableStateOf(false)
private var isDisplayCapable by mutableStateOf(false)
private var areVisualsOn by mutableStateOf(false)

private val requiredPermissions = listOf(
Manifest.permission.RECORD_AUDIO
Expand All @@ -43,11 +57,31 @@ class GlassesActivity : ComponentActivity() {
setupContent()
}

@OptIn(ExperimentalProjectedApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

viewModel.initializeGeminiLive(this)

val allGranted = checkAllPermissionsGranted()
isPermissionsGranted = allGranted

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
lifecycleScope.launch {
try {
val deviceController = ProjectedDeviceController.create(this@GlassesActivity)
isDisplayCapable = deviceController.capabilities.contains(Capability.CAPABILITY_VISUAL_UI)

val displayController = ProjectedDisplayController.create(this@GlassesActivity)
displayController.addPresentationModeChangedListener { flags ->
areVisualsOn = flags.hasPresentationMode(PresentationMode.VISUALS_ON)
}
} catch (e: Exception) {
Log.e(TAG, "Error initializing projected display controllers", e)
}
}
}

setupContent()


Expand All @@ -66,7 +100,12 @@ class GlassesActivity : ComponentActivity() {
private fun setupContent() {
setContent {
GlimmerTheme {
RootScreen(isGranted = isPermissionsGranted)
RootScreen(
isGranted = isPermissionsGranted,
isDisplayCapable = isDisplayCapable,
areVisualsOn = areVisualsOn,
viewModel = viewModel
)
}
}
}
Expand All @@ -86,14 +125,24 @@ class GlassesActivity : ComponentActivity() {


@Composable
fun RootScreen(isGranted: Boolean, modifier: Modifier = Modifier) {
if (isGranted) {
GlimmerTodoScreen(modifier = modifier)
} else {
fun RootScreen(
isGranted: Boolean,
isDisplayCapable: Boolean,
areVisualsOn: Boolean,
viewModel: TodoScreenViewModel,
modifier: Modifier = Modifier
) {
if (!isGranted) {
Text(
text = stringResource(R.string.permissions_denied_mic_access),
modifier = modifier
)
} else if (isDisplayCapable && areVisualsOn) {
// VISUAL MODE: Render UI for display-active glasses
GlimmerTodoScreen(viewModel = viewModel, modifier = modifier)
} else {
// AUDIO MODE: Fall back to Gemini for audio-only or display-off states
AudioExperience(viewModel = viewModel)
}
}

Expand All @@ -102,6 +151,11 @@ fun RootScreen(isGranted: Boolean, modifier: Modifier = Modifier) {
@Composable
fun PreviewRootScreen() {
GlimmerTheme {
RootScreen(isGranted = false)
RootScreen(
isGranted = false,
isDisplayCapable = true,
areVisualsOn = true,
viewModel = TodoScreenViewModel(com.android.ai.samples.geminilivetodo.data.TodoRepository())
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.android.ai.samples.geminilivetodo.data
import java.util.UUID.randomUUID

const val MIC_TODO_ID = 111
const val MIC_STATUS_TODO_ID = -999

sealed interface GlassesListItem {
val id: Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package com.android.ai.samples.geminilivetodo.data

import android.util.Log
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.filterNot
Expand All @@ -26,10 +27,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

private const val MIC_STATUS_TODO_ID = -999

@Singleton
class TodoRepository @Inject constructor() {
private val TAG = "TodoRepository"

private val _todos = MutableStateFlow<List<GlassesListItem>>(
listOf(
Expand All @@ -48,13 +49,15 @@ class TodoRepository @Inject constructor() {

fun getTodoList(): List<GlassesListItem> = _todos.value

fun addTodo(taskDescription: String) {
fun addTodo(taskDescription: String): Int? {
if (taskDescription.isNotBlank()) {
val newTodo = Todo(task = taskDescription, isCompleted = false)
_todos.update { currentList ->
listOf(newTodo) + currentList
}
return newTodo.id
}
return null
}


Expand Down Expand Up @@ -87,6 +90,7 @@ class TodoRepository @Inject constructor() {
}

fun toggleTodoStatus(todoId: Int) {
Log.d(TAG, "Toggling task status for ID: $todoId")
_todos.update { currentList ->
currentList.map { item ->
when (item) {
Expand Down
Loading