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
8 changes: 8 additions & 0 deletions .changeset/add_device_info_crate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
livekit: patch
livekit-api: patch
livekit-ffi: patch
device-info: patch
---

Add device-info crate and send device_info to telemetry - #982 (@maxheimbrock)
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ members = [
"soxr-sys",
"yuv-sys",
"imgproc",
"device-info",
"webrtc-sys",
"webrtc-sys/build",

Expand Down Expand Up @@ -41,6 +42,7 @@ repository = "https://github.com/livekit/rust-sdks"
license = "Apache-2.0"

[workspace.dependencies]
device-info = { version = "0.1.0", path = "device-info" }
imgproc = { version = "0.3.19", path = "imgproc" }
libwebrtc = { version = "0.3.29", path = "libwebrtc" }
livekit = { version = "0.7.36", path = "livekit" }
Expand Down
Empty file added device-info/CHANGELOG.md
Empty file.
29 changes: 29 additions & 0 deletions device-info/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "device-info"
version = "0.1.0"
edition.workspace = true
repository.workspace = true
license.workspace = true

[dependencies]
thiserror = { workspace = true }

[target.'cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "watchos", target_os = "linux"))'.dependencies]
libc = "0.2"

[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"

[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.59", features = [
"Win32_System_Registry",
"Win32_System_SystemInformation",
"Win32_System_Power",
] }

[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"

[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Navigator", "Window"] }
wasm-bindgen = "0.2"
126 changes: 126 additions & 0 deletions device-info/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright 2025 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::fmt;

#[cfg_attr(target_arch = "wasm32", path = "web/mod.rs")]
#[cfg_attr(not(target_arch = "wasm32"), path = "native/mod.rs")]
mod imp;

#[cfg(target_os = "android")]
pub use imp::android;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DeviceType {
Desktop,
Laptop,
Phone,
Tablet,
Headset,
Television,
Watch,
Unknown,
}

impl fmt::Display for DeviceType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DeviceType::Desktop => write!(f, "Desktop"),
DeviceType::Laptop => write!(f, "Laptop"),
DeviceType::Phone => write!(f, "Phone"),
DeviceType::Tablet => write!(f, "Tablet"),
DeviceType::Headset => write!(f, "Headset"),
DeviceType::Television => write!(f, "Television"),
DeviceType::Watch => write!(f, "Watch"),
DeviceType::Unknown => write!(f, "Unknown"),
}
}
}

#[derive(Debug, Clone)]
pub struct DeviceInfo {
pub model: String,
pub name: String,
pub device_type: DeviceType,
}

#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DeviceInfoError {
#[error("platform not supported")]
Unsupported,
#[error("failed to query device info: {0}")]
Query(String),
#[cfg(target_os = "android")]
#[error("android JNI not initialized — call device_info::android::init() first")]
NotInitialized,
#[cfg(target_os = "android")]
#[error("JNI error: {0}")]
Jni(String),
}

/// Query device model, name, and type for the current platform.
///
/// This function is safe to call from any thread.
pub fn device_info() -> Result<DeviceInfo, DeviceInfoError> {
imp::device_info()
}

// Compile-time assertions: DeviceInfo and DeviceInfoError must be Send + Sync.
const _: () = {
fn assert_send_sync<T: Send + Sync>() {}
fn assert_all() {
assert_send_sync::<DeviceInfo>();
assert_send_sync::<DeviceInfoError>();
}
};

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_device_info() {
let info = device_info().expect("device_info() should succeed");
assert!(!info.model.is_empty(), "model should not be empty");
assert!(!info.name.is_empty(), "name should not be empty");
println!("model: {}", info.model);
println!("name: {}", info.name);
println!("type: {}", info.device_type);
}

#[test]
fn test_device_info_from_multiple_threads() {
let handles: Vec<_> = (0..4)
.map(|_| std::thread::spawn(|| device_info().expect("device_info() should succeed")))
.collect();
for handle in handles {
let info = handle.join().expect("thread should not panic");
assert!(!info.model.is_empty());
}
}

#[test]
fn test_device_type_display() {
assert_eq!(DeviceType::Desktop.to_string(), "Desktop");
assert_eq!(DeviceType::Laptop.to_string(), "Laptop");
assert_eq!(DeviceType::Phone.to_string(), "Phone");
assert_eq!(DeviceType::Tablet.to_string(), "Tablet");
assert_eq!(DeviceType::Headset.to_string(), "Headset");
assert_eq!(DeviceType::Television.to_string(), "Television");
assert_eq!(DeviceType::Watch.to_string(), "Watch");
assert_eq!(DeviceType::Unknown.to_string(), "Unknown");
}
}
145 changes: 145 additions & 0 deletions device-info/src/native/android.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright 2025 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use crate::{DeviceInfo, DeviceInfoError, DeviceType};
use jni::objects::{GlobalRef, JObject, JValue};
use jni::JavaVM;
use std::sync::OnceLock;

