1use std::process::Command;
20
21use crate::AppError;
22use crate::wire::{InjectionCommand, Kind};
23
24pub const ACTION: &str = "com.harryaskham.omni.INJECT";
26pub const LEGACY_ACTION: &str = "com.ringmods.HID_EVENT";
28pub const SENDER_PACKAGE: &str = "com.harryaskham.omnicli";
31
32const META_SHIFT_ON: i64 = 0x1;
34const META_ALT_ON: i64 = 0x2;
35const META_CTRL_ON: i64 = 0x1000;
36const META_META_ON: i64 = 0x1_0000;
37
38#[must_use]
40pub fn meta_bit(name: &str) -> Option<i64> {
41 match name.to_ascii_lowercase().as_str() {
42 "ctrl" | "control" => Some(META_CTRL_ON),
43 "shift" => Some(META_SHIFT_ON),
44 "alt" | "option" => Some(META_ALT_ON),
45 "meta" | "cmd" | "command" | "super" | "win" => Some(META_META_ON),
46 _ => None,
47 }
48}
49
50#[must_use]
53pub fn keycode(name: &str) -> Option<i64> {
54 let n = name.to_ascii_lowercase();
55 let code = match n.as_str() {
56 "backspace" => 67, "delete" | "del" => 112, "enter" | "return" => 66, "tab" => 61, "esc" | "escape" => 111, "space" => 62, "left" => 21, "right" => 22, "up" => 19, "down" => 20, "home" => 122, "end" => 123, _ => {
69 let bytes = n.as_bytes();
70 if bytes.len() == 1 {
71 let c = bytes[0];
72 if c.is_ascii_lowercase() {
73 return Some(29 + i64::from(c - b'a')); }
75 if c.is_ascii_digit() {
76 return Some(7 + i64::from(c - b'0')); }
78 }
79 return None;
80 }
81 };
82 Some(code)
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct AmBroadcast {
88 pub kind: &'static str,
90 pub str_extras: Vec<(&'static str, String)>,
92 pub int_extras: Vec<(&'static str, i64)>,
94}
95
96impl AmBroadcast {
97 fn text(s: &str) -> Self {
98 Self {
99 kind: "text",
100 str_extras: vec![("text", s.to_owned())],
101 int_extras: vec![],
102 }
103 }
104
105 fn key(keycode: i64, meta_state: i64) -> Self {
106 Self {
107 kind: "key",
108 str_extras: vec![],
109 int_extras: vec![("keycode", keycode), ("meta_state", meta_state)],
110 }
111 }
112
113 fn scroll(amount: i64) -> Self {
114 Self {
115 kind: "scroll",
116 str_extras: vec![],
117 int_extras: vec![("amount", amount)],
118 }
119 }
120
121 #[must_use]
125 pub fn device_command(&self, action: &str, sender_package: Option<&str>) -> String {
126 let mut parts = vec![
127 "am".to_owned(),
128 "broadcast".to_owned(),
129 "-a".to_owned(),
130 action.to_owned(),
131 "--es".to_owned(),
132 "type".to_owned(),
133 self.kind.to_owned(),
134 ];
135 for (k, v) in &self.str_extras {
136 parts.push("--es".to_owned());
137 parts.push((*k).to_owned());
138 parts.push(shell_escape(v));
139 }
140 for (k, v) in &self.int_extras {
141 parts.push("--ei".to_owned());
142 parts.push((*k).to_owned());
143 parts.push(v.to_string());
144 }
145 parts.push("--es".to_owned());
146 parts.push("sender_package".to_owned());
147 parts.push(shell_escape(sender_package.unwrap_or(SENDER_PACKAGE)));
148 parts.join(" ")
149 }
150
151 #[must_use]
153 pub fn adb_display(
154 &self,
155 serial: Option<&str>,
156 action: &str,
157 sender_package: Option<&str>,
158 ) -> String {
159 let serial_part = serial.map(|s| format!(" -s {s}")).unwrap_or_default();
160 format!(
161 "adb{serial_part} shell {}",
162 shell_escape(&self.device_command(action, sender_package))
163 )
164 }
165}
166
167pub fn to_am_broadcasts(cmd: &InjectionCommand) -> Result<Vec<AmBroadcast>, AppError> {
177 match cmd.kind {
178 Kind::Text => {
179 let text = cmd
180 .text
181 .as_deref()
182 .ok_or_else(|| AppError::validation("android_text", "text command missing text"))?;
183 Ok(vec![AmBroadcast::text(text)])
184 }
185 Kind::Scroll => {
186 let amount = cmd.amount.ok_or_else(|| {
187 AppError::validation("android_scroll", "scroll command missing amount")
188 })?;
189 Ok(vec![AmBroadcast::scroll(amount)])
190 }
191 Kind::Key => {
192 let name = cmd
193 .key
194 .as_deref()
195 .ok_or_else(|| AppError::validation("android_key", "key command missing key"))?;
196 let kc = keycode(name).ok_or_else(|| {
197 AppError::validation(
198 "android_keycode",
199 format!("no Android keycode for key: {name}"),
200 )
201 })?;
202 Ok(vec![AmBroadcast::key(kc, 0)])
203 }
204 Kind::Combo => {
205 let spec = cmd.combo.as_deref().ok_or_else(|| {
206 AppError::validation("android_combo", "combo command missing combo")
207 })?;
208 expand_combo(spec)
209 }
210 }
211}
212
213fn expand_combo(spec: &str) -> Result<Vec<AmBroadcast>, AppError> {
214 let mut meta = 0i64;
215 let mut keys: Vec<i64> = Vec::new();
216 for tok in spec.split(',') {
217 let t = tok.trim();
218 if t.is_empty() {
219 continue;
220 }
221 if let Some(bit) = meta_bit(t) {
222 meta |= bit;
223 } else if let Some(kc) = keycode(t) {
224 keys.push(kc);
225 } else {
226 return Err(AppError::validation(
227 "android_combo_token",
228 format!("combo token is neither a modifier nor a known key: {t}"),
229 ));
230 }
231 }
232 if keys.is_empty() {
233 return Err(AppError::validation(
234 "android_combo_empty",
235 format!("combo has no non-modifier key: {spec}"),
236 ));
237 }
238 Ok(keys
239 .into_iter()
240 .map(|kc| AmBroadcast::key(kc, meta))
241 .collect())
242}
243
244pub fn dispatch(
251 casts: &[AmBroadcast],
252 serial: Option<&str>,
253 sender_package: Option<&str>,
254 execute: bool,
255) -> Result<Vec<String>, AppError> {
256 let mut rendered = Vec::with_capacity(casts.len());
257 for cast in casts {
258 rendered.push(cast.adb_display(serial, ACTION, sender_package));
259 if execute {
260 let mut command = Command::new("adb");
261 if let Some(s) = serial {
262 command.arg("-s").arg(s);
263 }
264 command
265 .arg("shell")
266 .arg(cast.device_command(ACTION, sender_package));
267 let status = command
268 .status()
269 .map_err(|e| AppError::internal("adb_spawn", format!("failed to run adb: {e}")))?;
270 if !status.success() {
271 return Err(AppError::internal(
272 "adb_broadcast",
273 format!("adb am broadcast exited with status {status}"),
274 ));
275 }
276 }
277 }
278 Ok(rendered)
279}
280
281fn shell_escape(s: &str) -> String {
283 if !s.is_empty()
284 && s.bytes().all(|b| {
285 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/' | b'@' | b'+')
286 })
287 {
288 return s.to_owned();
289 }
290 let mut out = String::with_capacity(s.len() + 2);
291 out.push('\'');
292 for ch in s.chars() {
293 if ch == '\'' {
294 out.push_str("'\\''");
295 } else {
296 out.push(ch);
297 }
298 }
299 out.push('\'');
300 out
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::wire::InjectionCommand;
307
308 #[test]
309 fn keycode_named_letters_digits() {
310 assert_eq!(keycode("enter"), Some(66));
311 assert_eq!(keycode("backspace"), Some(67));
312 assert_eq!(keycode("space"), Some(62));
313 assert_eq!(keycode("a"), Some(29));
314 assert_eq!(keycode("z"), Some(54));
315 assert_eq!(keycode("0"), Some(7));
316 assert_eq!(keycode("9"), Some(16));
317 assert_eq!(keycode("f13"), None);
318 }
319
320 #[test]
321 fn meta_bits() {
322 assert_eq!(meta_bit("ctrl"), Some(META_CTRL_ON));
323 assert_eq!(meta_bit("shift"), Some(META_SHIFT_ON));
324 assert_eq!(meta_bit("alt"), Some(META_ALT_ON));
325 assert_eq!(meta_bit("cmd"), Some(META_META_ON));
326 assert_eq!(meta_bit("a"), None);
327 }
328
329 #[test]
330 fn text_and_scroll_broadcasts() {
331 let t = to_am_broadcasts(&InjectionCommand::text("hi there")).unwrap();
332 assert_eq!(t, vec![AmBroadcast::text("hi there")]);
333 let s = to_am_broadcasts(&InjectionCommand::scroll(-2)).unwrap();
334 assert_eq!(s, vec![AmBroadcast::scroll(-2)]);
335 }
336
337 #[test]
338 fn key_broadcast() {
339 let k = to_am_broadcasts(&InjectionCommand::key("enter")).unwrap();
340 assert_eq!(k, vec![AmBroadcast::key(66, 0)]);
341 let err = to_am_broadcasts(&InjectionCommand::key("nope")).unwrap_err();
342 assert_eq!(err.code(), "android_keycode");
343 }
344
345 #[test]
346 fn combo_expands_to_key_sequence_with_meta() {
347 let casts = to_am_broadcasts(&InjectionCommand::combo("ctrl,a,g")).unwrap();
348 assert_eq!(
350 casts,
351 vec![
352 AmBroadcast::key(29, META_CTRL_ON),
353 AmBroadcast::key(35, META_CTRL_ON),
354 ]
355 );
356 assert_eq!(
357 to_am_broadcasts(&InjectionCommand::combo("ctrl"))
358 .unwrap_err()
359 .code(),
360 "android_combo_empty"
361 );
362 assert_eq!(
363 to_am_broadcasts(&InjectionCommand::combo("ctrl,f13"))
364 .unwrap_err()
365 .code(),
366 "android_combo_token"
367 );
368 }
369
370 #[test]
371 fn device_command_and_display_render() {
372 let cast = AmBroadcast::key(66, 0);
373 assert_eq!(
374 cast.device_command(ACTION, None),
375 "am broadcast -a com.harryaskham.omni.INJECT --es type key --ei keycode 66 --ei meta_state 0 --es sender_package com.harryaskham.omnicli"
376 );
377 let text = AmBroadcast::text("hello world");
378 assert_eq!(
379 text.device_command(ACTION, None),
380 "am broadcast -a com.harryaskham.omni.INJECT --es type text --es text 'hello world' --es sender_package com.harryaskham.omnicli"
381 );
382 assert_eq!(
383 text.adb_display(Some("emulator-5554"), ACTION, None),
384 "adb -s emulator-5554 shell 'am broadcast -a com.harryaskham.omni.INJECT --es type text --es text '\\''hello world'\\'' --es sender_package com.harryaskham.omnicli'"
385 );
386 }
387
388 #[test]
389 fn dispatch_dry_run_does_not_execute() {
390 let casts = to_am_broadcasts(&InjectionCommand::combo("ctrl,a,g")).unwrap();
391 let lines = dispatch(&casts, None, None, false).unwrap();
392 assert_eq!(lines.len(), 2);
393 assert!(lines[0].contains("--ei keycode 29 --ei meta_state 4096"));
394 assert!(lines[1].contains("--ei keycode 35 --ei meta_state 4096"));
395 }
396
397 #[test]
398 fn shell_escape_quotes_specials() {
399 assert_eq!(
400 shell_escape("com.harryaskham.omnicli"),
401 "com.harryaskham.omnicli"
402 );
403 assert_eq!(shell_escape("hello world"), "'hello world'");
404 assert_eq!(shell_escape("it's"), "'it'\\''s'");
405 assert_eq!(shell_escape(""), "''");
406 }
407}