Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/client-telemetry-native-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/react-native': minor
---

Client telemetry: `registerGlobals` hands `livekit-client`'s telemetry pipeline this platform's resource names, a write-ahead cache backed by a directory of files (`LKBatchStore.swift`, `BatchStore.kt`) so a session that ends with the app being killed replays at the next launch, and the device state a phone can see — app foreground/background, thermal state, low power mode and memory pressure — in the names SPEC uses. `disableTelemetry` is re-exported from `livekit-client`. Requires the first `livekit-client` release that ships the telemetry host seam (`configureTelemetryHost`); the `livekit-client` peer and dev minimum is raised to that release before this ships, and an older `livekit-client` loads without telemetry.
97 changes: 97 additions & 0 deletions android/src/main/java/com/livekit/reactnative/BatchStore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.livekit.reactnative

import android.content.Context
import java.io.File
import java.io.IOException

/**
* A directory of batches, mirroring the Rust core's `FileCache` so that a React Native app keeps
* the caching semantics an iOS or Android app gets: a batch is written before the network is
* tried, survives the process, and is removed only once the collector has taken it.
*
* Batch ids sort oldest-first as plain strings, so pruning never has to stat or parse anything.
* Writes go to a temporary name and are renamed into place, so a crash never leaves half a batch
* readable. Eviction is oldest-first above the byte, count and age budgets — and one batch always
* survives, because a cache that prunes itself empty is worse than one that is slightly too big.
*/
class BatchStore(
context: Context,
private val maxBytes: Long,
private val maxBatches: Int,
private val maxAgeMillis: Long,
) {
private val directory = File(context.cacheDir, "livekit-telemetry").apply { mkdirs() }

init {
directory.listFiles { file -> file.name.endsWith(".tmp") }?.forEach { it.delete() }
prune()
}

/**
* Stores a batch and returns the ids evicted to stay inside the budgets. Throws when the batch
* could not be written, so a caller never believes a lost batch is stored.
*/
@Throws(IOException::class)
fun put(id: String, body: ByteArray): List<String> {
val destination = file(id) ?: throw IOException("invalid batch id: $id")
// The system may clear cacheDir while the app is not running.
directory.mkdirs()
val temporary = File(directory, "$id.tmp")
try {
temporary.writeBytes(body)
if (!temporary.renameTo(destination)) {
throw IOException("could not move $temporary to $destination")
}
} catch (error: Exception) {
temporary.delete()
throw error
}
return prune()
}

fun pending(): List<String> =
directory.list()?.filterNot { it.endsWith(".tmp") }?.sorted() ?: emptyList()

fun read(id: String): ByteArray? = file(id)?.takeIf { it.isFile }?.readBytes()

fun remove(id: String) {
file(id)?.delete()
}

/** The batch's file, or null for an id that would escape the directory. */
private fun file(id: String): File? =
if (id.contains('/') || id.contains("..")) null else File(directory, id)

fun clear() {
pending().forEach { remove(it) }
}

/** Drops what is too old, then the oldest until the rest fits. Returns what it dropped. */
private fun prune(): List<String> {
val evicted = mutableListOf<String>()
val cutoff = System.currentTimeMillis() - maxAgeMillis
val kept = mutableListOf<Pair<String, Long>>()
var total = 0L

for (id in pending()) {
val file = File(directory, id)
if (file.lastModified() < cutoff) {
file.delete()
evicted.add(id)
continue
}
kept.add(id to file.length())
total += file.length()
}

var index = 0
while ((total > maxBytes || kept.size - index > maxBatches) && kept.size - index > 1) {
val (id, bytes) = kept[index]
remove(id)
evicted.add(id)
total -= bytes
index += 1
}
return evicted
}
}
115 changes: 115 additions & 0 deletions android/src/main/java/com/livekit/reactnative/DeviceStateMonitor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.livekit.reactnative

import android.content.BroadcastReceiver
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.os.Build
import android.os.PowerManager
import androidx.annotation.RequiresApi

/**
* Thermal state, power save mode and memory pressure: the three signals SPEC's cadence policy needs
* and no browser can answer. The pipeline never measures any of them itself — measuring CPU costs
* CPU — so each value here is the OS's own judgement, reported when it changes.
*
* Values are SPEC's names, not Android's, so that a record from Android and a record from iOS say
* the same thing.
*/
class DeviceStateMonitor(
private val context: Context,
private val onChange: (Map<String, Any>) -> Unit,
) {
companion object {
const val EVENT_NAME = "LK_DEVICE_STATE"

/** `PowerManager.THERMAL_STATUS_*` collapsed onto SPEC's four levels. */
fun thermalName(status: Int): String = when (status) {
PowerManager.THERMAL_STATUS_NONE -> "nominal"
PowerManager.THERMAL_STATUS_LIGHT -> "fair"
PowerManager.THERMAL_STATUS_MODERATE, PowerManager.THERMAL_STATUS_SEVERE -> "serious"
else -> "critical"
}

/**
* `onTrimMemory` levels as the Android SDK maps them: COMPLETE and RUNNING_CRITICAL are
* critical, every other trim is a warning. UI_HIDDEN is not a memory signal (it fires on
* every backgrounding) and maps to null. The OS never says pressure is over; JavaScript
* counts it as normal again when the app returns to the foreground.
*/
fun memoryName(level: Int): String? = when (level) {
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
-> "critical"
ComponentCallbacks2.TRIM_MEMORY_MODERATE,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE,
-> "warning"
else -> null
}
}

private val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager

// Lazy, and touched only behind the SDK_INT >= Q checks below: an eager initializer would
// load a class implementing an API 29 interface in the constructor, which fails to link on
// API 24-28 and takes the whole native module down with it.
@get:RequiresApi(Build.VERSION_CODES.Q)
private val thermalListener by lazy {
PowerManager.OnThermalStatusChangedListener { status ->
onChange(mapOf("thermal" to thermalName(status)))
}
}

private val powerSaveReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
onChange(mapOf("lowPower" to powerManager.isPowerSaveMode))
}
}

