Files
Hi3-Patcher/src/android/toolchain.rs
T
2026-10-31 23:35:41 +01:00

340 lines
11 KiB
Rust

//! 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<PathBuf> {
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::<Vec<_>>();
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<u32> {
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(())
}