summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2025-12-15 16:39:40 +0100
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2026-01-21 14:18:43 +0100
commit1727ddc1d87decd5d30eb15a41370641e1515bda (patch)
tree4acdfd7500c202727fdd2bde6674ef27d5e8e2ab
parent074823cab41c7e4690f2d43eab8f4f42a2a635a1 (diff)
downloadmullvadvpn-1727ddc1d87decd5d30eb15a41370641e1515bda.tar.xz
mullvadvpn-1727ddc1d87decd5d30eb15a41370641e1515bda.zip
Add new version system to android
- Use the same version code as desktop - Use json array provided by the releases page via the api to determine if the current version of the app is supported - Add code to update the supported version array and latest version via the builder server Co-authored-by: Jonatan Rhodin <jonatan.rhodin@mullvad.net>
-rw-r--r--Cargo.lock15
-rw-r--r--Cargo.toml1
-rwxr-xr-xandroid/scripts/release/publish-metadata-to-api65
-rwxr-xr-xandroid/scripts/release/release146
-rw-r--r--android/scripts/release/release-config.sh12
-rw-r--r--mullvad-api/src/version.rs209
-rw-r--r--mullvad-daemon/src/lib.rs5
-rw-r--r--mullvad-daemon/src/version/check.rs190
-rw-r--r--mullvad-daemon/src/version/router.rs7
-rw-r--r--mullvad-release-android/Cargo.toml23
-rw-r--r--mullvad-release-android/src/client/api.rs107
-rw-r--r--mullvad-release-android/src/client/defaults.rs7
-rw-r--r--mullvad-release-android/src/client/mod.rs3
-rw-r--r--mullvad-release-android/src/data_dir.rs7
-rw-r--r--mullvad-release-android/src/io_util.rs59
-rw-r--r--mullvad-release-android/src/main.rs69
-rw-r--r--mullvad-release-android/src/platform.rs226
17 files changed, 947 insertions, 204 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 92f3c9940e..fc3bd2fadb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3147,6 +3147,21 @@ dependencies = [
]
[[package]]
+name = "mullvad-release-android"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "mullvad-api",
+ "mullvad-api-constants",
+ "mullvad-version",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
+[[package]]
name = "mullvad-setup"
version = "0.0.0"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
index 5e62b7e724..99bded41af 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ members = [
"mullvad-types/intersection-derive",
"mullvad-update",
"mullvad-update/mullvad-release",
+ "mullvad-release-android",
"mullvad-version",
"talpid-core",
"talpid-dbus",
diff --git a/android/scripts/release/publish-metadata-to-api b/android/scripts/release/publish-metadata-to-api
new file mode 100755
index 0000000000..53725a0f8f
--- /dev/null
+++ b/android/scripts/release/publish-metadata-to-api
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+if [ $# -lt 3 ]; then
+ echo "Please provide the following arguments:"
+ echo " $(basename "$0") \\"
+ echo " <metadata directory> \\"
+ echo " <build server SSH destination> \\"
+ echo " <metadata server SSH destination>"
+ echo ""
+ echo "Note that the metadata server SSH destination is part of the rsync command executed on the build server and will be checked against the SSH config of build@\$buildserver_host."
+ exit 1
+fi
+
+# shellcheck source=desktop/scripts/release/release-config.sh
+source "$SCRIPT_DIR/release-config.sh"
+
+LOCAL_METADATA_DIR=$1
+BUILD_SERVER_HOST=$2
+METADATA_SERVER_HOST=$3
+
+BUILDSERVER_TMP_DIR="/tmp/android-upload-release"
+BUILDSERVER_METADATA_DIR="$BUILDSERVER_TMP_DIR/metadata"
+METADATA_SERVER_METADATA_DIR="android/metadata"
+
+RSYNC_OPTIONS=(-av --mkpath)
+METADATA_SERVER_RSYNC_OPTIONS=("${RSYNC_OPTIONS[@]}" '--rsh="ssh -p 1122"')
+
+function run_on_build_server {
+ # This should be expanded client side
+ # shellcheck disable=SC2029
+ ssh "$BUILD_SERVER_HOST" "$@"
+}
+
+function run_on_build_server_as_build_user {
+ run_on_build_server sudo -i -u "$BUILDSERVER_BUILDUSER" "$@"
+}
+
+function local_rsync {
+ rsync "${RSYNC_OPTIONS[@]}" "$@"
+}
+
+function buildserver_rsync {
+ run_on_build_server_as_build_user rsync "${METADATA_SERVER_RSYNC_OPTIONS[@]}" "$@"
+}
+
+function remove_buildserver_tmp_dir {
+ run_on_build_server rm -rf $BUILDSERVER_TMP_DIR
+}
+
+# Clean up previous metadata dir on build server in case this failed the last time this script ran
+remove_buildserver_tmp_dir
+
+# Send the local metadata dir to the build server
+local_rsync "$LOCAL_METADATA_DIR" "$BUILD_SERVER_HOST":$BUILDSERVER_METADATA_DIR
+
+# Send the metadata on the buildserver to the cdn server
+buildserver_rsync $BUILDSERVER_METADATA_DIR "$METADATA_SERVER_HOST":"$(dirname "$METADATA_SERVER_METADATA_DIR")"
+
+# Remove intermediate tmp dir when done
+remove_buildserver_tmp_dir
diff --git a/android/scripts/release/release b/android/scripts/release/release
new file mode 100755
index 0000000000..d6630d86f4
--- /dev/null
+++ b/android/scripts/release/release
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+
+# These scripts are for the release process of the android app.
+# They are:
+# 1. add_release, Add a new supported release to android.json
+# 2. set_latest_stable, Set a version to be the latest stable version. Needs to be supported.
+# 3. remove_release, removes a release from android.json, marking it as unsupported.
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+REPO_ROOT=../../../
+
+# The hostname (can be the alias in your ~/.ssh/config) of the build server
+BUILDSERVER_HOST=$1
+# The server to upload the metadata to *from* the build server (argument above)
+METADATA_SERVER_HOST=$2
+
+# shellcheck source=android/scripts/release/release-config.sh
+source "$SCRIPT_DIR/release-config.sh"
+source $REPO_ROOT/scripts/utils/log
+
+print_usage() {
+ log "Usage: release <option>"
+ log "Where option is one of the following flags:"
+ log " -a, --add-release"
+ log " Add new release to the metada file and publish it to releases.mullvad.net"
+ log " -s, --set-latest-stable"
+ log " Update the latest.json file on release.mullvad.net"
+ log " -r, --remove-release"
+ log " Remove release to the metadata file and publish it to release.mullvad.net."
+ log " This will cause this release to be shown as unsupported."
+ log " -h, --help"
+ log " Show this help page."
+}
+
+function main {
+ if [[ $# -eq 0 ]]; then
+ print_usage
+ exit 1
+ fi
+
+ if [[ $# -gt 5 ]]; then
+ log_error "Too many arguments"
+ print_usage
+ exit 1
+ fi
+
+ case "$3" in
+ "-a"|"--add-release")
+ add_release "$4"
+ ;;
+ "-s"|"--set-latest-stable")
+ set_latest_stable_version "$4"
+ ;;
+ "-r"|"--remove-release")
+ remove_release "$4"
+ ;;
+ "-h"|"--help")
+ print_usage
+ exit 0
+ ;;
+ *)
+ log_error "Invalid argument: \`$3\`"
+ print_usage
+ exit 1
+ ;;
+ esac
+}
+
+function clean_old_data {
+ rm -rf "$WORK_DIR"
+ rm -rf "$PUBLISHED_DIR"
+
+ mkdir -p "$DATA_DIR"
+}
+
+MULLVAD_RELEASE="cargo run -q --package mullvad-release-android --"
+
+function add_release {
+ version=$1
+
+ clean_old_data
+
+ log_header "Fetching current version metadata"
+ $MULLVAD_RELEASE pull --assume-yes
+
+ log_header "Backing up released data"
+ cp -r "$WORK_DIR" "$PUBLISHED_DIR"
+
+ log_header "Adding new release $version"
+ $MULLVAD_RELEASE add-release "$version"
+
+ log_header "New metadata including $version"
+ git --no-pager diff --no-index -- "$PUBLISHED_DIR" "$WORK_DIR" || true
+
+ read -rp "Press enter to upload if the diffs look good "
+ ./publish-metadata-to-api "$WORK_DIR" "$BUILDSERVER_HOST" "$METADATA_SERVER_HOST"
+}
+
+function set_latest_stable_version {
+ version=$1
+
+ clean_old_data
+
+ log_header "Fetching current version metadata and latest version file"
+ $MULLVAD_RELEASE pull --assume-yes --latest-file
+
+ log_header "Backing up released data"
+ cp -r "$WORK_DIR" "$PUBLISHED_DIR"
+
+ log_header "Set stable $version in latest.json"
+ $MULLVAD_RELEASE set-latest-stable-version "$version"
+
+ log_header "New latest file"
+ git --no-pager diff --no-index -- "$PUBLISHED_DIR" "$WORK_DIR" || true
+
+ read -rp "Press enter to upload if the diffs look good "
+ ./publish-metadata-to-api "$WORK_DIR" "$BUILDSERVER_HOST" "$METADATA_SERVER_HOST"
+}
+
+function remove_release {
+ version=$1
+
+ clean_old_data
+
+ log_header "Fetching current version metadata"
+ $MULLVAD_RELEASE pull --assume-yes
+
+ log_header "Backing up released data"
+ cp -r "$WORK_DIR" "$PUBLISHED_DIR"
+
+ log_header "Removing new release $version"
+ $MULLVAD_RELEASE remove-release "$version"
+
+ log_header "New metadata without $version"
+ git --no-pager diff --no-index -- "$PUBLISHED_DIR" "$WORK_DIR" || true
+
+ read -rp "Press enter to upload if the diffs look good "
+ ./publish-metadata-to-api "$WORK_DIR" "$BUILDSERVER_HOST" "$METADATA_SERVER_HOST"
+}
+
+# Run script
+main "$@"
diff --git a/android/scripts/release/release-config.sh b/android/scripts/release/release-config.sh
new file mode 100644
index 0000000000..a5c32cb70a
--- /dev/null
+++ b/android/scripts/release/release-config.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+# Configuration variables shared between the release scripts in this directory.
+
+# Where the release scripts and programs store temporary data
+export DATA_DIR="$HOME/.local/share/mullvad-release-android"
+
+export WORK_DIR="$DATA_DIR/work/"
+export PUBLISHED_DIR="$DATA_DIR/currently_published/"
+
+# The user on the buildserver that builds and uploads artifacts to the cdn servers
+export BUILDSERVER_BUILDUSER="build"
diff --git a/mullvad-api/src/version.rs b/mullvad-api/src/version.rs
index 1032233a14..519a7cd5fe 100644
--- a/mullvad-api/src/version.rs
+++ b/mullvad-api/src/version.rs
@@ -1,56 +1,36 @@
-use std::future::Future;
-use std::str::FromStr;
-use std::sync::Arc;
-
+use super::rest;
+#[cfg(target_os = "android")]
+use anyhow::Context;
use http::StatusCode;
use http::header;
+#[cfg(not(target_os = "android"))]
use mullvad_update::format::response::SignedResponse;
-use mullvad_update::version::{Rollout, VersionInfo, VersionParameters, is_version_supported};
-
-type AppVersion = String;
-
-use super::APP_URL_PREFIX;
-use super::rest;
+#[cfg(target_os = "android")]
+use mullvad_update::version::Metadata;
+use mullvad_update::version::VersionInfo;
+#[cfg(not(target_os = "android"))]
+use mullvad_update::version::{Rollout, VersionParameters, is_version_supported};
+#[cfg(target_os = "android")]
+use mullvad_version::Version;
+use serde::{Deserialize, Serialize};
+#[cfg(target_os = "android")]
+use std::cmp::Ordering;
+use std::future::Future;
+use std::str::FromStr;
+use std::sync::Arc;
#[derive(Clone)]
pub struct AppVersionProxy {
handle: super::rest::MullvadRestHandle,
}
-#[derive(Debug)]
-pub struct AppVersionResponse {
- response: AppVersionResponseRaw,
- pub etag: Option<String>,
-}
-
-#[derive(serde::Deserialize, Debug)]
-struct AppVersionResponseRaw {
- supported: bool,
- latest: AppVersion,
- latest_stable: Option<AppVersion>,
- latest_beta: Option<AppVersion>,
-}
-
-impl AppVersionResponse {
- pub const fn supported(&self) -> bool {
- self.response.supported
- }
-
- pub const fn latest_stable(&self) -> Option<&AppVersion> {
- self.response.latest_stable.as_ref()
- }
-
- pub const fn latest_beta(&self) -> Option<&AppVersion> {
- self.response.latest_beta.as_ref()
- }
-}
-
/// Reply from `/app/releases/<platform>.json` endpoint
-pub struct AppVersionResponse2 {
+pub struct AppVersionResponse {
/// Information about available versions for the current target
pub version_info: VersionInfo,
/// Index of the metadata version used to sign the response.
/// Used to prevent replay/downgrade attacks.
+ #[cfg(not(target_os = "android"))]
pub metadata_version: usize,
/// Whether or not the current app version (mullvad_version::VERSION) is supported.
pub current_version_supported: bool,
@@ -58,30 +38,55 @@ pub struct AppVersionResponse2 {
pub etag: Option<String>,
}
+/// Android releases
+#[derive(Default, Debug, Deserialize, Serialize, Clone)]
+pub struct AndroidReleases {
+ /// Available app releases
+ pub releases: Vec<Release>,
+}
+#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, PartialOrd)]
+pub struct Release {
+ /// Mullvad app version
+ pub version: mullvad_version::Version,
+}
+
impl AppVersionProxy {
- /// Maximum size of `version_check_2` response
+ /// Maximum size of `version_check` response
const SIZE_LIMIT: usize = 1024 * 1024;
pub fn new(handle: rest::MullvadRestHandle) -> Self {
Self { handle }
}
+ /// Get versions from `/app/releases/<platform>.json`
+ ///
+ /// This returns `None` if the server responds with 304 (version is same as etag).
+ #[cfg(not(target_os = "android"))]
pub fn version_check(
&self,
- app_version: AppVersion,
platform: &str,
+ architecture: mullvad_update::format::Architecture,
+ lowest_metadata_version: usize,
platform_version: Option<String>,
+ rollout: Rollout,
etag: Option<String>,
) -> impl Future<Output = Result<Option<AppVersionResponse>, rest::Error>> + use<> {
let service = self.handle.service.clone();
-
- let path = format!("{APP_URL_PREFIX}/releases/{platform}/{app_version}");
+ let path = format!("app/releases/{platform}.json");
let request = self.handle.factory.get(&path);
async move {
let mut request = request?.expected_status(&[StatusCode::NOT_MODIFIED, StatusCode::OK]);
if let Some(platform_version) = platform_version {
- request = request.header("M-Platform-Version", &platform_version)?;
+ request = request
+ .header(
+ "M-App-Version",
+ &sanitize_header_value(mullvad_version::VERSION),
+ )?
+ .header(
+ "M-Platform-Version",
+ &sanitize_header_value(&platform_version),
+ )?;
}
if let Some(ref tag) = etag {
request = request.header(header::IF_NONE_MATCH, tag)?;
@@ -91,30 +96,44 @@ impl AppVersionProxy {
return Ok(None);
}
let etag = Self::extract_etag(&response);
- let deserialized: AppVersionResponseRaw = response.deserialize().await?;
- let _ = deserialized.latest; // we do not use this
+ let bytes = response.body_with_max_size(Self::SIZE_LIMIT).await?;
+
+ let response = SignedResponse::deserialize_and_verify(&bytes, lowest_metadata_version)
+ .map_err(|err| rest::Error::FetchVersions(Arc::new(err)))?;
+
+ let params = VersionParameters {
+ architecture,
+ rollout,
+ // NOTE: On Linux, version metadata contains no installers
+ allow_empty: cfg!(target_os = "linux"),
+ lowest_metadata_version,
+ };
+
+ let current_version =
+ mullvad_version::Version::from_str(mullvad_version::VERSION).unwrap();
+ let current_version_supported = is_version_supported(current_version, &response.signed);
+
+ let metadata_version = response.signed.metadata_version;
Ok(Some(AppVersionResponse {
- response: deserialized,
+ version_info: VersionInfo::try_from_response(&params, response.signed)
+ .map_err(Arc::new)
+ .map_err(rest::Error::FetchVersions)?,
+ metadata_version,
+ current_version_supported,
etag,
}))
}
}
- /// Get versions from `/app/releases/<platform>.json`
- ///
- /// This returns `None` if the server responds with 304 (version is same as etag).
- pub fn version_check_2(
+ #[cfg(target_os = "android")]
+ pub fn version_check_android(
&self,
- platform: &str,
- architecture: mullvad_update::format::Architecture,
- lowest_metadata_version: usize,
platform_version: Option<String>,
- rollout: Rollout,
etag: Option<String>,
- ) -> impl Future<Output = Result<Option<AppVersionResponse2>, rest::Error>> + use<> {
+ ) -> impl Future<Output = Result<Option<AppVersionResponse>, rest::Error>> + use<> {
let service = self.handle.service.clone();
- let path = format!("app/releases/{platform}.json");
+ let path = "app/releases/android.json".to_string();
let request = self.handle.factory.get(&path);
async move {
@@ -141,27 +160,24 @@ impl AppVersionProxy {
let bytes = response.body_with_max_size(Self::SIZE_LIMIT).await?;
- let response = SignedResponse::deserialize_and_verify(&bytes, lowest_metadata_version)
+ let response: AndroidReleases = serde_json::from_slice(&bytes)
+ .context("Invalid version JSON")
.map_err(|err| rest::Error::FetchVersions(Arc::new(err)))?;
- let params = VersionParameters {
- architecture,
- rollout,
- // NOTE: On Linux, version metadata contains no installers
- allow_empty: cfg!(target_os = "linux"),
- lowest_metadata_version,
- };
+ let current_version = Version::from_str(mullvad_version::VERSION).unwrap();
+ let current_version_supported =
+ is_version_supported_android(&current_version, &response);
- let current_version =
- mullvad_version::Version::from_str(mullvad_version::VERSION).unwrap();
- let current_version_supported = is_version_supported(current_version, &response.signed);
+ let params = response
+ .releases
+ .iter()
+ .map(|release| release.clone().version)
+ .collect::<Vec<_>>();
- let metadata_version = response.signed.metadata_version;
- Ok(Some(AppVersionResponse2 {
- version_info: VersionInfo::try_from_response(&params, response.signed)
+ Ok(Some(AppVersionResponse {
+ version_info: find_latest_versions(params)
.map_err(Arc::new)
.map_err(rest::Error::FetchVersions)?,
- metadata_version,
current_version_supported,
etag,
}))
@@ -182,6 +198,55 @@ impl AppVersionProxy {
}
}
+/// Helper method for android to figure out the latest stable and beta version
+/// This is a scaled down version to the desktop one as it does not need to worry about rollout etc.
+#[cfg(target_os = "android")]
+fn find_latest_versions(versions: Vec<Version>) -> anyhow::Result<VersionInfo> {
+ // Find latest stable version
+ let stable = versions
+ .iter()
+ .clone()
+ .filter(|v| v.is_stable())
+ .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
+ .context("No stable version found")?;
+
+ // Find the latest beta version
+ let beta = versions
+ .iter()
+ .filter(|v| v.is_beta())
+ .filter(|v| !v.is_dev())
+ // If the latest beta version is older than latest stable, dispose of it
+ .filter(|v| v > &stable)
+ .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
+
+ Ok(VersionInfo {
+ stable: Metadata {
+ version: stable.clone(),
+ urls: vec![],
+ size: 0,
+ changelog: "".to_string(),
+ sha256: [0; 32],
+ },
+ beta: beta.map(|b| Metadata {
+ version: b.clone(),
+ urls: vec![],
+ size: 0,
+ changelog: "".to_string(),
+ sha256: [0; 32],
+ }),
+ })
+}
+
+pub fn is_version_supported_android(
+ current_version: &mullvad_version::Version,
+ response: &AndroidReleases,
+) -> bool {
+ response
+ .releases
+ .iter()
+ .any(|release| release.version == *current_version)
+}
+
// This function makes a string conform to the allowed characters and length of header values.
// Here's the rule it needs to implement: [A-Za-z0-9_.-]{1,64}
fn sanitize_header_value(value: &str) -> String {
diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs
index ce579c767c..9fc119dec6 100644
--- a/mullvad-daemon/src/lib.rs
+++ b/mullvad-daemon/src/lib.rs
@@ -79,8 +79,6 @@ use mullvad_types::{
};
#[cfg(not(target_os = "android"))]
use mullvad_update::version::Rollout;
-#[cfg(target_os = "android")]
-use mullvad_update::version::SUPPORTED_VERSION;
use relay_list::{RelayListUpdater, RelayListUpdaterHandle};
use settings::SettingsPersister;
use std::collections::BTreeSet;
@@ -1003,14 +1001,13 @@ impl Daemon {
.expect("App version to be parsable");
Rollout::threshold(seed, version)
};
- #[cfg(target_os = "android")]
- let rollout = SUPPORTED_VERSION;
let version_handle = version::router::spawn_version_router(
api_handle.clone(),
api_handle.availability.clone(),
config.cache_dir.clone(),
internal_event_tx.to_specialized_sender(),
settings.show_beta_releases,
+ #[cfg(not(target_os = "android"))]
rollout,
app_upgrade_broadcast,
);
diff --git a/mullvad-daemon/src/version/check.rs b/mullvad-daemon/src/version/check.rs
index 39ac52d933..43d8c82e54 100644
--- a/mullvad-daemon/src/version/check.rs
+++ b/mullvad-daemon/src/version/check.rs
@@ -6,7 +6,9 @@ use futures::{
use mullvad_api::{
availability::ApiAvailability, rest::MullvadRestHandle, version::AppVersionProxy,
};
-use mullvad_update::version::{Metadata, Rollout, VersionInfo};
+#[cfg(not(target_os = "android"))]
+use mullvad_update::version::Rollout;
+use mullvad_update::version::{Metadata, VersionInfo};
use mullvad_version::Version;
use serde::{Deserialize, Serialize};
use std::{
@@ -40,12 +42,8 @@ const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15);
/// After this one, we wait [UPDATE_INTERVAL] between checks.
const FIRST_CHECK_INTERVAL: Duration = Duration::from_secs(5);
/// How long to wait between version checks, regardless of whether they succeed
-#[cfg(not(target_os = "android"))]
const UPDATE_INTERVAL: Duration = Duration::from_hours(1);
-/// How long to wait between version checks, regardless of whether they succeed
-// On Android, be more conservative since we use old endpoint. Retry at most once per 6 hours.
-#[cfg(target_os = "android")]
-const UPDATE_INTERVAL: Duration = Duration::from_hours(6);
+
/// Wait this long before sending platform metadata in check
/// `M-Platform-Version` should only be sent once per 24h to make statistics predictable.
const PLATFORM_HEADER_INTERVAL: Duration = Duration::from_hours(24);
@@ -58,8 +56,6 @@ const PLATFORM: &str = "linux";
const PLATFORM: &str = "macos";
#[cfg(target_os = "windows")]
const PLATFORM: &str = "windows";
-#[cfg(target_os = "android")]
-const PLATFORM: &str = "android";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(super) struct VersionCache {
@@ -93,7 +89,7 @@ impl VersionUpdater {
cache_dir: PathBuf,
update_sender: mpsc::UnboundedSender<VersionCache>,
refresh_rx: mpsc::UnboundedReceiver<()>,
- rollout: Rollout,
+ #[cfg(not(target_os = "android"))] rollout: Rollout,
) {
let cache_path = cache_dir.join(VERSION_INFO_FILENAME);
// load the last known AppVersionInfo from cache
@@ -118,6 +114,7 @@ impl VersionUpdater {
version_proxy,
platform_version,
},
+ #[cfg(not(target_os = "android"))]
rollout,
),
);
@@ -163,6 +160,7 @@ impl VersionUpdaterInner {
/// Return when the last successful check including platform headers was made.
///
/// This should occur every [PLATFORM_HEADER_INTERVAL].
+ #[cfg(test)]
fn last_platform_check(&self) -> Option<SystemTime> {
self.last_app_version_info
.as_ref()
@@ -181,7 +179,7 @@ impl VersionUpdaterInner {
mut refresh_rx: mpsc::UnboundedReceiver<()>,
update: UpdateContext,
api: ApiContext,
- rollout: Rollout,
+ #[cfg(not(target_os = "android"))] rollout: Rollout,
) {
// If this is a dev build, there's no need to pester the API for version checks.
if !*CHECK_ENABLED {
@@ -193,9 +191,22 @@ impl VersionUpdaterInner {
}
let update = |info| Box::pin(update.update(info)) as BoxFuture<'static, _>;
- let do_version_check = |prev_cache| do_version_check(api.clone(), prev_cache, rollout);
- let do_version_check_in_background =
- |prev_cache| do_version_check_in_background(api.clone(), prev_cache, rollout);
+ let do_version_check = |prev_cache| {
+ do_version_check(
+ api.clone(),
+ prev_cache,
+ #[cfg(not(target_os = "android"))]
+ rollout,
+ )
+ };
+ let do_version_check_in_background = |prev_cache| {
+ do_version_check_in_background(
+ api.clone(),
+ prev_cache,
+ #[cfg(not(target_os = "android"))]
+ rollout,
+ )
+ };
self.run_inner(
refresh_rx,
@@ -231,15 +242,6 @@ impl VersionUpdaterInner {
// Check already running
continue;
}
-
- // On Android, avoid polling the API unless necessary as we're using the old endpoint
- // Only poll when bg check runs
- if cfg!(target_os = "android") && let Some(info) = self.last_app_version_info.as_ref() {
- log::trace!("Skipping version check on Android");
- self.update_version_info(&update, info.clone()).await;
- continue;
- }
-
version_check_fg = do_version_check(self.last_app_version_info.clone()).fuse();
}
None => {
@@ -248,14 +250,6 @@ impl VersionUpdaterInner {
},
_ = run_next_check_bg => {
- // On Android, avoid polling the API unless necessary as we're using the old endpoint
- // Only poll when collecting platform headers
- if cfg!(target_os = "android") && !should_include_platform_headers(self.last_platform_check()) {
- log::trace!("Skipping version check on Android");
- run_next_check_bg = Self::update_interval();
- continue;
- }
-
version_check_bg = do_version_check_in_background(self.last_app_version_info.clone()).fuse();
},
@@ -334,12 +328,18 @@ struct ApiContext {
fn do_version_check(
api: ApiContext,
prev_cache: Option<VersionCache>,
- rollout: Rollout,
+ #[cfg(not(target_os = "android"))] rollout: Rollout,
) -> BoxFuture<'static, Result<VersionCache, Error>> {
let api_handle = api.api_handle.clone();
- let download_future_factory =
- move || version_check_inner(api.clone(), prev_cache.clone(), rollout);
+ let download_future_factory = move || {
+ version_check_inner(
+ api.clone(),
+ prev_cache.clone(),
+ #[cfg(not(target_os = "android"))]
+ rollout,
+ )
+ };
// retry immediately on network errors (unless we're offline)
let should_retry_immediate = move |result: &Result<_, Error>| {
@@ -361,10 +361,15 @@ fn do_version_check(
fn do_version_check_in_background(
api: ApiContext,
cache: Option<VersionCache>,
- rollout: Rollout,
+ #[cfg(not(target_os = "android"))] rollout: Rollout,
) -> BoxFuture<'static, Result<VersionCache, Error>> {
let when_available = api.api_handle.wait_background();
- let version_cache = version_check_inner(api, cache, rollout);
+ let version_cache = version_check_inner(
+ api,
+ cache,
+ #[cfg(not(target_os = "android"))]
+ rollout,
+ );
Box::pin(async move {
when_available.await.map_err(Error::ApiCheck)?;
version_cache.await
@@ -372,12 +377,12 @@ fn do_version_check_in_background(
}
/// Fetch new version endpoint
-#[cfg(not(target_os = "android"))]
async fn version_check_inner(
api: ApiContext,
cache: Option<VersionCache>,
- rollout: Rollout,
+ #[cfg(not(target_os = "android"))] rollout: Rollout,
) -> Result<VersionCache, Error> {
+ #[cfg(not(target_os = "android"))]
let architecture = match talpid_platform_metadata::get_native_arch()
.expect("IO error while getting native architecture")
.expect("Failed to get native architecture")
@@ -401,9 +406,10 @@ async fn version_check_inner(
}
};
+ #[cfg(not(target_os = "android"))]
let Some(response) = api
.version_proxy
- .version_check_2(
+ .version_check(
PLATFORM,
architecture,
prev_cache.metadata_version,
@@ -421,60 +427,10 @@ async fn version_check_inner(
..prev_cache
});
};
- (response, get_last_platform_header_check())
- }
- // No cache available
- None => {
- let response = api
- .version_proxy
- .version_check_2(
- PLATFORM,
- architecture,
- mullvad_update::version::MIN_VERIFY_METADATA_VERSION,
- Some(api.platform_version),
- rollout,
- None,
- )
- .await
- .map_err(Error::Download)?
- .expect("function must return body if no etag was set");
- (response, SystemTime::now())
- }
- };
- Ok(VersionCache {
- cache_version: APP_VERSION.clone(),
- current_version_supported: response.current_version_supported,
- version_info: response.version_info,
- last_platform_header_check,
- metadata_version: response.metadata_version,
- etag: response.etag,
- })
-}
-
-#[cfg(target_os = "android")]
-async fn version_check_inner(
- api: ApiContext,
- cache: Option<VersionCache>,
- _rollout: Rollout,
-) -> Result<VersionCache, Error> {
- let (response, last_platform_header_check) = match cache {
- // Cache available
- Some(prev_cache) => {
- let add_platform_headers =
- should_include_platform_headers(Some(prev_cache.last_platform_header_check));
- let get_last_platform_header_check = || {
- if add_platform_headers {
- SystemTime::now()
- } else {
- prev_cache.last_platform_header_check
- }
- };
-
+ #[cfg(target_os = "android")]
let Some(response) = api
.version_proxy
- .version_check(
- mullvad_version::VERSION.to_owned(),
- PLATFORM,
+ .version_check_android(
add_platform_headers.then(|| api.platform_version.clone()),
prev_cache.etag.clone(),
)
@@ -488,60 +444,42 @@ async fn version_check_inner(
..prev_cache
});
};
-
(response, get_last_platform_header_check())
}
// No cache available
None => {
+ #[cfg(not(target_os = "android"))]
let response = api
.version_proxy
.version_check(
- mullvad_version::VERSION.to_owned(),
PLATFORM,
- Some(api.platform_version.clone()),
+ architecture,
+ mullvad_update::version::MIN_VERIFY_METADATA_VERSION,
+ Some(api.platform_version),
+ rollout,
None,
)
.await
.map_err(Error::Download)?
.expect("function must return body if no etag was set");
-
+ #[cfg(target_os = "android")]
+ let response = api
+ .version_proxy
+ .version_check_android(Some(api.platform_version), None)
+ .await
+ .map_err(Error::Download)?
+ .expect("function must return body if no etag was set");
(response, SystemTime::now())
}
};
-
- let latest_stable = response.latest_stable()
- .and_then(|version| version.parse().ok())
- // Suggested stable must actually be stable
- .filter(|version: &Version| version.pre_stable.is_none())
- .ok_or_else(|| Error::MissingStable)?;
- let latest_beta = response.latest_beta()
- .and_then(|version| version.parse().ok())
- // Suggested beta must actually be non-stable
- .filter(|version: &Version| version.pre_stable.is_some());
-
Ok(VersionCache {
cache_version: APP_VERSION.clone(),
- current_version_supported: response.supported(),
- etag: response.etag,
+ current_version_supported: response.current_version_supported,
+ version_info: response.version_info,
last_platform_header_check,
- // Note: We're pretending that this is complete information,
- // but on Android and Linux, most of the information is missing
- version_info: VersionInfo {
- stable: Metadata {
- version: latest_stable,
- changelog: "".to_owned(),
- urls: vec![],
- sha256: [0u8; 32],
- size: 0,
- },
- beta: latest_beta.map(|version| Metadata {
- version,
- changelog: "".to_owned(),
- urls: vec![],
- sha256: [0u8; 32],
- size: 0,
- }),
- },
+ #[cfg(not(target_os = "android"))]
+ metadata_version: response.metadata_version,
+ etag: response.etag,
})
}
@@ -672,7 +610,6 @@ mod test {
}
}
- /// If there's no cached version, we should perform a check now and include platform headers
#[test]
fn test_version_unknown_is_stale() {
let checker = VersionUpdaterInner::default();
@@ -727,6 +664,7 @@ mod test {
/// Platform timestamp and etag must be updated even if metadata version is unchanged
#[tokio::test]
+ #[cfg(not(target_os = "android"))]
async fn test_platform_timestamp_update() {
// If the metadata version is unchanged, we should keep the existing metadata
// But update the etag and platform timestamp anyway
diff --git a/mullvad-daemon/src/version/router.rs b/mullvad-daemon/src/version/router.rs
index 4d96a0f531..bbd58bced8 100644
--- a/mullvad-daemon/src/version/router.rs
+++ b/mullvad-daemon/src/version/router.rs
@@ -7,7 +7,9 @@ use mullvad_api::{availability::ApiAvailability, rest::MullvadRestHandle};
use mullvad_types::version::{AppVersionInfo, SuggestedUpgrade};
#[cfg(in_app_upgrade)]
use mullvad_update::app::{AppDownloader, AppDownloaderParameters, HttpAppDownloader};
-use mullvad_update::version::{Rollout, VersionInfo};
+#[cfg(not(target_os = "android"))]
+use mullvad_update::version::Rollout;
+use mullvad_update::version::VersionInfo;
use talpid_core::mpsc::Sender;
#[cfg(in_app_upgrade)]
use talpid_types::ErrorExt;
@@ -210,7 +212,7 @@ pub(crate) fn spawn_version_router(
cache_dir: PathBuf,
version_event_sender: DaemonEventSender<AppVersionInfo>,
beta_program: bool,
- rollout: Rollout,
+ #[cfg(not(target_os = "android"))] rollout: Rollout,
app_upgrade_broadcast: AppUpgradeBroadcast,
) -> VersionRouterHandle {
let (tx, rx) = mpsc::unbounded();
@@ -233,6 +235,7 @@ pub(crate) fn spawn_version_router(
cache_dir.clone(),
new_version_tx,
refresh_version_check_rx,
+ #[cfg(not(target_os = "android"))]
rollout,
)
.await;
diff --git a/mullvad-release-android/Cargo.toml b/mullvad-release-android/Cargo.toml
new file mode 100644
index 0000000000..db942115de
--- /dev/null
+++ b/mullvad-release-android/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "mullvad-release-android"
+description = "Tools for managing Mullvad release metadata for Android"
+authors.workspace = true
+repository.workspace = true
+license.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+anyhow = "1.0"
+clap = { workspace = true }
+serde_json = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+tokio = { version = "1", features = ["full"] }
+reqwest = { workspace = true }
+
+mullvad-api = { path = "../mullvad-api" }
+mullvad-api-constants = { path = "../mullvad-api/mullvad-api-constants" }
+mullvad-version = { path = "../mullvad-version", features = ["serde"] }
diff --git a/mullvad-release-android/src/client/api.rs b/mullvad-release-android/src/client/api.rs
new file mode 100644
index 0000000000..f23e24001c
--- /dev/null
+++ b/mullvad-release-android/src/client/api.rs
@@ -0,0 +1,107 @@
+//! This module implements fetching of information about app versions
+
+use std::net::IpAddr;
+
+use super::defaults;
+use anyhow::Context;
+
+use mullvad_api_constants::*;
+
+/// Obtain version data using a GET request
+pub struct HttpVersionInfoProvider {
+ /// Endpoint for GET request
+ url: String,
+ /// Optional host to resolve (to the IP) without DNS
+ resolve: Option<(&'static str, IpAddr)>,
+}
+
+impl HttpVersionInfoProvider {
+ /// Maximum size of the GET response, in bytes
+ const SIZE_LIMIT: usize = 1024 * 1024;
+
+ /// Retrieve released versions for Android.
+ pub async fn get_releases() -> anyhow::Result<mullvad_api::version::AndroidReleases> {
+ let info_provider = HttpVersionInfoProvider {
+ url: format!("{}{}", defaults::RELEASES_URL, "android.json"),
+ resolve: Some((API_HOST_DEFAULT, API_IP_DEFAULT)),
+ };
+ info_provider.get_releases_inner().await
+ }
+
+ async fn get_releases_inner(&self) -> anyhow::Result<mullvad_api::version::AndroidReleases> {
+ let raw_json = Self::get(&self.url, None, self.resolve).await?;
+ serde_json::from_slice(&raw_json).context("Failed to deserialize Android releases")
+ }
+
+ /// Retrieve the `latest.json` file for Android.
+ pub async fn get_latest_versions_file() -> anyhow::Result<String> {
+ Self::get(
+ &format!("{}{}", defaults::METADATA_URL, "latest.json"),
+ None,
+ None,
+ )
+ .await
+ .and_then(|raw_json: Vec<u8>| Ok(String::from_utf8(raw_json)?))
+ .context("Failed to get latest.json file")
+ }
+
+ /// Perform a simple GET request, with a size limit, and return it as bytes
+ ///
+ /// # Arguments
+ /// `url` - URL to fetch
+ /// `pinned_certificate` - Optional pinned certificate for TLS verification
+ /// `resolve` - Optional host to resolve (to the IP) without DNS
+ async fn get(
+ url: &str,
+ pinned_certificate: Option<reqwest::Certificate>,
+ resolve: Option<(&'static str, IpAddr)>,
+ ) -> anyhow::Result<Vec<u8>> {
+ let mut req_builder = reqwest::Client::builder();
+ req_builder = req_builder.min_tls_version(reqwest::tls::Version::TLS_1_3);
+
+ if let Some(pinned_certificate) = pinned_certificate {
+ req_builder = req_builder
+ .tls_built_in_root_certs(false)
+ .add_root_certificate(pinned_certificate);
+ }
+
+ // Resolve name without DNS
+ if let Some((host, addr)) = resolve {
+ req_builder = req_builder.resolve(host, (addr, 0).into());
+ }
+
+ // Initiate GET request
+ let mut req = req_builder
+ .build()?
+ .get(url)
+ .send()
+ .await
+ .context("Failed to fetch version")?;
+
+ // Fail if content length exceeds limit
+ let content_len_limit = Self::SIZE_LIMIT.try_into().expect("Invalid size limit");
+ if req.content_length() > Some(content_len_limit) {
+ anyhow::bail!("Version info exceeded limit: {} bytes", Self::SIZE_LIMIT);
+ }
+
+ if let Err(err) = req.error_for_status_ref() {
+ return Err(err).context("GET request failed");
+ }
+
+ let mut read_n = 0;
+ let mut data = vec![];
+
+ while let Some(chunk) = req.chunk().await.context("Failed to retrieve chunk")? {
+ read_n += chunk.len();
+
+ // Fail if content length exceeds limit
+ if read_n > Self::SIZE_LIMIT {
+ anyhow::bail!("Version info exceeded limit: {} bytes", Self::SIZE_LIMIT);
+ }
+
+ data.extend_from_slice(&chunk);
+ }
+
+ Ok(data)
+ }
+}
diff --git a/mullvad-release-android/src/client/defaults.rs b/mullvad-release-android/src/client/defaults.rs
new file mode 100644
index 0000000000..65cf3f98fb
--- /dev/null
+++ b/mullvad-release-android/src/client/defaults.rs
@@ -0,0 +1,7 @@
+/// Default URL for the `releases`-API.
+///
+/// Note that this is just a proxy to _some_ of the files in [METADATA_URL].
+pub const RELEASES_URL: &str = "https://api.mullvad.net/app/releases/";
+
+/// Default URL for version metadata repository.
+pub const METADATA_URL: &str = "https://releases.mullvad.net/android/metadata/";
diff --git a/mullvad-release-android/src/client/mod.rs b/mullvad-release-android/src/client/mod.rs
new file mode 100644
index 0000000000..4a0553d966
--- /dev/null
+++ b/mullvad-release-android/src/client/mod.rs
@@ -0,0 +1,3 @@
+pub mod api;
+
+pub mod defaults;
diff --git a/mullvad-release-android/src/data_dir.rs b/mullvad-release-android/src/data_dir.rs
new file mode 100644
index 0000000000..f8d3b8421c
--- /dev/null
+++ b/mullvad-release-android/src/data_dir.rs
@@ -0,0 +1,7 @@
+use std::path::PathBuf;
+
+pub fn get_data_dir() -> PathBuf {
+ std::env::home_dir()
+ .expect("No home dir found")
+ .join(".local/share/mullvad-release-android")
+}
diff --git a/mullvad-release-android/src/io_util.rs b/mullvad-release-android/src/io_util.rs
new file mode 100644
index 0000000000..91c347f1e6
--- /dev/null
+++ b/mullvad-release-android/src/io_util.rs
@@ -0,0 +1,59 @@
+//! File and I/O utilities
+
+use std::path::Path;
+
+use anyhow::Context;
+use tokio::fs;
+
+/// Wait for user to respond with yes or no
+/// This returns `false` if reading from stdin fails
+pub async fn wait_for_confirm(prompt: &str) -> bool {
+ const DEFAULT: bool = true;
+
+ let prompt = prompt.to_owned();
+
+ tokio::task::spawn_blocking(move || {
+ let stdin = std::io::stdin();
+
+ loop {
+ let mut s = String::new();
+
+ print!("{prompt}");
+ if DEFAULT {
+ println!(" [Y/n]");
+ } else {
+ println!(" [y/N]");
+ }
+
+ stdin.read_line(&mut s).context("Failed to read line")?;
+
+ match s.trim().to_ascii_lowercase().as_str() {
+ "" => break Ok::<bool, anyhow::Error>(DEFAULT),
+ "y" | "ye" | "yes" => break Ok(true),
+ "n" | "no" => break Ok(false),
+ _ => (),
+ }
+ }
+ })
+ .await
+ .unwrap()
+ .unwrap_or(false)
+}
+
+/// Recursively create directories and write to 'file'
+pub async fn create_dir_and_write(
+ path: impl AsRef<Path>,
+ contents: impl AsRef<[u8]>,
+) -> anyhow::Result<()> {
+ let path = path.as_ref();
+
+ let parent_dir = path.parent().context("Missing parent directory")?;
+ fs::create_dir_all(parent_dir)
+ .await
+ .context("Failed to create directories")?;
+
+ fs::write(path, contents)
+ .await
+ .with_context(|| format!("Failed to write to {}", path.display()))?;
+ Ok(())
+}
diff --git a/mullvad-release-android/src/main.rs b/mullvad-release-android/src/main.rs
new file mode 100644
index 0000000000..1e49e8faca
--- /dev/null
+++ b/mullvad-release-android/src/main.rs
@@ -0,0 +1,69 @@
+//! See [Opt].
+
+use clap::Parser;
+
+mod client;
+mod data_dir;
+mod io_util;
+mod platform;
+
+/// A tool that generates Mullvad version metadata for Android.
+#[derive(Parser)]
+pub enum Opt {
+ /// Download version metadata from releases.mullvad.net or API endpoint
+ Pull {
+ /// Replace files without asking for confirmation
+ #[arg(long, short = 'y')]
+ assume_yes: bool,
+ /// Also update the latest.json file
+ #[arg(long, default_value_t = false)]
+ latest_file: bool,
+ },
+
+ /// List releases in `work/`
+ ListReleases,
+
+ /// Add release to `work/`
+ AddRelease {
+ /// Version to add
+ version: mullvad_version::Version,
+ },
+
+ /// Remove release from `work/`
+ RemoveRelease {
+ /// Version to remove
+ version: mullvad_version::Version,
+ },
+
+ /// Return the latest releases.
+ /// The output is in JSON format.
+ SetLatestStableVersion {
+ /// Version to set at latest
+ version: mullvad_version::Version,
+ },
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ let opt = Opt::parse();
+
+ match opt {
+ Opt::Pull {
+ assume_yes,
+ latest_file,
+ } => {
+ platform::pull(assume_yes).await?;
+
+ // Download latest.json metadata if available
+ if latest_file {
+ platform::pull_latest(assume_yes).await?;
+ }
+
+ Ok(())
+ }
+ Opt::ListReleases => platform::list_releases().await,
+ Opt::AddRelease { version } => platform::add_release(&version).await,
+ Opt::RemoveRelease { version } => platform::remove_release(&version).await,
+ Opt::SetLatestStableVersion { version } => platform::set_latest_stable(&version).await,
+ }
+}
diff --git a/mullvad-release-android/src/platform.rs b/mullvad-release-android/src/platform.rs
new file mode 100644
index 0000000000..6c36e7d585
--- /dev/null
+++ b/mullvad-release-android/src/platform.rs
@@ -0,0 +1,226 @@
+//! Types for handling per-platform metadata
+
+use anyhow::{Context, anyhow, bail};
+use std::{cmp::Ordering, path::PathBuf};
+use tokio::{fs, io};
+
+use crate::{
+ client::api::HttpVersionInfoProvider,
+ data_dir::get_data_dir,
+ io_util::{create_dir_and_write, wait_for_confirm},
+};
+
+/// Output used by `Platform::set_latest_stable`
+#[derive(serde::Serialize)]
+pub struct Platform {
+ pub android: LatestVersion,
+}
+#[derive(serde::Serialize)]
+pub struct LatestVersion {
+ /// Stable version info
+ pub stable: Version,
+ /// Beta version info (if available and newer than `stable`).
+ /// If latest stable version is newer, this will be `None`.
+ pub beta: Option<Version>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Version {
+ pub version: mullvad_version::Version,
+}
+
+/// Path to WIP file in `work/` for this platform
+pub fn work_path() -> PathBuf {
+ get_data_dir().join("work").join("android.json")
+}
+
+pub fn work_path_latest() -> PathBuf {
+ get_data_dir().join("work").join("latest.json")
+}
+
+/// Pull latest metadata from repository and store it in `work/`
+pub async fn pull(assume_yes: bool) -> anyhow::Result<()> {
+ let releases = HttpVersionInfoProvider::get_releases()
+ .await
+ .context("Failed to retrieve versions")?;
+
+ let json =
+ serde_json::to_string_pretty(&releases).context("Failed to serialize updated metadata")?;
+
+ let work_path = work_path();
+
+ // Require confirmation if a file exists
+ if !assume_yes && work_path.exists() {
+ let msg = format!(
+ "This will replace the existing file at {}. Continue?",
+ work_path.display()
+ );
+ if !wait_for_confirm(&msg).await {
+ bail!("Aborted update");
+ }
+ }
+
+ println!("Writing metadata to {}", work_path.display());
+
+ create_dir_and_write(&work_path, &json).await?;
+
+ println!("Updated {}", work_path.display());
+ Ok(())
+}
+
+pub async fn pull_latest(assume_yes: bool) -> anyhow::Result<()> {
+ match HttpVersionInfoProvider::get_latest_versions_file().await {
+ Ok(json_str) => {
+ let work_path = work_path_latest();
+
+ if !assume_yes && work_path.exists() {
+ let msg = format!(
+ "This will replace the existing file at {}. Continue?",
+ work_path.display()
+ );
+ if !wait_for_confirm(&msg).await {
+ bail!("Aborted");
+ }
+ }
+
+ fs::write(&work_path, json_str)
+ .await
+ .context("Failed to write")?;
+
+ println!("Updated {}", work_path.display());
+ }
+ Err(err) => {
+ eprintln!("{err:?}");
+ }
+ }
+ Ok(())
+}
+
+/// Add release to platform in `work/`
+pub async fn add_release(version: &mullvad_version::Version) -> anyhow::Result<()> {
+ // Fetch WIP versions and verify that release does not exist
+ let work_path = work_path();
+ println!("Adding {version} from {}", work_path.display());
+
+ let mut work_response = read_work().await?;
+ if work_response
+ .releases
+ .iter()
+ .any(|release| &release.version == version)
+ {
+ // If it doesn't exist, treat as success
+ bail!("Version {version} already exists");
+ }
+
+ // Make release
+ let new_release = mullvad_api::version::Release {
+ version: version.clone(),
+ };
+
+ println!("- {}", &new_release.version);
+
+ work_response.releases.push(new_release);
+
+ let json = serde_json::to_string_pretty(&work_response)
+ .context("Failed to serialize updated metadata")?;
+ create_dir_and_write(&work_path, &json).await?;
+
+ println!("Added {version} to {}", work_path.display());
+
+ Ok(())
+}
+
+/// List releases for platforms in `work/`
+pub async fn list_releases() -> anyhow::Result<()> {
+ let work_path = work_path();
+ println!("Releases for file {}", work_path.display());
+
+ let mut response = read_work().await?;
+
+ if response.releases.is_empty() {
+ println!("No releases");
+ return Ok(());
+ }
+
+ response
+ .releases
+ .sort_by(|a, b| b.version.partial_cmp(&a.version).unwrap_or(Ordering::Equal));
+
+ for release in &response.releases {
+ println!("- {}", &release.version);
+ }
+ Ok(())
+}
+
+/// Remove version/release in `work/`
+pub async fn remove_release(version: &mullvad_version::Version) -> anyhow::Result<()> {
+ let work_path = work_path();
+ println!("Removing {version} from {}", work_path.display());
+
+ let mut work_response = read_work().await?;
+
+ let Some(found_release_ind) = work_response
+ .releases
+ .iter()
+ .position(|release| &release.version == version)
+ else {
+ // If it doesn't exist, treat as success
+ return Ok(());
+ };
+
+ let removed_release = work_response.releases.swap_remove(found_release_ind);
+
+ println!("- {}", &removed_release.version);
+
+ let json = serde_json::to_string_pretty(&work_response)
+ .context("Failed to serialize updated metadata")?;
+ create_dir_and_write(&work_path, &json).await?;
+
+ println!("Removed {version} in {}", work_path.display());
+
+ Ok(())
+}
+
+/// Set latest stable version in `work/`
+pub async fn set_latest_stable(version: &mullvad_version::Version) -> anyhow::Result<()> {
+ let work_path = work_path_latest();
+ println!("Setting latest stable {version} to {}", work_path.display());
+
+ let work_response = read_work().await?;
+
+ // Only set as latest if we have that version in the supported list
+ if mullvad_api::version::is_version_supported_android(version, &work_response) {
+ // Currently we never set a beta latest version so there is no need to check the current latest beta so we always just set it to null. If we ever want to set the latest beta we need to parse the current file first.
+ let new_latest = Platform {
+ android: LatestVersion {
+ stable: Version {
+ version: version.clone(),
+ },
+ beta: None,
+ },
+ };
+ let json = serde_json::to_string_pretty(&new_latest)
+ .context("Failed to serialize updated latest")?;
+ create_dir_and_write(&work_path, &json).await?;
+ } else {
+ bail!("Version {version} does not exist");
+ }
+
+ Ok(())
+}
+
+/// Reads the metadata for `platform` in the work directory.
+/// If the file doesn't exist, this returns a new, empty response.
+async fn read_work() -> anyhow::Result<mullvad_api::version::AndroidReleases> {
+ let work_path = work_path();
+ let bytes = match fs::read(&work_path).await {
+ Ok(bytes) => bytes,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {
+ // Return empty response
+ return Ok(mullvad_api::version::AndroidReleases::default());
+ }
+ Err(error) => bail!("Failed to read {}: {error}", work_path.display()),
+ };
+ serde_json::from_slice(&bytes)
+ .with_context(|| anyhow!("Failed to parse {}", work_path.display()))
+}