Использовать проецируемый контекст для доступа к оборудованию на аудио- и видеоочках

Поддерживаемые XR-устройства
Это руководство поможет вам создавать продукты для таких XR-устройств.
Аудио- и
видеоочки

После того как вы запросите и получите необходимые разрешения, ваше приложение сможет получить доступ к аппаратным компонентам аудио- или видеоочков. Чтобы получить доступ к аппаратным компонентам очков, а не телефона, нужно использовать проецируемый контекст.

Получить прогнозируемый контекст можно двумя основными способами в зависимости от того, где выполняется ваш код:

Получение контекста, если код выполняется в спроецированном действии

Если код приложения выполняется в рамках проецируемого действия, то контекст этого действия уже является проецируемым. В этом случае вызовы, сделанные в рамках этого действия, уже могут получить доступ к оборудованию очков.

Получение прогнозируемого контекста для кода, выполняемого в компоненте приложения для телефона

Если части приложения, не связанной с проецируемым действием (например, действию или сервису телефона), требуется доступ к аппаратным компонентам очков, она должна явно получить проецируемый контекст. Для этого используйте метод 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...")
                }
            }
    }
}

Проверка действительности

Оберните вызов createProjectedDeviceContext в ProjectedContext.isProjectedDeviceConnected. Хотя этот метод возвращает true, проецируемый контекст остается действительным для подключенного устройства, а ваше приложение или сервис (например, CameraManager) может получить доступ к аппаратным компонентам очков.

Очистка при отключении

Проецируемый контекст привязан к жизненному циклу подключенного устройства, поэтому он уничтожается при отключении устройства. Когда устройство отключается, ProjectedContext.isProjectedDeviceConnected возвращает false. Приложение должно отслеживать это изменение и очищать все системные службы (например, CameraManager) или ресурсы, созданные с использованием этого контекста.

Повторная инициализация при повторном подключении

Когда очки снова подключатся, приложение сможет получить другой экземпляр проецируемого контекста с помощью createProjectedDeviceContext, а затем повторно инициализировать системные службы или ресурсы, используя новый проецируемый контекст.

Записывать аудио с помощью микрофона очков.

Вы можете записывать аудио с очков, используя профиль Bluetooth Hands-Free (HFP). В этом режиме очки подключаются к телефону с помощью стандартных профилей Headset и Advanced Audio Distribution Profile (A2DP) и работают как обычное периферийное устройство Bluetooth. Если ваше приложение уже поддерживает стандартную запись по Bluetooth, вы можете использовать этот метод для записи аудио с очков без дополнительной разработки.

Запись аудио с помощью Bluetooth HFP

Чтобы записать аудио с помощью Bluetooth HFP, сначала запросите необходимые динамические разрешения, а затем запишите аудио с помощью API AudioManager, как описано в следующих разделах.

Запросить разрешения

Как и в случае с любым стандартным аудиоустройством Bluetooth, разрешения RECORD_AUDIO, BLUETOOTH_CONNECT и другие связанные разрешения контролируются телефоном, а не подключенным устройством (например, аудио- или видеоочками).

Чтобы запросить разрешения, выполните следующие действия:

  1. Объявите следующие разрешения в файле манифеста приложения:

  2. Запросите разрешения RECORD_AUDIO и BLUETOOTH_CONNECT во время выполнения, используя стандартный процесс получения разрешений Android.

Как использовать AudioManager для маршрутизации аудио

После того как пользователь предоставит приложению необходимые динамические разрешения, используйте API AudioManager, чтобы задать для устройства связи значение TYPE_BLUETOOTH_SCO. Это позволит маршрутизировать аудио через Bluetooth HFP. Это указывает системе, что аудио нужно получать с периферийного устройства 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()
}

Сделать снимок с помощью камеры очков

Чтобы сделать снимок с помощью камеры очков, настройте и привяжите вариант использования ImageCapture CameraX к камере очков, используя правильный контекст для вашего приложения:

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

Важные сведения о коде

  • Получает экземпляр ProcessCameraProvider с помощью контекста проецируемого устройства.
  • В контексте проецирования основная камера очков, направленная наружу, сопоставляется с DEFAULT_BACK_CAMERA при выборе камеры.
  • Перед привязкой с помощью cameraProvider.hasCamera(cameraSelector) проверяется, доступна ли выбранная камера на устройстве.
  • Использует Camera2 Interop с Camera2CameraInfo для чтения базового CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAP, что может быть полезно для расширенных проверок поддерживаемых разрешений.
  • Специальный ResolutionSelector создан для точного управления разрешением выходного изображения для ImageCapture.
  • Создает вариант использования ImageCapture, настроенный с помощью пользовательского ResolutionSelector.
  • Привязывает вариант использования ImageCapture к жизненному циклу объекта Activity. Это позволяет автоматически управлять открытием и закрытием камеры в зависимости от состояния действия (например, останавливать камеру, когда действие приостановлено).
  • Настройка для оборудования с одним потоком. Конвейер камеры очков ограничен одним активным потоком за раз. Если привязать только ImageCapture, CameraX по умолчанию подключит внутренний повторяющийся поток (MeteringRepeating) для фокусировки и замера, что приведет к остановке конвейера с одним потоком. Чтобы привязать ImageCapture без потока предварительного просмотра, отключите принудительный повторяющийся поток, установив для CameraXConfig.Builder значение setRepeatingStreamForced(false) (например, реализовав CameraXConfig.Provider в классе Application или настроив ProcessCameraProvider перед инициализацией).

