diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e4b6c63c..75ed8c7c 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 690fc956..65ec078a 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 bc726787..883cceae 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 00000000..4ea97fa2 --- /dev/null +++ b/xr/src/main/java/com/example/xr/projected/GlassesLifecycleObserver.kt @@ -0,0 +1,50 @@ +/* + * 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( + private val 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) { + controller.addPresentationModeChangedListener(executor, visualStateListener) + } + + override fun onStop(owner: LifecycleOwner) { + // unregister to stop consuming values and prevent memory 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 00000000..954666e0 --- /dev/null +++ b/xr/src/main/java/com/example/xr/projected/GlassesMainActivity.kt @@ -0,0 +1,115 @@ +/* + * 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.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() { + + private var projectedDisplayController: ProjectedDisplayController? = null + private var areVisualsOn by mutableStateOf(true) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + // initialize the controller + val controller = ProjectedDisplayController.create(this@GlassesMainActivity) + projectedDisplayController = controller + + // attach the observer to manage registration based on Activity visibility + val observer = GlassesLifecycleObserver( + context = this@GlassesMainActivity, + controller = controller, + onVisualsChanged = { visualsOn -> + areVisualsOn = visualsOn + } + ) + lifecycle.addObserver(observer) + } + + setContent { + GlimmerTheme { + HomeScreen( + visualsOn = areVisualsOn, + onClose = { finish() } + ) + } + } + } + + override fun onDestroy() { + super.onDestroy() + projectedDisplayController?.close() + } +} +// [END androidxr_projected_ai_glasses_activity] + +// [START androidxr_projected_ai_glasses_activity_homescreen] +@Composable +fun HomeScreen( + visualsOn: Boolean, + modifier: Modifier = Modifier, + onClose: () -> Unit +) { + Box( + modifier = modifier + .surface(focusable = false) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Card( + title = { Text("Android XR") }, + action = { + Button(onClick = { + onClose() + }) { + Text("Close") + } + } + ) { + // UI dynamically updates based on the observer's state + 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/PhoneMainActivity.kt b/xr/src/main/java/com/example/xr/projected/PhoneMainActivity.kt new file mode 100644 index 00000000..4534f304 --- /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 + ) + } + } +}