Skip to main content

rustferry_android/
generate.rs

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/// Deterministic Android manifest and resource source files.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct GeneratedAndroidContent {
21    /// Text Android manifest.
22    pub manifest: String,
23    /// Resource files relative to the generated root.
24    pub resources: Vec<(Utf8PathBuf, String)>,
25    /// Binary resource files relative to the generated root.
26    pub binary_resources: Vec<(Utf8PathBuf, Vec<u8>)>,
27    /// Internal Java bridge sources relative to the generated root.
28    pub java_sources: Vec<(Utf8PathBuf, String)>,
29    /// Content fingerprint used to isolate stale generated files.
30    pub fingerprint: String,
31}
32
33/// Paths written for AAPT2 consumption.
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct GeneratedAndroidFiles {
36    /// Fingerprinted generated root.
37    pub root: Utf8PathBuf,
38    /// `AndroidManifest.xml` path.
39    pub manifest: Utf8PathBuf,
40    /// Android `res` directory.
41    pub resources: Utf8PathBuf,
42    /// Internal Java source files passed directly to `javac`.
43    pub java_sources: Vec<Utf8PathBuf>,
44}
45
46/// Create manifest and resource source text without filesystem access.
47///
48/// # Errors
49///
50/// Returns an error for invalid configuration, unsafe metadata, or XML-incompatible control
51/// characters.
52pub 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
410/// Write generated content below a fingerprinted output directory.
411///
412/// # Errors
413///
414/// Returns an error when generated directories or files cannot be written.
415pub 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("&amp;"),
512            '<' => escaped.push_str("&lt;"),
513            '>' => escaped.push_str("&gt;"),
514            '"' => escaped.push_str("&quot;"),
515            '\'' => escaped.push_str("&apos;"),
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 &amp; 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}