После настройки камеры очков вы можете сделать снимок с помощью класса ImageCapture библиотеки CameraX. Информацию о том, как использовать takePicture для съемки изображений, можно найти в документации CameraX.

Записывайте трансляцию с помощью камеры очков

Чтобы захватывать потоки, а не изображения, замените компонент ImageCapture из примера захвата изображения на компонент VideoCapture или ImageAnalysis и измените логику захвата.

Дополнительную информацию об этих API и их использовании можно найти в документации по видеозаписи CameraX и анализу изображений CameraX.

Выберите разрешение и частоту кадров

Чтобы узнать, какие разрешения и максимальные частоты кадров поддерживает устройство, отправьте запрос. В таблице ниже приведены примеры значений, которые вы можете увидеть в зависимости от того, как используется ваше приложение. Чтобы экономить заряд батареи и уменьшить нагрев очков, выберите минимальные разрешение и частоту кадров, которые подходят для ваших целей.

Пример использования Разрешение Частота кадров
Распознавание текста (OCR) или окружающего мира 1920 x 1080 пикс. 1 кадр/с
Потоковая передача видео с ИИ (мультимодальные помощники) 1024 x 768 1 кадр/с
Локализация и визуальное позиционирование (VPS) 640 x 360 или 640 x 480 10 кадров/с
Видеовызовы 640 x 360 или 640 x 480 24 кадр/с

Запрос максимальной частоты кадров для каждого разрешения

CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES сообщает общие границы частоты кадров устройства, которые могут быть выше, чем частота потоковой передачи, поддерживаемая данным разрешением. Чтобы получить фактическое максимальное значение для каждого разрешения на аудио- и видеоочках, прочитайте минимальную длительность кадра и преобразуйте ее в кадры в секунду с помощью 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
    }
}

Затем используйте это значение, чтобы рассчитать minFps и maxFps границы для целевого диапазона частоты кадров. Ограничьте нижнюю границу (minFps) максимальной частотой кадров для трансляции в выбранном разрешении, рассчитанной на основе getOutputMinFrameDuration, и установите более высокую верхнюю границу (maxFps) в качестве окна догоняющего воспроизведения:

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

Важные сведения о коде

  • Выберите формат, который подходит для вашего случая. Для ImageAnalysis это YUV_420_888.
  • Для VideoCapture используйте перегрузки Class: getOutputSizes(MediaRecorder::class.java) и getOutputMinFrameDuration(MediaRecorder::class.java, size).
  • Укажите диапазон, в котором maxFps больше minFps (например, minFps*2), а не задавайте для обеих границ одно и то же значение, например Range(minFps, minFps). Нижняя граница (minFps) задает целевую частоту кадров трансляции и должна быть не больше значения, рассчитанного на основе getOutputMinFrameDuration. Верхняя граница (maxFps) действует как окно догоняющего воспроизведения для задержанных кадров, чтобы предотвратить постоянную задержку. Например, [1,2] или [1,3] для потока с частотой 1 кадр/с и [24,30] или [24,48] для потока с частотой 24 кадра/с.

Как настроить частоту кадров

Чтобы настроить диапазон частоты кадров в зависимости от варианта использования 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()

В этом примере показана конфигурация для VideoCapture и ImageAnalysis, но вы можете реализовать любые варианты использования, которые поддерживает ваше приложение.

Как работает поток

При работе с потоками захвата учитывайте следующее поведение системы:

  • Неподдерживаемые разрешения отклоняются. Если вы запрашиваете размер, которого нет в списке, Camera2 отклоняет конфигурацию до открытия потока.
  • Запрос частоты кадров выше максимальной. Если вы запрашиваете минимальную частоту кадров выше максимальной поддерживаемой для вашего разрешения, сеанс все равно открывается, но целевая частота кадров из очков ограничивается этим поддерживаемым максимумом, а верхняя граница продолжает действовать как ограничение частоты догоняющих кадров. Например, если запросить [30,60] при разрешении 640 x 480, целевая частота кадров будет ограничена 24 кадрами в секунду (как при [24,60]).
  • Троттлинг. Когда очки нагреваются, частота кадров снижается, но разрешение остается прежним. Разработайте приложение так, чтобы оно могло работать с более низкой или неравномерной частотой кадров, например проверяя временные метки кадров, а не предполагая фиксированную частоту.

Как получить доступ к аппаратным компонентам телефона из транслируемого действия

Проецируемое действие также может получить доступ к аппаратным компонентам телефона (например, камере или микрофону) с помощью createHostDeviceContext(context), чтобы получить контекст хост-устройства (телефона):

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

При обращении к оборудованию или ресурсам, которые относятся к хост-устройству (телефону), в гибридном приложении (приложении, которое работает и на телефоне, и на очках) необходимо явно выбрать правильный контекст, чтобы приложение могло получить доступ к нужному оборудованию.

  • Используйте контекст Activity из телефона Activity или ProjectedContext.createHostDeviceContext, чтобы получить контекст телефона.
  • Не используйте getApplicationContext, поскольку контекст приложения может неправильно возвращать контекст очков, если последним запущенным компонентом было проецируемое действие.