Usar un contexto proyectado para acceder al hardware de los lentes de audio y los lentes de visualización

Dispositivos de realidad extendida correspondientes
Esta guía te ayuda a crear experiencias para estos tipos de dispositivos de realidad extendida.
Lentes con audio y
pantalla

Después de solicitar y obtener los permisos necesarios, tu app puede acceder al hardware de los lentes con audio o con pantalla. La clave para acceder al hardware de los lentes (en lugar del hardware del teléfono) es usar un contexto proyectado.

Existen dos formas principales de obtener un contexto proyectado, según dónde se ejecute tu código:

Obtén un contexto proyectado si tu código se ejecuta en una actividad proyectada

Si el código de tu app se ejecuta desde tu actividad proyectada, su propio contexto de actividad ya es un contexto proyectado. En esta situación, las llamadas que se realicen dentro de esa actividad ya pueden acceder al hardware de los lentes.

Obtén un contexto proyectado para el código que se ejecuta en un componente de la app de Teléfono

Si una parte de tu app fuera de la actividad proyectada (como una actividad de teléfono o un servicio) necesita acceder al hardware de los lentes, debe obtener explícitamente un contexto proyectado. Para ello, usa el método createProjectedDeviceContext:

@RequiresApi(Build.VERSION_CODES.BAKLAVA)
@OptIn(ExperimentalProjectedApi::class, ExperimentalCoroutinesApi::class)
private fun monitorProjectedConnectivity(activity: ComponentActivity) {
    activity.lifecycleScope.launch {
        // Before creating a projected context, check to see if the projected device is connected.
        // While this method returns true, the projected context remains valid.
        ProjectedContext.isProjectedDeviceConnected(activity, coroutineContext)
            .collectLatest { isConnected ->
                if (isConnected) {
                    // From a phone Activity or Service, get a context for the audio and display glasses.
                    // Re-initialize on reconnect: Obtain another context instance.
                    val projectedContext = try {
                        ProjectedContext.createProjectedDeviceContext(activity)
                    } catch (e: IllegalStateException) {
                        Log.e(TAG, "Failed to create projected context", e)
                        return@collectLatest
                    }

                    // Use the projectedContext to initialize system services (e.g., CameraManager).
                    Log.i(TAG, "Projected device connected. Initializing hardware...")
                } else {
                    // The projected context is destroyed when the device disconnects.
                    // Clean up on disconnect: Listen for 'false' and release resources.
                    Log.i(TAG, "Projected device disconnected. Cleaning up hardware resources...")
                }
            }
    }
}

Verifica la validez

Une la llamada a createProjectedDeviceContext dentro de ProjectedContext.isProjectedDeviceConnected. Mientras este método devuelva true, el contexto proyectado seguirá siendo válido para el dispositivo conectado, y la actividad o el servicio de la app de teléfono (como un CameraManager) podrá acceder al hardware de las gafas.

Limpieza al desconectar

El contexto proyectado está vinculado al ciclo de vida del dispositivo conectado, por lo que se destruye cuando el dispositivo se desconecta. Cuando el dispositivo se desconecta, ProjectedContext.isProjectedDeviceConnected devuelve false. Tu app debe detectar este cambio y limpiar los servicios del sistema (como un CameraManager) o los recursos que creó con ese contexto proyectado.

Reinicializar cuando se vuelva a conectar

Cuando los lentes se vuelvan a conectar, tu app podrá obtener otra instancia de contexto proyectado con createProjectedDeviceContext y, luego, reinicializar los servicios o recursos del sistema con el nuevo contexto proyectado.

Grabar audio con el micrófono de los lentes

Puedes grabar audio desde los lentes con el perfil de manos libres (HFP) de Bluetooth. En este modo, los lentes se conectan al teléfono con los perfiles estándar de auriculares y perfil de distribución de audio avanzado (A2DP), y funcionan como un periférico Bluetooth típico. Si tu app ya está diseñada para la grabación estándar por Bluetooth, puedes usar este método para grabar audio desde los lentes sin necesidad de realizar ningún desarrollo adicional.

Graba audio con el HFP de Bluetooth

Para grabar audio con el HFP de Bluetooth, primero solicita los permisos de tiempo de ejecución necesarios y, luego, graba el audio con la API de AudioManager, como se describe en las siguientes secciones.

Solicitar permisos

Al igual que con cualquier dispositivo de audio Bluetooth estándar, el teléfono controla los permisos RECORD_AUDIO, BLUETOOTH_CONNECT y otros relacionados, y no el dispositivo conectado (como los lentes con audio o con pantalla).

Sigue estos pasos para solicitar los permisos:

  1. Declara los siguientes permisos en el archivo de manifiesto de tu app:

  2. Solicita los permisos RECORD_AUDIO y BLUETOOTH_CONNECT en el entorno de ejecución con el flujo de permisos estándar de Android.

