1use crate::TelemetryEvent;
16
17#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum ThermalState {
22 #[default]
24 Unknown,
25 Nominal,
27 Fair,
29 Serious,
31 Critical,
33}
34
35#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum AppState {
39 #[default]
41 Foreground,
42 Background,
44}
45
46#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum MemoryPressure {
51 #[default]
53 Normal,
54 Warning,
56 Critical,
58}
59
60#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum NetworkType {
64 #[default]
66 Unknown,
67 Wifi,
69 Cell,
71 Wired,
73 Vpn,
75 Bluetooth,
77 Other,
80 Unavailable,
82}
83
84#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub struct DeviceState {
96 pub thermal: ThermalState,
98 #[cfg_attr(feature = "uniffi", uniffi(default))]
101 pub low_power_mode: Option<bool>,
102 pub app_state: AppState,
104 pub memory: MemoryPressure,
107 pub network: NetworkType,
109 #[cfg_attr(feature = "uniffi", uniffi(default = false))]
111 pub network_expensive: bool,
112 #[cfg_attr(feature = "uniffi", uniffi(default = false))]
115 pub network_constrained: bool,
116 #[cfg_attr(feature = "uniffi", uniffi(default))]
118 pub battery_level: Option<u32>,
119 #[cfg_attr(feature = "uniffi", uniffi(default = false))]
121 pub battery_charging: bool,
122}
123
124pub(crate) const MAX_CADENCE_FACTOR: u32 = 4;
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129enum BatteryBucket {
130 Fine,
132 Low,
134 NearlyEmpty,
136}
137
138impl DeviceState {
139 const BATTERY_HOLD_PERCENT: u32 = 10;
141 const BATTERY_LOW_PERCENT: u32 = 20;
143
144 pub(crate) fn cadence_factor(&self) -> u32 {
149 let thermal = match self.thermal {
150 ThermalState::Unknown | ThermalState::Nominal | ThermalState::Fair => 1,
151 ThermalState::Serious => 2,
152 ThermalState::Critical => 4,
153 };
154 let memory = match self.memory {
155 MemoryPressure::Normal => 1,
156 MemoryPressure::Warning => 2,
157 MemoryPressure::Critical => 4,
158 };
159 let doubled = [
160 self.low_power_mode == Some(true),
161 self.app_state == AppState::Background,
162 self.battery_bucket() != BatteryBucket::Fine,
163 self.network_constrained,
164 ]
165 .iter()
166 .filter(|&&on| on)
167 .count() as u32;
168 ((thermal * memory) << doubled).min(MAX_CADENCE_FACTOR)
169 }
170
171 pub(crate) fn holds_uploads(&self) -> bool {
176 self.network_constrained || self.battery_bucket() == BatteryBucket::NearlyEmpty
177 }
178
179 fn battery_bucket(&self) -> BatteryBucket {
181 match self.battery_level {
182 Some(level) if !self.battery_charging && level <= Self::BATTERY_HOLD_PERCENT => {
183 BatteryBucket::NearlyEmpty
184 }
185 Some(level) if !self.battery_charging && level <= Self::BATTERY_LOW_PERCENT => {
186 BatteryBucket::Low
187 }
188 _ => BatteryBucket::Fine,
189 }
190 }
191
192 pub(crate) fn change_events(&self, previous: Option<&DeviceState>) -> Vec<TelemetryEvent> {
198 let mut events = Vec::new();
199 if self.thermal != ThermalState::Unknown
201 && previous.is_none_or(|p| p.thermal != self.thermal)
202 {
203 events.push(
204 TelemetryEvent::new("lk.device.thermal.changed")
205 .with_body(format!("thermal: {}", self.thermal.as_str()))
206 .with_attribute("lk.device.thermal.state", self.thermal.as_str()),
207 );
208 }
209 if let Some(low_power) = self
210 .low_power_mode
211 .filter(|_| previous.is_none_or(|p| p.low_power_mode != self.low_power_mode))
212 {
213 events.push(
214 TelemetryEvent::new("lk.device.low_power.changed")
215 .with_body(format!("low power mode: {}", if low_power { "on" } else { "off" }))
216 .with_attribute("lk.device.low_power.enabled", low_power),
217 );
218 }
219 if previous.is_none_or(|p| p.app_state != self.app_state) {
220 events.push(
221 TelemetryEvent::new("lk.device.app_state.changed")
222 .with_body(format!("app: {}", self.app_state.as_str()))
223 .with_attribute("lk.device.app_state", self.app_state.as_str()),
224 );
225 }
226 if previous.is_none_or(|p| p.memory != self.memory) {
227 events.push(
228 TelemetryEvent::new("lk.device.memory.changed")
229 .with_body(format!("memory pressure: {}", self.memory.as_str()))
230 .with_attribute("lk.device.memory.pressure", self.memory.as_str()),
231 );
232 }
233 if previous.is_none_or(|p| {
234 (p.network, p.network_expensive, p.network_constrained)
235 != (self.network, self.network_expensive, self.network_constrained)
236 }) {
237 events.push(
238 TelemetryEvent::new("lk.device.network.changed")
239 .with_body(format!(
240 "network: {}{}{}",
241 self.network.as_str(),
242 if self.network_expensive { ", expensive" } else { "" },
243 if self.network_constrained { ", constrained" } else { "" }
244 ))
245 .with_attribute("network.connection.type", self.network.as_str())
246 .with_attribute("lk.device.network.expensive", self.network_expensive)
247 .with_attribute("lk.device.network.constrained", self.network_constrained),
248 );
249 }
250 if let Some(level) = self.battery_level.filter(|_| {
252 previous.is_none_or(|p| {
253 p.battery_charging != self.battery_charging
254 || p.battery_bucket() != self.battery_bucket()
255 || p.battery_level.is_none()
256 })
257 }) {
258 events.push(
259 TelemetryEvent::new("lk.device.battery.changed")
260 .with_body(format!(
261 "battery: {level}%{}",
262 if self.battery_charging { ", charging" } else { "" }
263 ))
264 .with_attribute("lk.device.battery.level", i64::from(level))
265 .with_attribute("lk.device.battery.charging", self.battery_charging),
266 );
267 }
268 events
269 }
270}
271
272impl ThermalState {
273 fn as_str(self) -> &'static str {
274 match self {
275 ThermalState::Unknown => "unknown",
276 ThermalState::Nominal => "nominal",
277 ThermalState::Fair => "fair",
278 ThermalState::Serious => "serious",
279 ThermalState::Critical => "critical",
280 }
281 }
282}
283
284impl AppState {
285 fn as_str(self) -> &'static str {
286 match self {
287 AppState::Foreground => "foreground",
288 AppState::Background => "background",
289 }
290 }
291}
292
293impl MemoryPressure {
294 fn as_str(self) -> &'static str {
295 match self {
296 MemoryPressure::Normal => "normal",
297 MemoryPressure::Warning => "warning",
298 MemoryPressure::Critical => "critical",
299 }
300 }
301}
302
303impl NetworkType {
304 fn as_str(self) -> &'static str {
305 match self {
306 NetworkType::Unknown => "unknown",
307 NetworkType::Wifi => "wifi",
308 NetworkType::Cell => "cell",
309 NetworkType::Wired => "wired",
310 NetworkType::Vpn => "vpn",
311 NetworkType::Bluetooth => "bluetooth",
312 NetworkType::Other => "other",
313 NetworkType::Unavailable => "unavailable",
314 }
315 }
316}
317
318#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum AudioRouteReason {
323 NewDevice,
325 OldDeviceUnavailable,
327 CategoryChange,
329 Override,
331 WakeFromSleep,
333 NoSuitableRoute,
335 RouteConfigurationChange,
337 Unknown,
339}
340
341#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum AudioOutput {
346 Speaker,
348 Receiver,
350 WiredHeadset,
352 Bluetooth,
354 CarAudio,
356 AirPlay,
358 Hdmi,
360 Usb,
362 Other,
364}
365
366#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub enum CaptureDevice {
370 Camera,
372 Microphone,
374 ScreenShare,
376}
377
378#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub enum CaptureFailure {
383 PermissionDenied,
385 NotFound,
387 InUse,
389 Disconnected,
391 Other,
393}
394
395#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub enum DeviceEvent {
400 AudioRouteChanged {
402 outputs: Vec<AudioOutput>,
404 reason: AudioRouteReason,
406 },
407 AudioInterruption {
409 began: bool,
411 },
412 CaptureFailed {
414 device: CaptureDevice,
416 reason: CaptureFailure,
418 },
419}
420
421pub(crate) fn snake_case(debug: impl std::fmt::Debug) -> String {
425 let mut out = String::new();
426 for (i, c) in format!("{debug:?}").chars().enumerate() {
427 if c.is_uppercase() && i > 0 {
428 out.push('_');
429 }
430 out.push(c.to_ascii_lowercase());
431 }
432 out
433}
434
435impl From<DeviceEvent> for TelemetryEvent {
436 fn from(event: DeviceEvent) -> Self {
438 match event {
439 DeviceEvent::AudioRouteChanged { outputs, reason } => {
440 let outputs = outputs.into_iter().map(snake_case).collect::<Vec<_>>().join(",");
441 let reason = snake_case(reason);
442 TelemetryEvent::new("lk.device.audio_route.changed")
443 .with_body(format!("audio route: {outputs} ({reason})"))
444 .with_attribute("lk.device.audio_route.outputs", outputs)
445 .with_attribute("lk.device.audio_route.reason", reason)
446 }
447 DeviceEvent::AudioInterruption { began } => {
448 let phase = if began { "began" } else { "ended" };
449 TelemetryEvent::new("lk.device.audio.interruption")
450 .with_body(format!("audio interruption {phase}"))
451 .with_attribute("lk.device.audio.interruption", phase)
452 }
453 DeviceEvent::CaptureFailed { device, reason } => {
454 let (device, reason) = (snake_case(device), snake_case(reason));
455 TelemetryEvent::new("lk.device.capture.failed")
456 .with_severity(crate::Severity::Warn)
457 .with_body(format!("capture failed: {device} ({reason})"))
458 .with_attribute("lk.device.capture.device", device)
459 .with_attribute("lk.device.capture.reason", reason)
460 }
461 }
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn cadence_stretches_under_pressure_and_caps_at_four() {
471 assert_eq!(DeviceState::default().cadence_factor(), 1);
472 let serious = DeviceState { thermal: ThermalState::Serious, ..Default::default() };
473 assert_eq!(serious.cadence_factor(), 2);
474 let low_power = DeviceState { low_power_mode: Some(true), ..Default::default() };
475 assert_eq!(low_power.cadence_factor(), 2);
476 let tight = DeviceState { memory: MemoryPressure::Warning, ..Default::default() };
477 assert_eq!(tight.cadence_factor(), 2);
478 let low_battery = DeviceState { battery_level: Some(15), ..Default::default() };
479 assert_eq!(low_battery.cadence_factor(), 2);
480 let charging = DeviceState { battery_charging: true, ..low_battery };
481 assert_eq!(charging.cadence_factor(), 1, "a charging battery is not low");
482 let everything = DeviceState {
483 thermal: ThermalState::Critical,
484 low_power_mode: Some(true),
485 app_state: AppState::Background,
486 memory: MemoryPressure::Critical,
487 network_constrained: true,
488 ..Default::default()
489 };
490 assert_eq!(everything.cadence_factor(), 4);
491 }
492
493 #[test]
494 fn uploads_hold_on_constrained_network_or_empty_battery() {
495 assert!(!DeviceState::default().holds_uploads());
496 let saver = DeviceState { network_constrained: true, ..Default::default() };
497 assert!(saver.holds_uploads());
498 let empty = DeviceState { battery_level: Some(8), ..Default::default() };
499 assert!(empty.holds_uploads());
500 let plugged = DeviceState { battery_charging: true, ..empty };
501 assert!(!plugged.holds_uploads());
502 let low = DeviceState { battery_level: Some(15), ..Default::default() };
503 assert!(!low.holds_uploads(), "low only stretches the cadence");
504 }
505
506 #[test]
507 fn only_changed_fields_produce_events() {
508 let initial = DeviceState {
509 thermal: ThermalState::Nominal,
510 low_power_mode: Some(false),
511 ..Default::default()
512 };
513 let names: Vec<_> = initial.change_events(None).into_iter().map(|e| e.name).collect();
514 assert_eq!(
515 names,
516 [
517 "lk.device.thermal.changed",
518 "lk.device.low_power.changed",
519 "lk.device.app_state.changed",
520 "lk.device.memory.changed",
521 "lk.device.network.changed",
522 ],
523 "initial values at session start; battery is unknown so it stays silent"
524 );
525 let hot = DeviceState { thermal: ThermalState::Critical, ..initial };
526 let events = hot.change_events(Some(&initial));
527 assert_eq!(events.len(), 1);
528 assert_eq!(events[0].name, "lk.device.thermal.changed");
529 assert!(hot.change_events(Some(&hot)).is_empty());
530 }
531
532 #[test]
535 fn unknown_thermal_and_low_power_are_silent_and_neutral() {
536 let unknown = DeviceState::default();
537 assert_eq!((unknown.thermal, unknown.low_power_mode), (ThermalState::Unknown, None));
538 let names: Vec<_> = unknown.change_events(None).into_iter().map(|e| e.name).collect();
539 assert_eq!(
540 names,
541 [
542 "lk.device.app_state.changed",
543 "lk.device.memory.changed",
544 "lk.device.network.changed"
545 ]
546 );
547 assert_eq!(unknown.cadence_factor(), 1);
548 assert!(!unknown.holds_uploads());
549 let known = DeviceState {
550 thermal: ThermalState::Serious,
551 low_power_mode: Some(true),
552 ..Default::default()
553 };
554 assert!(unknown.change_events(Some(&known)).is_empty(), "losing the source is silent");
555 assert_eq!(known.change_events(Some(&unknown)).len(), 2, "a first real reading reports");
556 }
557
558 #[test]
559 fn battery_reports_bucket_crossings_not_every_percent() {
560 let at = |level| DeviceState { battery_level: Some(level), ..Default::default() };
561 assert_eq!(at(80).change_events(Some(&DeviceState::default())).len(), 1, "first reading");
562 assert!(at(79).change_events(Some(&at(80))).is_empty());
563 assert_eq!(at(20).change_events(Some(&at(21))).len(), 1, "crossed into low");
564 assert_eq!(at(10).change_events(Some(&at(11))).len(), 1, "crossed into hold");
565 let charging = DeviceState { battery_charging: true, ..at(10) };
566 assert_eq!(charging.change_events(Some(&at(10))).len(), 1, "plugged in");
567 }
568
569 #[test]
570 fn battery_events_carry_the_level_and_the_charging_flag() {
571 let low = DeviceState { battery_level: Some(15), ..Default::default() };
572 let events = low.change_events(Some(&DeviceState::default()));
573 let [event] = &events[..] else { panic!("one battery event: {events:?}") };
574 let value = |key: &str| event.attributes.iter().find(|a| a.key == key).map(|a| &a.value);
575 assert_eq!(value("lk.device.battery.level"), Some(&crate::AttributeValue::Int(15)));
576 assert_eq!(value("lk.device.battery.charging"), Some(&crate::AttributeValue::Bool(false)));
577 }
578
579 #[test]
580 fn the_core_owns_the_network_connection_type() {
581 assert!(!crate::event::valid_custom("network.connection.type", None));
582 assert!(crate::event::valid_custom("network.peer.address", None), "other `network.*`");
583 }
584
585 #[test]
587 fn wire_values_match_the_spec() {
588 let names = |values: Vec<String>| values.join(" | ");
589 use AudioRouteReason as R;
590 let reasons = [
591 R::NewDevice,
592 R::OldDeviceUnavailable,
593 R::CategoryChange,
594 R::Override,
595 R::WakeFromSleep,
596 R::NoSuitableRoute,
597 R::RouteConfigurationChange,
598 R::Unknown,
599 ];
600 assert_eq!(
601 names(reasons.into_iter().map(snake_case).collect()),
602 "new_device | old_device_unavailable | category_change | override | wake_from_sleep \
603 | no_suitable_route | route_configuration_change | unknown"
604 );
605 use AudioOutput as O;
606 let outputs = [
607 O::Speaker,
608 O::Receiver,
609 O::WiredHeadset,
610 O::Bluetooth,
611 O::CarAudio,
612 O::AirPlay,
613 O::Hdmi,
614 O::Usb,
615 O::Other,
616 ];
617 assert_eq!(
618 names(outputs.into_iter().map(snake_case).collect()),
619 "speaker | receiver | wired_headset | bluetooth | car_audio | air_play | hdmi | usb \
620 | other"
621 );
622 let devices =
623 [CaptureDevice::Camera, CaptureDevice::Microphone, CaptureDevice::ScreenShare];
624 assert_eq!(
625 names(devices.into_iter().map(snake_case).collect()),
626 "camera | microphone | screen_share"
627 );
628 use CaptureFailure as F;
629 let failures = [F::PermissionDenied, F::NotFound, F::InUse, F::Disconnected, F::Other];
630 assert_eq!(
631 names(failures.into_iter().map(snake_case).collect()),
632 "permission_denied | not_found | in_use | disconnected | other"
633 );
634 }
635
636 #[test]
637 fn device_events_have_bodies_and_snake_case_values() {
638 let event: TelemetryEvent = DeviceEvent::AudioRouteChanged {
639 outputs: vec![AudioOutput::Speaker],
640 reason: AudioRouteReason::OldDeviceUnavailable,
641 }
642 .into();
643 assert_eq!(event.body.as_deref(), Some("audio route: speaker (old_device_unavailable)"));
644 let denied: TelemetryEvent = DeviceEvent::CaptureFailed {
645 device: CaptureDevice::ScreenShare,
646 reason: CaptureFailure::PermissionDenied,
647 }
648 .into();
649 assert_eq!(
650 denied.body.as_deref(),
651 Some("capture failed: screen_share (permission_denied)")
652 );
653 assert_eq!(denied.severity, crate::Severity::Warn);
654 }
655}