Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,33 @@
# Rust build artifacts
/target/
**/*.rs.bk
*.pdb
Cargo.lock

# Swap files
*.swp
/target
*.swo
*~

# APK files
*.apk
*.xapk
*.json

# Download directories
downloads/
output/
cache/

# IDE files
.vscode/
.idea/
*.iml
.DS_Store

# Test files
test_helpers.sh

# Temporary files
*.tmp
*.log
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ hex = "0.4"
configparser = "3"
serde = { version = "1", features = ["derive"] }
indicatif = "0.18"
chrono = "0.4"

[build-dependencies]
clap = { version = "4", features = ["derive"] }
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,28 @@ specific release version, the following floating tags are available:

See [`USAGE`](https://github.com/EFForg/apkeep/blob/master/USAGE).

### New Features

**Enhanced Download Control:**
- `--user-agent <agent>` - Custom User-Agent string to avoid bot detection
- `--headers <headers>` - Custom HTTP headers (format: 'Header1:Value1,Header2:Value2')
- `--timeout <seconds>` - Request timeout in seconds (default: 300)
- `--verify` - Verify APK integrity using SHA256 checksum after download
- `--save-metadata` - Save download metadata (checksum, size, timestamp) as JSON
- `--skip-existing` - Skip files that already exist instead of resuming download

**Example with new features:**
```shell
# Download with custom headers and verification
apkeep -a com.instagram.android --user-agent "CustomBot/1.0" --verify .

# Download with metadata tracking
apkeep -a com.instagram.android --save-metadata .

# Download with custom headers for anti-blocking
apkeep -a com.instagram.android --headers "Accept:application/json,X-Custom:value" .
```

## Examples

The simplest example is to download a single APK to the current directory:
Expand Down Expand Up @@ -112,12 +134,21 @@ just treat it as a CSV with a single field.
You can use this tool to download from a few distinct sources.

* The Google Play Store (`-d google-play`), given an email address and AAS token
* APKPure (`-d apk-pure`), a third-party site hosting APKs available on the Play Store
* APKPure (`-d apk-pure`), a third-party site hosting APKs available on the Play Store (default)
* F-Droid (`-d f-droid`), a repository for free and open-source Android apps. `apkeep`
verifies that these APKs are signed by the F-Droid maintainers, and alerts the user if an APK
was downloaded but could not be verified
* The Huawei AppGallery (`-d huawei-app-gallery`), an app store popular in China

### Advanced Features

**Download Helper Utilities:**
- Automatic SHA256 checksum computation and verification
- Download metadata tracking (app ID, version, file size, timestamp, source)
- Resume support for interrupted downloads
- Anti-bot detection with customizable headers and User-Agent
- JSON metadata persistence alongside downloaded APKs

## Usage Note

Users should not use app lists or choose so many parallel APK fetches as to place unreasonable
Expand Down
14 changes: 13 additions & 1 deletion USAGE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Downloads APKs from various sources
Downloads APKs from various sources with integrity verification and metadata tracking

Usage: apkeep <-a app_id[@version] | -c csv [-f field] [-v version_field]> [-d download_source] [-r parallel] OUTPATH

Expand Down Expand Up @@ -34,6 +34,18 @@ Options:
Sleep duration (in ms) before download requests [default: 0]
-r, --parallel <parallel>
The number of parallel APK fetches to run at a time [default: 4]
--user-agent <user_agent>
Custom User-Agent string to avoid bot detection (default: Chrome/Windows)
--headers <headers>
Custom HTTP headers for anti-blocking (format: 'Header1:Value1,Header2:Value2')
--timeout <timeout>
Request timeout in seconds [default: 300]
--verify
Verify APK integrity using SHA256 checksum after download
--save-metadata
Save download metadata (checksum, size, timestamp) as JSON
--skip-existing
Skip files that already exist instead of resuming download
-h, --help
Print help
-V, --version
Expand Down
46 changes: 45 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub fn app() -> Command {
Command::new("apkeep")
.version(env!("CARGO_PKG_VERSION"))
.author("William Budington <bill@eff.org>")
.about("Downloads APKs from various sources")
.about("Downloads APKs from various sources with integrity verification and metadata tracking")
.override_usage("apkeep <-a app_id[@version] | -c csv [-f field] [-v version_field]> [-d download_source] [-r parallel] OUTPATH")
.arg(
Arg::new("app")
Expand Down Expand Up @@ -149,6 +149,50 @@ pub fn app() -> Command {
.default_value("4")
.required(false),
)
.arg(
Arg::new("user_agent")
.help("Custom User-Agent string to avoid bot detection (default: Chrome/Windows)")
.long("user-agent")
.action(ArgAction::Set)
.required(false),
)
.arg(
Arg::new("headers")
.help("Custom HTTP headers for anti-blocking (format: 'Header1:Value1,Header2:Value2')")
.long("headers")
.action(ArgAction::Set)
.required(false),
)
.arg(
Arg::new("timeout")
.help("Request timeout in seconds")
.long("timeout")
.action(ArgAction::Set)
.value_parser(value_parser!(u64))
.default_value("300")
.required(false),
)
.arg(
Arg::new("verify_checksum")
.help("Verify APK integrity using SHA256 checksum after download")
.long("verify")
.action(ArgAction::SetTrue)
.required(false),
)
.arg(
Arg::new("save_metadata")
.help("Save download metadata (checksum, size, timestamp) as JSON")
.long("save-metadata")
.action(ArgAction::SetTrue)
.required(false),
)
.arg(
Arg::new("skip_existing")
.help("Skip files that already exist instead of resuming download")
.long("skip-existing")
.action(ArgAction::SetTrue)
.required(false),
)
.arg(
Arg::new("OUTPATH")
.help("Path to store output files")
Expand Down
9 changes: 7 additions & 2 deletions src/download_sources/apkpure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ use serde_json::json;
use tokio_dl_stream_to_disk::{AsyncDownload, error::ErrorKind as TDSTDErrorKind};
use tokio::time::{sleep, Duration as TokioDuration};

use crate::util::{OutputFormat, progress_bar::progress_wrapper};
use crate::util::{OutputFormat, progress_bar::progress_wrapper, download_helper};

fn http_headers(options: &HashMap<&str, &str>) -> HeaderMap {
let mut headers = HeaderMap::new();
let mut headers = download_helper::build_headers(
options.get("user_agent").cloned(),
options.get("headers").cloned(),
);

// APKPure-specific headers
headers.insert("x-cv", HeaderValue::from_static("3172501"));
headers.insert("x-sv", HeaderValue::from_static("29"));
let arch = match options.get("arch"){
Expand Down
29 changes: 17 additions & 12 deletions src/download_sources/fdroid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,36 +693,41 @@ async fn download_and_extract_to_tempdir(dir: &TempDir, repo: &str, mp: Rc<Multi
match zip::ZipArchive::new(file) {
Ok(mut archive) => {
for i in 0..archive.len() {
let mut file = archive.by_index(i).unwrap();
let mut file = match archive.by_index(i) {
Ok(f) => f,
Err(_) => continue,
};
let outpath = match file.enclosed_name() {
Some(path) => dir.path().join(path.to_owned()),
None => continue,
};
if (&*file.name()).ends_with('/') {
fs::create_dir_all(&outpath).unwrap();
if file.is_dir() {
let _ = fs::create_dir_all(&outpath);
} else {
if let Some(p) = outpath.parent() {
if !p.exists() {
fs::create_dir_all(&p).unwrap();
let _ = fs::create_dir_all(&p);
}
if let Some(name) = file.enclosed_name() {
if let Some(name_str) = name.to_owned().into_os_string().into_string().ok() {
files.push(name_str);
}
}
files.push(file.enclosed_name().unwrap().to_owned().into_os_string().into_string().unwrap());
let mut outfile = fs::File::create(&outpath).unwrap();
io::copy(&mut file, &mut outfile).unwrap();
if let Ok(mut outfile) = fs::File::create(&outpath) {
let _ = io::copy(&mut file, &mut outfile);
}
}

// Get and Set permissions
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;

if let Some(mode) = file.unix_mode() {
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode)).unwrap();
let _ = fs::set_permissions(&outpath, fs::Permissions::from_mode(mode));
}
}
}
},
Err(_) => {
Err(e) => {
mp_log.suspend(|| eprintln!("ZIP extraction error: {:?}", e));
print_error("F-Droid package repository could not be extracted. Please try again.", output_format);
std::process::exit(1);
}
Expand Down
28 changes: 27 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,15 @@ async fn main() {
let matches = cli::app().get_matches();

let mut download_source = *matches.get_one::<DownloadSource>("download_source").unwrap();
let options: HashMap<&str, &str> = match matches.get_one::<String>("options") {

// Extract new CLI options as owned values first
let user_agent_opt = matches.get_one::<String>("user_agent").cloned();
let headers_opt = matches.get_one::<String>("headers").cloned();
let timeout_opt = matches.get_one::<u64>("timeout").cloned();
let verify_checksum = matches.get_flag("verify_checksum");
let skip_existing = matches.get_flag("skip_existing");

let mut options: HashMap<&str, &str> = match matches.get_one::<String>("options") {
Some(options) => {
let mut options_map = HashMap::new();
for option in options.split(",") {
Expand All @@ -221,6 +229,24 @@ async fn main() {
None => HashMap::new()
};

// Add new CLI options to the options map using Box::leak for string lifetime
if let Some(user_agent) = user_agent_opt {
options.insert("user_agent", Box::leak(user_agent.into_boxed_str()));
}
if let Some(headers) = headers_opt {
options.insert("headers", Box::leak(headers.into_boxed_str()));
}
if let Some(timeout) = timeout_opt {
let timeout_str = timeout.to_string();
options.insert("timeout", Box::leak(timeout_str.into_boxed_str()));
}
if verify_checksum {
options.insert("verify_checksum", "true");
}
if skip_existing {
options.insert("skip_existing", "true");
}

let oauth_token = matches.get_one::<String>("google_oauth_token").map(|v| v.to_string());
if oauth_token.is_some() {
download_source = DownloadSource::GooglePlay;
Expand Down
Loading