use std::path::{Path, PathBuf};
use std::process::Command;
use colored::Colorize;
use regex::Regex;
const DOWNLOAD_SOURCES: &[&str] = &["apk-pure", "f-droid", "huawei-app-gallery"];
pub fn parse_store_input(input: &str) -> anyhow::Result<Vec<String>> {
let input = input.trim();
if input.contains("play.google.com/store/apps/details") {
if let Some(pkg) = extract_id_param(input) {
println!(
" {} Extracted package: {}",
"▸".cyan(),
pkg.bold()
);
return Ok(vec![pkg]);
}
anyhow::bail!("Could not extract package ID from URL: {}", input);
}
if input.contains("play.google.com/store/apps/dev") {
println!(
" {} Detected developer page, attempting to enumerate apps...",
"▸".cyan()
);
return scrape_developer_apps(input);
}
let pkg_re = Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$").unwrap();
if pkg_re.is_match(input) {
println!(
" {} Using package: {}",
"▸".cyan(),
input.bold()
);
return Ok(vec![input.to_string()]);
}
anyhow::bail!(
"Unrecognized input: '{}'\n\
Expected a Play Store URL or a package name like com.example.app",
input
);
}
#[allow(unused_assignments)]
pub fn download_apk(package: &str, output_dir: &Path, verbose: bool) -> anyhow::Result<PathBuf> {
std::fs::create_dir_all(output_dir)?;
let mut last_error = String::new();
for (i, source) in DOWNLOAD_SOURCES.iter().enumerate() {
let is_primary = i == 0;
let label = if is_primary {
format!("apkeep (source: {})", source)
} else {
format!("apkeep fallback (source: {})", source)
};
if !is_primary {
println!(
" {} Trying fallback source: {}",
"↻".yellow(),
source.bold()
);
} else {
println!(
" {} Downloading {} via {}...",
"▸".cyan(),
package.bold(),
label.dimmed()
);
}
match try_apkeep_download(package, source, output_dir, verbose) {
Ok(apk_path) => {
return Ok(apk_path);
}
Err(e) => {
let msg = format!("{}: {}", source, e);
if verbose {
println!(" {} {}", "verbose".dimmed(), msg.dimmed());
}
if !is_primary {
println!(
" {} {} source failed: {}",
"⚠".yellow(),
source,
short_error(&e.to_string())
);
}
last_error = msg;
}
}
}
println!(
" {} All apkeep sources exhausted, trying direct APKPure web download...",
"↻".yellow()
);
match try_direct_apkpure_download(package, output_dir, verbose) {
Ok(apk_path) => return Ok(apk_path),
Err(e) => {
if verbose {
println!(
" {} Direct APKPure: {}",
"verbose".dimmed(),
e.to_string().dimmed()
);
}
last_error = format!("direct APKPure: {}", e);
}
}
anyhow::bail!(
"Failed to download {} from all sources.\n Last error: {}\n \
The app may not be available on any supported mirror.\n \
Tip: You can manually place an APK at {}/{}.apk and re-run.",
package,
last_error,
output_dir.display(),
package
)
}
fn try_apkeep_download(
package: &str,
source: &str,
output_dir: &Path,
verbose: bool,
) -> anyhow::Result<PathBuf> {
let output = Command::new("apkeep")
.arg("-a")
.arg(package)
.arg("-d")
.arg(source)
.arg(output_dir)
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if verbose {
if !stdout.is_empty() {
for line in stdout.lines() {
println!(" {}", line.dimmed());
}
}
if !stderr.is_empty() {
for line in stderr.lines() {
println!(" {}", line.dimmed());
}
}
}
if !output.status.success() {
let err_text = if !stderr.is_empty() {
stderr.trim().to_string()
} else if !stdout.is_empty() {
stdout.trim().to_string()
} else {
format!("exit code {}", output.status.code().unwrap_or(-1))
};
anyhow::bail!("apkeep exited with error: {}", short_error(&err_text));
}
match find_apk_in_dir(output_dir, package) {
Ok(path) => {
let size = std::fs::metadata(&path)
.map(|m| m.len())
.unwrap_or(0);
if size == 0 {
let _ = std::fs::remove_file(&path);
anyhow::bail!("Downloaded file is empty (0 bytes)");
}
if stdout.contains("downloaded successfully") {
}
Ok(path)
}
Err(_) => {
let hint = if stdout.contains("not found") || stderr.contains("not found") {
"app not found on this source"
} else if stdout.is_empty() && stderr.is_empty() {
"no output from apkeep (app likely not available on this source)"
} else {
"no APK file produced"
};
anyhow::bail!("{}", hint);
}
}
}
fn try_direct_apkpure_download(
package: &str,
output_dir: &Path,
verbose: bool,
) -> anyhow::Result<PathBuf> {
let client = reqwest::blocking::Client::builder()
.user_agent(
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 \
Chrome/120.0.0.0 Mobile Safari/537.36",
)
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::limited(10))
.build()?;
let page_url = format!("https://apkpure.com/search?q={}", package);
if verbose {
println!(
" {} Fetching APKPure search: {}",
"verbose".dimmed(),
page_url.dimmed()
);
}
let resp = client.get(&page_url).send()?;
let body = resp.text()?;
let link_re = Regex::new(&format!(
r#"href="(https://apkpure\.com/[^"]+/{})"#,
regex::escape(package)
))?;
let app_page_url = link_re
.captures(&body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.ok_or_else(|| anyhow::anyhow!("Package not found on APKPure search results"))?;
if verbose {
println!(
" {} Found app page: {}",
"verbose".dimmed(),
app_page_url.dimmed()
);
}
let download_page_url = format!("{}/download", app_page_url);
let resp = client.get(&download_page_url).send()?;
let body = resp.text()?;
let dl_re = Regex::new(r#"href="(https://d\.apkpure\.net/[^"]+\.apk[^"]*)"#)?;
let download_url = dl_re
.captures(&body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string());
let download_url = download_url.or_else(|| {
let alt_re = Regex::new(r#"data-dt-url="(https?://[^"]+\.apk[^"]*)"#).ok()?;
alt_re
.captures(&body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
});
let download_url = match download_url {
Some(url) => url,
None => {
anyhow::bail!(
"Could not extract download URL from APKPure page (site may require JS)"
);
}
};
if verbose {
println!(
" {} Download URL: {}",
"verbose".dimmed(),
short_error(&download_url).dimmed()
);
}
println!(
" {} Downloading from APKPure direct...",
"▸".cyan()
);
let resp = client.get(&download_url).send()?;
if !resp.status().is_success() {
anyhow::bail!("APKPure download returned HTTP {}", resp.status());
}
let bytes = resp.bytes()?;
if bytes.len() < 1000 {
anyhow::bail!(
"Downloaded file too small ({} bytes) — likely not a real APK",
bytes.len()
);
}
let dest = output_dir.join(format!("{}.apk", package));
std::fs::write(&dest, &bytes)?;
println!(
" {} Downloaded {} ({:.1} MB)",
"✓".green(),
package,
bytes.len() as f64 / 1_048_576.0
);
Ok(dest)
}
fn extract_id_param(url: &str) -> Option<String> {
let re = Regex::new(r"[?&]id=([a-zA-Z0-9_.]+)").ok()?;
re.captures(url).map(|c| c[1].to_string())
}
fn scrape_developer_apps(url: &str) -> anyhow::Result<Vec<String>> {
let client = reqwest::blocking::Client::builder()
.user_agent(
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 \
Chrome/120.0.0.0 Mobile Safari/537.36",
)
.timeout(std::time::Duration::from_secs(15))
.build()?;
let resp = client.get(url).send()?;
let body = resp.text()?;
let re = Regex::new(r"details\?id=([a-zA-Z0-9_.]+)").unwrap();
let mut packages: Vec<String> = re
.captures_iter(&body)
.map(|c| c[1].to_string())
.collect();
packages.sort();
packages.dedup();
if packages.is_empty() {
anyhow::bail!(
"Could not extract any app packages from the developer page.\n\
Google Play developer pages often require JavaScript rendering.\n\
Please provide individual app URLs or package names instead."
);
}
println!(
" {} Found {} app(s) on developer page:",
"▸".green(),
packages.len()
);
for pkg in &packages {
println!(" • {}", pkg.bold());
}
Ok(packages)
}
fn find_apk_in_dir(dir: &Path, package: &str) -> anyhow::Result<PathBuf> {
let entries = std::fs::read_dir(dir)?;
let mut candidates: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with(package)
&& (name.ends_with(".apk") || name.ends_with(".xapk"))
{
candidates.push(path);
}
}
}
if candidates.is_empty() {
let entries = std::fs::read_dir(dir)?;
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.ends_with(".apk") || name.ends_with(".xapk") {
candidates.push(path);
}
}
}
}
candidates.sort_by(|a, b| {
let ma = a.metadata().and_then(|m| m.modified()).ok();
let mb = b.metadata().and_then(|m| m.modified()).ok();
mb.cmp(&ma)
});
candidates.into_iter().next().ok_or_else(|| {
anyhow::anyhow!("No APK file found in {}", dir.display())
})
}
fn short_error(msg: &str) -> String {
let first_line = msg.lines().next().unwrap_or(msg);
if first_line.len() > 120 {
format!("{}...", &first_line[..120])
} else {
first_line.to_string()
}
}