From 673867aa0e1ccd766ee879ffe96aba58c758613c Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Wed, 1 Oct 2025 09:33:14 -0300 Subject: [PATCH] feat(cli): detect Android env and install SDK and NDK if needed (#14094) * feat(cli): detect Android env and install SDK and NDK if needed changes the Android setup to be a bit more automated - looking up ANDROID_HOME and NDK_HOME from common system paths and installing the Android SDK and NDK if needed using the command line tools * fix windows * clippy * lint * add prmopts and ci check * also check ANDROID_SDK_ROOT --- .changes/ensure-android-env.md | 6 + Cargo.lock | 3 + crates/tauri-cli/Cargo.toml | 3 + .../tauri-cli/src/helpers/cargo_manifest.rs | 15 +- crates/tauri-cli/src/helpers/http.rs | 24 ++ crates/tauri-cli/src/helpers/mod.rs | 1 + .../mobile/android/android_studio_script.rs | 2 +- crates/tauri-cli/src/mobile/android/build.rs | 2 +- crates/tauri-cli/src/mobile/android/dev.rs | 2 +- crates/tauri-cli/src/mobile/android/mod.rs | 238 +++++++++++++++++- crates/tauri-cli/src/mobile/init.rs | 42 ++-- 11 files changed, 290 insertions(+), 48 deletions(-) create mode 100644 .changes/ensure-android-env.md create mode 100644 crates/tauri-cli/src/helpers/http.rs diff --git a/.changes/ensure-android-env.md b/.changes/ensure-android-env.md new file mode 100644 index 000000000..f7e13f8da --- /dev/null +++ b/.changes/ensure-android-env.md @@ -0,0 +1,6 @@ +--- +"tauri-cli": minor:feat +"@tauri-apps/cli": minor:feat +--- + +Try to detect ANDROID_HOME and NDK_HOME environment variables from default system locations and install them if needed using the Android Studio command line tools. diff --git a/Cargo.lock b/Cargo.lock index 5b968e1dd..4ebbc36d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8627,6 +8627,7 @@ dependencies = [ "css-color", "ctrlc", "dialoguer", + "dirs 6.0.0", "duct", "dunce", "elf", @@ -8687,7 +8688,9 @@ dependencies = [ "url", "uuid", "walkdir", + "which", "windows-sys 0.60.2", + "zip 4.0.0", ] [[package]] diff --git a/crates/tauri-cli/Cargo.toml b/crates/tauri-cli/Cargo.toml index 573237e93..a1e32c001 100644 --- a/crates/tauri-cli/Cargo.toml +++ b/crates/tauri-cli/Cargo.toml @@ -69,6 +69,7 @@ toml = "0.9" jsonschema = "0.33" handlebars = "6" include_dir = "0.7" +dirs = "6" minisign = "=0.7.3" base64 = "0.22" ureq = { version = "3", default-features = false, features = ["gzip"] } @@ -110,6 +111,8 @@ memchr = "2" tempfile = "3" uuid = { version = "1", features = ["v5"] } rand = "0.9" +zip = { version = "4", default-features = false, features = ["deflate"] } +which = "8" [dev-dependencies] insta = "1" diff --git a/crates/tauri-cli/src/helpers/cargo_manifest.rs b/crates/tauri-cli/src/helpers/cargo_manifest.rs index 8ab152847..0ccf71790 100644 --- a/crates/tauri-cli/src/helpers/cargo_manifest.rs +++ b/crates/tauri-cli/src/helpers/cargo_manifest.rs @@ -131,20 +131,7 @@ struct CrateIoGetResponse { pub fn crate_latest_version(name: &str) -> Option { // Reference: https://github.com/rust-lang/crates.io/blob/98c83c8231cbcd15d6b8f06d80a00ad462f71585/src/controllers/krate/metadata.rs#L88 let url = format!("https://crates.io/api/v1/crates/{name}?include"); - #[cfg(feature = "platform-certs")] - let mut response = { - let agent = ureq::Agent::config_builder() - .tls_config( - ureq::tls::TlsConfig::builder() - .root_certs(ureq::tls::RootCerts::PlatformVerifier) - .build(), - ) - .build() - .new_agent(); - agent.get(&url).call().ok()? - }; - #[cfg(not(feature = "platform-certs"))] - let mut response = ureq::get(&url).call().ok()?; + let mut response = super::http::get(&url).ok()?; let metadata: CrateIoGetResponse = serde_json::from_reader(response.body_mut().as_reader()).unwrap(); metadata.krate.default_version diff --git a/crates/tauri-cli/src/helpers/http.rs b/crates/tauri-cli/src/helpers/http.rs new file mode 100644 index 000000000..9dfee185a --- /dev/null +++ b/crates/tauri-cli/src/helpers/http.rs @@ -0,0 +1,24 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use ureq::{http::Response, Body}; + +pub fn get(url: &str) -> Result, ureq::Error> { + #[cfg(feature = "platform-certs")] + { + let agent = ureq::Agent::config_builder() + .tls_config( + ureq::tls::TlsConfig::builder() + .root_certs(ureq::tls::RootCerts::PlatformVerifier) + .build(), + ) + .build() + .new_agent(); + agent.get(url).call() + } + #[cfg(not(feature = "platform-certs"))] + { + ureq::get(url).call() + } +} diff --git a/crates/tauri-cli/src/helpers/mod.rs b/crates/tauri-cli/src/helpers/mod.rs index 0fe4ed807..08f03f037 100644 --- a/crates/tauri-cli/src/helpers/mod.rs +++ b/crates/tauri-cli/src/helpers/mod.rs @@ -9,6 +9,7 @@ pub mod config; pub mod flock; pub mod framework; pub mod fs; +pub mod http; pub mod npm; #[cfg(target_os = "macos")] pub mod pbxproj; diff --git a/crates/tauri-cli/src/mobile/android/android_studio_script.rs b/crates/tauri-cli/src/mobile/android/android_studio_script.rs index 65ffd61ec..93edf027d 100644 --- a/crates/tauri-cli/src/mobile/android/android_studio_script.rs +++ b/crates/tauri-cli/src/mobile/android/android_studio_script.rs @@ -103,7 +103,7 @@ pub fn command(options: Options) -> Result<()> { )?; } - let env = env()?; + let env = env(std::env::var("CI").is_ok())?; if cli_options.dev { let dev_url = tauri_config diff --git a/crates/tauri-cli/src/mobile/android/build.rs b/crates/tauri-cli/src/mobile/android/build.rs index 94f5b3896..da3f2e0c4 100644 --- a/crates/tauri-cli/src/mobile/android/build.rs +++ b/crates/tauri-cli/src/mobile/android/build.rs @@ -163,7 +163,7 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> { MobileTarget::Android, )?; - let mut env = env()?; + let mut env = env(options.ci)?; configure_cargo(&mut env, &config)?; crate::build::setup(&interface, &mut build_options, tauri_config.clone(), true)?; diff --git a/crates/tauri-cli/src/mobile/android/dev.rs b/crates/tauri-cli/src/mobile/android/dev.rs index c667e7a1c..fcf16b204 100644 --- a/crates/tauri-cli/src/mobile/android/dev.rs +++ b/crates/tauri-cli/src/mobile/android/dev.rs @@ -158,7 +158,7 @@ fn run_command(options: Options, noise_level: NoiseLevel) -> Result<()> { .collect::>(), )?; - let env = env()?; + let env = env(false)?; let device = if options.open { None } else { diff --git a/crates/tauri-cli/src/mobile/android/mod.rs b/crates/tauri-cli/src/mobile/android/mod.rs index ce753b200..1d72e2f13 100644 --- a/crates/tauri-cli/src/mobile/android/mod.rs +++ b/crates/tauri-cli/src/mobile/android/mod.rs @@ -20,8 +20,10 @@ use cargo_mobile2::{ use clap::{Parser, Subcommand}; use std::{ env::set_var, - fs::{create_dir, create_dir_all, write}, - process::exit, + fs::{create_dir, create_dir_all, read_dir, write}, + io::Cursor, + path::{Path, PathBuf}, + process::{exit, Command}, thread::sleep, time::Duration, }; @@ -42,6 +44,19 @@ mod build; mod dev; pub(crate) mod project; +const NDK_VERSION: &str = "29.0.13846066"; +const SDK_VERSION: u8 = 36; + +#[cfg(target_os = "macos")] +const CMDLINE_TOOLS_URL: &str = + "https://dl.google.com/android/repository/commandlinetools-mac-13114758_latest.zip"; +#[cfg(target_os = "linux")] +const CMDLINE_TOOLS_URL: &str = + "https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip"; +#[cfg(windows)] +const CMDLINE_TOOLS_URL: &str = + "https://dl.google.com/android/repository/commandlinetools-win-13114758_latest.zip"; + #[derive(Parser)] #[clap( author, @@ -176,11 +191,228 @@ pub fn get_config( (config, metadata) } -fn env() -> Result { +pub fn env(non_interactive: bool) -> Result { let env = super::env()?; + ensure_env(non_interactive)?; cargo_mobile2::android::env::Env::from_env(env).map_err(Into::into) } +fn download_cmdline_tools(extract_path: &Path) -> Result<()> { + log::info!("Downloading Android command line tools..."); + + let mut response = crate::helpers::http::get(CMDLINE_TOOLS_URL)?; + let body = response + .body_mut() + .with_config() + .limit(200 * 1024 * 1024 /* 200MB */) + .read_to_vec()?; + + let mut zip = zip::ZipArchive::new(Cursor::new(body))?; + + log::info!( + "Extracting Android command line tools to {}", + extract_path.display() + ); + zip.extract(extract_path)?; + + Ok(()) +} + +fn ensure_env(non_interactive: bool) -> Result<()> { + ensure_java()?; + ensure_sdk(non_interactive)?; + ensure_ndk(non_interactive)?; + Ok(()) +} + +fn ensure_java() -> Result<()> { + if std::env::var_os("JAVA_HOME").is_none() { + #[cfg(windows)] + let default_java_home = "C:\\Program Files\\Android\\Android Studio\\jbr"; + #[cfg(target_os = "macos")] + let default_java_home = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"; + #[cfg(target_os = "linux")] + let default_java_home = "/opt/android-studio/jbr"; + + if Path::new(default_java_home).exists() { + log::info!("Using Android Studio's default Java installation: {default_java_home}"); + std::env::set_var("JAVA_HOME", default_java_home); + } else if which::which("java").is_err() { + anyhow::bail!("Java not found in PATH, default Android Studio Java installation not found at {default_java_home} and JAVA_HOME environment variable not set. Please install Java before proceeding"); + } + } + + Ok(()) +} + +fn ensure_sdk(non_interactive: bool) -> Result<()> { + let android_home = std::env::var_os("ANDROID_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("ANDROID_SDK_ROOT").map(PathBuf::from)); + if !android_home.as_ref().is_some_and(|v| v.exists()) { + log::info!( + "ANDROID_HOME {}, trying to locate Android SDK...", + if let Some(v) = &android_home { + format!("not found at {}", v.display()) + } else { + "not set".into() + } + ); + + #[cfg(target_os = "macos")] + let default_android_home = dirs::home_dir().unwrap().join("Library/Android/sdk"); + #[cfg(target_os = "linux")] + let default_android_home = dirs::home_dir().unwrap().join("Android/Sdk"); + #[cfg(windows)] + let default_android_home = dirs::data_local_dir().unwrap().join("Android/Sdk"); + + if default_android_home.exists() { + log::info!( + "Using installed Android SDK: {}", + default_android_home.display() + ); + } else if non_interactive { + anyhow::bail!("Android SDK not found. Make sure the SDK and NDK are installed and the ANDROID_HOME and NDK_HOME environment variables are set."); + } else { + log::error!( + "Android SDK not found at {}", + default_android_home.display() + ); + + let extract_path = if create_dir_all(&default_android_home).is_ok() { + default_android_home.clone() + } else { + std::env::current_dir()? + }; + + let sdk_manager_path = extract_path + .join("cmdline-tools/bin/sdkmanager") + .with_extension(if cfg!(windows) { "bat" } else { "" }); + + let mut granted_permission_to_install = false; + + if !sdk_manager_path.exists() { + granted_permission_to_install = crate::helpers::prompts::confirm( + "Do you want to install the Android Studio command line tools to setup the Android SDK?", + Some(false), + ) + .unwrap_or_default(); + + if !granted_permission_to_install { + anyhow::bail!("Skipping Android Studio command line tools installation. Please go through the manual setup process described in the documentation: https://tauri.app/start/prerequisites/#android"); + } + + download_cmdline_tools(&extract_path)?; + } + + if !granted_permission_to_install { + granted_permission_to_install = crate::helpers::prompts::confirm( + "Do you want to install the Android SDK using the command line tools?", + Some(false), + ) + .unwrap_or_default(); + + if !granted_permission_to_install { + anyhow::bail!("Skipping Android Studio SDK installation. Please go through the manual setup process described in the documentation: https://tauri.app/start/prerequisites/#android"); + } + } + + log::info!("Running sdkmanager to install platform-tools, android-{SDK_VERSION} and ndk-{NDK_VERSION} on {}...", default_android_home.display()); + let status = Command::new(&sdk_manager_path) + .arg(format!("--sdk_root={}", default_android_home.display())) + .arg("--install") + .arg("platform-tools") + .arg(format!("platforms;android-{SDK_VERSION}")) + .arg(format!("ndk;{NDK_VERSION}")) + .status()?; + + if !status.success() { + anyhow::bail!("Failed to install Android SDK"); + } + } + + std::env::set_var("ANDROID_HOME", default_android_home); + } + + Ok(()) +} + +fn ensure_ndk(non_interactive: bool) -> Result<()> { + // re-evaluate ANDROID_HOME + let android_home = std::env::var_os("ANDROID_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("ANDROID_SDK_ROOT").map(PathBuf::from)) + .ok_or_else(|| anyhow::anyhow!("Failed to locate Android SDK"))?; + let mut installed_ndks = read_dir(android_home.join("ndk")) + .map(|dir| { + dir + .into_iter() + .flat_map(|e| e.ok().map(|e| e.path())) + .collect::>() + }) + .unwrap_or_default(); + installed_ndks.sort(); + + if let Some(ndk) = installed_ndks.last() { + log::info!("Using installed NDK: {}", ndk.display()); + std::env::set_var("NDK_HOME", ndk); + } else if non_interactive { + anyhow::bail!("Android NDK not found. Make sure the NDK is installed and the NDK_HOME environment variable is set."); + } else { + let sdk_manager_path = android_home + .join("cmdline-tools/bin/sdkmanager") + .with_extension(if cfg!(windows) { "bat" } else { "" }); + + let mut granted_permission_to_install = false; + + if !sdk_manager_path.exists() { + granted_permission_to_install = crate::helpers::prompts::confirm( + "Do you want to install the Android Studio command line tools to setup the Android NDK?", + Some(false), + ) + .unwrap_or_default(); + + if !granted_permission_to_install { + anyhow::bail!("Skipping Android Studio command line tools installation. Please go through the manual setup process described in the documentation: https://tauri.app/start/prerequisites/#android"); + } + + download_cmdline_tools(&android_home)?; + } + + if !granted_permission_to_install { + granted_permission_to_install = crate::helpers::prompts::confirm( + "Do you want to install the Android NDK using the command line tools?", + Some(false), + ) + .unwrap_or_default(); + + if !granted_permission_to_install { + anyhow::bail!("Skipping Android Studio NDK installation. Please go through the manual setup process described in the documentation: https://tauri.app/start/prerequisites/#android"); + } + } + + log::info!( + "Running sdkmanager to install ndk-{NDK_VERSION} on {}...", + android_home.display() + ); + let status = Command::new(&sdk_manager_path) + .arg(format!("--sdk_root={}", android_home.display())) + .arg("--install") + .arg(format!("ndk;{NDK_VERSION}")) + .status()?; + + if !status.success() { + anyhow::bail!("Failed to install Android NDK"); + } + + let ndk_path = android_home.join("ndk").join(NDK_VERSION); + log::info!("Installed NDK: {}", ndk_path.display()); + std::env::set_var("NDK_HOME", ndk_path); + } + + Ok(()) +} + fn delete_codegen_vars() { for (k, _) in std::env::vars() { if k.starts_with("WRY_") && (k.ends_with("CLASS_EXTENSION") || k.ends_with("CLASS_INIT")) { diff --git a/crates/tauri-cli/src/mobile/init.rs b/crates/tauri-cli/src/mobile/init.rs index cd5b99d0c..9d9356132 100644 --- a/crates/tauri-cli/src/mobile/init.rs +++ b/crates/tauri-cli/src/mobile/init.rs @@ -9,7 +9,6 @@ use crate::{ ConfigValue, Result, }; use cargo_mobile2::{ - android::env::Env as AndroidEnv, config::app::App, reserved_names::KOTLIN_ONLY_KEYWORDS, util::{ @@ -123,33 +122,20 @@ pub fn exec( let app = match target { // Generate Android Studio project - Target::Android => match AndroidEnv::new() { - Ok(_env) => { - let (config, metadata) = - super::android::get_config(&app, tauri_config_, None, &Default::default()); - map.insert("android", &config); - super::android::project::gen( - &config, - &metadata, - (handlebars, map), - wrapper, - skip_targets_install, - )?; - app - } - Err(err) => { - if err.sdk_or_ndk_issue() { - Report::action_request( - " to initialize Android environment; Android support won't be usable until you fix the issue below and re-run `tauri android init`!", - err, - ) - .print(wrapper); - app - } else { - return Err(err.into()); - } - } - }, + Target::Android => { + let _env = super::android::env(non_interactive)?; + let (config, metadata) = + super::android::get_config(&app, tauri_config_, None, &Default::default()); + map.insert("android", &config); + super::android::project::gen( + &config, + &metadata, + (handlebars, map), + wrapper, + skip_targets_install, + )?; + app + } #[cfg(target_os = "macos")] // Generate Xcode project Target::Ios => {