Utiliser un contexte projeté pour accéder au matériel sur les lunettes audio et les lunettes à écran

Appareils XR concernés
Ces conseils vous aideront à créer des expériences pour ces types d'appareils XR.
Lunettes audio et
avec affichage

Une fois que vous avez demandé et obtenu les autorisations nécessaires, votre application peut accéder au matériel des lunettes audio ou des lunettes avec affichage. Pour accéder au matériel des lunettes (au lieu de celui du téléphone), vous devez utiliser un contexte projeté.

Il existe deux méthodes principales pour obtenir un contexte projeté, selon l'endroit où votre code est exécuté :

Obtenir un contexte projeté si votre code s'exécute dans une activité projetée

Si le code de votre application s'exécute à partir de votre activité projetée, son propre contexte d'activité est déjà un contexte projeté. Dans ce scénario, les appels effectués dans cette activité peuvent déjà accéder au matériel des lunettes.

Obtenir un contexte projeté pour le code exécuté dans un composant d'application pour téléphone

Si une partie de votre application en dehors de votre activité projetée (comme une activité téléphonique ou un service) doit accéder au matériel des lunettes, elle doit explicitement obtenir un contexte projeté. Pour ce faire, utilisez la méthode 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...")
                }
            }
    }
}

Vérifier la validité

Encapsulez l'appel createProjectedDeviceContext dans ProjectedContext.isProjectedDeviceConnected. Bien que cette méthode renvoie true, le contexte projeté reste valide pour l'appareil connecté, et l'activité ou le service de votre application Téléphone (comme un CameraManager) peut accéder au matériel des lunettes.

Nettoyer à la déconnexion

Le contexte projeté est lié au cycle de vie de l'appareil connecté. Il est donc détruit lorsque l'appareil se déconnecte. Lorsque l'appareil se déconnecte, ProjectedContext.isProjectedDeviceConnected renvoie false. Votre application doit écouter cette modification et nettoyer tous les services système (comme un CameraManager) ou les ressources qu'elle a créés à l'aide de ce contexte projeté.

Réinitialiser lors de la reconnexion

Lorsque les lunettes se reconnectent, votre application peut obtenir une autre instance de contexte projeté à l'aide de createProjectedDeviceContext, puis réinitialiser les services ou ressources système à l'aide du nouveau contexte projeté.

Enregistrer du contenu audio avec le micro des lunettes

Vous pouvez enregistrer du contenu audio à partir des lunettes à l'aide du profil mains libres (HFP) Bluetooth. Dans ce mode, les lunettes se connectent au téléphone à l'aide des profils standards Headset et Advanced Audio Distribution Profile (A2DP), et fonctionnent comme un périphérique Bluetooth classique. Si votre application est déjà conçue pour l'enregistrement Bluetooth standard, vous pouvez utiliser cette méthode pour enregistrer l'audio des lunettes sans développement supplémentaire.

Enregistrer du contenu audio à l'aide du profil mains libres Bluetooth

Pour enregistrer du contenu audio à l'aide du profil Bluetooth HFP, demandez d'abord les autorisations d'exécution requises, puis enregistrez le contenu audio à l'aide de l'API AudioManager, comme décrit dans les sections suivantes.

Demander des autorisations

Comme pour tout appareil audio Bluetooth standard, les autorisations RECORD_AUDIO, BLUETOOTH_CONNECT et autres autorisations associées sont contrôlées par le téléphone, et non par l'appareil connecté (comme des lunettes audio ou des lunettes avec affichage).

Pour demander les autorisations, procédez comme suit :

  1. Déclarez les autorisations suivantes dans le fichier manifeste de votre application :

  2. Demandez les autorisations RECORD_AUDIO et BLUETOOTH_CONNECT au moment de l'exécution à l'aide du processus d'autorisation Android standard.

Utiliser AudioManager pour acheminer l'audio