static ANDROID_VM: OnceLock<JavaVM> = OnceLock::new();
static ANDROID_CONTEXT: OnceLock<GlobalRef> = OnceLock::new();

/// Initialize with just the JavaVM. Sufficient for reading `Build.*` fields (model,
/// manufacturer). Call from `JNI_OnLoad` where no application `Context` is available.
pub fn init_vm(vm: &JavaVM) {
let vm = unsafe { JavaVM::from_raw(vm.get_java_vm_pointer()).unwrap() };
let _ = ANDROID_VM.set(vm);
}

/// Initialize with both the JavaVM and an application `Context`.
/// Provides full functionality including device name resolution via `Settings.Global`.
pub fn init(vm: &JavaVM, context: JObject) {
let vm = unsafe { JavaVM::from_raw(vm.get_java_vm_pointer()).unwrap() };
let mut env = vm.get_env().expect("failed to get JNI env");
let context = env.new_global_ref(context).expect("failed to create global ref");
let _ = ANDROID_VM.set(vm);
let _ = ANDROID_CONTEXT.set(context);
}

pub fn device_info() -> Result<DeviceInfo, DeviceInfoError> {
let vm = ANDROID_VM.get().ok_or(DeviceInfoError::NotInitialized)?;

let mut env = vm.attach_current_thread().map_err(|e| DeviceInfoError::Jni(e.to_string()))?;

let model = get_build_field(&mut env, "MODEL")?;
let manufacturer = get_build_field(&mut env, "MANUFACTURER")?;
let name = ANDROID_CONTEXT
.get()
.and_then(|ctx| get_device_name(&mut env, ctx).ok())
.unwrap_or_else(|| model.clone());
let device_type = detect_device_type(&manufacturer);

Ok(DeviceInfo { model, name, device_type })
}

fn get_build_field(env: &mut jni::JNIEnv, field: &str) -> Result<String, DeviceInfoError> {
let build_class = env
.find_class("android/os/Build")
.map_err(|e| DeviceInfoError::Jni(format!("find Build class: {e}")))?;

let value = env
.get_static_field(build_class, field, "Ljava/lang/String;")
.map_err(|e| DeviceInfoError::Jni(format!("get Build.{field}: {e}")))?
.l()
.map_err(|e| DeviceInfoError::Jni(format!("Build.{field} is not an Object: {e}")))?;

let jstring: jni::objects::JString = value.into();
let rust_str = env
.get_string(&jstring)
.map_err(|e| DeviceInfoError::Jni(format!("get string Build.{field}: {e}")))?;

Ok(rust_str.into())
}

fn get_device_name(env: &mut jni::JNIEnv, context: &GlobalRef) -> Result<String, DeviceInfoError> {
let content_resolver = env
.call_method(
context.as_obj(),
"getContentResolver",
"()Landroid/content/ContentResolver;",
&[],
)
.map_err(|e| DeviceInfoError::Jni(format!("getContentResolver: {e}")))?
.l()
.map_err(|e| DeviceInfoError::Jni(format!("getContentResolver result: {e}")))?;

// Try Settings.Global "device_name" first, then fall back to "bluetooth_name".
// Neither is guaranteed to exist on all devices/manufacturers.
for key_name in &["device_name", "bluetooth_name"] {
if let Some(name) = get_settings_string(env, &content_resolver, key_name)? {
if !name.is_empty() {
return Ok(name);
}
}
}

Err(DeviceInfoError::Query("device name not available".into()))
}

fn get_settings_string(
env: &mut jni::JNIEnv,
content_resolver: &JObject,
key_name: &str,
) -> Result<Option<String>, DeviceInfoError> {
let settings_class = env
.find_class("android/provider/Settings$Global")
.map_err(|e| DeviceInfoError::Jni(format!("find Settings.Global: {e}")))?;

let key =
env.new_string(key_name).map_err(|e| DeviceInfoError::Jni(format!("new_string: {e}")))?;

let result = env
.call_static_method(
settings_class,
"getString",
"(Landroid/content/ContentResolver;Ljava/lang/String;)Ljava/lang/String;",
&[JValue::Object(content_resolver), JValue::Object(&key)],
)
.map_err(|e| DeviceInfoError::Jni(format!("Settings.Global.getString({key_name}): {e}")))?
.l()
.map_err(|e| DeviceInfoError::Jni(format!("getString result: {e}")))?;

if result.is_null() {
return Ok(None);
}

let jstring = jni::objects::JString::from(result);
let rust_str: String = env
.get_string(&jstring)
.map_err(|e| DeviceInfoError::Jni(format!("get string {key_name}: {e}")))?
.into();

Ok(Some(rust_str))
}

fn detect_device_type(manufacturer: &str) -> DeviceType {
let m = manufacturer.to_lowercase();
if m.contains("meta") || m.contains("oculus") {
DeviceType::Headset
} else {
// Default to phone for Android devices; a more precise detection
// would require checking screen configuration via JNI.
DeviceType::Phone
}
}
Loading
Loading