1use std::{fmt::Write as _, fs};
2
3use camino::{Utf8Path, Utf8PathBuf};
4use rustferry_codegen::{
5 GeneratedAssetPlatform, GeneratedAssetSet, read_generated_platform_assets,
6 render_platform_assets_for,
7};
8use rustferry_core::{
9 AndroidLiveActivityFallback, FerryConfig, NetworkMode, Orientation, ProjectAssets, Theme,
10};
11use sha2::{Digest, Sha256};
12
13use crate::{
14 ACTIVITY_CLASS, AndroidError, FILE_PROVIDER_CLASS, NOTIFICATION_RECEIVER_CLASS,
15 WIDGET_PROVIDER_CLASS, bridge::generate_bridge_sources, error::io_error,
16};
17
18#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct GeneratedAndroidContent {
21 pub manifest: String,
23 pub resources: Vec<(Utf8PathBuf, String)>,
25 pub binary_resources: Vec<(Utf8PathBuf, Vec<u8>)>,
27 pub java_sources: Vec<(Utf8PathBuf, String)>,
29 pub fingerprint: String,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct GeneratedAndroidFiles {
36 pub root: Utf8PathBuf,
38 pub manifest: Utf8PathBuf,
40 pub resources: Utf8PathBuf,
42 pub java_sources: Vec<Utf8PathBuf>,
44}
45
46pub fn generate_android_content(
53 config: &FerryConfig,
54 native_library_name: &str,
55 target_sdk: u32,
56 has_dex: bool,
57 debuggable: bool,
58) -> Result<GeneratedAndroidContent, AndroidError> {
59 generate_android_content_inner(
60 config,
61 native_library_name,
62 target_sdk,
63 has_dex,
64 debuggable,
65 None,
66 )
67}
68
69pub(crate) fn generate_android_content_with_assets(
70 config: &FerryConfig,
71 native_library_name: &str,
72 target_sdk: u32,
73 has_dex: bool,
74 debuggable: bool,
75 assets: &ProjectAssets,
76) -> Result<GeneratedAndroidContent, AndroidError> {
77 generate_android_content_inner(
78 config,
79 native_library_name,
80 target_sdk,
81 has_dex,
82 debuggable,
83 Some(assets),
84 )
85}
86
87fn generate_android_content_inner(
88 config: &FerryConfig,
89 native_library_name: &str,
90 target_sdk: u32,
91 has_dex: bool,
92 debuggable: bool,
93 assets: Option<&ProjectAssets>,
94) -> Result<GeneratedAndroidContent, AndroidError> {
95 if !has_dex {
96 return Err(AndroidError::InvalidRequest(
97 "the mandatory Android runtime bridge requires DEX".to_owned(),
98 ));
99 }
100 validate_native_library_name(native_library_name)?;
101 config
102 .validate_or_error()
103 .map_err(|error| AndroidError::InvalidConfig(error.to_string()))?;
104 let app_name = escape_xml(&config.app.name)?;
105 let application_id = escape_xml(&config.app.identifier)?;
106 let native_library_name = escape_xml(native_library_name)?;
107 let permissions = android_manifest_permissions(config, target_sdk);
108 let mut permission_xml = String::new();
109 for (permission, max_sdk) in permissions {
110 if let Some(max_sdk) = max_sdk {
111 let _ = writeln!(
112 permission_xml,
113 " <uses-permission android:name=\"{permission}\" android:maxSdkVersion=\"{max_sdk}\" />"
114 );
115 } else {
116 let _ = writeln!(
117 permission_xml,
118 " <uses-permission android:name=\"{permission}\" />"
119 );
120 }
121 }
122
123 let orientation = match config.app.window.orientation {
124 Orientation::Automatic => String::new(),
125 Orientation::Portrait => "\n android:screenOrientation=\"portrait\"".to_owned(),
126 Orientation::Landscape => {
127 "\n android:screenOrientation=\"landscape\"".to_owned()
128 }
129 };
130 let deep_links = if config.capabilities.deep_links.schemes.is_empty() {
131 String::new()
132 } else {
133 let hosts = if config.capabilities.deep_links.allowed_hosts.is_empty() {
134 vec![None]
135 } else {
136 config
137 .capabilities
138 .deep_links
139 .allowed_hosts
140 .iter()
141 .map(|host| Some(host.as_str()))
142 .collect()
143 };
144 let actions = if config.capabilities.deep_links.allowed_actions.is_empty() {
145 vec![None]
146 } else {
147 config
148 .capabilities
149 .deep_links
150 .allowed_actions
151 .iter()
152 .map(|action| Some(action.as_str()))
153 .collect()
154 };
155 let mut data = String::new();
156 for scheme in &config.capabilities.deep_links.schemes {
157 let scheme = escape_xml(scheme)?;
158 for host in &hosts {
159 for action in &actions {
160 let _ = write!(data, " <data android:scheme=\"{scheme}\"");
161 if let Some(host) = host {
162 let _ = write!(data, " android:host=\"{}\"", escape_xml(host)?);
163 }
164 if let Some(action) = action {
165 let _ = write!(data, " android:pathPrefix=\"/{}\"", escape_xml(action)?);
166 }
167 data.push_str(" />\n");
168 }
169 }
170 }
171 format!(
172 " <intent-filter>\n <action android:name=\"android.intent.action.VIEW\" />\n <category android:name=\"android.intent.category.DEFAULT\" />\n <category android:name=\"android.intent.category.BROWSABLE\" />\n{data} </intent-filter>\n"
173 )
174 };
175 let widget_component = if config.extensions.widget.enabled {
176 format!(
177 " <receiver\n android:name=\"{WIDGET_PROVIDER_CLASS}\"\n android:enabled=\"true\"\n android:exported=\"false\">\n <intent-filter>\n <action android:name=\"android.appwidget.action.APPWIDGET_UPDATE\" />\n </intent-filter>\n <meta-data\n android:name=\"android.appwidget.provider\"\n android:resource=\"@xml/ferry_widget_info\" />\n </receiver>\n"
178 )
179 } else {
180 String::new()
181 };
182 let live_activity_fallback = config.extensions.live_activity.enabled
183 && config.extensions.live_activity.android_fallback
184 == AndroidLiveActivityFallback::OngoingNotification;
185 let notification_component = if config.capabilities.notifications.local
186 || live_activity_fallback
187 {
188 format!(
189 " <receiver\n android:name=\"{NOTIFICATION_RECEIVER_CLASS}\"\n android:enabled=\"true\"\n android:exported=\"false\" />\n"
190 )
191 } else {
192 String::new()
193 };
194 let file_provider_component = if config.capabilities.share.enabled {
195 format!(
196 " <provider\n android:name=\"{FILE_PROVIDER_CLASS}\"\n android:authorities=\"{application_id}.ferry-files\"\n android:exported=\"false\"\n android:grantUriPermissions=\"true\" />\n"
197 )
198 } else {
199 String::new()
200 };
201 let icon_resource = if assets.is_some() {
202 "@mipmap/ferry_icon"
203 } else {
204 "@drawable/ferry_icon"
205 };
206 let manifest = format!(
207 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\" package=\"{application_id}\">\n{permission_xml} <uses-sdk android:minSdkVersion=\"{}\" android:targetSdkVersion=\"{target_sdk}\" />\n <application\n android:allowBackup=\"false\"\n android:debuggable=\"{}\"\n android:extractNativeLibs=\"false\"\n android:hasCode=\"true\"\n android:icon=\"{icon_resource}\"\n android:label=\"@string/app_name\"\n android:supportsRtl=\"true\"\n android:theme=\"@style/FerryTheme\">\n <activity\n android:name=\"{ACTIVITY_CLASS}\"\n android:configChanges=\"orientation|screenSize|keyboardHidden|uiMode|density\"\n android:exported=\"true\"\n android:launchMode=\"singleTop\"{orientation}>\n <meta-data android:name=\"android.app.lib_name\" android:value=\"{native_library_name}\" />\n <intent-filter>\n <action android:name=\"android.intent.action.MAIN\" />\n <category android:name=\"android.intent.category.LAUNCHER\" />\n </intent-filter>\n{deep_links} </activity>\n{notification_component}{widget_component}{file_provider_component} </application>\n</manifest>\n",
208 config.android.min_sdk,
209 if debuggable { "true" } else { "false" },
210 );
211
212 let mut resources = vec![
213 (
214 Utf8PathBuf::from("res/values/strings.xml"),
215 format!(
216 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"app_name\">{app_name}</string>\n</resources>\n"
217 ),
218 ),
219 (
220 Utf8PathBuf::from("res/values/styles.xml"),
221 styles(config.app.window.theme, false),
222 ),
223 (
224 Utf8PathBuf::from("res/drawable/ferry_icon.xml"),
225 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:width=\"48dp\" android:height=\"48dp\" android:viewportWidth=\"48\" android:viewportHeight=\"48\">\n <path android:fillColor=\"#315CF5\" android:pathData=\"M8,4h32a4,4 0,0 1,4 4v32a4,4 0,0 1,-4 4h-32a4,4 0,0 1,-4 -4v-32a4,4 0,0 1,4 -4z\" />\n <path android:fillColor=\"#FFFFFFFF\" android:pathData=\"M15,14h13a8,8 0,0 1,0 16h-7v6h-6zM21,20v4h7a2,2 0,0 0,0 -4z\" />\n</vector>\n".to_owned(),
226 ),
227 (
228 Utf8PathBuf::from("res/drawable/ferry_splash.xml"),
229 splash(config.app.window.theme),
230 ),
231 ];
232 if config.app.window.theme == Theme::System {
233 resources.extend([
234 (
235 Utf8PathBuf::from("res/drawable-night/ferry_splash.xml"),
236 splash(Theme::Dark),
237 ),
238 (
239 Utf8PathBuf::from("res/values-night/styles.xml"),
240 styles(Theme::Dark, true),
241 ),
242 ]);
243 }
244 if config.extensions.widget.enabled {
245 resources.extend([
246 (
247 Utf8PathBuf::from("res/layout/ferry_widget.xml"),
248 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<LinearLayout xmlns:android=\"http://schemas.android.com/apk/res/android\" android:layout_width=\"match_parent\" android:layout_height=\"match_parent\" android:orientation=\"vertical\" android:gravity=\"center\" android:padding=\"16dp\">\n <TextView android:id=\"@+id/ferry_widget_text\" android:layout_width=\"match_parent\" android:layout_height=\"0dp\" android:layout_weight=\"1\" android:gravity=\"center\" android:text=\"@string/app_name\" android:textSize=\"18sp\" />\n <ProgressBar android:id=\"@+id/ferry_widget_progress\" style=\"?android:attr/progressBarStyleHorizontal\" android:layout_width=\"match_parent\" android:layout_height=\"wrap_content\" android:max=\"100\" android:visibility=\"gone\" />\n</LinearLayout>\n".to_owned(),
249 ),
250 (
251 Utf8PathBuf::from("res/xml/ferry_widget_info.xml"),
252 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<appwidget-provider xmlns:android=\"http://schemas.android.com/apk/res/android\" android:initialLayout=\"@layout/ferry_widget\" android:minWidth=\"110dp\" android:minHeight=\"40dp\" android:resizeMode=\"horizontal|vertical\" android:updatePeriodMillis=\"0\" android:widgetCategory=\"home_screen\" />\n".to_owned(),
253 ),
254 ]);
255 }
256 let mut binary_resources = Vec::new();
257 if let Some(assets) = assets {
258 resources.retain(|(path, _)| {
259 !matches!(
260 path.as_str(),
261 "res/drawable/ferry_icon.xml"
262 | "res/drawable/ferry_splash.xml"
263 | "res/drawable-night/ferry_splash.xml"
264 )
265 });
266 binary_resources = android_resource_paths(
267 render_platform_assets_for(assets, GeneratedAssetPlatform::Android)?
268 .files
269 .into_iter()
270 .filter_map(|(path, bytes)| {
271 path.strip_prefix("android")
272 .ok()
273 .map(|relative| (relative.to_owned(), bytes))
274 })
275 .collect(),
276 )?;
277 }
278 resources.sort_by(|left, right| left.0.cmp(&right.0));
279 binary_resources.sort_by(|left, right| left.0.cmp(&right.0));
280 let java_sources = generate_bridge_sources(config, &native_library_name);
281
282 let mut hasher = Sha256::new();
283 hasher.update(
284 serde_json::to_vec(config)
285 .map_err(|error| AndroidError::InvalidConfig(error.to_string()))?,
286 );
287 hasher.update(manifest.as_bytes());
288 for (path, contents) in &resources {
289 hasher.update(path.as_str().as_bytes());
290 hasher.update([0]);
291 hasher.update(contents.as_bytes());
292 hasher.update([0]);
293 }
294 for (path, contents) in &binary_resources {
295 hasher.update(path.as_str().as_bytes());
296 hasher.update([0]);
297 hasher.update(contents);
298 hasher.update([0]);
299 }
300 for (path, contents) in &java_sources {
301 hasher.update(path.as_str().as_bytes());
302 hasher.update([0]);
303 hasher.update(contents.as_bytes());
304 hasher.update([0]);
305 }
306 let fingerprint = hex::encode(&hasher.finalize()[..12]);
307 Ok(GeneratedAndroidContent {
308 manifest,
309 resources,
310 binary_resources,
311 java_sources,
312 fingerprint,
313 })
314}
315
316pub(crate) fn content_from_generated_asset_set(
317 planned: &GeneratedAndroidContent,
318 generated: &GeneratedAssetSet,
319) -> Result<GeneratedAndroidContent, AndroidError> {
320 let cached = android_resource_paths(read_generated_platform_assets(
321 generated,
322 GeneratedAssetPlatform::Android,
323 )?)?;
324 if cached != planned.binary_resources {
325 return Err(AndroidError::InvalidRequest(format!(
326 "generated Android asset cache {} differs from the planned source snapshot",
327 generated.root
328 )));
329 }
330 let mut content = planned.clone();
331 content.binary_resources = cached;
332 Ok(content)
333}
334
335fn android_resource_paths(
336 files: Vec<(Utf8PathBuf, Vec<u8>)>,
337) -> Result<Vec<(Utf8PathBuf, Vec<u8>)>, AndroidError> {
338 let mut resources = Vec::with_capacity(files.len());
339 for (relative, bytes) in files {
340 if relative.is_absolute()
341 || relative.as_str().is_empty()
342 || relative
343 .components()
344 .any(|component| !matches!(component, camino::Utf8Component::Normal(_)))
345 {
346 return Err(AndroidError::InvalidRequest(format!(
347 "generated Android asset path is unsafe: {relative}"
348 )));
349 }
350 resources.push((Utf8PathBuf::from("res").join(relative), bytes));
351 }
352 resources.sort_by(|left, right| left.0.cmp(&right.0));
353 Ok(resources)
354}
355
356pub(crate) fn android_manifest_permissions(
357 config: &FerryConfig,
358 target_sdk: u32,
359) -> Vec<(&'static str, Option<u32>)> {
360 let mut permissions = Vec::new();
361 match config.capabilities.network.mode {
362 NetworkMode::None => {}
363 NetworkMode::Status => {
364 permissions.push(("android.permission.ACCESS_NETWORK_STATE", None));
365 }
366 NetworkMode::Optional | NetworkMode::Required => {
367 permissions.push(("android.permission.ACCESS_NETWORK_STATE", None));
368 permissions.push(("android.permission.INTERNET", None));
369 }
370 }
371 if config.capabilities.network.probe_url.is_some()
372 && !permissions
373 .iter()
374 .any(|(name, _)| *name == "android.permission.INTERNET")
375 {
376 permissions.push(("android.permission.INTERNET", None));
377 }
378 let live_activity_fallback = config.extensions.live_activity.enabled
379 && config.extensions.live_activity.android_fallback
380 == AndroidLiveActivityFallback::OngoingNotification;
381 if config.capabilities.notifications.local || live_activity_fallback {
382 permissions.push(("android.permission.POST_NOTIFICATIONS", None));
383 }
384 if config.capabilities.haptics.enabled {
385 permissions.push(("android.permission.VIBRATE", None));
386 }
387 if config.permissions.camera.enabled {
388 permissions.push(("android.permission.CAMERA", None));
389 }
390 if config.permissions.microphone.enabled {
391 permissions.push(("android.permission.RECORD_AUDIO", None));
392 }
393 if config.permissions.location_when_in_use.enabled {
394 permissions.push(("android.permission.ACCESS_COARSE_LOCATION", None));
395 permissions.push(("android.permission.ACCESS_FINE_LOCATION", None));
396 }
397 if config.permissions.photos.enabled {
398 if config.android.min_sdk < 33 {
399 permissions.push(("android.permission.READ_EXTERNAL_STORAGE", Some(32)));
400 }
401 if target_sdk >= 33 {
402 permissions.push(("android.permission.READ_MEDIA_IMAGES", None));
403 }
404 }
405 permissions.sort_unstable();
406 permissions.dedup();
407 permissions
408}
409
410pub fn write_android_content(
416 output_root: &Utf8Path,
417 content: &GeneratedAndroidContent,
418) -> Result<GeneratedAndroidFiles, AndroidError> {
419 let root = output_root.join(&content.fingerprint);
420 let manifest = root.join("AndroidManifest.xml");
421 write_if_changed(&manifest, &content.manifest)?;
422 for (relative, contents) in &content.resources {
423 write_if_changed(&root.join(relative), contents)?;
424 }
425 for (relative, contents) in &content.binary_resources {
426 write_bytes_if_changed(&root.join(relative), contents)?;
427 }
428 let mut java_sources = Vec::with_capacity(content.java_sources.len());
429 for (relative, contents) in &content.java_sources {
430 let path = root.join(relative);
431 write_if_changed(&path, contents)?;
432 java_sources.push(path);
433 }
434 Ok(GeneratedAndroidFiles {
435 root: root.clone(),
436 manifest,
437 resources: root.join("res"),
438 java_sources,
439 })
440}
441
442fn write_if_changed(path: &Utf8Path, contents: &str) -> Result<(), AndroidError> {
443 if matches!(fs::read_to_string(path), Ok(existing) if existing == contents) {
444 return Ok(());
445 }
446 let parent = path.parent().ok_or_else(|| {
447 AndroidError::InvalidRequest(format!("generated path has no parent: {path}"))
448 })?;
449 fs::create_dir_all(parent)
450 .map_err(|source| io_error("create generated resource directory", parent, source))?;
451 fs::write(path, contents)
452 .map_err(|source| io_error("write generated Android file", path, source))
453}
454
455fn write_bytes_if_changed(path: &Utf8Path, contents: &[u8]) -> Result<(), AndroidError> {
456 if matches!(fs::read(path), Ok(existing) if existing == contents) {
457 return Ok(());
458 }
459 let parent = path.parent().ok_or_else(|| {
460 AndroidError::InvalidRequest(format!("generated path has no parent: {path}"))
461 })?;
462 fs::create_dir_all(parent)
463 .map_err(|source| io_error("create generated resource directory", parent, source))?;
464 fs::write(path, contents)
465 .map_err(|source| io_error("write generated Android file", path, source))
466}
467
468fn styles(theme: Theme, night_override: bool) -> String {
469 let parent = match theme {
470 Theme::Dark => "@android:style/Theme.Material.NoActionBar",
471 Theme::System | Theme::Light => "@android:style/Theme.Material.Light.NoActionBar",
472 };
473 let marker = if night_override {
474 " <!-- system night override -->"
475 } else {
476 ""
477 };
478 format!(
479 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>{marker}\n <style name=\"FerryTheme\" parent=\"{parent}\">\n <item name=\"android:windowBackground\">@drawable/ferry_splash</item>\n <item name=\"android:fontFamily\">sans</item>\n <item name=\"android:windowNoTitle\">true</item>\n </style>\n</resources>\n"
480 )
481}
482
483fn splash(theme: Theme) -> String {
484 let color = if theme == Theme::Dark {
485 "#FF101216"
486 } else {
487 "#FFF7F7FA"
488 };
489 format!(
490 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<layer-list xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <item>\n <shape android:shape=\"rectangle\">\n <solid android:color=\"{color}\" />\n </shape>\n </item>\n <item android:drawable=\"@drawable/ferry_icon\" android:gravity=\"center\" android:width=\"96dp\" android:height=\"96dp\" />\n</layer-list>\n"
491 )
492}
493
494fn validate_native_library_name(name: &str) -> Result<(), AndroidError> {
495 if name.is_empty()
496 || !name
497 .chars()
498 .all(|character| character.is_ascii_alphanumeric() || character == '_')
499 {
500 return Err(AndroidError::InvalidRequest(format!(
501 "native library name `{name}` must contain only ASCII letters, digits, or underscores"
502 )));
503 }
504 Ok(())
505}
506
507fn escape_xml(value: &str) -> Result<String, AndroidError> {
508 let mut escaped = String::with_capacity(value.len());
509 for character in value.chars() {
510 match character {
511 '&' => escaped.push_str("&"),
512 '<' => escaped.push_str("<"),
513 '>' => escaped.push_str(">"),
514 '"' => escaped.push_str("""),
515 '\'' => escaped.push_str("'"),
516 '\t' | '\n' | '\r' => escaped.push(character),
517 character if character.is_control() => {
518 return Err(AndroidError::InvalidRequest(
519 "application metadata contains a control character that XML cannot encode"
520 .to_owned(),
521 ));
522 }
523 character => escaped.push(character),
524 }
525 }
526 Ok(escaped)
527}
528
529#[cfg(test)]
530mod tests {
531 use rustferry_core::{FerryConfig, NetworkMode};
532
533 use super::*;
534
535 mod png_fixture {
536 include!(concat!(
537 env!("CARGO_MANIFEST_DIR"),
538 "/tests/fixtures/opaque_png.rs"
539 ));
540 }
541
542 use png_fixture::OPAQUE_1024_PNG as PNG;
543
544 #[test]
545 fn default_manifest_matches_golden() {
546 let config = FerryConfig::starter("RustFerry Counter", "com.example.counter");
547 let content = generate_android_content(&config, "counter", 35, true, true).unwrap();
548 assert_eq!(
549 content.manifest,
550 include_str!("../tests/golden/starter-AndroidManifest.xml")
551 );
552 let styles = content
553 .resources
554 .iter()
555 .find(|(path, _)| path == "res/values/styles.xml")
556 .map(|(_, source)| source)
557 .unwrap();
558 assert_eq!(styles, include_str!("../tests/golden/starter-styles.xml"));
559 assert!(content.resources.iter().any(|(path, source)| path
560 == "res/drawable-night/ferry_splash.xml"
561 && source.contains("#FF101216")));
562 }
563
564 #[test]
565 fn project_assets_use_density_icons_and_cached_packaging_bytes() {
566 let temporary = tempfile::tempdir().unwrap();
567 let root = Utf8Path::from_path(temporary.path()).unwrap();
568 fs::create_dir(root.join("assets")).unwrap();
569 fs::write(root.join("assets/icon.png"), PNG).unwrap();
570 fs::write(root.join("assets/splash.png"), PNG).unwrap();
571 let assets = ProjectAssets::load(root).unwrap();
572 let planned = generate_android_content_with_assets(
573 &FerryConfig::starter("Assets", "com.example.assets"),
574 "assets",
575 35,
576 true,
577 true,
578 &assets,
579 )
580 .unwrap();
581
582 assert!(
583 planned
584 .manifest
585 .contains("android:icon=\"@mipmap/ferry_icon\"")
586 );
587 assert_eq!(planned.binary_resources.len(), 6);
588 for (density, size) in [
589 ("mdpi", 48),
590 ("hdpi", 72),
591 ("xhdpi", 96),
592 ("xxhdpi", 144),
593 ("xxxhdpi", 192),
594 ] {
595 let path = Utf8PathBuf::from(format!("res/mipmap-{density}/ferry_icon.png"));
596 let bytes = planned
597 .binary_resources
598 .iter()
599 .find_map(|(candidate, bytes)| (candidate == &path).then_some(bytes))
600 .unwrap();
601 assert_eq!(png_dimensions(bytes), (size, size));
602 }
603 assert!(planned.binary_resources.iter().any(|(path, bytes)| path
604 == "res/drawable-nodpi/ferry_splash.png"
605 && png_dimensions(bytes) == (1_024, 1_024)));
606
607 let generated = rustferry_codegen::generate_platform_assets(root, None).unwrap();
608 let packaged = content_from_generated_asset_set(&planned, &generated).unwrap();
609 assert_eq!(packaged.binary_resources, planned.binary_resources);
610
611 fs::write(
612 generated.root.join("android/mipmap-mdpi/ferry_icon.png"),
613 b"tampered",
614 )
615 .unwrap();
616 assert!(content_from_generated_asset_set(&planned, &generated).is_err());
617 }
618
619 fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
620 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
621 (
622 u32::from_be_bytes(bytes[16..20].try_into().unwrap()),
623 u32::from_be_bytes(bytes[20..24].try_into().unwrap()),
624 )
625 }
626
627 #[test]
628 fn permissions_are_capability_minimal() {
629 let mut config = FerryConfig::starter("Offline", "com.example.offline");
630 config.capabilities.network.mode = NetworkMode::None;
631 config.capabilities.notifications.local = false;
632 let content = generate_android_content(&config, "offline", 35, true, true).unwrap();
633 assert!(!content.manifest.contains("permission.INTERNET"));
634 assert!(!content.manifest.contains("permission.ACCESS_NETWORK_STATE"));
635 assert!(!content.manifest.contains("permission.POST_NOTIFICATIONS"));
636 assert!(!content.manifest.contains("<receiver"));
637 }
638
639 #[test]
640 fn enabled_components_and_links_are_escaped_and_scoped() {
641 let mut config = FerryConfig::starter("Weather & More", "com.example.weather");
642 config.extensions.widget.enabled = true;
643 config.extensions.widget.app_group = Some("group.com.example.weather".to_owned());
644 config.capabilities.deep_links.schemes = vec!["weather".to_owned()];
645 config.capabilities.deep_links.allowed_hosts = vec!["app.example.com".to_owned()];
646 config.capabilities.deep_links.allowed_actions = vec!["forecast".to_owned()];
647 let content = generate_android_content(&config, "weather", 35, true, false).unwrap();
648 assert!(
649 content
650 .resources
651 .iter()
652 .any(|(_, source)| source.contains("Weather & More"))
653 );
654 assert!(content.manifest.contains(WIDGET_PROVIDER_CLASS));
655 assert!(
656 content
657 .manifest
658 .contains("android:launchMode=\"singleTop\"")
659 );
660 assert!(content.manifest.contains("android:scheme=\"weather\""));
661 assert!(
662 content
663 .manifest
664 .contains("android:host=\"app.example.com\"")
665 );
666 assert!(
667 content
668 .manifest
669 .contains("android:pathPrefix=\"/forecast\"")
670 );
671 assert!(
672 content
673 .resources
674 .iter()
675 .any(|(path, _)| path == "res/xml/ferry_widget_info.xml")
676 );
677 }
678
679 #[test]
680 fn rejects_library_path_injection() {
681 let config = FerryConfig::starter("App", "com.example.app");
682 assert!(generate_android_content(&config, "../evil", 35, true, true).is_err());
683 }
684
685 #[test]
686 fn platform_permissions_follow_android_api_policy() {
687 let mut config = FerryConfig::starter("Media", "com.example.media");
688 config.permissions.camera.enabled = true;
689 config.permissions.camera.purpose = Some("Scan a document".to_owned());
690 config.permissions.photos.enabled = true;
691 config.permissions.photos.purpose = Some("Choose an image".to_owned());
692 config.permissions.microphone.enabled = true;
693 config.permissions.microphone.purpose = Some("Record a note".to_owned());
694 config.permissions.location_when_in_use.enabled = true;
695 config.permissions.location_when_in_use.purpose = Some("Show local weather".to_owned());
696 config.permissions.local_network.enabled = true;
697 config.permissions.local_network.purpose = Some("Find a local service".to_owned());
698 let manifest = generate_android_content(&config, "media", 35, true, true)
699 .unwrap()
700 .manifest;
701 for permission in [
702 "android.permission.CAMERA",
703 "android.permission.RECORD_AUDIO",
704 "android.permission.ACCESS_COARSE_LOCATION",
705 "android.permission.ACCESS_FINE_LOCATION",
706 "android.permission.READ_MEDIA_IMAGES",
707 ] {
708 assert!(manifest.contains(permission));
709 }
710 assert!(
711 manifest
712 .contains("android.permission.READ_EXTERNAL_STORAGE\" android:maxSdkVersion=\"32")
713 );
714 assert!(!manifest.contains("LOCAL_NETWORK"));
715 assert!(!manifest.contains("Find a local service"));
716 }
717}