Usa AudioManager para enrutar el audio

Después de que el usuario otorgue a tu app los permisos de tiempo de ejecución necesarios, usa la API de AudioManager para configurar el dispositivo de comunicación en TYPE_BLUETOOTH_SCO para enrutar el audio a través de Bluetooth HFP. Esto indica al sistema que recupere el audio del periférico Bluetooth.

val audioManager = context.getSystemService(AudioManager::class.java) ?: return
val hfpDevice = audioManager.availableCommunicationDevices
    .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } ?: return

// HFP input is limited to 16 kHz, with mono or stereo channel configurations.
val sampleRate = 16_000
val channelMask = AudioFormat.CHANNEL_IN_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT

// Use a small multiple of the minimum buffer size to keep chunks short for low latency.
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
if (minBufferSize <= 0) return

val audioRecord = AudioRecord.Builder()
    .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION)
    .setAudioFormat(
        AudioFormat.Builder()
            .setSampleRate(sampleRate)
            .setChannelMask(channelMask)
            .setEncoding(encoding)
            .build()
    )
    .setBufferSizeInBytes(minBufferSize * 2)
    .build()

try {
    // Route voice communication audio through the Bluetooth device.
    if (!audioManager.setCommunicationDevice(hfpDevice)) return
    audioRecord.startRecording()
    // Read audio with audioRecord.read() on a background thread.
} finally {
    // Stop and release when done.
    if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) audioRecord.stop()
    audioRecord.release()
    audioManager.clearCommunicationDevice()
}

Captura una imagen con la cámara de los lentes

Para capturar una imagen con la cámara de los lentes, configura y vincula el caso de uso ImageCapture de CameraX a la cámara de los lentes con el contexto correcto para tu app:

private fun setUpGlassesCamera(activity: ComponentActivity) {
    activity.lifecycleScope.launch {
        // Before creating a projected context, check to see if the projected device is connected.
        ProjectedContext.isProjectedDeviceConnected(activity, coroutineContext)
            .collectLatest { isConnected ->
                if (isConnected) {
                    // 1. Get the CameraProvider using the projected context.
                    // When using the projected context, DEFAULT_BACK_CAMERA maps to the audio and display glasses' camera.
                    val projectedContext = try {
                        ProjectedContext.createProjectedDeviceContext(activity)
                    } catch (e: IllegalStateException) {
                        Log.e(TAG, "Projected context could not be created", e)
                        return@collectLatest
                    }

                    val cameraProviderFuture = ProcessCameraProvider.getInstance(projectedContext)

                    cameraProviderFuture.addListener({
                        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
                        val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

                        // 2. Check for the presence of a camera.
                        if (!cameraProvider.hasCamera(cameraSelector)) {
                            Log.w(TAG, "The selected camera is not available.")
                            return@addListener
                        }

                        // 3. Query supported streaming resolutions dynamically using Camera2 Interop.
                        val cameraInfo = cameraProvider.getCameraInfo(cameraSelector)
                        val map = Camera2CameraInfo.from(cameraInfo).getCameraCharacteristic(
                            CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
                        )
                        val supportedResolutions = map?.getOutputSizes(ImageFormat.JPEG)?.toList() ?: emptyList()
                        Log.i(TAG, "Supported resolutions for JPEG: ${supportedResolutions.joinToString()}")

                        // 4. Dynamically select the best target resolution from the queried list.
                        val preferredResolution = Size(1920, 1080)
                        val dynamicTargetResolution = when {
                            // Case A: The preferred resolution is explicitly supported by the glasses.
                            supportedResolutions.contains(preferredResolution) -> preferredResolution

                            // Case B: Find the highest supported 16:9 resolution as a fallback.
                            else -> {
                                val targetAspectRatio = 16.0 / 9.0
                                val tolerance = 0.01
                                supportedResolutions
                                    .filter { abs((it.width.toDouble() / it.height) - targetAspectRatio) < tolerance }
                                    .maxByOrNull { it.width * it.height }
                                    // Case C: Absolute fallback if no 16:9 resolutions are found.
                                    ?: supportedResolutions.firstOrNull()
                                    ?: preferredResolution
                            }
                        }
                        Log.i(TAG, "Selected target resolution: $dynamicTargetResolution")

                        val resolutionStrategy = ResolutionStrategy(
                            dynamicTargetResolution,
                            ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER
                        )
                        val resolutionSelector = ResolutionSelector.Builder()
                            .setResolutionStrategy(resolutionStrategy)
                            .build()

                        // 5. Initialize the ImageCapture use case with options.
                        val imageCapture = ImageCapture.Builder()
                            // Optional: Configure resolution, format, etc.
                            .setResolutionSelector(resolutionSelector)
                            .build()

                        try {
                            // Unbind use cases before rebinding.
                            cameraProvider.unbindAll()

                            // Bind use cases to camera using the Activity as the LifecycleOwner.
                            cameraProvider.bindToLifecycle(
                                activity,
                                cameraSelector,
                                imageCapture
                            )
                        } catch (exc: Exception) {
                            Log.e(TAG, "Use case binding failed", exc)
                        }
                    }, ContextCompat.getMainExecutor(activity))
                }
            }
    }
}

