Skip to main content

rustferry_android/
bridge.rs

1use camino::Utf8PathBuf;
2use rustferry_core::{AndroidLiveActivityFallback, FerryConfig, NetworkMode};
3
4/// Generated launcher activity and private Rust bridge entry point.
5pub const ACTIVITY_CLASS: &str = "org.rustferry.bridge.FerryActivity";
6/// Generated Java dispatcher invoked through the activity.
7pub const BRIDGE_CLASS: &str = "org.rustferry.bridge.FerryBridge";
8/// Generated alarm and notification-action receiver.
9pub const NOTIFICATION_RECEIVER_CLASS: &str = "org.rustferry.bridge.FerryNotificationReceiver";
10/// Generated Android home-screen widget provider.
11pub const WIDGET_PROVIDER_CLASS: &str = "org.rustferry.bridge.FerryWidgetProvider";
12/// Generated read-only provider for application-owned share files.
13pub const FILE_PROVIDER_CLASS: &str = "org.rustferry.bridge.FerryFileProvider";
14
15const ACTIVITY_SOURCE: &str = include_str!("../java/org/rustferry/bridge/FerryActivity.java");
16const BRIDGE_SOURCE: &str = include_str!("../java/org/rustferry/bridge/FerryBridge.java");
17const NOTIFICATION_SOURCE: &str =
18    include_str!("../java/org/rustferry/bridge/FerryNotificationReceiver.java");
19const WIDGET_SOURCE: &str = include_str!("../java/org/rustferry/bridge/FerryWidgetProvider.java");
20const FILE_PROVIDER_SOURCE: &str =
21    include_str!("../java/org/rustferry/bridge/FerryFileProvider.java");
22
23pub(crate) fn generate_bridge_sources(
24    config: &FerryConfig,
25    native_library_name: &str,
26) -> Vec<(Utf8PathBuf, String)> {
27    let network_status = config.capabilities.network.mode != NetworkMode::None;
28    let network_probe = matches!(
29        config.capabilities.network.mode,
30        NetworkMode::Optional | NetworkMode::Required
31    ) || config.capabilities.network.probe_url.is_some();
32    let notifications = config.capabilities.notifications.local;
33    let live_activity = config.extensions.live_activity.enabled
34        && config.extensions.live_activity.android_fallback
35            == AndroidLiveActivityFallback::OngoingNotification;
36    let any_permission = notifications
37        || live_activity
38        || network_status
39        || config.permissions.camera.enabled
40        || config.permissions.photos.enabled
41        || config.permissions.microphone.enabled
42        || config.permissions.location_when_in_use.enabled;
43    let replacements = [
44        ("@CAP_NETWORK_STATUS@", network_status),
45        ("@CAP_NETWORK_PROBE@", network_probe),
46        ("@CAP_STORAGE@", config.capabilities.storage.enabled),
47        ("@CAP_HAPTICS@", config.capabilities.haptics.enabled),
48        ("@CAP_CLIPBOARD@", config.capabilities.clipboard.enabled),
49        ("@CAP_SHARE@", config.capabilities.share.enabled),
50        ("@CAP_NOTIFICATIONS@", notifications),
51        (
52            "@CAP_DEEP_LINKS@",
53            !config.capabilities.deep_links.schemes.is_empty(),
54        ),
55        ("@CAP_WIDGET@", config.extensions.widget.enabled),
56        ("@CAP_LIVE_ACTIVITY@", live_activity),
57        ("@PERMISSION_CAMERA@", config.permissions.camera.enabled),
58        ("@PERMISSION_PHOTOS@", config.permissions.photos.enabled),
59        (
60            "@PERMISSION_MICROPHONE@",
61            config.permissions.microphone.enabled,
62        ),
63        (
64            "@PERMISSION_LOCATION@",
65            config.permissions.location_when_in_use.enabled,
66        ),
67        ("@ANY_GENERAL_PERMISSION@", any_permission),
68    ];
69    let mut bridge = BRIDGE_SOURCE.to_owned();
70    for (token, enabled) in replacements {
71        bridge = bridge.replace(token, if enabled { "true" } else { "false" });
72    }
73    let java_strings = |values: &[String]| {
74        values
75            .iter()
76            .map(|value| serde_json::to_string(value).expect("strings always serialize"))
77            .collect::<Vec<_>>()
78            .join(", ")
79    };
80    let deep_link_schemes = java_strings(&config.capabilities.deep_links.schemes);
81    let deep_link_hosts = java_strings(&config.capabilities.deep_links.allowed_hosts);
82    let deep_link_actions = java_strings(&config.capabilities.deep_links.allowed_actions);
83    bridge = bridge.replace("@DEEP_LINK_SCHEMES@", &deep_link_schemes);
84    bridge = bridge.replace("@DEEP_LINK_HOSTS@", &deep_link_hosts);
85    bridge = bridge.replace("@DEEP_LINK_ACTIONS@", &deep_link_actions);
86    let activity = ACTIVITY_SOURCE.replace(
87        "@NATIVE_LIBRARY_NAME@",
88        &serde_json::to_string(native_library_name).expect("strings always serialize"),
89    );
90    let prefix = "java/org/rustferry/bridge";
91    vec![
92        (
93            Utf8PathBuf::from(format!("{prefix}/FerryActivity.java")),
94            activity,
95        ),
96        (
97            Utf8PathBuf::from(format!("{prefix}/FerryBridge.java")),
98            bridge,
99        ),
100        (
101            Utf8PathBuf::from(format!("{prefix}/FerryFileProvider.java")),
102            FILE_PROVIDER_SOURCE.to_owned(),
103        ),
104        (
105            Utf8PathBuf::from(format!("{prefix}/FerryNotificationReceiver.java")),
106            NOTIFICATION_SOURCE.to_owned(),
107        ),
108        (
109            Utf8PathBuf::from(format!("{prefix}/FerryWidgetProvider.java")),
110            WIDGET_SOURCE.to_owned(),
111        ),
112    ]
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn bridge_capabilities_are_baked_without_template_tokens() {
121        let config = FerryConfig::starter("Counter", "com.example.counter");
122        let sources = generate_bridge_sources(&config, "counter");
123        let bridge = sources
124            .iter()
125            .find(|(path, _)| path.file_name() == Some("FerryBridge.java"))
126            .map(|(_, source)| source)
127            .unwrap();
128        assert!(!bridge.contains("@CAP_"));
129        assert!(!bridge.contains("@PERMISSION_"));
130        assert!(!bridge.contains("@DEEP_LINK_SCHEMES@"));
131        assert!(!bridge.contains("@DEEP_LINK_HOSTS@"));
132        assert!(!bridge.contains("@DEEP_LINK_ACTIONS@"));
133        assert!(bridge.contains("CAP_NETWORK_STATUS = true"));
134        assert!(bridge.contains("CAP_CLIPBOARD = false"));
135        assert!(bridge.contains("areNotificationsEnabled"));
136        assert!(bridge.contains("initialize(activity);\n            return JSONObject.NULL;"));
137        assert!(bridge.contains("new String[] {\"counter\"}"));
138        assert!(bridge.contains("isAllowedDeepLink(data)"));
139        assert!(bridge.contains("DEEP_LINK_HOSTS.length > 0"));
140        assert!(bridge.contains("DEEP_LINK_ACTIONS.length > 0"));
141        assert!(bridge.contains("validateWidgetContent"));
142        assert!(bridge.contains("one link/button action"));
143        assert!(bridge.contains("\"https\".equalsIgnoreCase(scheme)"));
144        assert!(!bridge.contains("\"data\".equalsIgnoreCase(scheme)"));
145        assert!(FILE_PROVIDER_SOURCE.contains("root.directory == null"));
146    }
147
148    #[test]
149    fn bridge_bakes_complete_deep_link_policy() {
150        let mut config = FerryConfig::starter("Routes", "com.example.routes");
151        config.capabilities.deep_links.schemes = vec!["routes".to_owned()];
152        config.capabilities.deep_links.allowed_hosts = vec!["app.example.com".to_owned()];
153        config.capabilities.deep_links.allowed_actions = vec!["details".to_owned()];
154        let sources = generate_bridge_sources(&config, "routes");
155        let bridge = sources
156            .iter()
157            .find(|(path, _)| path.file_name() == Some("FerryBridge.java"))
158            .map(|(_, source)| source)
159            .unwrap();
160        assert!(bridge.contains("new String[] {\"routes\"}"));
161        assert!(bridge.contains("new String[] {\"app.example.com\"}"));
162        assert!(bridge.contains("new String[] {\"details\"}"));
163        assert!(bridge.contains("allowed.equalsIgnoreCase(host)"));
164        assert!(bridge.contains("allowed.equals(action)"));
165    }
166
167    #[test]
168    fn activity_intents_are_initialized_and_consumed_once() {
169        assert!(ACTIVITY_SOURCE.contains("FerryBridge.attach(this, getIntent())"));
170        assert!(BRIDGE_SOURCE.contains(
171            "handleIntent(activity, activity.getIntent(), !startedSent);\n        nativeReady = true;"
172        ));
173        assert!(BRIDGE_SOURCE.contains("static synchronized void handleIntent"));
174        for consumed in [
175            "intent.removeExtra(EXTRA_NOTIFICATION_ID)",
176            "intent.removeExtra(EXTRA_OPEN_EVENT)",
177            "intent.removeExtra(EXTRA_DEEP_LINK)",
178            "intent.removeExtra(EXTRA_INTERNAL_TOKEN)",
179            "intent.setData(null)",
180        ] {
181            assert!(BRIDGE_SOURCE.contains(consumed));
182        }
183    }
184
185    #[test]
186    fn activity_loads_native_library_for_jni_callbacks() {
187        let config = FerryConfig::starter("Counter", "com.example.counter");
188        let sources = generate_bridge_sources(&config, "counter_core");
189        let activity = sources
190            .iter()
191            .find(|(path, _)| path.file_name() == Some("FerryActivity.java"))
192            .map(|(_, source)| source)
193            .unwrap();
194
195        assert!(activity.contains("System.loadLibrary(\"counter_core\");"));
196        assert!(!activity.contains("@NATIVE_LIBRARY_NAME@"));
197    }
198
199    #[test]
200    fn provider_paths_and_live_updates_fail_safely() {
201        assert!(FILE_PROVIDER_SOURCE.contains("filePath.substring(rootPath.length())"));
202        assert!(FILE_PROVIDER_SOURCE.contains(".appendPath(relative)"));
203        assert!(!FILE_PROVIDER_SOURCE.contains("toURI().relativize"));
204        let update = BRIDGE_SOURCE
205            .split("private static void updateLiveActivity")
206            .nth(1)
207            .unwrap()
208            .split("private static void endLiveActivity")
209            .next()
210            .unwrap();
211        assert!(update.contains("requireNotificationPermissionForContext(context);"));
212        assert!(BRIDGE_SOURCE.contains(
213            "if (ongoing) throw new SecurityException(\"notification permission is not granted\")"
214        ));
215    }
216}