diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff71ecf0a..6c805a7fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -99,6 +99,8 @@ wearRemoteInteractions = "1.1.0" wearToolingPreview = "1.0.0" webkit = "1.15.0" wfp = "1.0.0-beta01" +xrGlimmer = "1.0.0-alpha03" +xrProjected = "1.0.0-alpha03" [libraries] accompanist-adaptive = "com.google.accompanist:accompanist-adaptive:0.37.3" @@ -244,6 +246,8 @@ roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule" validator-push = { module = "com.google.android.wearable.watchface.validator:validator-push", version.ref = "validatorPush" } wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wearComposeMaterial" } wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wearComposeMaterial3" } +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..66cd13cb0 --- /dev/null +++ b/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt @@ -0,0 +1,126 @@ +/* + * 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.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +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() { + + private var displayController: ProjectedDisplayController? = null + private var isVisualUiSupported by mutableStateOf(false) + private var areVisualsOn by mutableStateOf(true) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + displayController?.close() + displayController = null + } + }) + + lifecycleScope.launch { + // [START androidxr_projected_device_capabilities_check] + // Check device capabilities + val projectedDeviceController = ProjectedDeviceController.create(this@GlassesMainActivity) + isVisualUiSupported = projectedDeviceController.capabilities.contains(CAPABILITY_VISUAL_UI) + // [END androidxr_projected_device_capabilities_check] + + val controller = ProjectedDisplayController.create(this@GlassesMainActivity) + displayController = controller + val observer = GlassesLifecycleObserver( + context = this@GlassesMainActivity, + controller = controller, + onVisualsChanged = { visualsOn -> areVisualsOn = visualsOn } + ) + lifecycle.addObserver(observer) + } + + setContent { + GlimmerTheme { + HomeScreen( + areVisualsOn = areVisualsOn, + isVisualUiSupported = isVisualUiSupported, + onClose = { finish() } + ) + } + } + } +} +// [END androidxr_projected_ai_glasses_activity] + +// [START androidxr_projected_ai_glasses_activity_homescreen] +@Composable +fun HomeScreen( + areVisualsOn: 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 (areVisualsOn) { + 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/PhoneMainActivity.kt b/xr/src/main/java/com/example/xr/projected/PhoneMainActivity.kt new file mode 100644 index 000000000..543ffe357 --- /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 areGlassesConnected 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 (areGlassesConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ), + enabled = areGlassesConnected + ) { + Text( + text = "Launch", + style = MaterialTheme.typography.headlineMedium + ) + } + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = "Status: " + if (areGlassesConnected) "Connected" else "Disconnected", + style = MaterialTheme.typography.titleMedium + ) + } + } +}