Usar um contexto projetado para acessar hardware em óculos de áudio e óculos de exibição

Dispositivos XR relevantes
Estas orientações ajudam você a criar experiências para esses tipos de dispositivos XR.
Óculos com áudio e
tela

Depois de solicitar e receber as permissões necessárias, seu app poderá acessar o hardware nos óculos com áudio ou com tela. Para acessar o hardware dos óculos (em vez do hardware do smartphone), use um contexto projetado.

Há duas maneiras principais de conseguir um contexto projetado, dependendo de onde o código está sendo executado:

Receber um contexto projetado se o código estiver sendo executado em uma atividade projetada

Se o código do app estiver sendo executado na atividade projetada, o contexto da atividade já será um contexto projetado. Nesse cenário, as chamadas feitas nessa atividade já podem acessar o hardware dos óculos.

Receber um contexto projetado para o código em execução em um componente de app Telefone

Se uma parte do app fora da atividade projetada (como uma atividade de smartphone ou um serviço) precisar acessar o hardware dos óculos, ela precisará receber explicitamente um contexto projetado. Para fazer isso, use o 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...")
                }
            }
    }
}

Verificar a validade

Una a chamada createProjectedDeviceContext ao ProjectedContext.isProjectedDeviceConnected. Enquanto esse método retornar true, o contexto projetado vai permanecer válido para o dispositivo conectado, e a atividade ou o serviço do app para smartphone (como um CameraManager) poderá acessar o hardware dos óculos.

Limpar ao desconectar

O contexto projetado está vinculado ao ciclo de vida do dispositivo conectado, portanto, é destruído quando o dispositivo se desconecta. Quando o dispositivo se desconecta, ProjectedContext.isProjectedDeviceConnected retorna false. O app precisa detectar essa mudança e limpar todos os serviços do sistema (como um CameraManager) ou recursos que o app criou usando esse contexto projetado.

Reinicializar ao reconectar

Quando os óculos se reconectarem, o app poderá receber outra instância de contexto projetado usando createProjectedDeviceContext e, em seguida, reinicializar todos os serviços ou recursos do sistema usando o novo contexto projetado.

Gravar áudio com o microfone dos óculos

É possível gravar áudio dos óculos usando o perfil de viva-voz (HFP, na sigla em inglês) do Bluetooth. Nesse modo, os óculos se conectam ao smartphone usando os perfis padrão de fone de ouvido e perfil de distribuição de áudio avançado (A2DP), funcionando como um periférico Bluetooth comum. Se o app já foi projetado para gravação Bluetooth padrão, use esse método para gravar áudio dos óculos sem nenhum desenvolvimento adicional.

Gravar áudio usando o HFP do Bluetooth

Para gravar áudio usando o HFP do Bluetooth, primeiro solicite as permissões de execução necessárias e, em seguida, grave o áudio usando a API AudioManager, conforme descrito nas seções a seguir.

Solicitar permissões

Como em qualquer dispositivo de áudio Bluetooth padrão, as permissões RECORD_AUDIO, BLUETOOTH_CONNECT e outras relacionadas são controladas pelo smartphone, e não pelo dispositivo conectado (como óculos com áudio ou óculos com tela).

Siga estas etapas para solicitar as permissões:

  1. Declare as seguintes permissões no arquivo de manifesto do app:

  2. Solicite as permissões RECORD_AUDIO e BLUETOOTH_CONNECT no momento da execução usando o fluxo de permissões padrão do Android.

Usar o AudioManager para rotear áudio

Depois que o usuário conceder ao app as permissões de execução necessárias, use a API AudioManager para definir o dispositivo de comunicação como TYPE_BLUETOOTH_SCO para rotear o áudio pelo Bluetooth HFP. Isso direciona o sistema para recuperar o áudio do 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()
}

Tirar uma foto com a câmera dos óculos

Para capturar uma imagem com a câmera dos óculos, configure e vincule o caso de uso ImageCapture do CameraX à câmera dos óculos usando o contexto correto para seu 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))
                }
            }
    }
}

Pontos principais sobre o código

  • Acessa uma instância de ProcessCameraProvider usando o contexto do dispositivo projetado.
  • No escopo do contexto projetado, a câmera principal dos óculos, que aponta para fora, é mapeada para DEFAULT_BACK_CAMERA ao selecionar uma câmera.
  • Uma verificação de pré-vinculação usa cameraProvider.hasCamera(cameraSelector) para verificar se a câmera selecionada está disponível no dispositivo antes de prosseguir.
  • Usa a interoperabilidade do Camera2 com Camera2CameraInfo para ler o CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAP subjacente, o que pode ser útil para verificações avançadas em resoluções compatíveis.
  • Um ResolutionSelector personalizado é criado para controlar com precisão a resolução da imagem de saída para ImageCapture.
  • Cria um caso de uso de ImageCapture configurado com um ResolutionSelector personalizado.
  • Vincula o caso de uso ImageCapture ao ciclo de vida da atividade. Isso gerencia automaticamente a abertura e o fechamento da câmera com base no estado da atividade (por exemplo, interrompendo a câmera quando a atividade é pausada).
  • Configuração para hardware de stream único: o pipeline da câmera dos óculos é limitado a um único stream ativo por vez. Quando ImageCapture é vinculado sozinho, o CameraX anexa um stream de repetição interno (MeteringRepeating) para foco e medição por padrão, o que faz com que o pipeline de stream único seja interrompido. Para vincular ImageCapture por conta própria sem um stream de pré-lançamento, desative o stream de repetição forçada definindo setRepeatingStreamForced(false) em CameraXConfig.Builder (por exemplo, implementando CameraXConfig.Provider na sua classe Application ou configurando ProcessCameraProvider antes da inicialização).

