Skip to main content

cranpose_capabilities/
lib.rs

1#![deny(missing_docs)]
2#![doc = include_str!("../README.md")]
3
4use std::{
5    collections::BTreeSet,
6    env,
7    ffi::OsString,
8    fmt::Write as _,
9    fs,
10    path::{Path, PathBuf},
11};
12
13/// Something an application uses that the device has to allow.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
15pub enum Service {
16    /// The camera.
17    Camera,
18    /// Reading the photo library.
19    PhotoLibrary,
20    /// Writing to the photo library.
21    PhotoLibraryAdd,
22    /// The microphone.
23    Microphone,
24    /// Where the device is.
25    Location,
26    /// Notifications the person sees.
27    Notifications,
28    /// Work that carries on with the application off screen.
29    Background,
30    /// Media playback that carries on with the application off screen.
31    Media,
32    /// Purchases through the platform store.
33    Billing,
34    /// A window drawn above other applications.
35    Overlay,
36    /// The vibrator.
37    Haptics,
38    /// Handing a downloaded package to the system installer.
39    Update,
40    /// The network, and whether the device is on one.
41    Network,
42    /// The wearer's heart rate, from the device's own sensor.
43    HeartRate,
44    /// Recording that can start from the background, kept possible by a
45    /// standby the application starts from its screen.
46    MicrophoneStandby,
47    /// Messages and streams to the same application on the paired phone or
48    /// watch.
49    Wearable,
50}
51
52impl Service {
53    /// The name this service carries in the build's own files.
54    pub const fn name(self) -> &'static str {
55        match self {
56            Service::Camera => "camera",
57            Service::PhotoLibrary => "photo-library",
58            Service::PhotoLibraryAdd => "photo-library-add",
59            Service::Microphone => "microphone",
60            Service::Location => "location",
61            Service::Notifications => "notifications",
62            Service::Background => "background",
63            Service::Media => "media",
64            Service::Billing => "billing",
65            Service::Overlay => "overlay",
66            Service::Haptics => "haptics",
67            Service::Update => "update",
68            Service::Network => "network",
69            Service::HeartRate => "heart-rate",
70            Service::MicrophoneStandby => "microphone-standby",
71            Service::Wearable => "wearable",
72        }
73    }
74
75    /// The Android permissions this service needs.
76    ///
77    /// Both photo library services are empty here: Android reads and writes
78    /// photos through the system picker, which asks the person for one file
79    /// and needs no permission from the application.
80    pub const fn android_permissions(self) -> &'static [&'static str] {
81        match self {
82            Service::Camera => &["android.permission.CAMERA"],
83            Service::PhotoLibrary => &[],
84            Service::PhotoLibraryAdd => &[],
85            Service::Microphone => &["android.permission.RECORD_AUDIO"],
86            Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
87            Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
88            Service::Background => &[
89                "android.permission.FOREGROUND_SERVICE",
90                "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
91            ],
92            Service::Media => &[
93                "android.permission.FOREGROUND_SERVICE",
94                "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
95            ],
96            Service::Billing => &["com.android.vending.BILLING"],
97            Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
98            Service::Haptics => &["android.permission.VIBRATE"],
99            Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
100            Service::Network => &[
101                "android.permission.INTERNET",
102                "android.permission.ACCESS_NETWORK_STATE",
103            ],
104            // Android 16 split the body sensors into the health
105            // permissions; older releases read the same sensor under
106            // BODY_SENSORS, which `android_permissions_up_to` carries.
107            Service::HeartRate => &["android.permission.health.READ_HEART_RATE"],
108            Service::MicrophoneStandby => &[
109                "android.permission.FOREGROUND_SERVICE",
110                "android.permission.FOREGROUND_SERVICE_MICROPHONE",
111                "android.permission.RECORD_AUDIO",
112            ],
113            // The Data Layer runs through Google Play services and asks for
114            // nothing.
115            Service::Wearable => &[],
116        }
117    }
118
119    /// The Android permissions this service needs only up to an API level,
120    /// with that level: what a platform release replaced, declared with
121    /// `android:maxSdkVersion` so a newer device is never asked for it.
122    pub const fn android_permissions_up_to(self) -> &'static [(&'static str, u32)] {
123        match self {
124            Service::HeartRate => &[("android.permission.BODY_SENSORS", 35)],
125            _ => &[],
126        }
127    }
128
129    /// The key an Apple platform reads the sentence from.
130    pub const fn apple_key(self) -> Option<&'static str> {
131        match self {
132            Service::Camera => Some("NSCameraUsageDescription"),
133            Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
134            Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
135            Service::Microphone => Some("NSMicrophoneUsageDescription"),
136            Service::Location => Some("NSLocationWhenInUseUsageDescription"),
137            Service::HeartRate => Some("NSHealthShareUsageDescription"),
138            _ => None,
139        }
140    }
141
142    /// The keys an Apple platform reads the same sentence from as well.
143    ///
144    /// A watch measures the pulse every few seconds only during a workout,
145    /// and starting one asks to save workouts, under a key of its own.
146    pub const fn apple_more_keys(self) -> &'static [&'static str] {
147        match self {
148            Service::HeartRate => &["NSHealthUpdateUsageDescription"],
149            _ => &[],
150        }
151    }
152
153    /// The background modes a watch app runs this service under: the
154    /// workout that the heart rate is read through.
155    pub const fn watch_background_modes(self) -> &'static [&'static str] {
156        match self {
157            Service::HeartRate => &["workout-processing"],
158            _ => &[],
159        }
160    }
161
162    /// Whether this service takes a sentence to show the person.
163    pub const fn takes_reason(self) -> bool {
164        self.apple_key().is_some()
165    }
166}
167
168/// One service an application uses, with the sentence it shows if it needs one.
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub struct Use {
171    service: Service,
172    reason: Option<&'static str>,
173}
174
175impl Use {
176    /// The camera, with what the person is told before it opens.
177    pub const fn camera(reason: &'static str) -> Self {
178        Self {
179            service: Service::Camera,
180            reason: Some(reason),
181        }
182    }
183
184    /// Reading the photo library, with what the person is told.
185    pub const fn photo_library(reason: &'static str) -> Self {
186        Self {
187            service: Service::PhotoLibrary,
188            reason: Some(reason),
189        }
190    }
191
192    /// Writing to the photo library, with what the person is told.
193    pub const fn photo_library_add(reason: &'static str) -> Self {
194        Self {
195            service: Service::PhotoLibraryAdd,
196            reason: Some(reason),
197        }
198    }
199
200    /// The microphone, with what the person is told.
201    pub const fn microphone(reason: &'static str) -> Self {
202        Self {
203            service: Service::Microphone,
204            reason: Some(reason),
205        }
206    }
207
208    /// Where the device is, with what the person is told.
209    pub const fn location(reason: &'static str) -> Self {
210        Self {
211            service: Service::Location,
212            reason: Some(reason),
213        }
214    }
215
216    /// Notifications the person sees.
217    pub const fn notifications() -> Self {
218        Self {
219            service: Service::Notifications,
220            reason: None,
221        }
222    }
223
224    /// Work that carries on with the application off screen.
225    pub const fn background() -> Self {
226        Self {
227            service: Service::Background,
228            reason: None,
229        }
230    }
231
232    /// Media playback that carries on with the application off screen.
233    pub const fn media() -> Self {
234        Self {
235            service: Service::Media,
236            reason: None,
237        }
238    }
239
240    /// Purchases through the platform store.
241    pub const fn billing() -> Self {
242        Self {
243            service: Service::Billing,
244            reason: None,
245        }
246    }
247
248    /// A window drawn above other applications.
249    pub const fn overlay() -> Self {
250        Self {
251            service: Service::Overlay,
252            reason: None,
253        }
254    }
255
256    /// The vibrator.
257    pub const fn haptics() -> Self {
258        Self {
259            service: Service::Haptics,
260            reason: None,
261        }
262    }
263
264    /// Recording that can start from the background; see
265    /// `cranpose_services::hold_microphone_standby`.
266    pub const fn microphone_standby() -> Self {
267        Self {
268            service: Service::MicrophoneStandby,
269            reason: None,
270        }
271    }
272
273    /// Messages and streams to the same application on the paired phone or
274    /// watch; see `cranpose_services::wearable`.
275    pub const fn wearable() -> Self {
276        Self {
277            service: Service::Wearable,
278            reason: None,
279        }
280    }
281
282    /// Handing a downloaded package to the system installer.
283    pub const fn update() -> Self {
284        Self {
285            service: Service::Update,
286            reason: None,
287        }
288    }
289
290    /// The network, and whether the device is on one.
291    pub const fn network() -> Self {
292        Self {
293            service: Service::Network,
294            reason: None,
295        }
296    }
297
298    /// The wearer's heart rate, with what the person is told before the
299    /// sensor is read. Declaring it is the only way an application's build
300    /// asks for the permission; reading it still takes an explicit request
301    /// at run time.
302    pub const fn heart_rate(reason: &'static str) -> Self {
303        Self {
304            service: Service::HeartRate,
305            reason: Some(reason),
306        }
307    }
308
309    /// Which service this is.
310    pub const fn service(self) -> Service {
311        self.service
312    }
313
314    /// The sentence the person is shown, for the services that have one.
315    pub const fn reason(self) -> Option<&'static str> {
316        self.reason
317    }
318}
319
320/// Hardware an application cannot run without.
321///
322/// Every other feature stays optional, so the application reaches devices
323/// that lack the hardware and asks the framework at run time.
324#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
325pub enum Demand {
326    /// A camera.
327    Camera,
328    /// A microphone.
329    Microphone,
330    /// A watch.
331    Watch,
332    /// Telephony.
333    Telephony,
334    /// Bluetooth.
335    Bluetooth,
336    /// Near-field communication.
337    Nfc,
338    /// Wi-Fi.
339    Wifi,
340    /// Location hardware.
341    Location,
342}
343
344impl Demand {
345    /// The Android feature name this demand becomes.
346    pub const fn android_feature(self) -> &'static str {
347        match self {
348            Demand::Camera => "android.hardware.camera",
349            Demand::Microphone => "android.hardware.microphone",
350            Demand::Watch => "android.hardware.type.watch",
351            Demand::Telephony => "android.hardware.telephony",
352            Demand::Bluetooth => "android.hardware.bluetooth",
353            Demand::Nfc => "android.hardware.nfc",
354            Demand::Wifi => "android.hardware.wifi",
355            Demand::Location => "android.hardware.location",
356        }
357    }
358
359    /// The name this demand carries in the build's own files.
360    pub const fn name(self) -> &'static str {
361        self.android_feature()
362    }
363}
364
365/// Everything one application asks of a device.
366#[derive(Clone, Copy, Debug, PartialEq, Eq)]
367pub struct Capabilities<'a> {
368    /// The services the application uses.
369    pub uses: &'a [Use],
370    /// The hardware the application cannot run without.
371    pub demands: &'a [Demand],
372    /// The media types of the files the application opens, such as
373    /// `"audio/*"` or `"application/pdf"`: the platform then offers it for
374    /// those files, in a share sheet and in "Open with", and hands what the
375    /// person picks to `cranpose_services::incoming_share`.
376    pub opens: &'a [&'a str],
377}
378
379impl Capabilities<'_> {
380    /// An application that asks for nothing.
381    pub const NONE: Capabilities<'static> = Capabilities {
382        uses: &[],
383        demands: &[],
384        opens: &[],
385    };
386
387    /// Whether the application declared this service.
388    pub fn has(&self, service: Service) -> bool {
389        self.uses.iter().any(|entry| entry.service == service)
390    }
391
392    /// The sentence declared for a service, if it has one.
393    pub fn reason_for(&self, service: Service) -> Option<&'static str> {
394        self.uses
395            .iter()
396            .find(|entry| entry.service == service)
397            .and_then(|entry| entry.reason())
398    }
399}
400
401/// What a build script declares, before [`Declaration::emit`] writes it out.
402#[must_use = "a declaration reaches the platform builds only through emit()"]
403#[derive(Clone, Copy, Debug)]
404pub struct Declaration<'a> {
405    capabilities: Capabilities<'a>,
406}
407
408/// Starts a declaration with the services an application uses.
409pub const fn declare(uses: &[Use]) -> Declaration<'_> {
410    Declaration {
411        capabilities: Capabilities {
412            uses,
413            demands: &[],
414            opens: &[],
415        },
416    }
417}
418
419impl<'a> Declaration<'a> {
420    /// Adds the hardware the application cannot run without.
421    pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
422        Declaration {
423            capabilities: Capabilities {
424                demands,
425                ..self.capabilities
426            },
427        }
428    }
429
430    /// Adds the media types of the files the application opens, such as
431    /// `"audio/*"`, so the platform offers it for them.
432    pub const fn opening(self, opens: &'a [&'a str]) -> Declaration<'a> {
433        Declaration {
434            capabilities: Capabilities {
435                opens,
436                ..self.capabilities
437            },
438        }
439    }
440
441    /// Writes the declaration where the application and every platform build
442    /// reads it.
443    ///
444    /// Call this from a build script. It panics when the files cannot be
445    /// written, which is what a build script does with a failure.
446    pub fn emit(self) {
447        let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
448        let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
449        write_file(
450            &out.join("cranpose_capabilities.rs"),
451            &rust_source(&self.capabilities),
452        );
453
454        let crate_dir = PathBuf::from(
455            env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
456        );
457        let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
458        fs::create_dir_all(&shared).expect("the shared capabilities directory");
459        let outputs = shared_outputs(&shared, &package);
460        let contents = [
461            json(&self.capabilities),
462            android_manifest(&self.capabilities),
463            apple_usage(&self.capabilities),
464        ];
465        for (path, text) in outputs.iter().zip(contents.iter()) {
466            write_file(path, text);
467        }
468        println!("cargo::rerun-if-changed=build.rs");
469        println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
470        for directive in rerun_directives(&outputs) {
471            println!("{directive}");
472        }
473    }
474}
475
476/// The files [`Declaration::emit`] writes outside `OUT_DIR`.
477///
478/// They go where every platform build reads them, which is also where anything
479/// that reclaims build artifacts can remove them.
480fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
481    [
482        shared.join(format!("{package}-capabilities.json")),
483        shared.join(format!("{package}-permissions.xml")),
484        shared.join(format!("{package}-usage.plist")),
485    ]
486}
487
488/// Tells cargo that these files are this build script's outputs.
489///
490/// Cargo does not know what a build script writes outside `OUT_DIR`, and a
491/// path named to `rerun-if-changed` counts as changed when it is missing. So
492/// naming them is what makes a deleted declaration come back: without it,
493/// cargo reads an unchanged `build.rs`, skips the script, and the tree keeps
494/// building without the permissions XML that carries SYSTEM_ALERT_WINDOW and
495/// VIBRATE into the merged Android manifest. The Android release APK then
496/// fails `cranposeReleaseManifestCheck` on every run in that workspace,
497/// because nothing will ever write the file again.
498fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
499    outputs
500        .iter()
501        .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
502        .collect()
503}
504
505fn write_file(path: &Path, text: &str) {
506    if fs::read(path).is_ok_and(|contents| contents == text.as_bytes()) {
507        return;
508    }
509    fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
510}
511
512/// Names the directory the declaration is written into.
513///
514/// A platform build sets it, because the build knows the tree it drives. A
515/// plain `cargo build` does not, and the declaration then goes to
516/// `<workspace>/target/cranpose`.
517const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
518
519/// Where the declaration goes: the directory the build named, or the one under
520/// the workspace this crate belongs to.
521fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
522    match named.filter(|value| !value.is_empty()) {
523        Some(value) => PathBuf::from(value),
524        None => workspace_root(crate_dir).join("target").join("cranpose"),
525    }
526}
527
528/// The workspace this crate belongs to, found from its own directory.
529///
530/// The platform builds read the declaration from `<workspace>/target/cranpose`,
531/// and they know the workspace because they already resolved this crate's
532/// source there. The directory Cargo happens to build into is not that place:
533/// it moves with `CARGO_TARGET_DIR`, and continuous integration sets it.
534fn workspace_root(crate_dir: &Path) -> PathBuf {
535    found_workspace(crate_dir, &|path| {
536        fs::read_to_string(path.join("Cargo.toml"))
537            .is_ok_and(|manifest| manifest.contains("[workspace]"))
538    })
539}
540
541fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
542    // The outermost workspace, so a crate inside a workspace that is itself
543    // vendored into another one still answers with the tree the build drives.
544    crate_dir
545        .ancestors()
546        .filter(|path| holds_workspace(path))
547        .last()
548        .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
549}
550
551/// The Rust the application includes, so it reads the same declaration the
552/// platform builds do.
553fn rust_source(capabilities: &Capabilities<'_>) -> String {
554    let mut text = String::from(
555        "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n    \
556         cranpose_capabilities::Capabilities {\n        uses: &[\n",
557    );
558    for entry in capabilities.uses {
559        let call = constructor(entry);
560        let _ = writeln!(text, "            cranpose_capabilities::Use::{call},");
561    }
562    text.push_str("        ],\n        demands: &[\n");
563    for demand in capabilities.demands {
564        let _ = writeln!(
565            text,
566            "            cranpose_capabilities::Demand::{demand:?},"
567        );
568    }
569    text.push_str("        ],\n        opens: &[\n");
570    for media_type in capabilities.opens {
571        let _ = writeln!(text, "            \"{}\",", escape(media_type));
572    }
573    text.push_str("        ],\n    };\n");
574    text
575}
576
577/// The call that rebuilds one entry, which is the service's own name with the
578/// dashes a Rust function cannot carry turned back into underscores.
579fn constructor(entry: &Use) -> String {
580    let name = entry.service.name().replace('-', "_");
581    match entry.reason {
582        Some(reason) => format!("{name}(\"{}\")", escape(reason)),
583        None => format!("{name}()"),
584    }
585}
586
587fn escape(text: &str) -> String {
588    text.replace('\\', "\\\\").replace('"', "\\\"")
589}
590
591/// The declaration as the platform builds read it.
592pub fn json(capabilities: &Capabilities<'_>) -> String {
593    let mut text = String::from("{\n  \"services\": [\n");
594    for (at, entry) in capabilities.uses.iter().enumerate() {
595        let comma = if at + 1 == capabilities.uses.len() {
596            ""
597        } else {
598            ","
599        };
600        let reason = match entry.reason {
601            Some(reason) => format!("\"{}\"", escape_json(reason)),
602            None => String::from("null"),
603        };
604        let _ = writeln!(
605            text,
606            "    {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
607            entry.service.name()
608        );
609    }
610    text.push_str("  ],\n  \"permissions\": [\n");
611    let permissions = android_permissions(capabilities);
612    for (at, permission) in permissions.iter().enumerate() {
613        let comma = if at + 1 == permissions.len() { "" } else { "," };
614        let _ = writeln!(text, "    \"{permission}\"{comma}");
615    }
616    text.push_str("  ],\n  \"permissionsUpTo\": [\n");
617    let capped = android_permissions_up_to(capabilities);
618    for (at, (permission, level)) in capped.iter().enumerate() {
619        let comma = if at + 1 == capped.len() { "" } else { "," };
620        let _ = writeln!(
621            text,
622            "    {{ \"name\": \"{permission}\", \"maxSdk\": {level} }}{comma}"
623        );
624    }
625    text.push_str("  ],\n  \"demands\": [\n");
626    for (at, demand) in capabilities.demands.iter().enumerate() {
627        let comma = if at + 1 == capabilities.demands.len() {
628            ""
629        } else {
630            ","
631        };
632        let _ = writeln!(text, "    \"{}\"{comma}", demand.android_feature());
633    }
634    text.push_str("  ],\n  \"opens\": [\n");
635    for (at, media_type) in capabilities.opens.iter().enumerate() {
636        let comma = if at + 1 == capabilities.opens.len() {
637            ""
638        } else {
639            ","
640        };
641        let _ = writeln!(text, "    \"{}\"{comma}", escape_json(media_type));
642    }
643    text.push_str("  ]\n}\n");
644    text
645}
646
647fn escape_json(text: &str) -> String {
648    escape(text).replace('\n', "\\n")
649}
650
651/// Every Android permission the declared services need, in order and without
652/// repeats.
653pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
654    let mut named = BTreeSet::new();
655    for entry in capabilities.uses {
656        for permission in entry.service.android_permissions() {
657            named.insert(*permission);
658        }
659    }
660    named.into_iter().collect()
661}
662
663/// The permissions the declaration needs only up to an API level, each with
664/// that level, without repeats and leaving out any the newer list already
665/// names.
666pub fn android_permissions_up_to(capabilities: &Capabilities<'_>) -> Vec<(&'static str, u32)> {
667    let plain = android_permissions(capabilities);
668    let mut named = std::collections::BTreeMap::new();
669    for entry in capabilities.uses {
670        for (permission, level) in entry.service.android_permissions_up_to() {
671            if !plain.contains(permission) {
672                named.insert(*permission, *level);
673            }
674        }
675    }
676    named.into_iter().collect()
677}
678
679/// The Android manifest fragment the declaration becomes.
680pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
681    let mut text = String::from(
682        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
683         <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
684    );
685    for permission in android_permissions(capabilities) {
686        let _ = writeln!(
687            text,
688            "    <uses-permission android:name=\"{permission}\" />"
689        );
690    }
691    for (permission, level) in android_permissions_up_to(capabilities) {
692        let _ = writeln!(
693            text,
694            "    <uses-permission android:name=\"{permission}\" android:maxSdkVersion=\"{level}\" />"
695        );
696    }
697    for demand in capabilities.demands {
698        let _ = writeln!(
699            text,
700            "    <uses-feature android:name=\"{}\" android:required=\"true\" />",
701            demand.android_feature()
702        );
703    }
704    if !capabilities.opens.is_empty() {
705        text.push_str(&android_opening_activity(capabilities.opens));
706    }
707    text.push_str("</manifest>\n");
708    text
709}
710
711/// The activity entry that offers the application for `opens`: sending a file
712/// to it and opening one with it.
713fn android_opening_activity(opens: &[&str]) -> String {
714    let mut text = String::from(
715        "    <application>\n        \
716         <activity android:name=\"dev.cranpose.android.CranposeActivity\">\n",
717    );
718    for actions in [
719        &[
720            "android.intent.action.SEND",
721            "android.intent.action.SEND_MULTIPLE",
722        ][..],
723        &["android.intent.action.VIEW"][..],
724    ] {
725        text.push_str("            <intent-filter>\n");
726        for action in actions {
727            let _ = writeln!(text, "                <action android:name=\"{action}\" />");
728        }
729        text.push_str(
730            "                <category android:name=\"android.intent.category.DEFAULT\" />\n",
731        );
732        for media_type in opens {
733            let _ = writeln!(
734                text,
735                "                <data android:mimeType=\"{}\" />",
736                escape_xml(media_type)
737            );
738        }
739        text.push_str("            </intent-filter>\n");
740    }
741    text.push_str("        </activity>\n    </application>\n");
742    text
743}
744
745/// The Apple usage descriptions the declaration becomes, with the watch
746/// background modes its services run under, as a property list to merge
747/// into an `Info.plist`.
748///
749/// It is a property list of its own, so an Apple build merges it with one
750/// line and no tool beyond the ones macOS ships:
751///
752/// ```text
753/// /usr/libexec/PlistBuddy -c "Merge target/cranpose/my-app-usage.plist" MyApp.app/Info.plist
754/// ```
755pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
756    let mut text = String::from(
757        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
758         <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
759         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
760         <plist version=\"1.0\">\n<dict>\n",
761    );
762    let mut modes: BTreeSet<&str> = BTreeSet::new();
763    for entry in capabilities.uses {
764        modes.extend(entry.service.watch_background_modes());
765        let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
766            continue;
767        };
768        for key in std::iter::once(key).chain(entry.service.apple_more_keys().iter().copied()) {
769            let _ = writeln!(text, "\t<key>{key}</key>");
770            let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
771        }
772    }
773    if !modes.is_empty() {
774        text.push_str("\t<key>WKBackgroundModes</key>\n\t<array>\n");
775        for mode in modes {
776            let _ = writeln!(text, "\t\t<string>{mode}</string>");
777        }
778        text.push_str("\t</array>\n");
779    }
780    text.push_str("</dict>\n</plist>\n");
781    text
782}
783
784fn escape_xml(text: &str) -> String {
785    text.replace('&', "&amp;")
786        .replace('<', "&lt;")
787        .replace('>', "&gt;")
788}
789
790#[cfg(test)]
791#[path = "tests/capabilities_tests.rs"]
792mod tests;