Puntos clave sobre el código

  • Obtiene una instancia de ProcessCameraProvider con el contexto del dispositivo proyectado.
  • Dentro del alcance del contexto proyectado, la cámara principal de los lentes, que apunta hacia afuera, se asigna a DEFAULT_BACK_CAMERA cuando se selecciona una cámara.
  • Una verificación previa a la vinculación usa cameraProvider.hasCamera(cameraSelector) para comprobar que la cámara seleccionada esté disponible en el dispositivo antes de continuar.
  • Usa la interoperabilidad de Camera2 con Camera2CameraInfo para leer el CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAP subyacente, que puede ser útil para realizar verificaciones avanzadas de las resoluciones admitidas.
  • Se compila un ResolutionSelector personalizado para controlar con precisión la resolución de la imagen de salida para ImageCapture.
  • Crea un caso de uso de ImageCapture que se configura con un ResolutionSelector personalizado.
  • Vincula el caso de uso ImageCapture al ciclo de vida de la actividad. Esto administra automáticamente la apertura y el cierre de la cámara según el estado de la actividad (por ejemplo, detiene la cámara cuando se pausa la actividad).
  • Configuración para hardware de transmisión única: La canalización de la cámara de los lentes se limita a una sola transmisión activa a la vez. Cuando ImageCapture se vincula solo, CameraX adjunta una transmisión repetitiva interna (MeteringRepeating) para el enfoque y la medición de forma predeterminada, lo que hace que la canalización de transmisión única se detenga. Para vincular ImageCapture por sí solo sin un flujo de vista previa, inhabilita el flujo de repetición forzada configurando setRepeatingStreamForced(false) en CameraXConfig.Builder (por ejemplo, implementando CameraXConfig.Provider en tu clase Application o configurando ProcessCameraProvider antes de la inicialización).

Después de configurar la cámara de los lentes, puedes capturar una imagen con la clase ImageCapture de CameraX. Consulta la documentación de CameraX para obtener información sobre cómo usar takePicture para capturar una imagen.

Captura una transmisión con la cámara de los lentes

Para capturar transmisiones en lugar de una imagen, reemplaza el componente ImageCapture del ejemplo de captura de imagen por los componentes VideoCapture o ImageAnalysis, y cambia la lógica de captura.

Para obtener más información sobre estas APIs y cómo usarlas, consulta la documentación de captura de video de CameraX y de análisis de imágenes de CameraX.

Elige una resolución y una velocidad de fotogramas

Debes consultar el dispositivo para encontrar las resoluciones admitidas y sus velocidades de fotogramas máximas correspondientes. En la siguiente tabla, se enumeran ejemplos de valores que podrías ver, según el caso de uso de tu app. Para conservar la batería y reducir el calor de los lentes, elige la resolución y la velocidad de fotogramas más bajas que funcionen para tu caso de uso.

Caso de uso Solución Velocidad de fotogramas
Reconocimiento de texto (OCR) o detección del mundo 1920 x 1080 1 FPS
Transmisión de video con IA (asistentes multimodales) 1024 × 768 1 FPS
Localización y posicionamiento visual (VPS) 640 × 360 o 640 × 480 10 FPS
Videollamadas 640 × 360 o 640 × 480 24 FPS

Consulta la velocidad de fotogramas máxima para cada resolución

CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES informa los límites generales de la velocidad de fotogramas del dispositivo, que pueden ser más altos que la velocidad de transmisión que admite una resolución determinada. Para obtener el máximo real de cada resolución en los lentes con pantalla y audio, lee su duración mínima de fotograma y conviértela en FPS con StreamConfigurationMap.getOutputMinFrameDuration(format, size):

@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
fun getMaxFpsBySize(cameraInfo: CameraInfo, format: Int): Map<Size, Int> {
    val map = Camera2CameraInfo.from(cameraInfo).getCameraCharacteristic(
        CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
    ) ?: return emptyMap()

    return map.getOutputSizes(format).associateWith { size ->
        val minFrameDurationNs = map.getOutputMinFrameDuration(format, size)
        if (minFrameDurationNs > 0) (1_000_000_000L / minFrameDurationNs).toInt() else 0
    }
}