Depois que a câmera dos óculos estiver configurada, você poderá capturar uma imagem com a classe ImageCapture da CameraX. Consulte a documentação do CameraX para saber como usar takePicture para capturar uma imagem.

Capturar um stream com a câmera dos óculos

Para capturar streams em vez de uma imagem, substitua o componente ImageCapture do exemplo de captura de imagem pelos componentes VideoCapture ou ImageAnalysis e mude a lógica de captura.

Para mais informações sobre essas APIs e como usá-las, consulte a documentação sobre captura de vídeo da CameraX e análise de imagens da CameraX.

Escolha uma resolução e um frame rate

Consulte o dispositivo para encontrar as resoluções compatíveis e as taxas de frames máximas correspondentes. A tabela a seguir lista exemplos de valores que podem aparecer, dependendo do caso de uso do app. Para economizar bateria e reduzir o calor nos óculos, escolha a menor resolução e frame rate que funcionem para seu caso de uso.

Caso de uso Resolução Frame rate
Reconhecimento de texto (OCR) ou detecção de mundo 1920 x 1080 1 QPS
Streaming de vídeo de IA (assistentes multimodais) 1.024 x 768 1 QPS
Localização e posicionamento visual (VPS) 640 x 360 ou 640 x 480 10 QPS
Videochamadas 640 x 360 ou 640 x 480 24 QPS

Consultar o frame rate máximo para cada resolução

CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES informa os limites gerais de frame rate do dispositivo, que podem ser maiores do que a taxa de streaming que uma determinada resolução aceita. Para conferir o máximo real de cada resolução em óculos de áudio e tela, leia a duração mínima do frame e converta-a em QPS com 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
    }
}

Em seguida, use esse valor para calcular os limites minFps e maxFps do intervalo de frame rate desejado. Limite o limite inferior (minFps) na frame rate máxima de streaming da resolução calculada com base em getOutputMinFrameDuration e defina um limite superior mais alto (maxFps) como uma janela de recuperação:

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

Pontos principais sobre o código

  • Use o formato que corresponde ao seu caso de uso. Para ImageAnalysis, isso é YUV_420_888.
  • Para VideoCapture, use as sobrecargas de Class: getOutputSizes(MediaRecorder::class.java) e getOutputMinFrameDuration(MediaRecorder::class.java, size).
  • Especifique um intervalo em que maxFps seja maior que minFps (como minFps*2) em vez de definir os dois limites com o mesmo valor, como Range(minFps, minFps). O limite inferior (minFps) define a frame rate de streaming desejada e precisa ser, no máximo, o valor calculado de getOutputMinFrameDuration, enquanto o limite superior (maxFps) atua como uma janela de compensação para frames atrasados para evitar latência persistente. Por exemplo, [1,2] ou [1,3] para um stream de 1 QPS e [24,30] ou [24,48] para um stream de 24 QPS.

Configurar o frame rate

Para configurar o intervalo de frame rate com base no seu caso de uso do 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()

Este exemplo mostra a configuração para VideoCapture e ImageAnalysis, mas é possível implementar qualquer caso de uso compatível com o app.

Entender como o stream se comporta

Lembre-se dos seguintes comportamentos do sistema ao trabalhar com streams de captura:

  • Resoluções sem suporte são rejeitadas: se você solicitar um tamanho que não esteja na lista, a Camera2 vai rejeitar a configuração antes que o stream seja aberto.
  • Solicitação de frame rates acima do máximo: se você solicitar um frame rate mínimo acima do máximo aceito para sua resolução, a sessão ainda será aberta, mas o frame rate desejado dos óculos será limitado ao máximo aceito, enquanto o limite superior continuará atuando como o limite de taxa de recuperação. Por exemplo, solicitar [30,60] a 640 x 480 limita o frame rate alvo a 24 QPS (comportando-se como [24,60]).
  • Limitação térmica: quando os óculos esquentam, o frame rate cai, mas a resolução permanece a mesma. Crie seu app para lidar com frame rates mais baixos ou irregulares, por exemplo, verificando os carimbos de data/hora dos frames em vez de presumir uma taxa fixa.

Acessar o hardware de um smartphone em uma atividade projetada

Uma atividade projetada também pode acessar o hardware do smartphone (como a câmera ou o microfone) usando createHostDeviceContext(context) para acessar o contexto do dispositivo host (smartphone):

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

Ao acessar hardware ou recursos específicos do dispositivo host (smartphone) em um app híbrido (um app que contém experiências para dispositivos móveis e óculos), é necessário selecionar explicitamente o contexto correto para garantir que o app possa acessar o hardware correto: