batch_apk_installer 1.1.3

A command-line tool for batch installation of Android Packages (APKs).
use crate::config::Platform;
use crate::error::Error;
use crate::installation::CommandOutcome;
use crate::package::Package;
use regex::Regex;
use std::fmt::Display;
use std::process::Command;

pub struct Device {
	name: String,
	id: String,
	platform: String,
}

impl Device {
	pub fn supports(&self, package: &Package) -> bool {
		package
			.platforms()
			.iter()
			.any(|p| match package.match_file_name() {
				false => &self.platform == p,
				true => package.file_name().to_lowercase().contains(&self.platform),
			})
	}

	pub async fn install(&self, package: &Package) -> CommandOutcome {
		let path = package.path();
		let command = tokio::process::Command::new("adb")
			.args(["-s", &self.id, "install", path])
			.output();
		let description = format!("Installation of {} on {}", package.id(), self.name);
		match command.await {
			Ok(output) if output.status.success() => CommandOutcome::from_success(&description),
			Ok(output) => {
				let error = Error::from_installation_error(&output.stderr);
				CommandOutcome::from_error(&description, error)
			}
			Err(e) => CommandOutcome::from_error(&description, Error::Installation(e.to_string())),
		}
	}

	pub async fn uninstall(&self, package: &Package) -> CommandOutcome {
		let description = format!("Uninstallation of {} on {}", package.id(), self.name);
		let command = tokio::process::Command::new("adb")
			.args(["-s", &self.id, "uninstall", package.id()])
			.output();
		match command.await {
			Ok(output) if output.status.success() => CommandOutcome::from_success(&description),
			Ok(output) => {
				let message = String::from_utf8_lossy(&output.stdout);
				let error = Error::Uninstall(message.to_string());
				CommandOutcome::from_error(&description, error)
			}
			Err(e) => {
				let error = Error::Uninstall(e.to_string());
				CommandOutcome::from_error(&description, error)
			}
		}
	}

	pub async fn has_app_installed(&self, app_name: &str) -> Result<bool, Error> {
		let output = tokio::process::Command::new("adb")
			.args([
				"-s",
				&self.id,
				"shell",
				"pm list packages",
				"| grep",
				app_name,
			])
			.output();
		match output.await {
			Ok(output) => Ok(output.status.success()),
			Err(e) => Err(Error::Uninstall(e.to_string())),
		}
	}

	fn from_str_with_platforms(line: &str, platforms: &[Platform]) -> Option<Device> {
		let (id, platform) = Self::parse_info(line, platforms)?;
		let name = Self::parse_name(&id).ok()?;
		let device = Self { name, id, platform };
		Some(device)
	}

	fn parse_name(id: &str) -> Result<String, Error> {
		let output = Command::new("adb")
			.args(["-s", id, "shell", "settings get global device_name"])
			.output()?;
		let name = String::from_utf8(output.stdout)?;
		match name.len() {
			0 => Err(Error::NoDeviceName),
			_ => Ok(String::from(name.trim_end())),
		}
	}

	fn parse_info(line: &str, platforms: &[Platform]) -> Option<(String, Platform)> {
		let regex = Regex::new(r"(\w+)\s+.*model:(\w+)\sdevice:(\w+)").ok()?;
		let caps = regex.captures(line)?;
		let id = String::from(caps.get(1)?.as_str());
		let model = caps.get(2)?.as_str();
		let device_name = caps.get(3)?.as_str();
		let platform = get_platform(model, platforms).or(get_platform(device_name, platforms))?;
		Some((id, platform))
	}

	pub fn get_devices(platforms: &[Platform]) -> Result<Vec<Device>, Error> {
		let output = Command::new("adb").args(["devices", "-l"]).output()?;
		let output = String::from_utf8(output.stdout)?;
		let header_line_ix = output
			.lines()
			.position(|l| l.contains("List of devices attached"))
			.ok_or(Error::DevicesFetching)?;

		let devices = output
			.lines()
			.skip(header_line_ix + 1)
			.filter(|l| !l.is_empty())
			.filter_map(|l| Self::from_str_with_platforms(l, platforms))
			.collect();
		Ok(devices)
	}
}

impl Display for Device {
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
		write!(f, "[{}] {} ({})", self.platform, self.name, self.id)
	}
}

fn get_platform(identifier: &str, platforms: &[Platform]) -> Option<Platform> {
	let identifier = identifier.to_lowercase();
	let platform = platforms.iter().find(|p| identifier.contains(*p))?;
	Some(platform.clone())
}

#[cfg(test)]
mod tests {
	use super::*;

	static PLATFORMS: [&str; 3] = ["pico", "quest", "sm_g"];

	fn get_platforms() -> [String; 3] {
		PLATFORMS.map(String::from)
	}

	#[test]
	fn test_get_platform_pico() {
		let pico = Some(String::from("pico"));
		let platforms = get_platforms();
		assert_eq!(get_platform("Pico_Neo_3", &platforms), pico);
		assert_eq!(get_platform("PICOA7H10", &platforms), pico);
		assert_eq!(get_platform("PICOA8110", &platforms), pico);
	}

	#[test]
	fn test_get_platform_quest() {
		let pico = Some(String::from("quest"));
		let platforms = get_platforms();
		assert_eq!(get_platform("Quest_2", &platforms), pico);
		assert_eq!(get_platform("Quest_3", &platforms), pico);
		assert_eq!(get_platform("Quest_3S", &platforms), pico);
		assert_eq!(get_platform("Quest_3_2", &platforms), pico);
	}

	#[test]
	fn test_get_platform_galaxy() {
		let pico = Some(String::from("sm_g"));
		let platforms = get_platforms();
		assert_eq!(get_platform("SM_G950F", &platforms), pico);
	}

	#[test]
	fn parse_pico_neo_3() {
		let data = "PA7L50MGF8290021W      device usb:34873344X product:A7H10 model:Pico_Neo_3 \
		 device:PICOA7H10 transport_id:2";
		let platforms = get_platforms();
		let info = Device::parse_info(data, &platforms);
		assert!(info.is_some());
		let (id, platform) = info.unwrap();
		assert_eq!(id, String::from("PA7L50MGF8290021W"));
		assert_eq!(platform, "pico");
	}

	#[test]
	fn parse_pico_neo_4() {
		let data = "PA8150MGGB230744G      device usb:34603008X product:Phoenix_ovs model:A8110 \
		 device:PICOA8110 transport_id:7";
		let platforms = get_platforms();
		let info = Device::parse_info(data, &platforms);
		assert!(info.is_some());
		let (id, platform) = info.unwrap();
		assert_eq!(id, String::from("PA8150MGGB230744G"));
		assert_eq!(platform, "pico");
	}

	#[test]
	fn parse_pico_quest_2() {
		let data = "1WMHHA63PR1501         device usb:34603008X product:hollywood model:Quest_2 \
		 device:hollywood transport_id:9";
		let platforms = get_platforms();
		let info = Device::parse_info(data, &platforms);
		assert!(info.is_some());
		let (id, platform) = info.unwrap();
		assert_eq!(id, String::from("1WMHHA63PR1501"));
		assert_eq!(platform, "quest");
	}

	#[test]
	fn parse_pico_galaxy_s8() {
		let data = "ce031713396bc92803     device usb:1048576X product:dreamltexx model:SM_G950F \
		 device:dreamlte transport_id:4";
		let platforms = get_platforms();
		let info = Device::parse_info(data, &platforms);
		assert!(info.is_some());
		let (id, platform) = info.unwrap();
		assert_eq!(id, String::from("ce031713396bc92803"));
		assert_eq!(platform, "sm_g");
	}
}