diff --git a/xr/src/main/java/com/example/xr/input/app/build.gradle.kts b/xr/src/main/java/com/example/xr/input/app/build.gradle.kts new file mode 100644 index 000000000..a6a6a6c5a --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/app/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.example.aiglasses.camera" + compileSdk = 36 + + defaultConfig { + applicationId = "com.example.aiglasses.camera" + minSdk = 36 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + freeCompilerArgs += listOf( + "-opt-in=androidx.xr.projected.experimental.ExperimentalProjectedApi" + ) + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // ProGuard minification extension library required for Jetpack XR alpha05+ + compileOnly("com.android.extensions.xr:extensions-xr:1.1.0") + + // Android XR & AI Glasses libraries + implementation("androidx.xr.runtime:runtime:1.0.0-alpha10") + implementation("androidx.xr.glimmer:glimmer:1.0.0-alpha07") + implementation("androidx.xr.projected:projected:1.0.0-alpha04") + implementation("androidx.xr.arcore:arcore:1.0.0-alpha10") + + // Jetpack Compose + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + + // Core utilities + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") +} + diff --git a/xr/src/main/java/com/example/xr/input/app/src/main/AndroidManifest.xml b/xr/src/main/java/com/example/xr/input/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8eca7bfe5 --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/app/src/main/AndroidManifest.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/BaseProjectedActivity.kt b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/BaseProjectedActivity.kt new file mode 100644 index 000000000..222fe0ff2 --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/BaseProjectedActivity.kt @@ -0,0 +1,133 @@ +package com.example.aiglasses.camera + +import android.media.AudioAttributes +import android.media.AudioManager +import android.os.Bundle +import android.speech.tts.TextToSpeech +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.lifecycleScope +import androidx.xr.projected.ProjectedContext +import androidx.xr.projected.ProjectedDisplayController +import androidx.xr.projected.ProjectedDisplayController.PresentationMode.Companion.VISUALS_ON +import kotlinx.coroutines.launch +import java.util.Locale + +/** + * Base activity for AI Glasses projected samples (`CameraAction`, `MotionGesture`, `BackGesture`). + * + * Encapsulates non-sample-specific hardware boilerplate and state management: + * - Initializes [TextToSpeech] using [ProjectedDeviceContext] (`USAGE_ASSISTANT`) for glasses speaker routing. + * - Observes [ProjectedDisplayController] presentation modes (`VISUALS_ON`) into [isDisplayOn]. + * - Holds common UI state ([notificationText], [gestureSourceText]) so samples don't need to declare them. + * - Provides [triggerFeedback] for unified visual chips, [Toast], and audio [TextToSpeech] notifications. + */ +abstract class BaseProjectedActivity(val sampleTitle: String) : ComponentActivity() { + + companion object { + private const val BASE_TAG = "BaseProjectedActivity" + } + + /** Observable Compose state indicating whether the projected display currently has visuals active. */ + val isDisplayOn = mutableStateOf(true) + + /** Observable notification message string for Glimmer UI rendering. */ + val notificationText = mutableStateOf(null) + + /** Observable source description (e.g., "Touchpad Tap", "ACTION_DOWN") for Glimmer UI rendering. */ + val gestureSourceText = mutableStateOf(null) + + /** Counter incremented on every triggerFeedback invocation so UI reacts even for repeated identical messages. */ + val feedbackEventCount = mutableStateOf(0) + + private var tts: TextToSpeech? = null + private var displayController: ProjectedDisplayController? = null + private var lastSpokenTimestamp = 0L + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initializeTextToSpeech() + observeDisplayState() + } + + private fun initializeTextToSpeech() { + val ttsContext = try { + ProjectedContext.createProjectedDeviceContext(this) + } catch (e: Exception) { + Log.w(BASE_TAG, "Could not create ProjectedDeviceContext for TTS, falling back to activity context: ${e.message}") + this + } + tts = TextToSpeech(ttsContext) { status -> + if (status == TextToSpeech.SUCCESS) { + tts?.language = Locale.getDefault() + val attributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + tts?.setAudioAttributes(attributes) + onTextToSpeechReady() + } else { + Log.e(BASE_TAG, "TextToSpeech initialization failed.") + } + } + } + + protected open fun onTextToSpeechReady() {} + + private fun observeDisplayState() { + lifecycleScope.launch { + try { + val controller = ProjectedDisplayController.create(this@BaseProjectedActivity) + displayController = controller + controller.addPresentationModeChangedListener { flags -> + val visualsOn = flags.hasPresentationMode(VISUALS_ON) + isDisplayOn.value = visualsOn + Log.d(BASE_TAG, "Display state changed. Visuals On: $visualsOn") + } + } catch (e: Exception) { + Log.w(BASE_TAG, "ProjectedDisplayController not available: ${e.message}") + } + } + } + + /** + * Updates observable UI states ([notificationText], [gestureSourceText]) and triggers + * a spoken and visual [Toast] notification on the AI Glasses. + * + * @param message The text string to speak and display. + * @param source Description of the trigger source (e.g., "System Back", "Touchpad Swipe"). + * @param utteranceId Identifier for the TTS utterance. + * @param throttleMs Minimum milliseconds between consecutive speeches. + */ + fun triggerFeedback( + message: String, + source: String, + utteranceId: String = "projected_utterance", + throttleMs: Long = 0L, + speakAudio: Boolean = true + ) { + val now = System.currentTimeMillis() + if (throttleMs > 0L && now - lastSpokenTimestamp < throttleMs) { + return + } + lastSpokenTimestamp = now + + notificationText.value = message + gestureSourceText.value = source + feedbackEventCount.value = feedbackEventCount.value + 1 + + Toast.makeText(this, "$message ($source)", Toast.LENGTH_SHORT).show() + if (speakAudio) { + tts?.speak(message, TextToSpeech.QUEUE_FLUSH, null, utteranceId) + } + } + + override fun onDestroy() { + super.onDestroy() + displayController?.close() + tts?.stop() + tts?.shutdown() + } +} diff --git a/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/HelloBackGestureActivity.kt b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/HelloBackGestureActivity.kt new file mode 100644 index 000000000..b983b2dac --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/HelloBackGestureActivity.kt @@ -0,0 +1,59 @@ +package com.example.aiglasses.camera + +import android.os.Bundle +import android.util.Log +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.xr.glimmer.GlimmerTheme + +/** + * Demonstrates how to intercept and handle system back gestures + * from a Projected Activity running on AI Glasses. + */ +class HelloBackGestureActivity : BaseProjectedActivity("Back Gestures") { + + // [START androidxr_projected_back_gesture_callback] + private val backCallback = + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + showExitConfirmation() + } + } + // [END androidxr_projected_back_gesture_callback] + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // [START androidxr_projected_back_gesture_register] + onBackPressedDispatcher.addCallback(this, backCallback) + // [END androidxr_projected_back_gesture_register] + + setContent { + GlimmerTheme { + ProjectedSampleScreen( + activity = this@HelloBackGestureActivity, + subtitle = "System Back Intercept Enabled", + consumeSwipeBackward = false, + onGestureAction = {} + ) + } + } + } + + private fun showExitConfirmation() { + Log.i("HelloBackGesture", "System Back gesture received") + triggerFeedback( + "Back gesture detected.", + "System Back (handleOnBackPressed)", + "back_gesture", + throttleMs = 1000L + ) + } + + private fun setExitConfirmationVisible(visible: Boolean) { + backCallback.isEnabled = visible + } +} + + + diff --git a/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/HelloCameraActionActivity.kt b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/HelloCameraActionActivity.kt new file mode 100644 index 000000000..38ea1e4bc --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/HelloCameraActionActivity.kt @@ -0,0 +1,104 @@ +/* + * 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.aiglasses.camera + +import android.content.Intent +import android.os.Bundle +import android.provider.MediaStore +import android.util.Log +import android.view.KeyEvent +import androidx.activity.compose.setContent +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.xr.glimmer.GlimmerTheme +import androidx.xr.projected.ProjectedActivityCompat +import androidx.xr.projected.ProjectedInputEvent.ProjectedInputAction.Companion.TOGGLE_APP_CAMERA +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Demonstrates how to observe hardware camera action inputs (double-press) + * and camera intents in a Projected Activity running on AI Glasses. + */ +class HelloCameraActionActivity : BaseProjectedActivity("Camera Action") { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // [START androidxr_projected_camera_action_observe] + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + withContext(Dispatchers.Default) { + try { + val projectedActivity = ProjectedActivityCompat.create(this@HelloCameraActionActivity) + try { + projectedActivity.projectedInputEvents.collect { inputEvent -> + if (inputEvent.inputAction == TOGGLE_APP_CAMERA) { + withContext(Dispatchers.Main) { + triggerCameraAction("Glasses Double-Press (TOGGLE_APP_CAMERA)") + } + } + } + } finally { + projectedActivity.close() + } + } catch (e: Exception) { + Log.w("HelloCameraActionActivity", "ProjectedActivityCompat unavailable: ${e.message}") + } + } + } + } + // [END androidxr_projected_camera_action_observe] + + // 2. Render unified sample UI + setContent { + GlimmerTheme { + ProjectedSampleScreen( + activity = this@HelloCameraActionActivity, + onGestureAction = { source -> triggerCameraAction(source) } + ) + } + } + } + + override fun onTextToSpeechReady() = handleIntentAction(intent) + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); setIntent(intent); handleIntentAction(intent) } + + private fun handleIntentAction(incomingIntent: Intent?) { + val action = incomingIntent?.action ?: return + if (action == MediaStore.ACTION_IMAGE_CAPTURE || action.contains("CAMERA", ignoreCase = true) || action == Intent.ACTION_CAMERA_BUTTON) { + triggerCameraAction("System Camera Intent ($action)") + } + } + + private fun triggerCameraAction(source: String) = triggerFeedback("Camera action detected.", source, "camera_action") + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_UP && ( + event.keyCode == KeyEvent.KEYCODE_CAMERA || + event.keyCode == KeyEvent.KEYCODE_ENTER || + event.keyCode == KeyEvent.KEYCODE_DPAD_CENTER + )) { + triggerCameraAction("Camera Button / Side Tap") + return true + } + return super.dispatchKeyEvent(event) + } +} + diff --git a/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/HelloMotionGestureActivity.kt b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/HelloMotionGestureActivity.kt new file mode 100644 index 000000000..f1c00c7d9 --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/HelloMotionGestureActivity.kt @@ -0,0 +1,69 @@ +package com.example.aiglasses.camera + +import android.os.Bundle +import android.util.Log +import android.view.MotionEvent +import androidx.activity.compose.setContent +import androidx.xr.glimmer.GlimmerTheme + +/** + * Demonstrates how to inspect generic motion events (touchpad gestures) + * delivered to a Projected Activity running on AI Glasses. + */ +class HelloMotionGestureActivity : BaseProjectedActivity("Motion Gestures") { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + GlimmerTheme { + ProjectedSampleScreen( + activity = this@HelloMotionGestureActivity, + onGestureAction = { source -> + triggerFeedback("Motion gesture detected.", source, "motion_gesture", throttleMs = 1000L) + } + ) + } + } + } + + // [START androidxr_projected_motion_gesture_dispatch] + override fun dispatchGenericMotionEvent(ev: MotionEvent): Boolean { + val actionName = when (ev.actionMasked) { + // Touch down: first contact with the glasses touchpad. + MotionEvent.ACTION_DOWN -> "ACTION_DOWN" + // Touch move: contact moving across the glasses touchpad. + MotionEvent.ACTION_MOVE -> "ACTION_MOVE" + // Touch up: contact lifted from the glasses touchpad. + MotionEvent.ACTION_UP -> "ACTION_UP" + else -> "ACTION_${ev.actionMasked}" + } + + Log.d( + "ProjectedInput", + "MotionEvent: action=$actionName " + + "source=${ev.source} deviceId=${ev.deviceId} " + + "x=${ev.x} y=${ev.y} rawX=${ev.rawX} rawY=${ev.rawY}", + ) + + if ( + ev.actionMasked == MotionEvent.ACTION_DOWN || + ev.actionMasked == MotionEvent.ACTION_MOVE || + ev.actionMasked == MotionEvent.ACTION_UP + ) { + triggerFeedback( + "Motion event detected.", + "Generic Motion ($actionName: x=${ev.x.toInt()}, y=${ev.y.toInt()})", + "motion_gesture", + throttleMs = if (ev.actionMasked == MotionEvent.ACTION_MOVE) 500L else 0L, + speakAudio = (ev.actionMasked == MotionEvent.ACTION_DOWN) + ) + } + + // Keep normal dispatch unless this activity intentionally consumes the event. + return super.dispatchGenericMotionEvent(ev) + } + // [END androidxr_projected_motion_gesture_dispatch] +} + + + diff --git a/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/MainActivity.kt b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/MainActivity.kt new file mode 100644 index 000000000..480385840 --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/MainActivity.kt @@ -0,0 +1,317 @@ +package com.example.aiglasses.camera + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +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.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import androidx.xr.projected.ProjectedContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Phone Companion Activity for AI Glasses Camera Sample. + * + * Because `AIGlassesCameraActivity` declares `android:requiredDisplayCategory="xr_projected"`, + * the Android system blocks it from launching directly on a phone screen (`display-id=0`). + * + * This `MainActivity` serves as the standard phone entry point when tapped from the phone's app drawer. + * It monitors connection status to the AI glasses and allows launching `AIGlassesCameraActivity` + * directly into the projected glasses context. + */ +class MainActivity : ComponentActivity() { + + companion object { + private const val TAG = "CameraSampleHost" + } + + private var isProjectedConnected by mutableStateOf(false) + private var projectedConnectionStatus by mutableStateOf("Checking AI Glasses connection...") + private var connectionJob: Job? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + observeGlassesConnection() + + setContent { + val sophisticatedDarkScheme = darkColorScheme( + background = Color(0xFF0D0F14), + surface = Color(0xFF161922), + surfaceVariant = Color(0xFF1E222D), + onSurface = Color(0xFFE6E8ED), + onSurfaceVariant = Color(0xFF9EA4B0), + primary = Color(0xFF72A7FF), + onPrimary = Color(0xFF0A1E3C), + primaryContainer = Color(0xFF1B2E4E), + onPrimaryContainer = Color(0xFFD2E3FF) + ) + MaterialTheme(colorScheme = sophisticatedDarkScheme) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + PhoneDashboardScreen( + connectionStatus = projectedConnectionStatus, + isConnected = isProjectedConnected, + onLaunchCameraActivity = { launchOnGlasses() }, + onLaunchMotionGestureActivity = { launchMotionGestureOnGlasses() }, + onLaunchBackGestureActivity = { launchBackGestureOnGlasses() } + ) + } + } + } + } + + override fun onDestroy() { + connectionJob?.cancel() + super.onDestroy() + } + + private fun observeGlassesConnection() { + connectionJob?.cancel() + connectionJob = lifecycleScope.launch { + val flow = runCatching { + ProjectedContext.isProjectedDeviceConnected(this@MainActivity, Dispatchers.IO) + }.onFailure { + projectedConnectionStatus = "Glasses connectivity service unavailable" + Log.w(TAG, "Failed to observe glasses connectivity: ${it.message}") + }.getOrNull() ?: return@launch + + flow.collect { connected -> + isProjectedConnected = connected + projectedConnectionStatus = if (connected) { + "AI Glasses Connected" + } else { + "AI Glasses Not Connected" + } + Log.d(TAG, "Glasses connection state updated: $connected") + } + } + } + + private fun launchOnGlasses() { + if (!isProjectedConnected) { + Toast.makeText(this, "Please connect your AI glasses first.", Toast.LENGTH_SHORT).show() + return + } + try { + val projectedContext = ProjectedContext.createProjectedDeviceContext(this) + val options = ProjectedContext.createProjectedActivityOptions(projectedContext) + val intent = Intent(this, HelloCameraActionActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(intent, options.toBundle()) + } catch (e: Exception) { + Log.e(TAG, "Failed to launch camera activity: ${e.message}", e) + Toast.makeText(this, "Failed to launch on glasses: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + private fun launchMotionGestureOnGlasses() { + if (!isProjectedConnected) { + Toast.makeText(this, "Please connect your AI glasses first.", Toast.LENGTH_SHORT).show() + return + } + try { + val projectedContext = ProjectedContext.createProjectedDeviceContext(this) + val options = ProjectedContext.createProjectedActivityOptions(projectedContext) + val intent = Intent(this, HelloMotionGestureActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(intent, options.toBundle()) + } catch (e: Exception) { + Log.e(TAG, "Failed to launch motion gesture activity: ${e.message}", e) + Toast.makeText(this, "Failed to launch on glasses: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + private fun launchBackGestureOnGlasses() { + if (!isProjectedConnected) { + Toast.makeText(this, "Please connect your AI glasses first.", Toast.LENGTH_SHORT).show() + return + } + try { + val projectedContext = ProjectedContext.createProjectedDeviceContext(this) + val options = ProjectedContext.createProjectedActivityOptions(projectedContext) + val intent = Intent(this, HelloBackGestureActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(intent, options.toBundle()) + } catch (e: Exception) { + Log.e(TAG, "Failed to launch back gesture activity: ${e.message}", e) + Toast.makeText(this, "Failed to launch on glasses: ${e.message}", Toast.LENGTH_SHORT).show() + } + } +} + +@Composable +fun PhoneDashboardScreen( + connectionStatus: String, + isConnected: Boolean, + onLaunchCameraActivity: () -> Unit, + onLaunchMotionGestureActivity: () -> Unit, + onLaunchBackGestureActivity: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(28.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Detect UI Input", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Test out different inputs on glasses's Glimmer UI elements.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(36.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, Color(0xFF282D3A)), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "STATUS", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Spacer( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (isConnected) Color(0xFF42BE65) else Color(0xFFE2A03F)) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = connectionStatus, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + Spacer(modifier = Modifier.height(36.dp)) + + Button( + onClick = onLaunchCameraActivity, + enabled = isConnected, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + Text( + text = "Launch Camera Action Sample", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + + Button( + onClick = onLaunchMotionGestureActivity, + enabled = isConnected, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + Text( + text = "Launch Hello Motion Gesture", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + + Button( + onClick = onLaunchBackGestureActivity, + enabled = isConnected, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + Text( + text = "Launch Hello Back Gesture", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + } + } +} + diff --git a/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/ProjectedSampleScreen.kt b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/ProjectedSampleScreen.kt new file mode 100644 index 000000000..aa1fb44b8 --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/app/src/main/java/com/example/aiglasses/camera/ProjectedSampleScreen.kt @@ -0,0 +1,113 @@ +package com.example.aiglasses.camera + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.xr.glimmer.GlimmerTheme +import androidx.xr.glimmer.Text +import androidx.xr.glimmer.TitleChip +import androidx.xr.glimmer.onIndirectPointerGesture +import kotlinx.coroutines.delay + +/** + * Reusable Glimmer UI component for all AI Glasses projected samples. + * + * Encapsulates: + * 1. Requesting focus via [FocusRequester] and [focusTarget] so Glimmer can receive indirect pointer events. + * 2. Intercepting indirect pointer touchpad events ([onIndirectPointerGesture]) (`onClick`, `onSwipeForward`, + * `onSwipeBackward`) and routing them to [onGestureAction]. + * 3. Auto-dismissing active notifications after 3.5 seconds. + * 4. Rendering standard additive UI chips (`TitleChip` over `Color.Black` transparent background). + */ +@Composable +fun ProjectedSampleScreen( + activity: BaseProjectedActivity, + subtitle: String? = null, + consumeSwipeBackward: Boolean = true, + onGestureAction: (String) -> Unit = {}, + modifier: Modifier = Modifier +) { + val notificationText by remember { activity.notificationText } + val gestureSource by remember { activity.gestureSourceText } + val feedbackEventCount by remember { activity.feedbackEventCount } + val isDisplayOn by remember { activity.isDisplayOn } + + var isShowingFeedback by remember { mutableStateOf(false) } + + LaunchedEffect(feedbackEventCount) { + if (feedbackEventCount > 0) { + isShowingFeedback = true + delay(3500L) + isShowingFeedback = false + } + } + + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + runCatching { focusRequester.requestFocus() } + } + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black) // Black is 100% transparent on additive AI glasses displays + .focusRequester(focusRequester) + .focusTarget() + .let { m -> + if (consumeSwipeBackward) { + m.onIndirectPointerGesture( + enabled = true, + onClick = { onGestureAction("Glimmer UI: Touchpad Tap") }, + onSwipeForward = { onGestureAction("Glimmer UI: Swipe Forward") }, + onSwipeBackward = { onGestureAction("Glimmer UI: Swipe Backward") } + ) + } else { + m.onIndirectPointerGesture( + enabled = true, + onClick = { onGestureAction("Glimmer UI: Touchpad Tap") }, + onSwipeForward = { onGestureAction("Glimmer UI: Swipe Forward") } + ) + } + }, + contentAlignment = Alignment.Center + ) { + TitleChip { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp) + ) { + Text( + text = if (isShowingFeedback) "Input detected" else "No action has happened", + style = GlimmerTheme.typography.bodyMedium, + color = if (isShowingFeedback) GlimmerTheme.colors.positive else GlimmerTheme.colors.primary + ) + val subtext = if (isShowingFeedback) gestureSource else subtitle + if (!subtext.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtext, + style = GlimmerTheme.typography.bodySmall, + color = if (isShowingFeedback) GlimmerTheme.colors.primary else Color.LightGray + ) + } + } + } + } +} diff --git a/xr/src/main/java/com/example/xr/input/build.gradle.kts b/xr/src/main/java/com/example/xr/input/build.gradle.kts new file mode 100644 index 000000000..f5473b8cb --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.10" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false +} diff --git a/xr/src/main/java/com/example/xr/input/gradle.properties b/xr/src/main/java/com/example/xr/input/gradle.properties new file mode 100644 index 000000000..d984f58b2 --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +org.gradle.java.home=/Applications/Android Studio Preview.app/Contents/jbr/Contents/Home diff --git a/xr/src/main/java/com/example/xr/input/gradle/wrapper/gradle-wrapper.jar b/xr/src/main/java/com/example/xr/input/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..943f0cbfa Binary files /dev/null and b/xr/src/main/java/com/example/xr/input/gradle/wrapper/gradle-wrapper.jar differ diff --git a/xr/src/main/java/com/example/xr/input/gradle/wrapper/gradle-wrapper.properties b/xr/src/main/java/com/example/xr/input/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..2733ed5dc --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/xr/src/main/java/com/example/xr/input/gradlew b/xr/src/main/java/com/example/xr/input/gradlew new file mode 100755 index 000000000..65dcd68d6 --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/xr/src/main/java/com/example/xr/input/gradlew.bat b/xr/src/main/java/com/example/xr/input/gradlew.bat new file mode 100644 index 000000000..6689b85be --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/xr/src/main/java/com/example/xr/input/settings.gradle.kts b/xr/src/main/java/com/example/xr/input/settings.gradle.kts new file mode 100644 index 000000000..730870b4e --- /dev/null +++ b/xr/src/main/java/com/example/xr/input/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + maven { + url = uri("https://androidx.dev/snapshots/builds/14790439/artifacts/repository") + } + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { + url = uri("https://androidx.dev/snapshots/builds/14790439/artifacts/repository") + } + } +} + +rootProject.name = "HelloCameraAction" +include(":app")