VECTRA XR/Introduction & Setup
GITHUB

DEVELOPER DOCUMENTATION

VECTRA XR SDK

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.

terminalBASH
1# Clone the Vectra XR development template
2git clone https://github.com/Astra-Matrix/vectra-xr-template.git
3cd vectra-xr-template
4
5# Sync Gradle dependencies (requires JDK 17+)
6./gradlew dependencies --configuration releaseRuntimeClasspath
build.gradle.kts (app)KOTLIN
1plugins {
2 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.android)
4 alias(libs.plugins.kotlin.compose)
5}
6
7android {
8 compileSdk = 36
9 defaultConfig {
10 applicationId = "com.astramatrix.vectraxr"
11 minSdk = 32 // Quest 3 minimum
12 targetSdk = 36
13 versionCode = 24
14 versionName = "2.4.1"
15 }
16 buildFeatures { compose = true }
17 composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }
18}
19
20dependencies {
21 // Meta Spatial SDK — core runtime + scene graph
22 implementation("com.meta.spatial:meta-spatial-sdk:0.5.2")
23 implementation("com.meta.spatial:meta-spatial-sdk-compose:0.5.2")
24 implementation("com.meta.spatial:meta-spatial-sdk-physics:0.5.2")
25
26 // Jetpack Compose XR
27 implementation(platform("androidx.compose:compose-bom:2024.09.00"))
28 implementation("androidx.compose.ui:ui")
29 implementation("androidx.compose.material3:material3")
30 implementation("androidx.xr.compose:compose:1.0.0-alpha01")
31
32 // Vision pipeline
33 implementation("androidx.camera:camera-camera2:1.3.4")
34 implementation("androidx.camera:camera-lifecycle:1.3.4")
35 implementation("com.google.mlkit:barcode-scanning:17.3.0")
36
37 // Async / networking
38 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
39 implementation("io.ktor:ktor-client-okhttp:2.3.12")
40 implementation("io.ktor:ktor-client-websockets:2.3.12")
41}

Prerequisites

Android Studio Ladybug (2024.2) or later with the Android XR SDK plugin installed.

Meta Quest Developer Hub 5.x connected to a Quest 3 device over USB-C or Wi-Fi ADB.

A Meta Developer account with the application registered in the Meta Quest Developer Dashboard to receive a valid App ID for the Spatial SDK runtime.

JDK 17 is required. The build will fail on JDK 21 due to an incompatibility in the Meta Spatial SDK's native code generator.

terminalBASH
1# Verify toolchain
2java -version # Must be 17.x
3adb devices # Quest 3 must appear as 'device', not 'unauthorized'
4
5# Enable developer mode on device via Meta Quest mobile app:
6# Settings → Developer → USB Connection Dialog → Always allow

Core Architecture

Core Architecture: ECS & Compose XR

How the Entity-Component-System scene graph powers the Vectra XR spatial canvas

DATA FLOW · VECTRA XR PIPELINE

Physical Scan

QR / Barcode on hardware asset

01

CameraX Analyzer

YUV_420_888 frame pipeline at 30fps

02

ML Kit Barcode Engine

On-device decode · < 80ms · no network

03

ViewModel State

Kotlin StateFlow · ECS component write

04

Spatial Anchor

Meta Scene API · sub-centimeter persistence

05

Compose XR Panel

Jetpack Compose XR · Compositor layer

06

Astra Matrix Backend

Metastrate Substrate · WebSocket sync

07
FRAME → DECODE:< 80ms
DECODE → ANCHOR:< 20ms
ANCHOR → COMPOSE:< 8ms
COMPOSE → SUBSTRATE:< 120ms

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
1class VectraXrActivity : ImmersiveActivity() {
2
3 private val system by lazy { systemManager }
4
5 override fun registerSystems() {
6 // Register Vectra custom systems before the SDK's built-in pass
7 system.registerSystem(DiagnosticsStreamSystem())
8 system.registerSystem(SpatialAnchorBindingSystem())
9 system.registerSystem(LedgerSyncSystem())
10 }
11
12 override fun registerComponents() {
13 ComponentManager.registerComponent<DiagnosticsState>()
14 ComponentManager.registerComponent<AssetBindingTag>()
15 ComponentManager.registerComponent<ScannerActiveTag>()
16 }
17
18 override fun onSceneReady() {
19 // Bootstrap the main diagnostics panel entity
20 Entity.create(
21 Panel(R.xml.diagnostics_panel_config),
22 Transform(Pose(Vector3(0f, 1.6f, -0.8f))),
23 DiagnosticsState(connected = false),
24 )
25 }
26}