private val memoryCallbacks = object : ComponentCallbacks2 {
override fun onTrimMemory(level: Int) {
memoryName(level)?.let { onChange(mapOf("memory" to it)) }
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit

@Deprecated("Required by ComponentCallbacks2 below API 34")
override fun onLowMemory() = onChange(mapOf("memory" to "critical"))
}

private var started = false

fun start() {
if (started) return
started = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
powerManager.addThermalStatusListener(thermalListener)
}
context.registerReceiver(
powerSaveReceiver,
IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED),
)
context.applicationContext.registerComponentCallbacks(memoryCallbacks)
}

fun stop() {
if (!started) return
started = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
powerManager.removeThermalStatusListener(thermalListener)
}
runCatching { context.unregisterReceiver(powerSaveReceiver) }
context.applicationContext.unregisterComponentCallbacks(memoryCallbacks)
}

/** Everything that is a state rather than an edge, for the first report. */
fun snapshot(): Map<String, Any> = buildMap {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put("thermal", thermalName(powerManager.currentThermalStatus))
}
put("lowPower", powerManager.isPowerSaveMode)
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.livekit.reactnative

import android.media.AudioAttributes
import android.util.Base64
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableArray
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.livekit.reactnative.audio.AudioDeviceKind
import com.livekit.reactnative.audio.AudioManagerUtils
Expand All @@ -31,6 +33,92 @@ class LivekitReactNativeModule(reactContext: ReactApplicationContext) : ReactCon

val audioSinkManager = AudioSinkManager(reactContext)
val audioManager = AudioSwitchManager(reactContext.applicationContext)

/**
* Thermal state, power save mode and memory pressure in SPEC's names, for the cadence policy
* of `livekit-client`'s telemetry pipeline. Nothing is observed until `startDeviceStateUpdates`
* is called, and the observers stop when React Native invalidates the module.
*/
private val deviceStateMonitor = DeviceStateMonitor(reactContext.applicationContext) { change ->
// Delivered on the thermal or broadcast thread, so it can race invalidate().
if (!reactContext.hasActiveReactInstance()) return@DeviceStateMonitor
val payload = Arguments.createMap()
change.forEach { (key, value) ->
when (value) {
is Boolean -> payload.putBoolean(key, value)
else -> payload.putString(key, value.toString())
}
}
reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(DeviceStateMonitor.EVENT_NAME, payload)
}

/**
* The write-ahead cache `livekit-client`'s pipeline stores batches in. Synchronous, because its
* queue path has no await in it; base64 because the bridge does not carry bytes.
*
* SPEC's budget: 4 MiB across at most 512 batches, nothing older than a day.
*/
private val batchStore by lazy {
BatchStore(reactContext.applicationContext, 4L * 1024 * 1024, 512, 24L * 60 * 60 * 1000)
}

/**
* Answers null, never an empty list, when the batch could not be written; JavaScript turns that
* into a thrown error so the pipeline counts the loss.
*/
@ReactMethod(isBlockingSynchronousMethod = true)
fun batchStorePut(id: String, body: String): WritableArray? {
val evicted = try {
batchStore.put(id, Base64.decode(body, Base64.NO_WRAP))
} catch (error: Exception) {
return null
}
val out = Arguments.createArray()
evicted.forEach { out.pushString(it) }
return out
}

@ReactMethod(isBlockingSynchronousMethod = true)
fun batchStorePending(): WritableArray {
val out = Arguments.createArray()
batchStore.pending().forEach { out.pushString(it) }
return out
}

@ReactMethod(isBlockingSynchronousMethod = true)
fun batchStoreRead(id: String): String? =
batchStore.read(id)?.let { Base64.encodeToString(it, Base64.NO_WRAP) }

@ReactMethod(isBlockingSynchronousMethod = true)
fun batchStoreRemove(id: String): Boolean {
batchStore.remove(id)
return true
}

@ReactMethod(isBlockingSynchronousMethod = true)
fun batchStoreClear(): Boolean {
batchStore.clear()
return true
}

/**
* The first state comes back on the promise rather than as an event — an event sent from inside
* this call would race the listener JS is registering around it, and be dropped.
*/
@ReactMethod
fun startDeviceStateUpdates(promise: Promise) {
deviceStateMonitor.start()
val snapshot = Arguments.createMap()
deviceStateMonitor.snapshot().forEach { (key, value) ->
when (value) {
is Boolean -> snapshot.putBoolean(key, value)
else -> snapshot.putString(key, value.toString())
}
}
promise.resolve(snapshot)
}

override fun getName(): String {
return "LivekitReactNativeModule"
}
Expand Down Expand Up @@ -230,6 +318,7 @@ class LivekitReactNativeModule(reactContext: ReactApplicationContext) : ReactCon
}

override fun invalidate() {
deviceStateMonitor.stop()
LiveKitReactNative.invalidate(reactApplicationContext)
}
}
Loading
Loading