Skip to main content

cranpose_capabilities/
lib.rs

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