Defining Custom Components

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
2data class 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
11data class AssetBindingTag(
12 val metastrateHash: String = "",
13 val anchorPersistenceId: String = "",
14)
15
16// Marker component — zero-size, used purely for system queries
17@Component
18class 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
1class DiagnosticsStreamSystem : System() {
2
3 private val 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
10 private val pendingUpdates = ArrayDeque<Pair<Long, DiagnosticsState>>()
11
12 override fun execute() {
13 // Drain pending BLE state updates into the ECS
14 val now = System.nanoTime()
15 while (pendingUpdates.isNotEmpty()) {
16 val (entityId, state) = pendingUpdates.removeFirst()
17 val entity = Entity(entityId) ?: continue
18 entity.setComponent(state.copy(lastScanTimestamp = now))
19 }
20
21 // 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.

VisionPipelineManager.ktKOTLIN
1class VisionPipelineManager(
2 private val context: Context,
3 private val lifecycleOwner: LifecycleOwner,
4) {
5 private val _scanResults = MutableSharedFlow<BarcodeResult>(
6 extraBufferCapacity = 8,
7 onBufferOverflow = BufferOverflow.DROP_OLDEST,
8 )
9 val scanResults: SharedFlow<BarcodeResult> = _scanResults.asSharedFlow()
10
11 private val barcodeOptions = BarcodeScannerOptions.Builder()
12 .setBarcodeFormats(
13 Barcode.FORMAT_QR_CODE,
14 Barcode.FORMAT_DATA_MATRIX,
15 Barcode.FORMAT_CODE_128,
16 )
17 .build()
18
19 private val scanner = BarcodeScanning.getClient(barcodeOptions)
20 private val analysisExecutor = Executors.newSingleThreadExecutor()
21
22 fun bindCamera() {
23 val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
24 cameraProviderFuture.addListener({
25 val provider = cameraProviderFuture.get()
26
27 val imageAnalysis = ImageAnalysis.Builder()
28 .setTargetResolution(Size(1280, 720))
29 .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
30 .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
31 .build()
32 .also { analysis ->
33 analysis.setAnalyzer(analysisExecutor) { imageProxy ->
34 processFrame(imageProxy)
35 }
36 }
37
38 provider.bindToLifecycle(
39 lifecycleOwner,
40 CameraSelector.DEFAULT_BACK_CAMERA,
41 imageAnalysis,
42 )
43 }, ContextCompat.getMainExecutor(context))
44 }
45
46 private fun processFrame(imageProxy: ImageProxy) {
47 val mediaImage = imageProxy.image ?: run {
48 imageProxy.close()
49 return
50 }
51 val inputImage = InputImage.fromMediaImage(
52 mediaImage,
53 imageProxy.imageInfo.rotationDegrees,
54 )
55
56 scanner.process(inputImage)
57 .addOnSuccessListener { barcodes ->
58 barcodes.firstOrNull()?.rawValue?.let { raw ->
59 _scanResults.tryEmit(BarcodeResult(raw = raw, format = barcodes[0].format))
60 }
61 }
62 .addOnCompleteListener { imageProxy.close() }
63 }
64}
65
66data class 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+.

AndroidManifest.xmlXML / YAML
1<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
3 <uses-permission android:name="android.permission.CAMERA" />
4 <uses-permission android:name="com.oculus.permission.USE_SCENE" />
5 <uses-permission android:name="com.oculus.permission.EYE_TRACKING" />
6 <uses-permission android:name="com.oculus.permission.HAND_TRACKING" />
7
8 <!-- Declare Quest 3 as the minimum target device -->
9 <uses-feature
10 android:name="android.hardware.vr.headtracking"
11 android:required="true"
12 android:version="1" />
13
14 <application
15 android:label="Vectra XR"
16 android:icon="@mipmap/ic_launcher">
17
18 <activity
19 android:name=".VectraXrActivity"
20 android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"
21 android:screenOrientation="landscape"
22 android:exported="true">
23
24 <intent-filter>
25 <action android:name="android.intent.action.MAIN" />
26 <category android:name="android.intent.category.LAUNCHER" />
27 <category android:name="com.oculus.intent.category.VR" />
28 </intent-filter>
29 </activity>
30 </application>
31</manifest>

Diagnostics & Hardware Tracking

Diagnostics & Hardware Tracking

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
1class BleConnectionManager(private val context: Context) {
2
3 companion object {
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")
9 }
10
11 private val _telemetry = MutableStateFlow<DiagnosticsPayload?>(null)
12 val telemetry: StateFlow<DiagnosticsPayload?> = _telemetry.asStateFlow()
13
14 private val bleDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
15 private var gatt: BluetoothGatt? = null
16
17 suspend fun connect(device: BluetoothDevice) = withContext(bleDispatcher) {
18 gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
19
20 override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
21 if (newState == BluetoothProfile.STATE_CONNECTED) {
22 g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
23 g.requestMtu(512)
24 }
25 }
26
27 override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
28 if (status == BluetoothGatt.GATT_SUCCESS) g.discoverServices()
29 }
30
31 override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
32 val service = g.getService(ASTRA_SERVICE_UUID) ?: return
33 subscribeToCharacteristic(g, service.getCharacteristic(VOLTAGE_CHAR_UUID))
34 subscribeToCharacteristic(g, service.getCharacteristic(TEMP_CHAR_UUID))
35 subscribeToCharacteristic(g, service.getCharacteristic(FLAGS_CHAR_UUID))
36 }
37
38 override fun onCharacteristicChanged(
39 g: BluetoothGatt,
40 characteristic: BluetoothGattCharacteristic,
41 value: ByteArray,
42 ) {
43 val current = _telemetry.value ?: DiagnosticsPayload()
44 _telemetry.value = when (characteristic.uuid) {
45 VOLTAGE_CHAR_UUID -> current.copy(
46 voltage = ByteBuffer.wrap(value).float
47 )
48 TEMP_CHAR_UUID -> current.copy(
49 temperatureCelsius = ByteBuffer.wrap(value).float
50 )
51 FLAGS_CHAR_UUID -> current.copy(
52 maintenanceRequired = value[0] != 0.toByte()
53 )
54 else -> current
55 }
56 }
57 }, BluetoothDevice.TRANSPORT_LE)
58 }
59
60 private fun subscribeToCharacteristic(
61 gatt: BluetoothGatt,
62 char: BluetoothGattCharacteristic?,
63 ) {
64 char ?: return
65 gatt.setCharacteristicNotification(char, true)
66 val cccd = char.getDescriptor(CCCD_UUID)
67 gatt.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
68 }
69
70 fun disconnect() { gatt?.disconnect(); gatt?.close(); gatt = null }
71}
72
73data class 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
2fun DiagnosticsPanel(viewModel: DiagnosticsViewModel = viewModel()) {
3 val payload by viewModel.telemetry.collectAsStateWithLifecycle()
4
5 VectraTheme {
6 Column(
7 modifier = Modifier
8 .fillMaxSize()
9 .background(Color(0xFF121214))
10 .padding(24.dp),
11 verticalArrangement = Arrangement.spacedBy(16.dp),
12 ) {
13 TelemetryRow(
14 label = "VOLTAGE",
15 value = "${"%.2f".format(payload?.voltage ?: 0f)} V",
16 nominal = (payload?.voltage ?: 0f) in 3.2f..4.2f,
17 )
18 TelemetryRow(
19 label = "TEMPERATURE",
20 value = "${"%.1f".format(payload?.temperatureCelsius ?: 0f)} °C",
21 nominal = (payload?.temperatureCelsius ?: 0f) < 45f,
22 )
23 MaintenanceFlag(active = payload?.maintenanceRequired == true)
24 }
25 }
26}
27
28@Composable
29private fun TelemetryRow(label: String, value: String, nominal: Boolean) {
30 Row(
31 modifier = Modifier.fillMaxWidth(),
32 horizontalArrangement = Arrangement.SpaceBetween,
33 ) {
34 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
1class MetastrateClient(
2 private val deviceId: String,
3 private val keyStore: VectraKeyStore,
4) {
5 companion object {
6 private const val WS_ENDPOINT = "wss://substrate.astramatrix.io/v1/sync"
7 private const val HEARTBEAT_INTERVAL_MS = 30_000L
8 }
9
10 private val httpClient = HttpClient(OkHttp) {
11 install(WebSockets) {
12 pingInterval = HEARTBEAT_INTERVAL_MS
13 }
14 install(ContentNegotiation) { json() }
15 }
16
17 private val _ledgerEvents = MutableSharedFlow<LedgerEvent>(replay = 0)
18 val ledgerEvents: SharedFlow<LedgerEvent> = _ledgerEvents.asSharedFlow()
19
20 private var sequenceCounter = AtomicLong(0L)
21
22 suspend fun connect() {
23 val lastAck = getLastAcknowledgedSequence()
24
25 httpClient.webSocket(
26 method = HttpMethod.Get,
27 host = "substrate.astramatrix.io",
28 path = "/v1/sync?deviceId=$deviceId&since=$lastAck",
29 request = { header("X-Device-Id", deviceId) },
30 ) {
31 // Receive delta events from ledger head
32 launch {
33 for (frame in incoming) {
34 if (frame is Frame.Text) {
35 val event = Json.decodeFromString<LedgerEvent>(frame.readText())
36 _ledgerEvents.emit(event)
37 persistLastAck(event.sequence)
38 }
39 }
40 }
41 }
42 }
43
44 suspend fun publishAssetEvent(event: AssetEvent) {
45 val seq = sequenceCounter.incrementAndGet()
46 val payload = AssetEventPayload(
47 deviceId = deviceId,
48 sequence = seq,
49 timestamp = System.currentTimeMillis(),
50 event = event,
51 )
52 val signature = keyStore.sign(Json.encodeToString(payload).toByteArray())
53 val signed = SignedPayload(payload = payload, signature = signature.encodeBase64())
54 // enqueue for WebSocket send on the active session
55 outboundQueue.send(signed)
56 }
57
58 private fun getLastAcknowledgedSequence(): Long =
59 // Read from encrypted SharedPreferences backed by Android Keystore
60 sharedPrefs.getLong("metastrate_last_ack", 0L)
61
62 private fun persistLastAck(seq: Long) =
63 sharedPrefs.edit().putLong("metastrate_last_ack", seq).apply()
64}
65
66@Serializable
67data class AssetEvent(
68 val type: String, // "SCAN" | "DIAGNOSTICS_UPDATE" | "MAINTENANCE_FLAG"
69 val assetId: String,
70 val payload: JsonObject,
71)
72
73@Serializable
74data class 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
1class VectraKeyStore(context: Context) {
2
3 companion object {
4 private const val KEY_ALIAS = "vectra_xr_substrate_signing_key"
5 }
6
7 private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
8
9 fun getOrCreateSigningKey(): KeyPair {
10 if (!keyStore.containsAlias(KEY_ALIAS)) {
11 val spec = KeyPairGeneratorSpec.Builder(context)
12 .setAlias(KEY_ALIAS)
13 .setKeyType("EC")
14 .setKeySize(256)
15 .setDigests(KeyProperties.DIGEST_SHA256)
16 .setIsStrongBoxBacked(true) // Uses Qualcomm SPU if available
17 .build()
18 KeyPairGenerator.getInstance("EC", "AndroidKeyStore")
19 .apply { initialize(spec) }
20 .generateKeyPair()
21 }
22 return KeyPair(
23 keyStore.getCertificate(KEY_ALIAS).publicKey,
24 keyStore.getKey(KEY_ALIAS, null) as PrivateKey,
25 )
26 }
27
28 fun sign(data: ByteArray): ByteArray {
29 val key = keyStore.getKey(KEY_ALIAS, null) as PrivateKey
30 return Signature.getInstance("SHA256withECDSA").run {
31 initSign(key)
32 update(data)
33 sign()
34 }
35 }
36}