Skip to main content

day_build/
permissions.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! The portable-permission → native-declaration table (docs/permissions.md).
5//!
6//! Two consumers need exactly this knowledge and must never disagree about it:
7//!
8//! - `day-cli` turns a `[permissions]` table in `Day.toml` into `<uses-permission>` entries, iOS and
9//!   macOS `Info.plist` usage-description keys, and HarmonyOS `module.json5` `requestPermissions`.
10//! - `day-part-permissions` asks the OS about the same permissions at runtime.
11//!
12//! It lives here rather than in a new crate because `day-build` is already published, already a
13//! `day-cli` dependency, and already carries the tree's other CLI-and-runtime shared mapping (the
14//! resource name → identifier table), for the same reason: a generated declaration must never drift
15//! from the constant the app's code names.
16//!
17//! Two rows break any naive version of this table, so they are worth stating up front:
18//! **notifications** needs an Android permission but NO iOS/macOS plist key and no HarmonyOS entry,
19//! and **photos** needs three Android permissions, one of them version-capped.
20
21/// An Android permission id, plus the API level after which it must not be requested.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct AndroidPermission {
24    pub name: &'static str,
25    /// Emitted as `android:maxSdkVersion`. Only the legacy storage permission needs it: from API 33
26    /// the granular `READ_MEDIA_*` permissions replace it, and leaving it uncapped makes stores flag
27    /// the app for requesting broad storage access it no longer uses.
28    pub max_sdk: Option<u32>,
29}
30
31/// When a HarmonyOS permission is used, which its `module.json5` entry must declare.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum OhosScene {
34    InUse,
35    Always,
36}
37
38impl OhosScene {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            OhosScene::InUse => "inuse",
42            OhosScene::Always => "always",
43        }
44    }
45}
46
47/// A HarmonyOS permission name and the scene it is requested for.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct OhosPermission {
50    pub name: &'static str,
51    pub when: OhosScene,
52}
53
54/// One portable permission, and everything each platform needs declared for it.
55#[derive(Clone, Copy, Debug)]
56pub struct PermissionSpec {
57    /// The kebab-case name written in `Day.toml`'s `[permissions]` table.
58    pub name: &'static str,
59    /// The `day_part_permissions::Permission` variant spelling, so `day lint` can map a source
60    /// reference back to a declaration.
61    pub variant: &'static str,
62    pub android: &'static [AndroidPermission],
63    pub ios: &'static [&'static str],
64    pub macos: &'static [&'static str],
65    pub ohos: &'static [OhosPermission],
66    /// Whether a user-facing reason is required. False only for notifications, which no platform
67    /// asks a reason for.
68    pub needs_reason: bool,
69}
70
71const fn android(name: &'static str) -> AndroidPermission {
72    AndroidPermission {
73        name,
74        max_sdk: None,
75    }
76}
77
78const fn ohos(name: &'static str) -> OhosPermission {
79    OhosPermission {
80        name,
81        when: OhosScene::InUse,
82    }
83}
84
85/// Every portable permission Day knows how to declare.
86pub const ALL: &[PermissionSpec] = &[
87    PermissionSpec {
88        name: "location-when-in-use",
89        variant: "Location",
90        android: &[
91            android("android.permission.ACCESS_FINE_LOCATION"),
92            android("android.permission.ACCESS_COARSE_LOCATION"),
93        ],
94        ios: &["NSLocationWhenInUseUsageDescription"],
95        // macOS also wants the legacy key: some frameworks still read it.
96        macos: &[
97            "NSLocationWhenInUseUsageDescription",
98            "NSLocationUsageDescription",
99        ],
100        ohos: &[
101            ohos("ohos.permission.APPROXIMATELY_LOCATION"),
102            ohos("ohos.permission.LOCATION"),
103        ],
104        needs_reason: true,
105    },
106    PermissionSpec {
107        name: "location-always",
108        variant: "LocationAlways",
109        android: &[
110            android("android.permission.ACCESS_FINE_LOCATION"),
111            android("android.permission.ACCESS_COARSE_LOCATION"),
112            android("android.permission.ACCESS_BACKGROUND_LOCATION"),
113        ],
114        // Apple requires BOTH keys: a plist carrying only the Always key suppresses the prompt.
115        ios: &[
116            "NSLocationAlwaysAndWhenInUseUsageDescription",
117            "NSLocationWhenInUseUsageDescription",
118        ],
119        macos: &[
120            "NSLocationAlwaysAndWhenInUseUsageDescription",
121            "NSLocationWhenInUseUsageDescription",
122            "NSLocationUsageDescription",
123        ],
124        ohos: &[
125            ohos("ohos.permission.APPROXIMATELY_LOCATION"),
126            ohos("ohos.permission.LOCATION"),
127            OhosPermission {
128                name: "ohos.permission.LOCATION_IN_BACKGROUND",
129                when: OhosScene::Always,
130            },
131        ],
132        needs_reason: true,
133    },
134    PermissionSpec {
135        name: "camera",
136        variant: "Camera",
137        android: &[android("android.permission.CAMERA")],
138        ios: &["NSCameraUsageDescription"],
139        macos: &["NSCameraUsageDescription"],
140        ohos: &[ohos("ohos.permission.CAMERA")],
141        needs_reason: true,
142    },
143    PermissionSpec {
144        name: "microphone",
145        variant: "Microphone",
146        android: &[android("android.permission.RECORD_AUDIO")],
147        ios: &["NSMicrophoneUsageDescription"],
148        macos: &["NSMicrophoneUsageDescription"],
149        ohos: &[ohos("ohos.permission.MICROPHONE")],
150        needs_reason: true,
151    },
152    PermissionSpec {
153        name: "notifications",
154        variant: "Notifications",
155        // Ignored below API 33, so it is declared unconditionally.
156        android: &[android("android.permission.POST_NOTIFICATIONS")],
157        // Apple asks for notification permission at runtime with no plist key, and HarmonyOS gates
158        // it through a runtime `requestEnableNotification` call rather than the manifest.
159        ios: &[],
160        macos: &[],
161        ohos: &[],
162        needs_reason: false,
163    },
164    PermissionSpec {
165        name: "photos",
166        variant: "Photos",
167        android: &[
168            AndroidPermission {
169                name: "android.permission.READ_MEDIA_IMAGES",
170                max_sdk: None,
171            },
172            AndroidPermission {
173                name: "android.permission.READ_MEDIA_VIDEO",
174                max_sdk: None,
175            },
176            AndroidPermission {
177                name: "android.permission.READ_EXTERNAL_STORAGE",
178                max_sdk: Some(32),
179            },
180        ],
181        ios: &["NSPhotoLibraryUsageDescription"],
182        macos: &["NSPhotoLibraryUsageDescription"],
183        // NOTE: `READ_IMAGEVIDEO` is `system_basic` apl, which an app signed at `normal` cannot
184        // hold — see OHOS_PHOTOS_APL_NOTE. The picker needs no permission at all.
185        ohos: &[ohos("ohos.permission.READ_IMAGEVIDEO")],
186        needs_reason: true,
187    },
188    PermissionSpec {
189        name: "motion",
190        variant: "Motion",
191        android: &[android("android.permission.ACTIVITY_RECOGNITION")],
192        ios: &["NSMotionUsageDescription"],
193        // CoreMotion's activity APIs are iOS-only; macOS has nothing to declare.
194        macos: &[],
195        ohos: &[ohos("ohos.permission.ACTIVITY_MOTION")],
196        needs_reason: true,
197    },
198];
199
200/// The warning both the CLI and the docs use for HarmonyOS photo access, kept in one place so they
201/// cannot drift.
202pub const OHOS_PHOTOS_APL_NOTE: &str = "ohos.permission.READ_IMAGEVIDEO is a system_basic permission, which an app signed at the \
203     default `normal` level cannot be granted. Prefer PhotoViewPicker, which needs no permission.";
204
205/// Look a permission up by its `Day.toml` name.
206pub fn find(name: &str) -> Option<&'static PermissionSpec> {
207    ALL.iter().find(|s| s.name == name)
208}
209
210/// Look a permission up by its Rust variant spelling (`day lint`'s source scan).
211pub fn find_variant(variant: &str) -> Option<&'static PermissionSpec> {
212    ALL.iter().find(|s| s.variant == variant)
213}
214
215/// Every valid `[permissions]` key, for error messages.
216pub fn names() -> Vec<&'static str> {
217    ALL.iter().map(|s| s.name).collect()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn names_are_unique_kebab_and_round_trip() {
226        let mut seen = std::collections::BTreeSet::new();
227        for spec in ALL {
228            assert!(seen.insert(spec.name), "duplicate name {}", spec.name);
229            assert!(
230                spec.name
231                    .chars()
232                    .all(|c| c.is_ascii_lowercase() || c == '-'),
233                "{} is not kebab-case",
234                spec.name
235            );
236            assert_eq!(find(spec.name).map(|s| s.variant), Some(spec.variant));
237            assert_eq!(find_variant(spec.variant).map(|s| s.name), Some(spec.name));
238        }
239        assert_eq!(names().len(), ALL.len());
240        assert!(find("nonsense").is_none());
241    }
242
243    /// A plist carrying only `NSLocationAlwaysAndWhenInUseUsageDescription` suppresses the prompt —
244    /// Apple requires the when-in-use key alongside it.
245    #[test]
246    fn location_always_also_declares_when_in_use() {
247        let spec = find("location-always").expect("location-always");
248        assert!(spec.ios.contains(&"NSLocationWhenInUseUsageDescription"));
249        assert!(spec.macos.contains(&"NSLocationWhenInUseUsageDescription"));
250    }
251
252    /// The row a naive table gets wrong in the other direction: an Android permission, but nothing
253    /// to declare on Apple or HarmonyOS, and no reason anywhere.
254    #[test]
255    fn notifications_declares_android_only() {
256        let spec = find("notifications").expect("notifications");
257        assert_eq!(spec.android.len(), 1);
258        assert!(spec.ios.is_empty() && spec.macos.is_empty() && spec.ohos.is_empty());
259        assert!(!spec.needs_reason);
260    }
261
262    /// Legacy storage must be capped at 32 or stores flag the app for over-broad access.
263    #[test]
264    fn photos_caps_legacy_storage() {
265        let spec = find("photos").expect("photos");
266        let legacy = spec
267            .android
268            .iter()
269            .find(|p| p.name.ends_with("READ_EXTERNAL_STORAGE"))
270            .expect("legacy storage permission");
271        assert_eq!(legacy.max_sdk, Some(32));
272        // The granular replacements must NOT be capped.
273        for p in spec
274            .android
275            .iter()
276            .filter(|p| p.name.contains("READ_MEDIA"))
277        {
278            assert_eq!(p.max_sdk, None, "{} should not be capped", p.name);
279        }
280    }
281
282    /// Every permission that needs a reason must have somewhere to put one.
283    #[test]
284    fn reasons_have_a_destination() {
285        for spec in ALL {
286            if spec.needs_reason {
287                assert!(
288                    !spec.ios.is_empty() || !spec.ohos.is_empty(),
289                    "{} needs a reason but no platform consumes it",
290                    spec.name
291                );
292            } else {
293                assert!(
294                    spec.ios.is_empty() && spec.macos.is_empty() && spec.ohos.is_empty(),
295                    "{} needs no reason, so it must declare no reason-carrying key",
296                    spec.name
297                );
298            }
299        }
300    }
301
302    /// macOS has no CoreMotion activity API, so it has nothing to declare for motion.
303    #[test]
304    fn motion_is_ios_only_on_apple() {
305        let spec = find("motion").expect("motion");
306        assert!(!spec.ios.is_empty());
307        assert!(spec.macos.is_empty());
308    }
309}