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..a70fb208b --- /dev/null +++ b/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt @@ -0,0 +1,128 @@ +/* + * 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.ProjectedDeviceController +import androidx.xr.projected.ProjectedDeviceController.Capability.Companion.CAPABILITY_VISUAL_UI +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 { + // [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 = displayController, + onVisualsChanged = viewModel::updateVisuals + ) + lifecycle.addObserver(observer) + + + // Cleanup observer to close the display controller + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + displayController.close() + } + }) + } + + setContent { + // [required] Use collectAsStateWithLifecycle for safe collection + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + GlimmerTheme { + HomeScreen( + visualsOn = uiState.areVisualsOn, + isVisualUiSupported = uiState.isVisualUiSupported, + onClose = { finish() } + ) + } + } + } +} +// [END androidxr_projected_ai_glasses_activity] + +// [START androidxr_projected_ai_glasses_activity_homescreen] +@Composable +fun HomeScreen( + visualsOn: Boolean, + isVisualUiSupported: Boolean, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .surface(focusable = false) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + 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.") + } + } + } else { + Text("Audio Guidance Mode 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..5dff15811 --- /dev/null +++ b/xr/src/main/java/com/example/xr/projected/GlassesViewModel.kt @@ -0,0 +1,40 @@ +/* + * 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, + 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) } + } +} 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 + ) + } + } +}