1use std::fmt;
5use std::path::PathBuf;
6
7use super::discovery;
8
9#[derive(Clone, PartialEq, Eq)]
11pub struct Endpoint {
12 pub serial: String,
14 pub uri: String,
16 pub token: Option<String>,
18 pub console_port: Option<u16>,
20 pub discovery_file: Option<PathBuf>,
22}
23
24impl fmt::Debug for Endpoint {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 f.debug_struct("Endpoint")
28 .field("serial", &self.serial)
29 .field("uri", &self.uri)
30 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
31 .field("console_port", &self.console_port)
32 .field("discovery_file", &self.discovery_file)
33 .finish()
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Unavailable {
41 pub serial: String,
42 pub reason: String,
44 pub console_port: Option<u16>,
46}
47
48impl Unavailable {
49 pub fn new(serial: &str, console_port: Option<u16>, reason: impl Into<String>) -> Self {
50 Self {
51 serial: serial.to_owned(),
52 reason: reason.into(),
53 console_port,
54 }
55 }
56
57 pub fn suggested_port(&self) -> u16 {
60 self.console_port
61 .and_then(|console| console.checked_add(3000))
62 .unwrap_or(8554)
63 }
64
65 pub fn warning(&self) -> String {
68 let port = self.suggested_port();
69 let serial = &self.serial;
70 format!(
71 "emulator control endpoint unavailable for {serial} ({reason}): taps and frames \
72 fall back to the agent. Start the emulator with `-grpc {port} -grpc-use-token` \
73 to use the fast path, or enable it without a restart through the emulator \
74 console command `grpc {port}`, for example `adb -s {serial} emu grpc {port}`.",
75 reason = self.reason
76 )
77 }
78}
79
80impl fmt::Display for Unavailable {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.write_str(&self.warning())
83 }
84}
85
86impl std::error::Error for Unavailable {}
87
88pub fn resolve(serial: &str) -> Result<Endpoint, Unavailable> {
91 resolve_in(serial, &discovery::candidate_dirs())
92}
93
94pub fn resolve_in(serial: &str, dirs: &[PathBuf]) -> Result<Endpoint, Unavailable> {
97 let Some(console_port) = discovery::console_port_from_serial(serial) else {
98 return Err(Unavailable::new(
99 serial,
100 None,
101 format!("{serial} is not an emulator serial"),
102 ));
103 };
104 let unavailable = |reason: String| Unavailable::new(serial, Some(console_port), reason);
105 let mut found: Vec<_> = dirs
106 .iter()
107 .filter_map(|dir| discovery::scan_dir(dir).ok())
108 .flatten()
109 .filter_map(|f| match f.parsed {
110 Ok(d) if d.console_port == Some(console_port) => Some((f.modified, f.path, d)),
111 _ => None,
112 })
113 .collect();
114 found.sort_by_key(|(modified, _, _)| *modified);
115 let Some((_, path, discovery)) = found.pop() else {
116 let searched: Vec<String> = dirs.iter().map(|d| d.display().to_string()).collect();
117 return Err(unavailable(format!(
118 "no discovery file names {serial} in {}",
119 searched.join(", ")
120 )));
121 };
122 let Some(uri) = discovery.grpc_endpoint() else {
123 return Err(unavailable(format!(
124 "its discovery file {} has no grpc.port, so it was started without -grpc",
125 path.display()
126 )));
127 };
128 Ok(Endpoint {
129 serial: serial.to_owned(),
130 uri,
131 token: discovery.grpc_token,
132 console_port: Some(console_port),
133 discovery_file: Some(path),
134 })
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use std::fs;
141
142 fn temp_dir(tag: &str) -> PathBuf {
143 let dir = std::env::temp_dir().join(format!(
144 "mobile-agent-endpoint-{tag}-{}",
145 std::process::id()
146 ));
147 let _ = fs::remove_dir_all(&dir);
148 fs::create_dir_all(&dir).unwrap();
149 dir
150 }
151
152 #[test]
153 fn the_warning_names_the_launch_flag_and_the_console_command() {
154 let warning = Unavailable::new("emulator-5554", Some(5554), "connection refused").warning();
155 assert!(warning.contains("emulator-5554"), "{warning}");
156 assert!(warning.contains("connection refused"), "{warning}");
157 assert!(warning.contains("fall back to the agent"), "{warning}");
158 assert!(warning.contains("-grpc 8554 -grpc-use-token"), "{warning}");
159 assert!(warning.contains("`grpc 8554`"), "{warning}");
160 assert!(
161 warning.contains("adb -s emulator-5554 emu grpc 8554"),
162 "{warning}"
163 );
164 assert!(warning.contains("without a restart"), "{warning}");
165 }
166
167 #[test]
168 fn the_suggested_port_follows_the_console_port() {
169 assert_eq!(
170 Unavailable::new("emulator-5556", Some(5556), "").suggested_port(),
171 8556
172 );
173 assert_eq!(
174 Unavailable::new("emulator-5554", None, "").suggested_port(),
175 8554
176 );
177 let warning = Unavailable::new("emulator-5556", Some(5556), "no grpc.port").warning();
178 assert!(warning.contains("-grpc 8556 -grpc-use-token"), "{warning}");
179 }
180
181 #[test]
182 fn a_discovery_file_with_a_grpc_port_resolves_to_an_endpoint() {
183 let dir = temp_dir("resolve");
184 let file = dir.join("pid_42.ini");
185 fs::write(
186 &file,
187 "port.serial=5554\ngrpc.port=8554\ngrpc.token=placeholder+token==\n",
188 )
189 .unwrap();
190 let endpoint = resolve_in("emulator-5554", std::slice::from_ref(&dir));
191 fs::remove_dir_all(&dir).unwrap();
192 let endpoint = endpoint.unwrap();
193 assert_eq!(endpoint.uri, "http://127.0.0.1:8554");
194 assert_eq!(endpoint.token.as_deref(), Some("placeholder+token=="));
195 assert_eq!(endpoint.console_port, Some(5554));
196 assert_eq!(endpoint.discovery_file, Some(file));
197 assert!(!format!("{endpoint:?}").contains("placeholder+token"));
198 }
199
200 #[test]
201 fn no_discovery_file_is_unavailable() {
202 let dir = temp_dir("none");
203 let err = resolve_in("emulator-5556", std::slice::from_ref(&dir)).unwrap_err();
204 fs::remove_dir_all(&dir).unwrap();
205 assert_eq!(err.console_port, Some(5556));
206 assert!(err.reason.contains("no discovery file"), "{}", err.reason);
207 assert!(err.warning().contains("-grpc 8556 -grpc-use-token"));
208 }
209
210 #[test]
211 fn a_discovery_file_without_a_grpc_port_is_unavailable() {
212 let dir = temp_dir("nogrpc");
213 fs::write(dir.join("pid_7.ini"), "port.serial=5556\nport.adb=5557\n").unwrap();
214 let err = resolve_in("emulator-5556", std::slice::from_ref(&dir)).unwrap_err();
215 fs::remove_dir_all(&dir).unwrap();
216 assert!(err.reason.contains("grpc.port"), "{}", err.reason);
217 }
218
219 #[test]
220 fn a_device_serial_has_no_controller() {
221 let err = resolve_in("R58M123ABC", &[]).unwrap_err();
222 assert_eq!(err.console_port, None);
223 assert!(err.reason.contains("not an emulator"), "{}", err.reason);
224 }
225}