//! Discovery and optional download of the Android SDK Build-Tools and a Java //! 17+ runtime used to zip-align and sign patched APKs. use anyhow::{Context, Result, ensure}; use sha2::{Digest, Sha256}; use std::{ env, fs, io::{Read, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, time::{Duration, Instant}, }; use zip::ZipArchive; const ALIGN: &str = if cfg!(windows) { "zipalign.exe" } else { "zipalign" }; /// Locate a Build-Tools directory containing `zipalign` and `apksigner.jar`. pub fn build_tools() -> Result { let mut candidates = vec![local_build_tools()?]; if let Some(p) = env::var_os("HI3_ANDROID_TOOLS") { candidates.push(PathBuf::from(p)); } for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] { if let Some(root) = env::var_os(key) { let path = PathBuf::from(root).join("build-tools"); if path.is_dir() { let mut entries = fs::read_dir(path)? .filter_map(|e| e.ok().map(|v| v.path())) .collect::>(); entries.sort(); entries.reverse(); candidates.extend(entries); } } } if let Some(paths) = env::var_os("PATH") { candidates.extend(env::split_paths(&paths)); } let exe = env::current_exe()?; candidates.push(exe.parent().unwrap().join("tools/android-build-tools")); candidates .into_iter() .find(|p| p.join(ALIGN).is_file() && p.join("lib/apksigner.jar").is_file()) .context("Android Build-Tools are not installed. Run the Android setup to download them.") } pub fn local_build_tools() -> Result { Ok(env::current_exe()? .parent() .context("patcher directory missing")? .join("android-build-tools")) } pub fn install_build_tools(destination: &Path) -> Result<()> { let (platform, hash) = if cfg!(windows) { ( "windows", "5753c679a1b90bcf6fbc9945a2ce39dfb9e74f1df0831a1e1866ae5b594326f0", ) } else if cfg!(target_os = "linux") { ( "linux", "bd3a4966912eb8b30ed0d00b0cda6b6543b949d5ffe00bea54c04c81e1561d88", ) } else { anyhow::bail!("Automatic Android tools setup supports Windows and Linux only."); }; ensure!( cfg!(target_arch = "x86_64"), "Automatic Android tools setup supports x86-64 Windows and Linux only." ); ensure!( !destination.exists(), "Tools folder already exists but is incomplete: {}. Move it aside, then retry.", destination.display() ); let work = tempfile::tempdir_in(destination.parent().context("tools parent missing")?) .context("cannot write beside the patcher. Move it to a writable folder and retry.")?; let archive = work.path().join("tools.zip"); let total = if cfg!(windows) { 59_878_107 } else { 61_958_799 }; download( &format!("https://dl.google.com/android/repository/build-tools_r35_{platform}.zip"), &archive, total, )?; verify_download(&archive, hash)?; let unpacked = work.path().join("unpacked"); ZipArchive::new(fs::File::open(&archive)?)?.extract(&unpacked)?; let folder = fs::read_dir(&unpacked)? .next() .context("empty Build-Tools archive")?? .path(); let align = folder.join(ALIGN); ensure!( align.is_file() && folder.join("lib/apksigner.jar").is_file(), "incomplete Build-Tools archive" ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&align, fs::Permissions::from_mode(0o755))?; } fs::rename(folder, destination).context("cannot install Android Build-Tools")?; Ok(()) } pub fn local_java() -> Result { Ok(env::current_exe()? .parent() .context("patcher directory missing")? .join("java")) } fn java_name(name: &str) -> String { if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() } } /// Path to a `java` launcher backed by a supported (17+) runtime with keytool. pub fn check_java() -> Result { let mut candidates = Vec::new(); if let Some(root) = env::var_os("JAVA_HOME") { candidates.push(PathBuf::from(root).join("bin")); } candidates.push(local_java()?.join("bin")); if let Some(paths) = env::var_os("PATH") { candidates.extend(env::split_paths(&paths)); } candidates .into_iter() .find(|bin| supported_java(bin)) .context("No supported Java found. Android signing requires Java 17+ with keytool.") } /// The `java`/`keytool` executable names under a `bin` directory. pub fn java_tool(bin: &Path, name: &str) -> PathBuf { bin.join(java_name(name)) } fn java_major(output: &str) -> Option { output.lines().find_map(|line| { let version = line.trim().strip_prefix("java.specification.version = ")?; version.strip_prefix("1.").unwrap_or(version).parse().ok() }) } fn supported_java(bin: &Path) -> bool { if !bin.join(java_name("java")).is_file() || !bin.join(java_name("keytool")).is_file() { return false; } let Ok(output) = Command::new(bin.join(java_name("java"))) .args(["-XshowSettings:properties", "-version"]) .output() else { return false; }; output.status.success() && java_major(&String::from_utf8_lossy(&output.stderr)).is_some_and(|v| v >= 17) } pub fn install_java(destination: &Path) -> Result<()> { ensure!( cfg!(target_arch = "x86_64") && (cfg!(windows) || cfg!(target_os = "linux")), "Automatic Java setup supports x86-64 Windows and Linux only." ); ensure!( !destination.exists(), "Java folder already exists but is unusable: {}. Move it aside, then retry.", destination.display() ); let (archive_name, total, hash) = if cfg!(windows) { ( "OpenJDK17U-jdk_x64_windows_hotspot_17.0.20.1_1.zip", 190_817_615, "e53a79c3c3d86865bd7e787903884331068e71321714ffd44f145785affc7cb0", ) } else { ( "OpenJDK17U-jdk_x64_linux_hotspot_17.0.20.1_1.tar.gz", 193_252_603, "3808d1d15e3ec6bd5b84057fb5d84c33d8a1536a258146bcea2e603fc726e08e", ) }; let work = tempfile::tempdir_in(destination.parent().context("Java parent missing")?) .context("cannot write beside the patcher. Move it to a writable folder and retry.")?; let archive = work.path().join(archive_name); download( &format!( "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20.1%2B1/{archive_name}" ), &archive, total, )?; verify_download(&archive, hash)?; let unpacked = work.path().join("unpacked"); fs::create_dir(&unpacked)?; if cfg!(windows) { ZipArchive::new(fs::File::open(&archive)?)?.extract(&unpacked)?; } else { let output = Command::new("tar") .arg("-xzf") .arg(&archive) .arg("-C") .arg(&unpacked) .output() .context("cannot extract Java. Install tar, then retry.")?; ensure!( output.status.success(), "Java extraction failed: {}", String::from_utf8_lossy(&output.stderr) ); } let folder = fs::read_dir(&unpacked)? .next() .context("empty Java archive")?? .path(); ensure!( supported_java(&folder.join("bin")), "downloaded Java cannot run on this system." ); fs::rename(folder, destination).context("cannot install portable Java")?; Ok(()) } fn download(url: &str, archive: &Path, total: u64) -> Result<()> { let mut downloaded = 0; let started = Instant::now(); let mut updated = started; let mut file = fs::File::create(archive)?; let errors = tempfile::tempfile()?; let mut child = Command::new(if cfg!(windows) { "curl.exe" } else { "curl" }) .args([ "--fail", "--location", "--silent", "--show-error", "--proto", "=https", "--proto-redir", "=https", "--connect-timeout", "30", "--max-time", "600", ]) .arg(url) .stdout(Stdio::piped()) .stderr(errors.try_clone()?) .spawn() .context("cannot start curl. Install curl, then retry the download.")?; let transfer = (|| -> Result<()> { draw_download(downloaded, total, started.elapsed())?; let mut stream = child.stdout.take().context("missing download stream")?; let mut buffer = [0; 64 * 1024]; loop { let count = stream.read(&mut buffer)?; if count == 0 { break; } file.write_all(&buffer[..count])?; downloaded += count as u64; if updated.elapsed() >= Duration::from_millis(250) { draw_download(downloaded, total, started.elapsed())?; updated = Instant::now(); } } Ok(()) })(); if transfer.is_err() { let _ = child.kill(); } let status = child.wait()?; if transfer.is_ok() { draw_download(downloaded, total, started.elapsed())?; } println!(); transfer?; let mut errors = errors; use std::io::{Seek, SeekFrom}; errors.seek(SeekFrom::Start(0))?; let mut message = String::new(); errors.read_to_string(&mut message)?; ensure!(status.success(), "download failed: {}", message.trim()); ensure!(downloaded == total, "incomplete download"); drop(file); Ok(()) } fn download_progress(downloaded: u64, total: u64, elapsed: Duration) -> String { let fraction = (downloaded as f64 / total as f64).min(1.0); let filled = (fraction * 20.0) as usize; let speed = downloaded as f64 / elapsed.as_secs_f64().max(0.001); let remaining = if downloaded >= total { "00:00".to_owned() } else if downloaded == 0 { "--:--".to_owned() } else { let seconds = ((total - downloaded) as f64 / speed).ceil() as u64; format!("{:02}:{:02}", seconds / 60, seconds % 60) }; format!( "Downloading ... [{}{}] {:3.0}% {:.2} MB/s ETA {remaining}", "X".repeat(filled), " ".repeat(20 - filled), fraction * 100.0, speed / 1_000_000.0 ) } fn draw_download(downloaded: u64, total: u64, elapsed: Duration) -> Result<()> { crossterm::execute!( std::io::stdout(), crossterm::cursor::MoveToColumn(0), crossterm::terminal::Clear(crossterm::terminal::ClearType::CurrentLine), crossterm::style::Print(download_progress(downloaded, total, elapsed)) )?; Ok(()) } fn verify_download(archive: &Path, hash: &str) -> Result<()> { let mut file = fs::File::open(archive)?; let mut digest = Sha256::new(); std::io::copy(&mut file, &mut digest)?; ensure!( format!("{:x}", digest.finalize()) == hash, "download checksum mismatch; nothing installed." ); Ok(()) }