rustferry_android/
bridge.rs1use camino::Utf8PathBuf;
2use rustferry_core::{AndroidLiveActivityFallback, FerryConfig, NetworkMode};
3
4pub const ACTIVITY_CLASS: &str = "org.rustferry.bridge.FerryActivity";
6pub const BRIDGE_CLASS: &str = "org.rustferry.bridge.FerryBridge";
8pub const NOTIFICATION_RECEIVER_CLASS: &str = "org.rustferry.bridge.FerryNotificationReceiver";
10pub const WIDGET_PROVIDER_CLASS: &str = "org.rustferry.bridge.FerryWidgetProvider";
12pub 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}