שימוש בהקשר מוקרן כדי לגשת לציוד במשקפי שמע ובמשקפי תצוגה

מכשירי XR רלוונטיים
ההנחיות האלה יעזרו לכם ליצור חוויות למכשירי XR מהסוגים הבאים.
משקפיים עם
אודיו ותצוגה פנימית

אחרי שתבקשו ותקבלו את ההרשאות הנדרשות, האפליקציה תוכל לגשת לחומרה של המשקפיים עם הממשק הקולי או המשקפיים עם התצוגה הפנימית. כדי לגשת לחומרה של המשקפיים (במקום לחומרה של הטלפון), צריך להשתמש בהקשר מושלך.

יש שתי דרכים עיקריות לקבל הקשר מוקרן, בהתאם למקום שבו הקוד מופעל:

קבלת הקשר מוצג אם הקוד פועל בפעילות מוצגת

אם הקוד של האפליקציה פועל מתוך הפעילות המוקרנת, הקשר הפעילות שלה הוא כבר הקשר מוקרן. בתרחיש הזה, קריאות שמתבצעות במסגרת הפעילות הזו כבר יכולות לגשת לחומרה של המשקפיים.

קבלת הקשר משוער לקוד שפועל ברכיב של אפליקציית טלפון

אם חלק מהאפליקציה מחוץ לפעילות המוקרנת (כמו פעילות בטלפון או שירות) צריך לגשת לחומרה של המשקפיים, הוא חייב לקבל במפורש הקשר מוקרן. כדי לעשות זאת, משתמשים ב-method‏ 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) של Bluetooth. במצב הזה, המשקפיים מתחברים לטלפון באמצעות פרופילים רגילים של אוזניות ו-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 למחזור החיים של הפעילות. כך הפתיחה והסגירה של המצלמה מנוהלות באופן אוטומטי על סמך מצב הפעילות (לדוגמה, עצירת המצלמה כשהפעילות מושהית).
  • הגדרה לחומרה עם סטרימינג יחיד: צינור עיבוד הנתונים של מצלמת המשקפיים מוגבל לסטרימינג פעיל אחד בכל פעם. כש-ImageCapture מקושר לבד, CameraX מצרף כברירת מחדל סטרימינג פנימי חוזר (MeteringRepeating) למיקוד ולמדידה, מה שגורם לפייפליין זורם של הסטרימינג היחיד להיתקע. כדי לקשר את ImageCapture לבד בלי שידור מקדים, צריך להשבית את השידור החוזר הכפוי על ידי הגדרת setRepeatingStreamForced(false) ב-CameraXConfig.Builder (לדוגמה, על ידי הטמעת CameraXConfig.Provider במחלקה Application או הגדרת ProcessCameraProvider לפני האתחול).

אחרי שמגדירים את המצלמה של המשקפיים, אפשר לצלם תמונה באמצעות המחלקה ImageCapture של CameraX. במסמכי התיעוד של CameraX מוסבר איך משתמשים ב-takePicture כדי לצלם תמונה.

צילום שידור באמצעות המצלמה של המשקפיים

כדי לצלם שידורים במקום תמונה, מחליפים את הרכיב ImageCapture מהדוגמה לצילום תמונה ברכיבים VideoCapture או ImageAnalysis ומשנים את לוגיקת הצילום.

מידע נוסף על ממשקי ה-API האלה ואיך להשתמש בהם זמין במסמכי התיעוד של צילום וידאו ב-CameraX וניתוח תמונות ב-CameraX.

בוחרים רזולוציה וקצב פריימים

כדאי לשלוח שאילתה למכשיר כדי לברר מהן הרזולוציות הנתמכות ומהם קצבי הפריימים המקסימליים שלהן. בטבלה הבאה מפורטים ערכים לדוגמה שאתם עשויים לראות, בהתאם לתרחיש השימוש של האפליקציה. כדי לחסוך בחשמל ולהפחית את החום במשקפיים, כדאי לבחור את הרזולוציה וקצב הפריימים הנמוכים ביותר שמתאימים לתרחיש השימוש שלכם.

תרחיש שימוש רזולוציה קצב פריימים
זיהוי טקסט (OCR) או חישת העולם ‎1920 x 1080 פריים אחד לשנייה (FPS)
סטרימינג של וידאו באמצעות AI (עוזרים אישיים מולטי-מודאליים) ‎1,024 x 768 פריים אחד לשנייה (FPS)
לוקליזציה ומיקום חזותי (VPS) ‫‎640 x 360 או ‎640 x 480 ‫10 פריימים לשנייה (FPS)
שיחות וידאו ‫‎640 x 360 או ‎640 x 480 ‎24 FPS

שאילתה לגבי קצב הפריימים המקסימלי לכל רזולוציה

‫CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES מדווח על גבולות קצב הפריימים הכולל של המכשיר, שיכול להיות גבוה יותר מקצב הסטרימינג שרזולוציה נתונה תומכת בו. כדי לקבל את הערך המקסימלי בפועל לכל רזולוציה במשקפיים עם אודיו ותצוגה, קוראים את משך הפריים המינימלי וממירים אותו ל-FPS באמצעות 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 FPS, ו-[24,30] או [24,48] לשידור של 24 FPS.

הגדרת קצב הפריימים

כדי להגדיר את טווח קצב הפריימים בהתאם לתרחיש לדוגמה של 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 FPS (מתנהגת כמו [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 כי הקשר האפליקציה יכול להחזיר בטעות את ההקשר של המשקפיים אם הפעילות שהוקרנה הייתה הרכיב שהופעל לאחרונה.