Skip to main content

tauri_plugin_widgets/models/
activity.rs

1//! Live activities: a short-lived status surface for an ongoing task.
2//!
3//! | Platform | Surface |
4//! |---|---|
5//! | iOS 16.2+ | Live Activity (Lock Screen, Dynamic Island) — `layout` drawn by `TauriLiveActivity` |
6//! | Android 8+ | ongoing notification; Android 16 promotes it to a Live Update chip |
7//! | macOS / Windows / Linux | Dynamic-Island pill at the top centre (`activitySurface`, default `island`) |
8//!
9//! One request shape everywhere. Without `layout`, the plugin builds one from `title`,
10//! `body`, `progress` and `endsAt` ([`ActivityContent::effective_layout`]), so a plain
11//! request looks alike on every OS.
12
13use 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
23/// ActivityKit drops content states over 4 KiB; the same budget applies on every platform
24/// so a request that works on desktop does not fail on an iPhone.
25pub const MAX_ACTIVITY_BYTES: usize = 4096;
26
27/// Start or update request for a live activity.
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29#[cfg_attr(feature = "schema", derive(JsonSchema))]
30#[serde(rename_all = "camelCase")]
31pub struct ActivityContent {
32    /// Your key for this activity (letters, digits, `.`, `_`, `-`). Starting an id that is
33    /// already running updates it.
34    pub id: String,
35    /// Main line (notification title on Android). Required unless a `template` gives it.
36    #[serde(default)]
37    pub title: String,
38    /// Second line.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub body: Option<String>,
41    /// Progress `0..=1`.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub progress: Option<f64>,
44    /// Android 16 Live Updates `ProgressStyle` track: relative lengths and colors (one
45    /// `tint` segment when omitted). Other platforms draw a plain bar.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub progress_segments: Option<Vec<ProgressSegment>>,
48    /// Android 16 Live Updates `ProgressStyle` markers along the track.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub progress_points: Option<Vec<ProgressPoint>>,
51    /// Unix ms the countdown runs to (live timer; Android status-bar chip countdown).
52    #[serde(rename = "endsAt", default, skip_serializing_if = "Option::is_none")]
53    pub ends_at: Option<u64>,
54    /// SF Symbol name (mapped to Material / emoji off Apple).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub icon: Option<String>,
57    /// Accent color (hex) for the icon, progress and Android notification.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub tint: Option<String>,
60    /// Full layout (Lock Screen, expanded Dynamic Island, desktop window).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub layout: Option<WidgetElement>,
63    /// Dynamic Island compact, leading side.
64    #[serde(
65        rename = "compactLeading",
66        default,
67        skip_serializing_if = "Option::is_none"
68    )]
69    pub compact_leading: Option<WidgetElement>,
70    /// Dynamic Island compact, trailing side.
71    #[serde(
72        rename = "compactTrailing",
73        default,
74        skip_serializing_if = "Option::is_none"
75    )]
76    pub compact_trailing: Option<WidgetElement>,
77    /// Dynamic Island minimal (several activities at once).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub minimal: Option<WidgetElement>,
80    /// iOS Lock Screen layout when it should differ from `layout` (the expanded island).
81    #[serde(
82        rename = "lockScreen",
83        default,
84        skip_serializing_if = "Option::is_none"
85    )]
86    pub lock_screen: Option<WidgetElement>,
87    /// Expanded Dynamic Island, leading region.
88    #[serde(
89        rename = "islandLeading",
90        default,
91        skip_serializing_if = "Option::is_none"
92    )]
93    pub island_leading: Option<WidgetElement>,
94    /// Expanded Dynamic Island, trailing region.
95    #[serde(
96        rename = "islandTrailing",
97        default,
98        skip_serializing_if = "Option::is_none"
99    )]
100    pub island_trailing: Option<WidgetElement>,
101    /// Expanded Dynamic Island, center region (under the camera).
102    #[serde(
103        rename = "islandCenter",
104        default,
105        skip_serializing_if = "Option::is_none"
106    )]
107    pub island_center: Option<WidgetElement>,
108    /// Dynamic Island keyline color (hex).
109    #[serde(
110        rename = "keylineTint",
111        default,
112        skip_serializing_if = "Option::is_none"
113    )]
114    pub keyline_tint: Option<String>,
115    /// Lock Screen background color (hex) when the layout has none.
116    #[serde(
117        rename = "backgroundTint",
118        default,
119        skip_serializing_if = "Option::is_none"
120    )]
121    pub background_tint: Option<String>,
122    /// Android: small text after the app name (notification sub text).
123    #[serde(rename = "subText", default, skip_serializing_if = "Option::is_none")]
124    pub sub_text: Option<String>,
125    /// Android 16: short text for the promoted status-bar chip (≤ 7 characters).
126    #[serde(
127        rename = "shortCriticalText",
128        default,
129        skip_serializing_if = "Option::is_none"
130    )]
131    pub short_critical_text: Option<String>,
132    /// Unix ms the activity started: a stopwatch counts up from it (when `endsAt` is unset).
133    #[serde(rename = "startedAt", default, skip_serializing_if = "Option::is_none")]
134    pub started_at: Option<u64>,
135    /// Android lock-screen visibility: `public` (default), `private`, `secret`.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub visibility: Option<String>,
138    /// Android notification category (`progress` default, `navigation`, `transport`,
139    /// `workout`, `status`, `alarm`, `service`, `event`).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub category: Option<String>,
142    /// Buttons (Android notification actions, at most 3). Taps arrive as `widget-action`
143    /// with `widgetId = "activity.<id>"`.
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub actions: Vec<ActivityAction>,
146    /// Deep link opened on tap.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub url: Option<String>,
149    /// Unix ms after which the OS marks the content outdated (iOS `staleDate`).
150    #[serde(rename = "staleAt", default, skip_serializing_if = "Option::is_none")]
151    pub stale_at: Option<u64>,
152    /// iOS: request an APNs push token (`activity-token` event, `pushToken` in the result).
153    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
154    pub push: bool,
155    /// Name of a layout stored with `registerActivityTemplate`: the device fills it with
156    /// `data` (`{{data.eta}}`, `when`, `repeat`, …), so updates carry only data. Fields
157    /// set on this content override the template's.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub template: Option<String>,
160    /// iOS: which of several activities the system shows first (higher wins).
161    #[serde(
162        rename = "relevanceScore",
163        default,
164        skip_serializing_if = "Option::is_none"
165    )]
166    pub relevance_score: Option<f64>,
167    /// iOS: alert shown with this update (lights the screen, plays `sound`).
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub alert: Option<ActivityAlert>,
170    /// Unix ms the content was produced: an older update than the shown one is dropped.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub timestamp: Option<u64>,
173    /// Values for `{{data.…}}` in the `template`.
174    #[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/// Alert of a live activity update (iOS `AlertConfiguration`).
183#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
184#[cfg_attr(feature = "schema", derive(JsonSchema))]
185#[serde(rename_all = "camelCase")]
186pub struct ActivityAlert {
187    /// Alert title (Apple Watch / notification banner).
188    pub title: String,
189    /// Alert body.
190    pub body: String,
191    /// Sound file name in the app bundle; system default when unset.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub sound: Option<String>,
194}
195
196/// A button of a live activity.
197#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
198#[cfg_attr(feature = "schema", derive(JsonSchema))]
199#[serde(rename_all = "camelCase")]
200pub struct ActivityAction {
201    /// Action verb delivered in `widget-action`.
202    pub id: String,
203    /// Button text.
204    pub label: String,
205    /// Icon (SF Symbol; Material / emoji elsewhere).
206    #[serde(
207        rename = "systemName",
208        default,
209        skip_serializing_if = "Option::is_none"
210    )]
211    pub system_name: Option<String>,
212    /// Open this deep link instead of queuing a `widget-action`.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub url: Option<String>,
215}
216
217/// What `start_activity` returns.
218#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
219#[serde(rename_all = "camelCase")]
220pub struct ActivityHandle {
221    /// The request id.
222    pub id: String,
223    /// `ios` | `android` | `desktop`.
224    pub surface: String,
225    /// iOS APNs token (hex), when `push` was requested and already known.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub push_token: Option<String>,
228}
229
230/// Widget id under which a desktop activity window stores its config.
231pub fn activity_widget_id(id: &str) -> String {
232    format!("activity.{id}")
233}
234
235impl ActivityContent {
236    /// Reject what no platform can show: empty id or title, bad charset, too many actions,
237    /// progress out of range, payload over [`MAX_ACTIVITY_BYTES`].
238    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        // A templated request may take its title from the template.
244        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    /// The request with `layout` filled by [`Self::effective_layout`]: what every surface
290    /// receives, so a native renderer never needs its own default.
291    pub fn filled(mut self) -> Self {
292        self.layout = Some(self.effective_layout());
293        self
294    }
295
296    /// `layout`, or one built from the plain fields: icon + title, body, timer, progress.
297    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        // Countdown to `endsAt`, else a stopwatch from `startedAt`.
319        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
385/// A templated request with the stored template applied: the template (read by `lookup`
386/// from `activity-template:{name}`) is resolved with `data` like a widget config
387/// (`{{data.x}}`, `when`, `repeat`, …), then fields set on `content` override it. Returns
388/// `content` unchanged without `template`; errors when the template is missing.
389pub 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    // No `entries` here, so the time is never read.
405    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
428/// `YYYY-MM-DDTHH:MM:SSZ` for Unix ms (the timer element takes ISO-8601).
429pub 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    // Howard Hinnant's civil_from_days.
433    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    /// The layout built from plain fields, growing field by field: title only → icon,
476    /// countdown, body, progress, actions (typography and spacing included) → stopwatch and
477    /// link buttons → a custom `layout` that wins → `filled` storing it once.
478    #[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        // Without `endsAt` a `startedAt` counts up; a link button has a url, no action.
543        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        // A custom layout is used as is.
560        let custom = ActivityContent {
561            layout: Some(WidgetElement::Spacer(SpacerElement::default())),
562            ..full.clone()
563        };
564        assert_eq!(layout(&custom)["type"], "spacer");
565
566        // `filled` stores the built layout; filling again changes nothing.
567        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        // The timer takes ISO-8601 UTC.
580        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    /// A templated request: register, resolve with data, own fields override (an empty own
587    /// title falls back, any other own value wins, even an empty body), missing template.
588    #[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/// One part of an Android Live Updates progress track ([`ActivityContent::progress_segments`]).
641#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
642#[cfg_attr(feature = "schema", derive(JsonSchema))]
643pub struct ProgressSegment {
644    /// Relative length (any positive unit; lengths are normalized).
645    pub length: f64,
646    /// Hex color; `tint` when omitted.
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub color: Option<String>,
649}
650
651/// A marker on an Android Live Updates progress track ([`ActivityContent::progress_points`]).
652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
653#[cfg_attr(feature = "schema", derive(JsonSchema))]
654pub struct ProgressPoint {
655    /// Position `0..=1` along the track.
656    pub at: f64,
657    /// Hex color; `tint` when omitted.
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub color: Option<String>,
660}