Skip to main content

omni_cli/transport/
android.rs

1//! Android adb `am broadcast` transport (bd-17026f).
2//!
3//! Delivers [`crate::wire::InjectionCommand`]s to the live
4//! `com.harryaskham.omni` receiver (android-utils `RingModsReceiver`) by
5//! shelling out to `adb shell am broadcast`. The receiver consumes:
6//!
7//! - `type=text`   + `text`(String)            -> `commitText`
8//! - `type=key`    + `keycode`(int) `meta_state`(int) -> `sendKeyEvent`
9//! - `type=scroll` + `amount`(int)             -> DPAD up/down sequence
10//! - `sender_package`(String) gated by the receiver allowlist
11//!   (`com.harryaskham.*` / `com.termux.*`).
12//!
13//! Semantic key/combo NAMES are mapped to Android keycodes + meta_state HERE
14//! (CLI-side), so the wire stays platform-neutral. A `combo` expands to a
15//! sequence of `type=key` broadcasts carrying the accumulated modifier
16//! meta_state — matching the receiver's "single key+meta, or a sequence of
17//! type=key for multi-key chords" contract.
18
19use std::process::Command;
20
21use crate::AppError;
22use crate::wire::{InjectionCommand, Kind};
23
24/// Canonical Omni injection action.
25pub const ACTION: &str = "com.harryaskham.omni.INJECT";
26/// Legacy ring-mods action, still accepted by the receiver.
27pub const LEGACY_ACTION: &str = "com.ringmods.HID_EVENT";
28/// Sender package omni-cli declares (omni-cli is a host Rust binary, not an
29/// installed Android package); within the receiver allowlist prefix.
30pub const SENDER_PACKAGE: &str = "com.harryaskham.omnicli";
31
32// Android `KeyEvent` meta_state bits.
33const 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/// Map a modifier name to its Android meta_state bit, if it is a modifier.
39#[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/// Map a semantic key name (or single letter/digit) to an Android `KeyEvent`
51/// keycode. Returns `None` for unknown names.
52#[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,        // KEYCODE_DEL
57        "delete" | "del" => 112,  // KEYCODE_FORWARD_DEL
58        "enter" | "return" => 66, // KEYCODE_ENTER
59        "tab" => 61,              // KEYCODE_TAB
60        "esc" | "escape" => 111,  // KEYCODE_ESCAPE
61        "space" => 62,            // KEYCODE_SPACE
62        "left" => 21,             // KEYCODE_DPAD_LEFT
63        "right" => 22,            // KEYCODE_DPAD_RIGHT
64        "up" => 19,               // KEYCODE_DPAD_UP
65        "down" => 20,             // KEYCODE_DPAD_DOWN
66        "home" => 122,            // KEYCODE_MOVE_HOME
67        "end" => 123,             // KEYCODE_MOVE_END
68        _ => {
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')); // KEYCODE_A..Z
74                }
75                if c.is_ascii_digit() {
76                    return Some(7 + i64::from(c - b'0')); // KEYCODE_0..9
77                }
78            }
79            return None;
80        }
81    };
82    Some(code)
83}
84
85/// One resolved `am broadcast` invocation (its extras).
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct AmBroadcast {
88    /// "text" | "key" | "scroll".
89    pub kind: &'static str,
90    /// String extras (besides `type` / `sender_package`).
91    pub str_extras: Vec<(&'static str, String)>,
92    /// Integer extras.
93    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    /// The device-side `am broadcast ...` command string (shell-escaped string
122    /// values), suitable as the single argument to `adb shell`. `sender_package`
123    /// overrides the default [`SENDER_PACKAGE`] extra (e.g. a per-target value).
124    #[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    /// The full `adb [-s SERIAL] shell '<device command>'` line for display.
152    #[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
167/// Expand an [`InjectionCommand`] into one or more [`AmBroadcast`]s.
168///
169/// `text` / `scroll` -> one broadcast. `key` -> one key broadcast. `combo` ->
170/// a sequence of key broadcasts, each carrying the accumulated modifier
171/// meta_state.
172///
173/// # Errors
174/// Returns [`AppError::validation`] for an unknown key name, a combo token that
175/// is neither a modifier nor a key, an empty combo, or a missing payload field.
176pub 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
244/// Render (and, when `execute`, run) the adb broadcasts. Returns the displayable
245/// `adb ... shell ...` command lines (one per broadcast).
246///
247/// # Errors
248/// Returns [`AppError::internal`] if `adb` cannot be spawned or a broadcast
249/// exits non-zero.
250pub 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
281/// Single-quote-escape a value for a POSIX device shell.
282fn 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        // ctrl modifier + keys a, g -> two key broadcasts carrying CTRL.
349        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}