1use serde::{Deserialize, Serialize};
14
15#[cfg(feature = "schema")]
16use schemars::JsonSchema;
17
18use super::{
19 ElementStyle, HStackElement, ImageElement, ProgressElement, SpacerElement, TextElement,
20 TimerElement, VStackElement, WidgetElement,
21};
22
23pub const MAX_ACTIVITY_BYTES: usize = 4096;
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29#[cfg_attr(feature = "schema", derive(JsonSchema))]
30#[serde(rename_all = "camelCase")]
31pub struct ActivityContent {
32 pub id: String,
35 #[serde(default)]
37 pub title: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub body: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub progress: Option<f64>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub progress_segments: Option<Vec<ProgressSegment>>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub progress_points: Option<Vec<ProgressPoint>>,
51 #[serde(rename = "endsAt", default, skip_serializing_if = "Option::is_none")]
53 pub ends_at: Option<u64>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub icon: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub tint: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub layout: Option<WidgetElement>,
63 #[serde(
65 rename = "compactLeading",
66 default,
67 skip_serializing_if = "Option::is_none"
68 )]
69 pub compact_leading: Option<WidgetElement>,
70 #[serde(
72 rename = "compactTrailing",
73 default,
74 skip_serializing_if = "Option::is_none"
75 )]
76 pub compact_trailing: Option<WidgetElement>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub minimal: Option<WidgetElement>,
80 #[serde(
82 rename = "lockScreen",
83 default,
84 skip_serializing_if = "Option::is_none"
85 )]
86 pub lock_screen: Option<WidgetElement>,
87 #[serde(
89 rename = "islandLeading",
90 default,
91 skip_serializing_if = "Option::is_none"
92 )]
93 pub island_leading: Option<WidgetElement>,
94 #[serde(
96 rename = "islandTrailing",
97 default,
98 skip_serializing_if = "Option::is_none"
99 )]
100 pub island_trailing: Option<WidgetElement>,
101 #[serde(
103 rename = "islandCenter",
104 default,
105 skip_serializing_if = "Option::is_none"
106 )]
107 pub island_center: Option<WidgetElement>,
108 #[serde(
110 rename = "keylineTint",
111 default,
112 skip_serializing_if = "Option::is_none"
113 )]
114 pub keyline_tint: Option<String>,
115 #[serde(
117 rename = "backgroundTint",
118 default,
119 skip_serializing_if = "Option::is_none"
120 )]
121 pub background_tint: Option<String>,
122 #[serde(rename = "subText", default, skip_serializing_if = "Option::is_none")]
124 pub sub_text: Option<String>,
125 #[serde(
127 rename = "shortCriticalText",
128 default,
129 skip_serializing_if = "Option::is_none"
130 )]
131 pub short_critical_text: Option<String>,
132 #[serde(rename = "startedAt", default, skip_serializing_if = "Option::is_none")]
134 pub started_at: Option<u64>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub visibility: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub category: Option<String>,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub actions: Vec<ActivityAction>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub url: Option<String>,
149 #[serde(rename = "staleAt", default, skip_serializing_if = "Option::is_none")]
151 pub stale_at: Option<u64>,
152 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
154 pub push: bool,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub template: Option<String>,
160 #[serde(
162 rename = "relevanceScore",
163 default,
164 skip_serializing_if = "Option::is_none"
165 )]
166 pub relevance_score: Option<f64>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub alert: Option<ActivityAlert>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub timestamp: Option<u64>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 #[cfg_attr(
176 feature = "schema",
177 schemars(with = "Option<serde_json::Map<String, serde_json::Value>>")
178 )]
179 pub data: Option<serde_json::Value>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
184#[cfg_attr(feature = "schema", derive(JsonSchema))]
185#[serde(rename_all = "camelCase")]
186pub struct ActivityAlert {
187 pub title: String,
189 pub body: String,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub sound: Option<String>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
198#[cfg_attr(feature = "schema", derive(JsonSchema))]
199#[serde(rename_all = "camelCase")]
200pub struct ActivityAction {
201 pub id: String,
203 pub label: String,
205 #[serde(
207 rename = "systemName",
208 default,
209 skip_serializing_if = "Option::is_none"
210 )]
211 pub system_name: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub url: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
219#[serde(rename_all = "camelCase")]
220pub struct ActivityHandle {
221 pub id: String,
223 pub surface: String,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub push_token: Option<String>,
228}
229
230pub fn activity_widget_id(id: &str) -> String {
232 format!("activity.{id}")
233}
234
235impl ActivityContent {
236 pub fn validate(&self) -> crate::Result<()> {
239 crate::group::validate_widget_id(&self.id)?;
240 if let Some(t) = self.template.as_deref() {
241 crate::group::validate_widget_id(t)?;
242 }
243 if self.template.is_none() && self.title.trim().is_empty() {
245 return Err(crate::Error::new("activity title must not be empty"));
246 }
247 if self.actions.len() > 3 {
248 return Err(crate::Error::new("an activity has at most 3 actions"));
249 }
250 if let Some(p) = self.progress {
251 if !(0.0..=1.0).contains(&p) {
252 return Err(crate::Error::new("activity progress must be within 0..=1"));
253 }
254 }
255 if let Some(v) = self.visibility.as_deref() {
256 if !matches!(v, "public" | "private" | "secret") {
257 return Err(crate::Error::new(
258 "activity visibility must be public, private or secret",
259 ));
260 }
261 }
262 if let Some(c) = self.category.as_deref() {
263 const CATS: [&str; 8] = [
264 "progress",
265 "navigation",
266 "transport",
267 "workout",
268 "status",
269 "alarm",
270 "service",
271 "event",
272 ];
273 if !CATS.contains(&c) {
274 return Err(crate::Error::new(format!(
275 "activity category must be one of {}",
276 CATS.join(", ")
277 )));
278 }
279 }
280 let bytes = serde_json::to_vec(self)?.len();
281 if bytes > MAX_ACTIVITY_BYTES {
282 return Err(crate::Error::new(format!(
283 "activity payload is {bytes} bytes; the limit is {MAX_ACTIVITY_BYTES} (ActivityKit)"
284 )));
285 }
286 Ok(())
287 }
288
289 pub fn filled(mut self) -> Self {
292 self.layout = Some(self.effective_layout());
293 self
294 }
295
296 pub fn effective_layout(&self) -> WidgetElement {
298 if let Some(l) = &self.layout {
299 return l.clone();
300 }
301 let tint = self.tint.clone().map(super::ColorValue::Solid);
302 let mut head: Vec<WidgetElement> = Vec::new();
303 if let Some(icon) = &self.icon {
304 head.push(WidgetElement::Image(ImageElement {
305 system_name: Some(icon.clone()),
306 size: Some(18.0),
307 color: tint.clone(),
308 ..Default::default()
309 }));
310 }
311 head.push(WidgetElement::Text(TextElement {
312 content: self.title.clone(),
313 font_size: Some(15.0),
314 font_weight: Some(super::FontWeight::Semibold),
315 line_limit: Some(1),
316 ..Default::default()
317 }));
318 let timer = match (self.ends_at, self.started_at) {
320 (Some(end), _) => Some((end, super::TimerCounting::Down)),
321 (None, Some(start)) => Some((start, super::TimerCounting::Up)),
322 _ => None,
323 };
324 if let Some((at, counting)) = timer {
325 head.push(WidgetElement::Spacer(SpacerElement::default()));
326 head.push(WidgetElement::Timer(TimerElement {
327 target_date: iso8601_utc(at),
328 counting: Some(counting),
329 font_size: Some(15.0),
330 ..Default::default()
331 }));
332 }
333 let mut rows = vec![WidgetElement::HStack(HStackElement {
334 children: head,
335 spacing: Some(8.0),
336 ..Default::default()
337 })];
338 if let Some(body) = &self.body {
339 rows.push(WidgetElement::Text(TextElement {
340 content: body.clone(),
341 font_size: Some(13.0),
342 line_limit: Some(2),
343 ..Default::default()
344 }));
345 }
346 if let Some(p) = self.progress {
347 rows.push(WidgetElement::Progress(ProgressElement {
348 value: p,
349 tint,
350 ..Default::default()
351 }));
352 }
353 if !self.actions.is_empty() {
354 rows.push(WidgetElement::HStack(HStackElement {
355 children: self
356 .actions
357 .iter()
358 .map(|a| {
359 WidgetElement::Button(super::ButtonElement {
360 label: a.label.clone(),
361 action: a.url.is_none().then(|| a.id.clone()),
362 url: a.url.clone(),
363 system_name: a.system_name.clone(),
364 ..Default::default()
365 })
366 })
367 .collect(),
368 spacing: Some(8.0),
369 ..Default::default()
370 }));
371 }
372 WidgetElement::VStack(VStackElement {
373 children: rows,
374 spacing: Some(6.0),
375 alignment: Some(super::HorizontalAlignment::Leading),
376 justify: None,
377 style: ElementStyle {
378 padding: Some(super::PaddingValue::Uniform(12.0)),
379 ..Default::default()
380 },
381 })
382 }
383}
384
385pub fn resolve_activity(
390 content: ActivityContent,
391 lookup: impl Fn(&str) -> Option<String>,
392) -> crate::Result<ActivityContent> {
393 let Some(name) = content.template.clone() else {
394 return Ok(content);
395 };
396 let raw = lookup(&crate::store::activity_template_key(&name))
397 .filter(|s| !s.is_empty())
398 .ok_or_else(|| {
399 crate::Error::new(format!(
400 "activity template \"{name}\" is not registered (registerActivityTemplate)"
401 ))
402 })?;
403 let template: serde_json::Value = serde_json::from_str(&raw)?;
404 let ctx = crate::resolve::ResolveContext {
406 data: content.data.as_ref(),
407 ..Default::default()
408 };
409 let mut merged = match crate::resolve::resolve_fields(&template, &ctx) {
410 serde_json::Value::Object(m) => m,
411 _ => serde_json::Map::new(),
412 };
413 let serde_json::Value::Object(own) = serde_json::to_value(&content)? else {
414 return Ok(content);
415 };
416 for (k, v) in own {
417 let empty_title = k == "title" && v.as_str().is_some_and(|t| t.trim().is_empty());
418 if !(empty_title || k == "template" || k == "data") {
419 merged.insert(k, v);
420 }
421 }
422 merged
423 .entry("title")
424 .or_insert_with(|| serde_json::Value::String(String::new()));
425 Ok(serde_json::from_value(serde_json::Value::Object(merged))?)
426}
427
428pub fn iso8601_utc(ms: u64) -> String {
430 let secs = (ms / 1000) as i64;
431 let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
432 let z = days + 719_468;
434 let era = z.div_euclid(146_097);
435 let doe = z - era * 146_097;
436 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
437 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
438 let mp = (5 * doy + 2) / 153;
439 let d = doy - (153 * mp + 2) / 5 + 1;
440 let m = if mp < 10 { mp + 3 } else { mp - 9 };
441 let y = yoe + era * 400 + i64::from(m <= 2);
442 format!(
443 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
444 rem / 3600,
445 rem % 3600 / 60,
446 rem % 60
447 )
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use serde_json::{json, Value};
454
455 fn base() -> ActivityContent {
456 ActivityContent {
457 id: "order.42".into(),
458 title: "Order".into(),
459 ..Default::default()
460 }
461 }
462
463 fn action(id: &str, label: &str) -> ActivityAction {
464 ActivityAction {
465 id: id.into(),
466 label: label.into(),
467 ..Default::default()
468 }
469 }
470
471 fn layout(c: &ActivityContent) -> Value {
472 serde_json::to_value(c.effective_layout()).unwrap()
473 }
474
475 #[test]
479 fn default_layout_scenario() {
480 let plain = layout(&base());
481 assert_eq!(plain["type"], "vstack");
482 assert_eq!(
483 (&plain["spacing"], &plain["alignment"], &plain["padding"]),
484 (&json!(6.0), &json!("leading"), &json!(12.0))
485 );
486 assert_eq!(plain["children"].as_array().unwrap().len(), 1);
487 let title = &plain["children"][0]["children"];
488 assert_eq!(title.as_array().unwrap().len(), 1);
489 assert_eq!(
490 title[0],
491 json!({"type": "text", "content": "Order", "fontSize": 15.0, "fontWeight": "semibold", "lineLimit": 1})
492 );
493
494 let full = ActivityContent {
495 icon: Some("bolt".into()),
496 body: Some("Driver en route".into()),
497 progress: Some(0.5),
498 ends_at: Some(0),
499 tint: Some("#ff0000".into()),
500 actions: vec![action("stop", "Stop")],
501 ..base()
502 };
503 let v = layout(&full);
504 let head = &v["children"][0];
505 assert_eq!(head["spacing"], 8.0);
506 let types: Vec<&str> = head["children"]
507 .as_array()
508 .unwrap()
509 .iter()
510 .map(|e| e["type"].as_str().unwrap())
511 .collect();
512 assert_eq!(types, ["image", "text", "spacer", "timer"]);
513 let icon = &head["children"][0];
514 assert_eq!(
515 (&icon["systemName"], &icon["size"], &icon["color"]),
516 (&json!("bolt"), &json!(18.0), &json!("#ff0000"))
517 );
518 assert_eq!(head["children"][1], title[0]);
519 let timer = &head["children"][3];
520 assert_eq!(
521 (&timer["targetDate"], &timer["counting"], &timer["fontSize"]),
522 (&json!("1970-01-01T00:00:00Z"), &json!("down"), &json!(15.0))
523 );
524 let body = &v["children"][1];
525 assert_eq!(
526 (&body["content"], &body["fontSize"], &body["lineLimit"]),
527 (&json!("Driver en route"), &json!(13.0), &json!(2))
528 );
529 let bar = &v["children"][2];
530 assert_eq!(
531 (&bar["type"], &bar["value"], &bar["tint"]),
532 (&json!("progress"), &json!(0.5), &json!("#ff0000"))
533 );
534 let buttons = &v["children"][3];
535 assert_eq!(buttons["spacing"], 8.0);
536 let b = &buttons["children"][0];
537 assert_eq!(
538 (&b["type"], &b["action"], &b["label"]),
539 (&json!("button"), &json!("stop"), &json!("Stop"))
540 );
541
542 let v = layout(&ActivityContent {
544 started_at: Some(0),
545 actions: vec![ActivityAction {
546 url: Some("app://x".into()),
547 system_name: Some("arrow.up".into()),
548 ..action("open", "Open")
549 }],
550 ..base()
551 });
552 assert_eq!(v["children"][0]["children"][2]["counting"], "up");
553 let b = &v["children"][1]["children"][0];
554 assert_eq!(
555 (b["url"].as_str(), b.get("action"), b["systemName"].as_str()),
556 (Some("app://x"), None, Some("arrow.up"))
557 );
558
559 let custom = ActivityContent {
561 layout: Some(WidgetElement::Spacer(SpacerElement::default())),
562 ..full.clone()
563 };
564 assert_eq!(layout(&custom)["type"], "spacer");
565
566 let built = layout(&full);
568 let f = full.filled();
569 assert_eq!(
570 serde_json::to_value(f.layout.as_ref().unwrap()).unwrap(),
571 built
572 );
573 let again = f.clone().filled();
574 assert_eq!(
575 serde_json::to_value(&again).unwrap(),
576 serde_json::to_value(&f).unwrap()
577 );
578
579 assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z");
581 assert_eq!(iso8601_utc(951_782_400_000), "2000-02-29T00:00:00Z");
582 assert_eq!(iso8601_utc(1_763_051_445_999), "2025-11-13T16:30:45Z");
583 assert_eq!(iso8601_utc(4_107_542_400_000), "2100-03-01T00:00:00Z");
584 }
585
586 #[test]
589 fn template_scenario() {
590 let tpl = json!({
591 "title": "Driver {{data.name}}",
592 "body": "template body",
593 "lockScreen": {"type": "text", "content": "{{data.eta}} min"},
594 "minimal": {"type": "text", "content": "!", "when": {"path": "data.late"}}
595 })
596 .to_string();
597 let lookup = |k: &str| (k == "activity-template:ride").then(|| tpl.clone());
598 let c = ActivityContent {
599 id: "r1".into(),
600 template: Some("ride".into()),
601 data: Some(json!({"name": "Ann", "eta": 4, "late": false})),
602 body: Some("own body".into()),
603 ..Default::default()
604 };
605 c.validate().unwrap();
606 let r = resolve_activity(c.clone(), lookup).unwrap();
607 assert_eq!(r.title, "Driver Ann");
608 assert_eq!(r.body.as_deref(), Some("own body"));
609 assert!(r.minimal.is_none() && r.template.is_none() && r.data.is_none());
610 let lock = serde_json::to_value(r.lock_screen.unwrap()).unwrap();
611 assert_eq!(lock["content"], "4 min");
612
613 let own = resolve_activity(
614 ActivityContent {
615 title: "Own".into(),
616 body: Some(String::new()),
617 ..c.clone()
618 },
619 lookup,
620 )
621 .unwrap();
622 assert_eq!((own.title.as_str(), own.body.as_deref()), ("Own", Some("")));
623
624 assert!(resolve_activity(
625 ActivityContent {
626 template: Some("nope".into()),
627 ..c
628 },
629 lookup
630 )
631 .is_err());
632 let plain = base();
633 assert_eq!(
634 resolve_activity(plain.clone(), lookup).unwrap().title,
635 plain.title
636 );
637 }
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
642#[cfg_attr(feature = "schema", derive(JsonSchema))]
643pub struct ProgressSegment {
644 pub length: f64,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub color: Option<String>,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
653#[cfg_attr(feature = "schema", derive(JsonSchema))]
654pub struct ProgressPoint {
655 pub at: f64,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub color: Option<String>,
660}