استخدام سياق مُسقَط للوصول إلى الأجهزة على نظّارة الصوت والنظّارة المزودة بشاشة عرض

أجهزة 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، ثم إعادة إعداد أي خدمات أو موارد للنظام باستخدام السياق المعروض الجديد.

تسجيل الصوت باستخدام ميكروفون النظارة

يمكنك تسجيل الصوت من النظارات باستخدام ملف "بلوتوث" الشخصي بدون لمس الجهاز (HFP). في هذا الوضع، تتصل النظارات بالهاتف باستخدام ملفات تعريف سماعات الرأس العادية وملفات تعريف توزيع الصوت المتقدم (A2DP)، وتعمل كجهاز طرفي عادي يعمل بالبلوتوث. إذا كان تطبيقك مصمّمًا للتسجيل عبر البلوتوث العادي، يمكنك استخدام هذه الطريقة لتسجيل الصوت من النظارات بدون أي تطوير إضافي.

تسجيل الصوت باستخدام ملف HFP للبلوتوث

لتسجيل الصوت باستخدام Bluetooth HFP، عليك أولاً طلب أذونات التشغيل المطلوبة، ثم تسجيل الصوت باستخدام واجهة برمجة التطبيقات AudioManager، كما هو موضّح في الأقسام التالية.

طلب الحصول على الأذونات

كما هو الحال مع أي جهاز صوتي عادي يتضمّن بلوتوث، يتم التحكّم في RECORD_AUDIO وBLUETOOTH_CONNECT والأذونات الأخرى ذات الصلة من خلال الهاتف وليس الجهاز المتصل (مثل النظارات الصوتية أو النظارات بشاشة عرض).

اتّبِع الخطوات التالية لطلب الأذونات:

  1. يجب الإفصاح عن الأذونات التالية في ملف بيان تطبيقك:

  2. اطلب الحصول على إذنَي RECORD_AUDIO وBLUETOOTH_CONNECT في وقت التشغيل باستخدام مسار أذونات Android العادي.

استخدام AudioManager لتوجيه الصوت

بعد أن يمنح المستخدم تطبيقك أذونات التشغيل اللازمة، استخدِم واجهة برمجة التطبيقات AudioManager لضبط جهاز الاتصال على TYPE_BLUETOOTH_SCO لتوجيه الصوت من خلال ملف HFP للبلوتوث. يوجّه هذا الإجراء النظام لاسترداد الصوت من جهاز البلوتوث الملحق.

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 بمراحل النشاط. يؤدي ذلك إلى إدارة فتح الكاميرا وإغلاقها تلقائيًا استنادًا إلى حالة النشاط (على سبيل المثال، إيقاف الكاميرا عند إيقاف النشاط مؤقتًا).
  • الضبط للأجهزة ذات البث الواحد: يقتصر مسار كاميرا النظارات على بث نشط واحد في كل مرة. عند ربط ImageCapture وحده، يرفق CameraX دفقًا متكررًا داخليًا (MeteringRepeating) للتركيز والقياس بشكل تلقائي، ما يؤدي إلى توقف مسار الدفق الفردي. لربط ImageCapture بمفرده بدون بث معاينة، عليك إيقاف بث التكرار الإجباري من خلال ضبط setRepeatingStreamForced(false) على CameraXConfig.Builder (على سبيل المثال، من خلال تنفيذ CameraXConfig.Provider في فئة Application أو ضبط ProcessCameraProvider قبل الإعداد).

بعد إعداد كاميرا النظارات، يمكنك التقاط صورة باستخدام فئة ImageCapture في CameraX. يُرجى الرجوع إلى مستندات CameraX للتعرّف على كيفية استخدام takePicture من أجل التقاط صورة.

التقاط بث باستخدام كاميرا النظّارة

لالتقاط أحداث البث بدلاً من صورة، استبدِل المكوّن ImageCapture من مثال التقاط الصورة بالمكوّن VideoCapture أو ImageAnalysis وغيِّر منطق الالتقاط.

لمزيد من المعلومات حول واجهات برمجة التطبيقات هذه وكيفية استخدامها، يُرجى الاطّلاع على مستندات تسجيل الفيديو باستخدام CameraX وتحليل الصور باستخدام CameraX.

اختيار درجة الدقة وعدد اللقطات في الثانية

يجب الاستعلام عن الجهاز للعثور على درجات الدقة المتوافقة والحد الأقصى لعدد اللقطات في الثانية المقابل لها. يسرد الجدول التالي أمثلة على القيم التي قد تظهر لك، بناءً على حالة استخدام تطبيقك. للحفاظ على طاقة البطارية وتقليل الحرارة في النظارات، اختَر أقل دقة وعدد لقطات في الثانية يناسبان حالة الاستخدام.

حالة الاستخدام درجة الدقة عدد اللقطات في الثانية
التعرّف على النص (OCR) أو استشعار العالم 1920 × 1080 لقطة واحدة في الثانية
بث فيديوهات بالذكاء الاصطناعي (المساعدون المتعددو الوسائط) ‫‎1024 x 768 لقطة واحدة في الثانية
تحديد الموقع الجغرافي وتحديد الموضع المرئي (VPS) ‫640 × 360 أو 640 × 480 ‫10 لقطات في الثانية
مكالمات الفيديو ‫640 × 360 أو 640 × 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] لبث بمعدل عدد اللقطات في الثانية، و[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 × 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 لأنّ سياق التطبيق يمكن أن يعرض سياق النظارات بشكل غير صحيح إذا كان النشاط المعروض هو المكوّن الذي تم إطلاقه مؤخرًا.