1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct AndroidPermission {
21 pub name: &'static str,
22 pub max_sdk: Option<u32>,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum OhosScene {
31 InUse,
32 Always,
33}
34
35impl OhosScene {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 OhosScene::InUse => "inuse",
39 OhosScene::Always => "always",
40 }
41 }
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct OhosPermission {
47 pub name: &'static str,
48 pub when: OhosScene,
49}
50
51#[derive(Clone, Copy, Debug)]
53pub struct PermissionSpec {
54 pub name: &'static str,
56 pub variant: &'static str,
59 pub android: &'static [AndroidPermission],
60 pub ios: &'static [&'static str],
61 pub macos: &'static [&'static str],
62 pub ohos: &'static [OhosPermission],
63 pub needs_reason: bool,
66}
67
68const fn android(name: &'static str) -> AndroidPermission {
69 AndroidPermission {
70 name,
71 max_sdk: None,
72 }
73}
74
75const fn ohos(name: &'static str) -> OhosPermission {
76 OhosPermission {
77 name,
78 when: OhosScene::InUse,
79 }
80}
81
82pub const ALL: &[PermissionSpec] = &[
84 PermissionSpec {
85 name: "location-when-in-use",
86 variant: "Location",
87 android: &[
88 android("android.permission.ACCESS_FINE_LOCATION"),
89 android("android.permission.ACCESS_COARSE_LOCATION"),
90 ],
91 ios: &["NSLocationWhenInUseUsageDescription"],
92 macos: &[
94 "NSLocationWhenInUseUsageDescription",
95 "NSLocationUsageDescription",
96 ],
97 ohos: &[
98 ohos("ohos.permission.APPROXIMATELY_LOCATION"),
99 ohos("ohos.permission.LOCATION"),
100 ],
101 needs_reason: true,
102 },
103 PermissionSpec {
104 name: "location-always",
105 variant: "LocationAlways",
106 android: &[
107 android("android.permission.ACCESS_FINE_LOCATION"),
108 android("android.permission.ACCESS_COARSE_LOCATION"),
109 android("android.permission.ACCESS_BACKGROUND_LOCATION"),
110 ],
111 ios: &[
113 "NSLocationAlwaysAndWhenInUseUsageDescription",
114 "NSLocationWhenInUseUsageDescription",
115 ],
116 macos: &[
117 "NSLocationAlwaysAndWhenInUseUsageDescription",
118 "NSLocationWhenInUseUsageDescription",
119 "NSLocationUsageDescription",
120 ],
121 ohos: &[
122 ohos("ohos.permission.APPROXIMATELY_LOCATION"),
123 ohos("ohos.permission.LOCATION"),
124 OhosPermission {
125 name: "ohos.permission.LOCATION_IN_BACKGROUND",
126 when: OhosScene::Always,
127 },
128 ],
129 needs_reason: true,
130 },
131 PermissionSpec {
132 name: "camera",
133 variant: "Camera",
134 android: &[android("android.permission.CAMERA")],
135 ios: &["NSCameraUsageDescription"],
136 macos: &["NSCameraUsageDescription"],
137 ohos: &[ohos("ohos.permission.CAMERA")],
138 needs_reason: true,
139 },
140 PermissionSpec {
141 name: "microphone",
142 variant: "Microphone",
143 android: &[android("android.permission.RECORD_AUDIO")],
144 ios: &["NSMicrophoneUsageDescription"],
145 macos: &["NSMicrophoneUsageDescription"],
146 ohos: &[ohos("ohos.permission.MICROPHONE")],
147 needs_reason: true,
148 },
149 PermissionSpec {
150 name: "notifications",
151 variant: "Notifications",
152 android: &[android("android.permission.POST_NOTIFICATIONS")],
154 ios: &[],
157 macos: &[],
158 ohos: &[],
159 needs_reason: false,
160 },
161 PermissionSpec {
162 name: "photos",
163 variant: "Photos",
164 android: &[
165 AndroidPermission {
166 name: "android.permission.READ_MEDIA_IMAGES",
167 max_sdk: None,
168 },
169 AndroidPermission {
170 name: "android.permission.READ_MEDIA_VIDEO",
171 max_sdk: None,
172 },
173 AndroidPermission {
174 name: "android.permission.READ_EXTERNAL_STORAGE",
175 max_sdk: Some(32),
176 },
177 ],
178 ios: &["NSPhotoLibraryUsageDescription"],
179 macos: &["NSPhotoLibraryUsageDescription"],
180 ohos: &[ohos("ohos.permission.READ_IMAGEVIDEO")],
183 needs_reason: true,
184 },
185 PermissionSpec {
186 name: "motion",
187 variant: "Motion",
188 android: &[android("android.permission.ACTIVITY_RECOGNITION")],
189 ios: &["NSMotionUsageDescription"],
190 macos: &[],
192 ohos: &[ohos("ohos.permission.ACTIVITY_MOTION")],
193 needs_reason: true,
194 },
195];
196
197pub const OHOS_PHOTOS_APL_NOTE: &str = "ohos.permission.READ_IMAGEVIDEO is a system_basic permission, which an app signed at the \
200 default `normal` level cannot be granted. Prefer PhotoViewPicker, which needs no permission.";
201
202pub fn find(name: &str) -> Option<&'static PermissionSpec> {
204 ALL.iter().find(|s| s.name == name)
205}
206
207pub fn find_variant(variant: &str) -> Option<&'static PermissionSpec> {
209 ALL.iter().find(|s| s.variant == variant)
210}
211
212pub fn names() -> Vec<&'static str> {
214 ALL.iter().map(|s| s.name).collect()
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn names_are_unique_kebab_and_round_trip() {
223 let mut seen = std::collections::BTreeSet::new();
224 for spec in ALL {
225 assert!(seen.insert(spec.name), "duplicate name {}", spec.name);
226 assert!(
227 spec.name
228 .chars()
229 .all(|c| c.is_ascii_lowercase() || c == '-'),
230 "{} is not kebab-case",
231 spec.name
232 );
233 assert_eq!(find(spec.name).map(|s| s.variant), Some(spec.variant));
234 assert_eq!(find_variant(spec.variant).map(|s| s.name), Some(spec.name));
235 }
236 assert_eq!(names().len(), ALL.len());
237 assert!(find("nonsense").is_none());
238 }
239
240 #[test]
243 fn location_always_also_declares_when_in_use() {
244 let spec = find("location-always").expect("location-always");
245 assert!(spec.ios.contains(&"NSLocationWhenInUseUsageDescription"));
246 assert!(spec.macos.contains(&"NSLocationWhenInUseUsageDescription"));
247 }
248
249 #[test]
252 fn notifications_declares_android_only() {
253 let spec = find("notifications").expect("notifications");
254 assert_eq!(spec.android.len(), 1);
255 assert!(spec.ios.is_empty() && spec.macos.is_empty() && spec.ohos.is_empty());
256 assert!(!spec.needs_reason);
257 }
258
259 #[test]
261 fn photos_caps_legacy_storage() {
262 let spec = find("photos").expect("photos");
263 let legacy = spec
264 .android
265 .iter()
266 .find(|p| p.name.ends_with("READ_EXTERNAL_STORAGE"))
267 .expect("legacy storage permission");
268 assert_eq!(legacy.max_sdk, Some(32));
269 for p in spec
271 .android
272 .iter()
273 .filter(|p| p.name.contains("READ_MEDIA"))
274 {
275 assert_eq!(p.max_sdk, None, "{} should not be capped", p.name);
276 }
277 }
278
279 #[test]
281 fn reasons_have_a_destination() {
282 for spec in ALL {
283 if spec.needs_reason {
284 assert!(
285 !spec.ios.is_empty() || !spec.ohos.is_empty(),
286 "{} needs a reason but no platform consumes it",
287 spec.name
288 );
289 } else {
290 assert!(
291 spec.ios.is_empty() && spec.macos.is_empty() && spec.ohos.is_empty(),
292 "{} needs no reason, so it must declare no reason-carrying key",
293 spec.name
294 );
295 }
296 }
297 }
298
299 #[test]
301 fn motion_is_ios_only_on_apple() {
302 let spec = find("motion").expect("motion");
303 assert!(!spec.ios.is_empty());
304 assert!(spec.macos.is_empty());
305 }
306}