Architecture guides, Kotlin code samples, and integration tutorials for building on the Meta Spatial SDK with Jetpack Compose XR, CameraX, ML Kit, and the Metastrate Substrate.
Meta Spatial SDK 0.5+Kotlin 1.9Compose XR 1.0-alphaAndroid API 32+Quest 3 Only
Introduction & Setup
Introduction & Environment Setup
Bootstrapping Vectra XR for Meta Quest 3 development
Vectra XR is a native Horizon OS application built on the Meta Spatial SDK — Meta's first-party framework for building fully immersive and mixed-reality experiences on the Quest platform without routing through a traditional game engine. Unlike Unity or Unreal integrations, Vectra XR compiles directly against the Android runtime and surfaces its UI through Jetpack Compose XR panels rendered by the Compositor layer.
This architecture choice is deliberate. Removing the game-engine intermediary reduces binary size by ~40%, eliminates frame-pacing overhead introduced by engine abstraction layers, and gives Vectra XR direct access to the full Android API surface — including CameraX, Bluetooth LE, and WorkManager — with zero bridging cost.
The stack is: Kotlin 1.9 + Coroutines + Flow, Meta Spatial SDK 0.5+, Jetpack Compose XR, CameraX, ML Kit Barcode Scanning, and a WebSocket channel to the Astra Matrix backend for Metastrate Ledger synchronization.
The Meta Spatial SDK is built around a strict Entity-Component-System (ECS) architecture. Every object in the 3D scene — a spatial panel, a 3D asset, a physics collider, or a spatial anchor — is an Entity. Behavior and data are attached to entities via Components. Systems run each frame and query entities that hold specific component combinations to execute logic.
This pattern is fundamentally different from traditional object-oriented scene graphs (Unity GameObjects, Unreal Actors). It enables aggressive cache-friendly iteration over component arrays and makes it trivial to attach, detach, and hot-swap behaviors at runtime — which Vectra XR exploits heavily when transitioning between "scan mode" and "diagnostics mode" without destroying and rebuilding scene objects.
Jetpack Compose XR panels are attached to entities via the Panel component. Each panel hosts a standard Composable tree — the same code you'd write for a phone UI. The Compositor automatically reprojects the panel texture into the 3D scene at the correct depth and scale, handling all distortion correction for the Quest 3 optics.
VectraXrActivity.ktKOTLIN
1
class VectraXrActivity : ImmersiveActivity() {
2
3
privateval system bylazy { systemManager }
4
5
overridefun registerSystems() {
6
// Register Vectra custom systems before the SDK's built-in pass
Components in the Meta Spatial SDK are plain Kotlin data classes annotated with @Component. They must be registered before onSceneReady() fires or the system will throw a ComponentNotRegisteredException.
Components should be pure data containers — no logic, no coroutines. All mutation happens inside System classes via the EntityQuery DSL.
DiagnosticsState.ktKOTLIN
1
@Component
2
dataclass DiagnosticsState(
3
val connected: Boolean = false,
4
val batteryVoltage: Float = 0f, // Volts
5
val cellTemperature: Float = 0f, // Celsius
6
val lastScanTimestamp: Long = 0L,
7
val assetId: String? = null,
8
)
9
10
@Component
11
dataclass AssetBindingTag(
12
val metastrateHash: String = "",
13
val anchorPersistenceId: String = "",
14
)
15
16
// Marker component — zero-size, used purely for system queries
17
@Component
18
class ScannerActiveTag
Writing a Custom System
Systems are classes that extend System and implement execute(). The SDK calls execute() once per frame on the render thread. Avoid blocking I/O here — use SharedFlow collectors on a background dispatcher and write the result back to components atomically.
The EntityQuery builder lets you filter entities by component presence or absence. The has() predicate is the primary filter; without() excludes entities holding a given component type.
DiagnosticsStreamSystem.ktKOTLIN
1
class DiagnosticsStreamSystem : System() {
2
3
privateval query = EntityQuery.Builder()
4
.has(DiagnosticsState::class)
5
.has(AssetBindingTag::class)
6
.without(ScannerActiveTag::class)
7
.build()
8
9
// Pending updates from BLE coroutine — lock-free ringbuffer
// Drive UI panel visibility based on diagnostics connection state
22
query.forEach { entity ->
23
val state = entity.getComponent<DiagnosticsState>()
24
val panel = entity.getComponent<Panel>() ?: return@forEach
25
panel.isVisible = state.connected
26
entity.setComponent(panel)
27
}
28
}
29
30
fun enqueueUpdate(entityId: Long, state: DiagnosticsState) {
31
pendingUpdates.addLast(entityId to state)
32
}
33
}
Vision Pipeline
Vision Pipeline: CameraX & ML Kit
Passthrough camera access, barcode analysis, and real-time QR parsing
Vectra XR's primary input modality for asset identification is passive barcode and QR scanning via the Quest 3 passthrough cameras. The device exposes both left and right monochrome cameras to the Android CameraX API under the CAMERA permission group — no special Meta SDK permissions are required for basic capture; however high-framerate access above 30fps requires the com.oculus.permission.USE_SCENE permission declared in the manifest.
The vision pipeline is a three-stage process: (1) CameraX ImageAnalysis fires a Analyzer callback on a dedicated background Executor with each new frame; (2) the frame is passed as an InputImage to ML Kit's BarcodeScanning client; (3) on successful decode, the result is emitted on a SharedFlow that the DiagnosticsStreamSystem collects on the main thread.
Critically, the ML Kit scanner runs on-device with the bundled model — no network call is made during barcode processing. Latency from frame capture to Kotlin callback is consistently under 80ms on the XR2 Gen 3 SOC.
_scanResults.tryEmit(BarcodeResult(raw = raw, format = barcodes[0].format))
60
}
61
}
62
.addOnCompleteListener { imageProxy.close() }
63
}
64
}
65
66
dataclass BarcodeResult(val raw: String, val format: Int)
Manifest & Permissions
The Quest 3 passthrough cameras require explicit manifest declarations. The com.oculus.permission.USE_SCENE permission gates access to the scene understanding mesh; without it, CameraX will still bind but ImageAnalysis frames will be blank on Quest 3 firmware 65+.
BLE peripheral scanning, live sensor telemetry, and real-time state streams
Once a hardware asset is identified via the vision pipeline, Vectra XR attempts to establish a Bluetooth Low Energy (BLE) connection to the asset's embedded telemetry module — a Nordic nRF52840-based board running the Vectra Telemetry Firmware (VTF). The telemetry board broadcasts voltage, cell temperature, maintenance flags, and firmware version over three custom GATT characteristics under the Astra Matrix service UUID (0x4153).
The Android BLE stack is notoriously fragile; Vectra XR uses a state-machine-driven connection manager with exponential backoff, connection priority escalation (BluetoothGatt.CONNECTION_PRIORITY_HIGH), and MTU negotiation to 512 bytes. All GATT operations are serialized through a dedicated single-thread coroutine dispatcher to avoid the BluetoothGattCallback threading pitfalls that cause silent characteristic read failures.
Diagnostics data is modelled as a Kotlin Flow pipeline: the BLE GATT callback emits raw ByteArray payloads, a parser stage decodes the binary VTF protocol into typed DiagnosticsPayload objects, and a conflation stage collapses bursts into a single emission to prevent the Compose UI from re-composing at the raw 20Hz BLE notification rate.
BleConnectionManager.ktKOTLIN
1
class BleConnectionManager(privateval context: Context) {
2
3
companionobject {
4
val ASTRA_SERVICE_UUID: UUID = UUID.fromString("0000ffff-0000-1000-8000-00805f9b34fb")
5
val VOLTAGE_CHAR_UUID: UUID = UUID.fromString("00004153-0000-1000-8000-00805f9b34fb")
6
val TEMP_CHAR_UUID: UUID = UUID.fromString("00004154-0000-1000-8000-00805f9b34fb")
7
val FLAGS_CHAR_UUID: UUID = UUID.fromString("00004155-0000-1000-8000-00805f9b34fb")
8
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
fun disconnect() { gatt?.disconnect(); gatt?.close(); gatt = null }
71
}
72
73
dataclass DiagnosticsPayload(
74
val voltage: Float = 0f,
75
val temperatureCelsius: Float = 0f,
76
val maintenanceRequired: Boolean = false,
77
)
Composable Diagnostics Panel
The diagnostics data is consumed by a Jetpack Compose XR panel attached to a spatial anchor above the scanned physical hardware. The panel auto-sizes to 600×400dp and updates at a conflated rate so a BLE burst of 20 notifications in 50ms results in a single Composition, not 20.
DiagnosticsPanel.ktKOTLIN
1
@Composable
2
fun DiagnosticsPanel(viewModel: DiagnosticsViewModel = viewModel()) {
3
val payload by viewModel.telemetry.collectAsStateWithLifecycle()
Text(label, color = Color(0xFF00F0FF).copy(alpha = 0.5f), fontFamily = FontFamily.Monospace)
35
Text(
36
value,
37
color = if (nominal) Color(0xFF00F0FF) else Color(0xFFFF9900),
38
fontFamily = FontFamily.Monospace,
39
fontWeight = FontWeight.Bold,
40
)
41
}
42
}
Infrastructure Sync
Infrastructure Sync: Metastrate Substrate
Connecting Vectra XR to the immutable decentralized asset ledger
Metastrate is the distributed asset ledger that backs all Vectra XR physical-digital bindings. Every scan event, diagnostics snapshot, maintenance flag change, and location update is written as an immutable transaction to a permissioned ledger maintained across Astra Matrix's infrastructure nodes. The ledger is content-addressed: each asset state hash is derived from the previous state hash (Merkle-chain), making the audit trail cryptographically tamper-evident.
Vectra XR communicates with the Metastrate Substrate via a persistent WebSocket connection authenticated with a per-device Ed25519 key pair generated at first launch and bound to the device's Android Keystore. The private key never leaves the secure enclave. Each outbound message is signed; the Substrate validates the signature and rejects malformed or replayed messages using a monotonic sequence counter.
The Ktor WebSocket client on the device maintains the connection with a heartbeat interval of 30 seconds. On reconnect (network switch, sleep-wake cycle), a differential sync protocol catches the device up: the Substrate sends only the delta between the device's last acknowledged sequence and the current ledger head, minimizing bandwidth on the Quest's Wi-Fi 6E radio.
MetastrateClient.ktKOTLIN
1
class MetastrateClient(
2
privateval deviceId: String,
3
privateval keyStore: VectraKeyStore,
4
) {
5
companionobject {
6
private const val WS_ENDPOINT = "wss://substrate.astramatrix.io/v1/sync"
val type: String, // "SCAN" | "DIAGNOSTICS_UPDATE" | "MAINTENANCE_FLAG"
69
val assetId: String,
70
val payload: JsonObject,
71
)
72
73
@Serializable
74
dataclass LedgerEvent(
75
val sequence: Long,
76
val hash: String,
77
val previousHash: String,
78
val events: List<AssetEvent>,
79
)
Device Key Pair Generation
The Ed25519 signing key is generated in the Android Hardware-backed Keystore on first launch. The public key is registered with the Metastrate Substrate during device enrollment. The private key is marked StrongBoxBacked where the Qualcomm SPU (Secure Processing Unit) is available (Quest 3 supports this on firmware 65.0+).
VectraKeyStore.ktKOTLIN
1
class VectraKeyStore(context: Context) {
2
3
companionobject {
4
private const val KEY_ALIAS = "vectra_xr_substrate_signing_key"