A continuación, usa ese valor para calcular los límites de minFps y maxFps de tu rango de velocidad de fotogramas objetivo. Limita el límite inferior (minFps) a la velocidad de fotogramas de transmisión máxima de la resolución calculada a partir de getOutputMinFrameDuration y establece un límite superior más alto (maxFps) como una ventana de recuperación:

val maxFpsBySize = getMaxFpsBySize(cameraInfo, ImageFormat.YUV_420_888)
val maxSupportedFps = maxFpsBySize[Size(640, 480)] ?: return ImageAnalysis.Builder().build()

// minFps is the target streaming frame rate and should be at most maxSupportedFps.
val minFps = minOf(desiredFps, maxSupportedFps)
// Set the upper bound to 2x minFps so the stream can catch up if frames are delayed.
val maxFps = minFps * 2

Puntos clave sobre el código

  • Usa el formato que coincida con tu caso de uso. Para ImageAnalysis, es YUV_420_888.
  • Para VideoCapture, usa las sobrecargas de Class en su lugar: getOutputSizes(MediaRecorder::class.java) y getOutputMinFrameDuration(MediaRecorder::class.java, size).
  • Especifica un rango en el que maxFps sea mayor que minFps (como minFps*2) en lugar de establecer ambos límites en el mismo valor, como Range(minFps, minFps). El límite inferior (minFps) establece la velocidad de fotogramas de transmisión objetivo y debe ser, como máximo, el valor calculado a partir de getOutputMinFrameDuration, mientras que el límite superior (maxFps) actúa como una ventana de recuperación para los fotogramas retrasados para evitar la latencia persistente. Por ejemplo, [1,2] o [1,3] para una transmisión de 1 FPS, y [24,30] o [24,48] para una transmisión de 24 FPS.

Configura la velocidad de fotogramas

Para configurar el rango de velocidad de fotogramas según tu caso de uso de CameraX, haz lo siguiente:

// Configure Resolution Strategy for the stream
val resolutionStrategy = ResolutionStrategy(
    size,
    ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER
)
val resolutionSelector = ResolutionSelector.Builder()
    .setResolutionStrategy(resolutionStrategy)
    .build()

// Create and return the ImageAnalysis builder with proper frame rate ranges
// ImageAnalysis: OCR, AI streaming, and VPS
val analysisBuilder = ImageAnalysis.Builder()
    .setResolutionSelector(resolutionSelector)

Camera2Interop.Extender(analysisBuilder)
    .setCaptureRequestOption(
        CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE,
        Range(minFps, maxFps)
    )
return analysisBuilder.build()

En este ejemplo, se muestra la configuración para VideoCapture y ImageAnalysis, pero puedes implementar los casos de uso que admita tu app.

Comprende cómo se comporta la transmisión

Ten en cuenta los siguientes comportamientos del sistema cuando trabajes con flujos de captura:

  • Se rechazan las resoluciones no admitidas: Si solicitas un tamaño que no está en la lista, Camera2 rechaza la configuración antes de que se abra la transmisión.
  • Solicitud de velocidades de fotogramas superiores al máximo: Si solicitas una velocidad de fotogramas mínima superior al máximo admitido para tu resolución, la sesión se abrirá de todos modos, pero la velocidad de fotogramas objetivo de los lentes se limitará a ese máximo admitido, mientras que el límite superior seguirá actuando como el límite de velocidad de recuperación. Por ejemplo, si se solicita [30,60] a 640 x 480, se limita la velocidad de fotogramas objetivo a 24 FPS (se comporta como [24,60]).
  • Limitación térmica: Cuando los lentes se calientan, la velocidad de fotogramas disminuye, pero la resolución se mantiene igual. Compila tu app para que controle velocidades de fotogramas más bajas o irregulares; por ejemplo, verificando las marcas de tiempo de los fotogramas en lugar de suponer una velocidad fija.

Cómo acceder al hardware de un teléfono desde una actividad proyectada

Una actividad proyectada también puede acceder al hardware del teléfono (como la cámara o el micrófono) con createHostDeviceContext(context) para obtener el contexto del dispositivo host (teléfono):

@OptIn(ExperimentalProjectedApi::class)
private fun getPhoneContext(activity: ComponentActivity): Context? {
    return try {
        // From a projected Activity, get a context for the phone.
        ProjectedContext.createHostDeviceContext(activity)
    } catch (e: IllegalStateException) {
        Log.e(TAG, "Failed to create host device context", e)
        null
    }
}

Cuando accedas al hardware o a los recursos específicos del dispositivo host (teléfono) en una app híbrida (una app que contiene experiencias para dispositivos móviles y lentes), debes seleccionar explícitamente el contexto correcto para asegurarte de que la app pueda acceder al hardware correcto: