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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]
- Add the ability to download dex metadata for an app from Google Play
- Added RuStore support

## [0.18.0] - 2025-10-30
- Adding the ability to specify json as an output format when listing versions of an app available
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ Or, to download from the Huawei AppGallery:
apkeep -a com.elysiumlabs.newsbytes -d huawei-app-gallery .
```

Or, to download from RuStore:

```shell
apkeep -a com.vk.calls -d ru-store .
```

To download a specific version of an APK (possible for APKPure or F-Droid), use the `@version`
convention:

Expand Down Expand Up @@ -117,6 +123,7 @@ You can use this tool to download from a few distinct sources.
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
* RuStore (`-d ru-store`), a Russian app store platform

## Usage Note

Expand Down
2 changes: 1 addition & 1 deletion USAGE
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Options:
-l, --list-versions
List the versions available
-d, --download-source <download_source>
Where to download the APKs from [default: apk-pure] [possible values: apk-pure, google-play, f-droid, huawei-app-gallery]
Where to download the APKs from [default: apk-pure] [possible values: apk-pure, google-play, f-droid, huawei-app-gallery, ru-store]
-o, --options <options>
A comma-separated list of additional options to pass to the download source
-i, --ini <ini>
Expand Down
1 change: 1 addition & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub enum DownloadSource {
GooglePlay,
FDroid,
HuaweiAppGallery,
RuStore,
}

impl std::fmt::Display for DownloadSource {
Expand Down
10 changes: 5 additions & 5 deletions src/download_sources/fdroid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,11 +539,11 @@ fn verify_and_return_json(dir: &TempDir, files: &[String], fingerprint: &[u8], v
let actual_manifest_shasum = if use_entry {
let mut hasher = Sha256::new();
hasher.update(manifest_file_data.clone());
Vec::from(hasher.finalize().as_slice())
hasher.finalize().to_vec()
} else {
let mut hasher = Sha1::new();
hasher.update(manifest_file_data.clone());
Vec::from(hasher.finalize().as_slice())
hasher.finalize().to_vec()
};
if signed_file_manifest_shasum != actual_manifest_shasum[..] {
return Err(Box::new(SimpleError::new(format!("The manifest {} from the signed file does not match the actual manifest {}.", sha_algorithm_name, sha_algorithm_name))));
Expand Down Expand Up @@ -572,11 +572,11 @@ fn verify_and_return_json(dir: &TempDir, files: &[String], fingerprint: &[u8], v
let actual_shasum = if use_entry {
let mut hasher = Sha256::new();
hasher.update(json_file_data.clone());
Vec::from(hasher.finalize().as_slice())
hasher.finalize().to_vec()
} else {
let mut hasher = Sha1::new();
hasher.update(json_file_data.clone());
Vec::from(hasher.finalize().as_slice())
hasher.finalize().to_vec()
};
if manifest_file_shasum != actual_shasum[..] {
return Err(Box::new(SimpleError::new(format!("The {} from the manifest file does not match the actual {}.", file_algo, file_algo))));
Expand Down Expand Up @@ -621,7 +621,7 @@ async fn verify_and_return_index_from_entry(dir: &TempDir, repo: &str, json: &st
let actual_index_shasum = {
let mut hasher = Sha256::new();
hasher.update(index_file_data.clone());
Vec::from(hasher.finalize().as_slice())
hasher.finalize().to_vec()
};
let index_sha256 = match hex::decode(index_sha256) {
Ok(index_sha256) => index_sha256,
Expand Down
4 changes: 3 additions & 1 deletion src/download_sources/huawei_app_gallery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async fn download_from_response(response: Response, app_string: String, outpath:
let list_value = response_obj.get("list").unwrap();
if list_value.is_array() {
let list = list_value.as_array().unwrap();
if !list.is_empty() && list[0].is_object(){
if !list.is_empty() && list[0].is_object() {
let first_list_entry = list[0].as_object().unwrap();
if first_list_entry.contains_key("downurl") {
let downurl = first_list_entry.get("downurl").unwrap();
Expand Down Expand Up @@ -118,6 +118,8 @@ async fn download_from_response(response: Response, app_string: String, outpath:
}
}
}
} else {
mp_log.println(format!("App not found on Huawei AppGallery: {}. Skipping...", app_string)).unwrap();
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/download_sources/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pub mod google_play;
pub mod fdroid;
pub mod apkpure;
pub mod huawei_app_gallery;
pub mod rustore;
200 changes: 200 additions & 0 deletions src/download_sources/rustore.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
use std::path::Path;
use std::rc::Rc;

use futures_util::StreamExt;
use indicatif::MultiProgress;
use reqwest::header::{HeaderMap, HeaderValue};
use serde_json::{Value, json};
use tokio_dl_stream_to_disk::{AsyncDownload, error::ErrorKind as TDSTDErrorKind};
use tokio::time::{sleep, Duration as TokioDuration};

use crate::util::progress_bar::progress_wrapper;

fn http_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("User-Agent", HeaderValue::from_static("RuStore/1.78.0.1 (Android 11; SDK 30; arm64-v8a; samsung SM-N935F; en)"));
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
headers
}

fn download_request_body(app_id: u64) -> String {
serde_json::to_string(&json!({
"appId": app_id,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it might be a good candidates to expose as options for -o flag?

"firstInstall": true,
"mobileServices": ["GMS", "HMS"],
"supportedAbis": ["arm64-v8a"],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Especially this one.

"screenDensity": 420,
"supportedLocales": ["en_US"],
"sdkVersion": 30,
"withoutSplits": true,
"signatureFingerprint": null
})).unwrap()
}

async fn get_app_id(http_client: &reqwest::Client, headers: &HeaderMap, package_name: &str) -> Option<u64> {
let lookup_url = format!("https://backapi.rustore.ru/applicationData/store-app?packageNames={}", package_name);

match http_client.get(&lookup_url).headers(headers.clone()).send().await {
Ok(response) if response.status().is_success() => {
match response.text().await {
Ok(body) => {
match serde_json::from_str::<Value>(&body) {
Ok(json_response) => {
if let Some(array) = json_response.as_array() {
if let Some(first_app) = array.first() {
if let Some(id) = first_app.get("id") {
return id.as_u64();
}
}
}
None
}
Err(_) => None
}
}
Err(_) => None
}
}
_ => None
}
}

async fn get_download_url(http_client: &reqwest::Client, headers: &HeaderMap, app_id: u64) -> Option<String> {
let download_url = "https://backapi.rustore.ru/applicationData/v2/download-link";

match http_client
.post(download_url)
.headers(headers.clone())
.body(download_request_body(app_id))
.send().await
{
Ok(response) if response.status().is_success() => {
match response.text().await {
Ok(body) => {
match serde_json::from_str::<Value>(&body) {
Ok(json_response) => {
if let Some(code) = json_response.get("code") {
if code.as_str() == Some("OK") {
if let Some(body) = json_response.get("body") {
if let Some(download_urls) = body.get("downloadUrls") {
if let Some(array) = download_urls.as_array() {
if let Some(first_url) = array.first() {
if let Some(url) = first_url.get("url") {
return url.as_str().map(|s| s.to_string());
}
}
}
}
}
}
}
None
}
Err(_) => None
}
}
Err(_) => None
}
}
_ => None
}
}

pub async fn download_apps(
apps: Vec<(String, Option<String>)>,
parallel: usize,
sleep_duration: u64,
outpath: &Path,
) {
let http_client = Rc::new(reqwest::Client::new());
let headers = http_headers();

let mp = Rc::new(MultiProgress::new());
futures_util::stream::iter(
apps.into_iter().map(|app| {
let (app_id, app_version) = app;
let http_client = Rc::clone(&http_client);
let headers = headers.clone();
let mp = Rc::clone(&mp);
let mp_log = Rc::clone(&mp);
async move {
if app_version.is_none() {
mp_log.suspend(|| println!("Downloading {}...", app_id));
if sleep_duration > 0 {
sleep(TokioDuration::from_millis(sleep_duration)).await;
}

match get_app_id(&http_client, &headers, &app_id).await {
Some(rustore_app_id) => {
match get_download_url(&http_client, &headers, rustore_app_id).await {
Some(download_url) => {
download_apk(download_url, app_id.to_string(), outpath, mp).await;
}
None => {
mp_log.println(format!("Could not get download URL for {}. Skipping...", app_id)).unwrap();
}
}
}
None => {
mp_log.println(format!("App not found on RuStore: {}. Skipping...", app_id)).unwrap();
}
}
} else {
mp_log.println(format!("Specific versions can not be downloaded from RuStore ({}@{}). Skipping...", app_id, app_version.unwrap())).unwrap();
}
}
})
).buffer_unordered(parallel).collect::<Vec<()>>().await;
}

async fn download_apk(download_url: String, app_string: String, outpath: &Path, mp: Rc<MultiProgress>) {
let mp_log = Rc::clone(&mp);
let mp = Rc::clone(&mp);
let fname = format!("{}.apk", app_string);

match AsyncDownload::new(&download_url, Path::new(outpath), &fname).get().await {
Ok(mut dl) => {
let length = dl.length();
let cb = match length {
Some(length) => Some(progress_wrapper(mp)(fname.clone(), length)),
None => None,
};

match dl.download(&cb).await {
Ok(_) => mp_log.suspend(|| println!("{} downloaded successfully!", app_string)),
Err(err) if matches!(err.kind(), TDSTDErrorKind::FileExists) => {
mp_log.println(format!("File already exists for {}. Skipping...", app_string)).unwrap();
},
Err(err) if matches!(err.kind(), TDSTDErrorKind::PermissionDenied) => {
mp_log.println(format!("Permission denied when attempting to write file for {}. Skipping...", app_string)).unwrap();
},
Err(_) => {
mp_log.println(format!("An error has occurred attempting to download {}. Retry #1...", app_string)).unwrap();
match AsyncDownload::new(&download_url, Path::new(outpath), &fname).download(&cb).await {
Ok(_) => mp_log.suspend(|| println!("{} downloaded successfully!", app_string)),
Err(_) => {
mp_log.println(format!("An error has occurred attempting to download {}. Retry #2...", app_string)).unwrap();
match AsyncDownload::new(&download_url, Path::new(outpath), &fname).download(&cb).await {
Ok(_) => mp_log.suspend(|| println!("{} downloaded successfully!", app_string)),
Err(_) => {
mp_log.println(format!("An error has occurred attempting to download {}. Skipping...", app_string)).unwrap();
}
}
}
}
}
}
},
Err(_) => {
mp_log.println(format!("Invalid response for {}. Skipping...", app_string)).unwrap();
}
}
}

pub async fn list_versions(apps: Vec<(String, Option<String>)>) {
for app in apps {
let (app_id, _) = app;
println!("Versions available for {} on RuStore:", app_id);
println!("| RuStore does not make old versions of apps available.");
}
}
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ use download_sources::google_play;
use download_sources::fdroid;
use download_sources::apkpure;
use download_sources::huawei_app_gallery;
use download_sources::rustore;

type CSVList = Vec<(String, Option<String>)>;
fn fetch_csv_list(csv: &str, field: usize, version_field: Option<usize>) -> Result<CSVList, Box<dyn Error>> {
Expand Down Expand Up @@ -279,6 +280,9 @@ async fn main() {
DownloadSource::HuaweiAppGallery => {
huawei_app_gallery::list_versions(list).await;
}
DownloadSource::RuStore => {
rustore::list_versions(list).await;
}
}
} else {
let parallel = matches.get_one::<usize>("parallel").map(|v| *v).unwrap();
Expand Down Expand Up @@ -390,6 +394,9 @@ async fn main() {
DownloadSource::HuaweiAppGallery => {
huawei_app_gallery::download_apps(list, parallel, sleep_duration, &outpath.unwrap()).await;
}
DownloadSource::RuStore => {
rustore::download_apps(list, parallel, sleep_duration, &outpath.unwrap()).await;
}
}
}
}