Une fois que l'utilisateur a accordé à votre application les autorisations d'exécution nécessaires, utilisez l'API AudioManager pour définir l'appareil de communication sur TYPE_BLUETOOTH_SCO afin de router l'audio via le profil Bluetooth HFP. Cela indique au système de récupérer l'audio à partir du périphérique 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()
}

Prendre une photo avec la caméra des lunettes

Pour capturer une image avec la caméra des lunettes, configurez et associez le cas d'utilisation ImageCapture de CameraX à la caméra des lunettes en utilisant le contexte approprié pour votre application :

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))
                }
            }
    }
}

Points clés concernant le code

  • Obtient une instance de ProcessCameraProvider à l'aide du contexte de l'appareil projeté.
  • Dans le champ d'application du contexte projeté, la caméra principale des lunettes, orientée vers l'extérieur, correspond à DEFAULT_BACK_CAMERA lors de la sélection d'une caméra.
  • Une vérification de pré-liaison utilise cameraProvider.hasCamera(cameraSelector) pour vérifier que la caméra sélectionnée est disponible sur l'appareil avant de continuer.
  • Utilise l'interopérabilité Camera2 avec Camera2CameraInfo pour lire l'élément CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAP sous-jacent, ce qui peut être utile pour les vérifications avancées des résolutions compatibles.
  • Un ResolutionSelector personnalisé est conçu pour contrôler précisément la résolution de l'image de sortie pour ImageCapture.
  • Crée un cas d'utilisation ImageCapture configuré avec un ResolutionSelector personnalisé.
  • Associe le cas d'utilisation ImageCapture au cycle de vie de l'activité. Cela gère automatiquement l'ouverture et la fermeture de l'appareil photo en fonction de l'état de l'activité (par exemple, l'arrêt de l'appareil photo lorsque l'activité est mise en pause).
  • Configuration pour le matériel à flux unique : le pipeline de la caméra des lunettes est limité à un seul flux actif à la fois. Lorsque ImageCapture est lié seul, CameraX associe par défaut un flux de répétition interne (MeteringRepeating) pour la mise au point et la mesure, ce qui entraîne le blocage du pipeline à flux unique. Pour lier ImageCapture seul sans flux d'aperçu, désactivez le flux de répétition forcé en définissant setRepeatingStreamForced(false) sur CameraXConfig.Builder (par exemple, en implémentant CameraXConfig.Provider dans votre classe Application ou en configurant ProcessCameraProvider avant l'initialisation).

Une fois la caméra des lunettes configurée, vous pouvez capturer une image avec la classe ImageCapture de CameraX. Consultez la documentation de CameraX pour savoir comment utiliser takePicture pour capturer une image.

Enregistrer un flux avec la caméra des lunettes

Pour capturer des flux au lieu d'une image, remplacez le composant ImageCapture de l'exemple de capture d'image par les composants VideoCapture ou ImageAnalysis, puis modifiez la logique de capture.

Pour en savoir plus sur ces API et leur utilisation, consultez la documentation sur la capture vidéo CameraX et l'analyse d'images CameraX.

Choisir une résolution et une fréquence d'images

Vous devez interroger l'appareil pour connaître les résolutions compatibles et les fréquences d'images maximales correspondantes. Le tableau suivant présente des exemples de valeurs que vous pouvez voir, en fonction du cas d'utilisation de votre application. Pour économiser la batterie et réduire la chaleur sur les lunettes, choisissez la résolution et la fréquence d'images les plus basses adaptées à votre cas d'utilisation.

Cas d'utilisation Solution Fréquence d'images
Reconnaissance de texte (OCR) ou détection de l'environnement 1920 x 1080 1 FPS
Streaming vidéo IA (assistants multimodaux) 1 024 x 768 1 FPS
Localisation et positionnement visuel (VPS) 640 x 360 ou 640 x 480 10 FPS
Appels vidéo 640 x 360 ou 640 x 480 24 FPS

Interroger la fréquence d'images maximale pour chaque résolution

CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES indique les limites globales de fréquence d'images de l'appareil, qui peuvent être supérieures au débit de streaming accepté par une résolution donnée. Pour obtenir le maximum réel pour chaque résolution sur les lunettes audio et avec affichage, lisez la durée minimale de l'image et convertissez-la en FPS avec 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
    }
}

Ensuite, utilisez cette valeur pour calculer les limites minFps et maxFps de votre plage de fréquences d'images cible. Limitez la limite inférieure (minFps) à la fréquence d'images maximale de la résolution calculée à partir de getOutputMinFrameDuration, et définissez une limite supérieure plus élevée (maxFps) comme fenêtre de rattrapage :

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

Points clés concernant le code

  • Utilisez le format qui correspond à votre cas d'utilisation. Pour ImageAnalysis, il s'agit de YUV_420_888.
  • Pour VideoCapture, utilisez plutôt les surcharges Class : getOutputSizes(MediaRecorder::class.java) et getOutputMinFrameDuration(MediaRecorder::class.java, size).
  • Spécifiez une plage où maxFps est supérieur à minFps (par exemple, minFps*2) plutôt que de définir les deux limites sur la même valeur, comme Range(minFps, minFps). La limite inférieure (minFps) définit la fréquence d'images de streaming cible et doit être au maximum la valeur calculée à partir de getOutputMinFrameDuration, tandis que la limite supérieure (maxFps) sert de fenêtre de rattrapage pour les images retardées afin d'éviter une latence persistante. Par exemple, [1,2] ou [1,3] pour un flux à 1 FPS, et [24,30] ou [24,48] pour un flux à 24 FPS.

Configurer la fréquence d'images

Pour configurer la plage de fréquences d'images en fonction de votre cas d'utilisation de CameraX :

// 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()

Cet exemple montre la configuration pour VideoCapture et ImageAnalysis, mais vous pouvez implémenter les cas d'utilisation compatibles avec votre application.

Comprendre le comportement du flux

Tenez compte des comportements système suivants lorsque vous utilisez des flux de capture :

  • Les résolutions non compatibles sont refusées : si vous demandez une taille qui ne figure pas dans la liste, Camera2 refuse la configuration avant l'ouverture du flux.
  • Demander des fréquences d'images supérieures au maximum : si vous demandez une fréquence d'images minimale supérieure au maximum accepté pour votre résolution, la session s'ouvre quand même, mais la fréquence d'images cible des lunettes est limitée à ce maximum accepté, tandis que la limite supérieure continue de servir de limite de fréquence de rattrapage. Par exemple, si vous demandez [30,60] à 640 x 480, la fréquence d'images cible est limitée à 24 FPS (comportement semblable à [24,60]).
  • Limitation thermique : lorsque les lunettes chauffent, la fréquence d'images diminue, mais la résolution reste la même. Créez votre application de manière à gérer des fréquences d'images plus faibles ou irrégulières, par exemple en vérifiant les codes temporels des images plutôt qu'en supposant une fréquence fixe.

Accéder au matériel d'un téléphone à partir d'une activité projetée

Une activité projetée peut également accéder au matériel du téléphone (comme l'appareil photo ou le micro) en utilisant createHostDeviceContext(context) pour obtenir le contexte de l'appareil hôte (téléphone) :

@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
    }
}

Lorsque vous accédez à du matériel ou à des ressources spécifiques à l'appareil hôte (téléphone) dans une application hybride (une application contenant à la fois des expériences mobiles et des expériences sur lunettes), vous devez sélectionner explicitement le contexte approprié pour vous assurer que votre application peut accéder au matériel approprié :

  • Utilisez le contexte Activity du téléphone Activity ou ProjectedContext.createHostDeviceContext pour obtenir le contexte du téléphone.
  • N'utilisez pas getApplicationContext, car le contexte de l'application peut renvoyer de manière incorrecte le contexte des lunettes si une activité projetée était le composant lancé le plus récemment.