From e44fb10647b6cb8d0153c18afb315d90d73aff28 Mon Sep 17 00:00:00 2001 From: Tram Bui Date: Mon, 22 Dec 2025 15:22:37 -0800 Subject: [PATCH 1/2] add in projected snippets for AI glasses: glasses activity, launching a glasses activity --- gradle/libs.versions.toml | 4 + xr/build.gradle.kts | 4 +- xr/src/main/AndroidManifest.xml | 19 ++- .../xr/projected/GlassesLifecycleObserver.kt | 51 ++++++++ .../xr/projected/GlassesMainActivity.kt | 112 ++++++++++++++++++ .../example/xr/projected/GlassesViewModel.kt | 35 ++++++ .../example/xr/projected/PhoneMainActivity.kt | 111 +++++++++++++++++ 7 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 xr/src/main/java/com/example/xr/projected/GlassesLifecycleObserver.kt create mode 100644 xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt create mode 100644 xr/src/main/java/com/example/xr/projected/GlassesViewModel.kt create mode 100644 xr/src/main/java/com/example/xr/projected/PhoneMainActivity.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e4b6c63c0..75ed8c7c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -98,6 +98,8 @@ wearToolingPreview = "1.0.0" webkit = "1.14.0" wearPhoneInteractions = "1.1.0" wearRemoteInteractions = "1.1.0" +xrGlimmer = "1.0.0-alpha03" +xrProjected = "1.0.0-alpha03" [libraries] accompanist-adaptive = "com.google.accompanist:accompanist-adaptive:0.37.3" @@ -241,6 +243,8 @@ wear-compose-material = { module = "androidx.wear.compose:compose-material", ver wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wearComposeMaterial3" } androidx-wear-phone-interactions = { group = "androidx.wear", name = "wear-phone-interactions", version.ref = "wearPhoneInteractions" } androidx-wear-remote-interactions = { group = "androidx.wear", name = "wear-remote-interactions", version.ref = "wearRemoteInteractions" } +androidx-glimmer = { group = "androidx.xr.glimmer", name = "glimmer", version.ref = "xrGlimmer" } +androidx-projected = { group = "androidx.xr.projected", name = "projected", version.ref = "xrProjected" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/xr/build.gradle.kts b/xr/build.gradle.kts index 690fc9563..65ec078a1 100644 --- a/xr/build.gradle.kts +++ b/xr/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { implementation(libs.androidx.activity.ktx) implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.glimmer) + implementation(libs.androidx.projected) val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) @@ -68,4 +70,4 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) -} \ No newline at end of file +} diff --git a/xr/src/main/AndroidManifest.xml b/xr/src/main/AndroidManifest.xml index bc726787c..883cceaec 100644 --- a/xr/src/main/AndroidManifest.xml +++ b/xr/src/main/AndroidManifest.xml @@ -19,6 +19,23 @@ + tools:ignore="MissingApplicationIcon"> + + + + + + + + + + + + diff --git a/xr/src/main/java/com/example/xr/projected/GlassesLifecycleObserver.kt b/xr/src/main/java/com/example/xr/projected/GlassesLifecycleObserver.kt new file mode 100644 index 000000000..77c4ab8e0 --- /dev/null +++ b/xr/src/main/java/com/example/xr/projected/GlassesLifecycleObserver.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.xr.projected + +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.xr.projected.ProjectedDisplayController +import androidx.xr.projected.ProjectedDisplayController.PresentationMode +import androidx.xr.projected.experimental.ExperimentalProjectedApi +import java.util.function.Consumer + +@OptIn(ExperimentalProjectedApi::class) +class GlassesLifecycleObserver( + context: Context, + private val controller: ProjectedDisplayController, + private val onVisualsChanged: (Boolean) -> Unit +) : DefaultLifecycleObserver { + + private val executor = ContextCompat.getMainExecutor(context) + + private val visualStateListener = Consumer { flags -> + val visualsOn = flags.hasPresentationMode(PresentationMode.VISUALS_ON) + onVisualsChanged(visualsOn) + } + + override fun onStart(owner: LifecycleOwner) { + // Register when the Activity is visible + controller.addPresentationModeChangedListener(executor, visualStateListener) + } + + override fun onStop(owner: LifecycleOwner) { + // Unregister when the Activity is hidden to save battery and prevent leaks + controller.removePresentationModeChangedListener(visualStateListener) + } +} diff --git a/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt b/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt new file mode 100644 index 000000000..0003a2c7e --- /dev/null +++ b/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.xr.projected + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.xr.glimmer.Button +import androidx.xr.glimmer.Card +import androidx.xr.glimmer.GlimmerTheme +import androidx.xr.glimmer.Text +import androidx.xr.glimmer.surface +import androidx.xr.projected.ProjectedDisplayController +import androidx.xr.projected.experimental.ExperimentalProjectedApi +import kotlinx.coroutines.launch + +// [START androidxr_projected_ai_glasses_activity] +@OptIn(ExperimentalProjectedApi::class) +class GlassesMainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val viewModel: GlassesViewModel by viewModels() + + lifecycleScope.launch { + val controller = ProjectedDisplayController.create(this@GlassesMainActivity) + + val observer = GlassesLifecycleObserver( + context = this@GlassesMainActivity, + controller = controller, + onVisualsChanged = viewModel::updateVisuals + ) + lifecycle.addObserver(observer) + + // Cleanup observer to close the controller + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + controller.close() + } + }) + } + + setContent { + // [required] Use collectAsStateWithLifecycle for safe collection + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + GlimmerTheme { + HomeScreen( + visualsOn = uiState.areVisualsOn, + onClose = { finish() } + ) + } + } + } +} +// [END androidxr_projected_ai_glasses_activity] + +// [START androidxr_projected_ai_glasses_activity_homescreen] +@Composable +fun HomeScreen( + visualsOn: Boolean, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .surface(focusable = false) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Card( + title = { Text("Android XR") }, + action = { + Button(onClick = onClose) { + Text("Close") + } + } + ) { + if (visualsOn) { + Text("Hello, AI Glasses!") + } else { + Text("Display is off. Audio guidance active.") + } + } + } +} +// [END androidxr_projected_ai_glasses_activity_homescreen] diff --git a/xr/src/main/java/com/example/xr/projected/GlassesViewModel.kt b/xr/src/main/java/com/example/xr/projected/GlassesViewModel.kt new file mode 100644 index 000000000..21bafcd64 --- /dev/null +++ b/xr/src/main/java/com/example/xr/projected/GlassesViewModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.xr.projected + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class GlassesUiState( + val areVisualsOn: Boolean = true +) +class GlassesViewModel : ViewModel() { + private val _uiState = MutableStateFlow(GlassesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateVisuals(visualsOn: Boolean) { + _uiState.update { it.copy(areVisualsOn = visualsOn) } + } +} diff --git a/xr/src/main/java/com/example/xr/projected/PhoneMainActivity.kt b/xr/src/main/java/com/example/xr/projected/PhoneMainActivity.kt new file mode 100644 index 000000000..4534f3047 --- /dev/null +++ b/xr/src/main/java/com/example/xr/projected/PhoneMainActivity.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.xr.projected + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.xr.projected.ProjectedContext +import androidx.xr.projected.experimental.ExperimentalProjectedApi + +class PhoneMainActivity : ComponentActivity() { + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + ConnectionScreen() + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.BAKLAVA) +@OptIn(ExperimentalProjectedApi::class) +@Composable +fun ConnectionScreen() { + val context = LocalContext.current + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Hello AI Glasses", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(32.dp)) + val scope = rememberCoroutineScope() + val isGlassesConnected by ProjectedContext.isProjectedDeviceConnected( + context, + scope.coroutineContext + ).collectAsStateWithLifecycle(initialValue = false) + Button( + onClick = { + // [START androidxr_projected_start_glasses_activity] + + val options = ProjectedContext.createProjectedActivityOptions(context) + val intent = Intent(context, GlassesMainActivity::class.java) + context.startActivity(intent, options.toBundle()) + + // [END androidxr_projected_start_glasses_activity] + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isGlassesConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ), + enabled = isGlassesConnected + ) { + Text( + text = "Launch", + style = MaterialTheme.typography.headlineMedium + ) + } + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = "Status: " + if (isGlassesConnected) "Connected" else "Disconnected", + style = MaterialTheme.typography.titleMedium + ) + } + } +} From d22ea4cc4fc32b5bd572cb758a7a9ae49ae8f2d0 Mon Sep 17 00:00:00 2001 From: Tram Bui Date: Mon, 12 Jan 2026 15:00:25 -0800 Subject: [PATCH 2/2] add in device capabilty check --- .../xr/projected/GlassesMainActivity.kt | 46 +++++++++++++------ .../example/xr/projected/GlassesViewModel.kt | 7 ++- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt b/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt index 0003a2c7e..a70fb208b 100644 --- a/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt +++ b/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt @@ -36,6 +36,8 @@ import androidx.xr.glimmer.GlimmerTheme import androidx.xr.glimmer.Text import androidx.xr.glimmer.surface import androidx.xr.projected.ProjectedDisplayController +import androidx.xr.projected.ProjectedDeviceController +import androidx.xr.projected.ProjectedDeviceController.Capability.Companion.CAPABILITY_VISUAL_UI import androidx.xr.projected.experimental.ExperimentalProjectedApi import kotlinx.coroutines.launch @@ -48,19 +50,27 @@ class GlassesMainActivity : ComponentActivity() { val viewModel: GlassesViewModel by viewModels() lifecycleScope.launch { - val controller = ProjectedDisplayController.create(this@GlassesMainActivity) + // [START androidxr_projected_device_capabilities_check] + // Check device capabilities + val projectedDeviceController = ProjectedDeviceController.create(this@GlassesMainActivity) + val isVisualSupported = projectedDeviceController.capabilities.contains(CAPABILITY_VISUAL_UI) + viewModel.setVisualUiSupported(isVisualSupported) + // [END androidxr_projected_device_capabilities_check] + + val displayController = ProjectedDisplayController.create(this@GlassesMainActivity) val observer = GlassesLifecycleObserver( context = this@GlassesMainActivity, - controller = controller, + controller = displayController, onVisualsChanged = viewModel::updateVisuals ) lifecycle.addObserver(observer) - // Cleanup observer to close the controller + + // Cleanup observer to close the display controller lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { - controller.close() + displayController.close() } }) } @@ -72,6 +82,7 @@ class GlassesMainActivity : ComponentActivity() { GlimmerTheme { HomeScreen( visualsOn = uiState.areVisualsOn, + isVisualUiSupported = uiState.isVisualUiSupported, onClose = { finish() } ) } @@ -84,6 +95,7 @@ class GlassesMainActivity : ComponentActivity() { @Composable fun HomeScreen( visualsOn: Boolean, + isVisualUiSupported: Boolean, onClose: () -> Unit, modifier: Modifier = Modifier ) { @@ -93,19 +105,23 @@ fun HomeScreen( .fillMaxSize(), contentAlignment = Alignment.Center ) { - Card( - title = { Text("Android XR") }, - action = { - Button(onClick = onClose) { - Text("Close") + if (isVisualUiSupported) { + Card( + title = { Text("Android XR") }, + action = { + Button(onClick = onClose) { + Text("Close") + } + } + ) { + if (visualsOn) { + Text("Hello, AI Glasses!") + } else { + Text("Display is off. Audio guidance active.") } } - ) { - if (visualsOn) { - Text("Hello, AI Glasses!") - } else { - Text("Display is off. Audio guidance active.") - } + } else { + Text("Audio Guidance Mode Active") } } } diff --git a/xr/src/main/java/com/example/xr/projected/GlassesViewModel.kt b/xr/src/main/java/com/example/xr/projected/GlassesViewModel.kt index 21bafcd64..5dff15811 100644 --- a/xr/src/main/java/com/example/xr/projected/GlassesViewModel.kt +++ b/xr/src/main/java/com/example/xr/projected/GlassesViewModel.kt @@ -23,12 +23,17 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update data class GlassesUiState( - val areVisualsOn: Boolean = true + val areVisualsOn: Boolean = true, + val isVisualUiSupported: Boolean = false ) class GlassesViewModel : ViewModel() { private val _uiState = MutableStateFlow(GlassesUiState()) val uiState: StateFlow = _uiState.asStateFlow() + fun setVisualUiSupported(isSupported: Boolean) { + _uiState.update { it.copy(isVisualUiSupported = isSupported) } + } + fun updateVisuals(visualsOn: Boolean) { _uiState.update { it.copy(areVisualsOn = visualsOn) } }