use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cli::{CliError, Result};
use crate::config::Config;
use crate::pack::PackError;
use crate::pack::symbols::{DebugSymbolArtifact, DebugSymbolArtifactKind, write_debug_symbols_zip};
use crate::target::{BuiltLibrary, Platform, RustTarget};
use crate::toolchain::AndroidToolchain;
use super::AndroidBindingMode;
pub struct AndroidPackager<'a> {
config: &'a Config,
libraries: Vec<BuiltLibrary>,
release: bool,
binding_mode: AndroidBindingMode,
}
pub struct AndroidOutput;
struct AndroidLinkedOutput {
target: RustTarget,
abi: &'static str,
path: PathBuf,
}
impl<'a> AndroidPackager<'a> {
pub fn new(
config: &'a Config,
libraries: Vec<BuiltLibrary>,
release: bool,
binding_mode: AndroidBindingMode,
) -> Self {
Self {
config,
libraries,
release,
binding_mode,
}
}
pub fn package(self) -> Result<AndroidOutput> {
let android_libs = self.filter_android_libraries();
if android_libs.is_empty() {
return Err(PackError::NoLibrariesFound {
platform: "Android".to_string(),
}
.into());
}
let jnilibs_path = self.android_jnilibs_path();
let android_toolchain = AndroidToolchain::discover(
self.config.android_min_sdk(),
self.config.android_ndk_version(),
)?;
std::fs::create_dir_all(&jnilibs_path).map_err(|source| {
CliError::CreateDirectoryFailed {
path: jnilibs_path.clone(),
source,
}
})?;
let jni_glue_path = self.android_jni_glue_path()?;
let header_include_dir = self.config.android_header_output();
let header_path = header_include_dir.join(format!("{}.h", self.config.library_name()));
if !header_path.exists() {
return Err(CliError::FileNotFound(header_path));
}
let mut linked_outputs = Vec::with_capacity(android_libs.len());
for lib in &android_libs {
linked_outputs.push(self.link_shared_library(
lib,
&jnilibs_path,
&android_toolchain,
&jni_glue_path,
&header_include_dir,
)?);
}
if self.android_debug_symbols_enabled() {
write_android_debug_symbols(self.config, &linked_outputs)?;
}
self.remove_stale_packaged_libraries(&jnilibs_path, &android_libs)?;
Ok(AndroidOutput)
}
fn remove_stale_packaged_libraries(
&self,
jnilibs_path: &Path,
android_libs: &[&BuiltLibrary],
) -> Result<()> {
let packaged_triples: std::collections::HashSet<_> = android_libs
.iter()
.map(|library| library.target.triple())
.collect();
let lib_file_name = format!("lib{}.so", self.android_library_name());
for target in RustTarget::ALL_ANDROID {
if packaged_triples.contains(target.triple()) {
continue;
}
let stale_output = jnilibs_path
.join(target.architecture().android_abi())
.join(&lib_file_name);
if stale_output.exists() {
std::fs::remove_file(&stale_output).map_err(|source| CliError::CommandFailed {
command: format!("remove stale android library {}", stale_output.display()),
status: source.raw_os_error(),
})?;
}
}
Ok(())
}
fn filter_android_libraries(&self) -> Vec<&BuiltLibrary> {
self.libraries
.iter()
.filter(|lib| lib.target.platform() == Platform::Android)
.collect()
}
fn android_jnilibs_path(&self) -> PathBuf {
match self.binding_mode {
AndroidBindingMode::Kotlin => self.config.android_pack_output(),
AndroidBindingMode::KotlinMultiplatform => self
.config
.kotlin_multiplatform_output()
.join("src/androidMain/jniLibs"),
}
}
fn android_jni_glue_path(&self) -> Result<PathBuf> {
let jni_glue_path = match self.binding_mode {
AndroidBindingMode::Kotlin => self
.config
.android_kotlin_output()
.join("jni")
.join("jni_glue.c"),
AndroidBindingMode::KotlinMultiplatform => self
.config
.kotlin_multiplatform_output()
.join("src/androidMain/c/jni_glue.c"),
};
jni_glue_path
.exists()
.then_some(jni_glue_path.clone())
.ok_or(CliError::FileNotFound(jni_glue_path))
}
fn link_shared_library(
&self,
library: &BuiltLibrary,
jnilibs_path: &Path,
android_toolchain: &AndroidToolchain,
jni_glue_path: &Path,
header_include_dir: &Path,
) -> Result<AndroidLinkedOutput> {
let abi = library.target.architecture().android_abi();
let abi_dir = jnilibs_path.join(abi);
std::fs::create_dir_all(&abi_dir).map_err(|source| CliError::CreateDirectoryFailed {
path: abi_dir.clone(),
source,
})?;
let lib_name = self.android_library_name();
let dest_path = abi_dir.join(format!("lib{}.so", lib_name));
let build_dir = PathBuf::from("target")
.join("boltffi")
.join("android")
.join(library.target.triple())
.join(if self.release { "release" } else { "debug" });
std::fs::create_dir_all(&build_dir).map_err(|source| CliError::CreateDirectoryFailed {
path: build_dir.clone(),
source,
})?;
let clang = android_toolchain.clang_for_target(&library.target)?;
let object_path = build_dir.join("jni_glue.o");
let export_script_path = build_dir.join("exports.map");
let mut compile = Command::new(&clang);
compile.args(android_jni_compile_args(
&object_path,
header_include_dir,
jni_glue_path,
self.release,
self.android_debug_symbols_enabled(),
));
run_command(compile)?;
write_android_export_version_script(&export_script_path)?;
let mut link = Command::new(&clang);
link.args(android_shared_link_args(
&dest_path,
&object_path,
&library.path,
&export_script_path,
));
run_command(link)?;
Ok(AndroidLinkedOutput {
target: library.target,
abi,
path: dest_path,
})
}
fn android_library_name(&self) -> String {
self.config.resolved_android_kotlin_library_name()
}
fn android_debug_symbols_enabled(&self) -> bool {
matches!(self.binding_mode, AndroidBindingMode::Kotlin)
&& self.config.android_debug_symbols_enabled()
}
}
fn android_jni_compile_args(
object_path: &Path,
header_include_dir: &Path,
jni_glue_path: &Path,
release: bool,
emit_debug_info: bool,
) -> Vec<OsString> {
let mut args = vec![
OsString::from("-c"),
OsString::from("-fPIC"),
OsString::from(if release { "-O3" } else { "-O0" }),
];
if emit_debug_info {
args.push(OsString::from("-g"));
}
args.extend([
OsString::from("-I"),
header_include_dir.as_os_str().to_os_string(),
jni_glue_path.as_os_str().to_os_string(),
OsString::from("-o"),
object_path.as_os_str().to_os_string(),
]);
args
}
fn write_android_debug_symbols(
config: &Config,
linked_outputs: &[AndroidLinkedOutput],
) -> Result<PathBuf> {
let artifacts = linked_outputs
.iter()
.map(|output| DebugSymbolArtifact {
source_path: output.path.clone(),
archive_path: PathBuf::from("jniLibs").join(output.abi).join(
output
.path
.file_name()
.expect("android library should have a filename"),
),
kind: DebugSymbolArtifactKind::Shared,
target_triple: Some(output.target.triple().to_string()),
platform: Some(output.target.platform()),
architecture: Some(output.target.architecture()),
abi: Some(output.abi.to_string()),
host_target: None,
})
.collect::<Vec<_>>();
write_debug_symbols_zip(
&config.android_debug_symbols_output(),
&match config.android_debug_symbols_format() {
crate::config::DebugSymbolsFormat::Zip => {
format!("{}.android.symbols.zip", config.crate_artifact_name())
}
},
"android",
match config.android_debug_symbols_bundle() {
crate::config::DebugSymbolsBundle::Unstripped => "unstripped",
},
&artifacts,
)
}
fn android_shared_link_args(
dest_path: &Path,
object_path: &Path,
library_path: &Path,
export_script_path: &Path,
) -> Vec<OsString> {
vec![
OsString::from("-shared"),
OsString::from("-o"),
dest_path.as_os_str().to_os_string(),
object_path.as_os_str().to_os_string(),
OsString::from("-Wl,--whole-archive"),
library_path.as_os_str().to_os_string(),
OsString::from("-Wl,--no-whole-archive"),
OsString::from("-Xlinker"),
OsString::from("--version-script"),
OsString::from("-Xlinker"),
export_script_path.as_os_str().to_os_string(),
OsString::from("-Wl,--gc-sections"),
OsString::from("-lm"),
OsString::from("-llog"),
OsString::from("-ldl"),
]
}
fn write_android_export_version_script(path: &Path) -> Result<()> {
std::fs::write(path, android_export_version_script()).map_err(|source| {
CliError::CommandFailed {
command: format!("write android linker version script {}", path.display()),
status: source.raw_os_error(),
}
})
}
fn android_export_version_script() -> &'static str {
r#"{
global:
Java_*;
JNI_OnLoad*;
JNI_OnUnload*;
boltffi_*;
local:
*;
};
"#
}
fn run_command(mut command: Command) -> Result<()> {
let command_string = format!("{:?}", command);
let status = command.status().map_err(|_| CliError::CommandFailed {
command: command_string.clone(),
status: None,
})?;
status
.success()
.then_some(())
.ok_or(CliError::CommandFailed {
command: command_string,
status: status.code(),
})
}
#[cfg(test)]
mod tests {
use super::{
AndroidPackager, android_export_version_script, android_jni_compile_args,
android_shared_link_args,
};
use crate::config::Config;
use crate::pack::android::AndroidBindingMode;
use crate::target::{BuiltLibrary, RustTarget};
use std::ffi::OsString;
use std::fs;
#[cfg(unix)]
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn parse_config(input: &str) -> Config {
let parsed: Config = toml::from_str(input).expect("toml parse failed");
parsed.validate().expect("config validation failed");
parsed
}
#[test]
fn stale_cleanup_removes_only_boltffi_library_file() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
let root = std::env::temp_dir().join(format!("boltffi-android-packager-test-{unique}"));
let pack_output = root.join("jniLibs");
let config = parse_config(&format!(
r#"
[package]
name = "demo"
[targets.android.pack]
output = "{}"
"#,
pack_output.display()
));
let stale_abi_dir = pack_output.join("x86");
fs::create_dir_all(&stale_abi_dir).expect("create stale abi dir");
let stale_boltffi = stale_abi_dir.join("libdemo.so");
let unrelated = stale_abi_dir.join("libdependency.so");
fs::write(&stale_boltffi, []).expect("write stale boltffi lib");
fs::write(&unrelated, []).expect("write unrelated lib");
let arm64_abi_dir = pack_output.join("arm64-v8a");
fs::create_dir_all(&arm64_abi_dir).expect("create configured abi dir");
let packager = AndroidPackager::new(
&config,
vec![BuiltLibrary {
target: RustTarget::ANDROID_ARM64,
path: root.join("libdemo.a"),
}],
false,
AndroidBindingMode::Kotlin,
);
let android_libs = packager.filter_android_libraries();
packager
.remove_stale_packaged_libraries(&pack_output, &android_libs)
.expect("cleanup succeeds");
assert!(!stale_boltffi.exists());
assert!(unrelated.exists());
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn android_jni_glue_path_uses_kotlin_output_for_legacy_android_bindings() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
let root = std::env::temp_dir().join(format!("boltffi-android-jni-path-test-{unique}"));
let kotlin_output = root.join("kotlin");
let expected_glue = kotlin_output.join("jni/jni_glue.c");
fs::create_dir_all(expected_glue.parent().expect("jni glue parent"))
.expect("create kotlin jni dir");
fs::write(&expected_glue, []).expect("write kotlin jni glue");
let config = parse_config(&format!(
r#"
[package]
name = "demo"
[targets.android.kotlin]
output = "{}"
"#,
kotlin_output.display()
));
let packager = AndroidPackager::new(&config, Vec::new(), false, AndroidBindingMode::Kotlin);
assert_eq!(
packager.android_jni_glue_path().expect("jni glue path"),
expected_glue
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn android_jnilibs_path_uses_android_pack_output_for_legacy_android_bindings_even_with_kmp() {
let root = std::env::temp_dir().join("boltffi-android-jni-output-test");
let pack_output = root.join("configured-jniLibs");
let kmp_output = root.join("kmp");
let config = parse_config(&format!(
r#"
experimental = ["kotlin_multiplatform"]
[package]
name = "demo"
[targets.android.pack]
output = "{}"
[targets.kotlin_multiplatform]
enabled = true
output = "{}"
"#,
pack_output.display(),
kmp_output.display()
));
let packager = AndroidPackager::new(&config, Vec::new(), false, AndroidBindingMode::Kotlin);
assert_eq!(packager.android_jnilibs_path(), pack_output);
}
#[test]
fn android_library_name_uses_kotlin_loader_override() {
let config = parse_config(
r#"
[package]
name = "demo"
[targets.android.kotlin]
library_name = "configured-library"
"#,
);
let legacy_packager =
AndroidPackager::new(&config, Vec::new(), false, AndroidBindingMode::Kotlin);
let kmp_packager = AndroidPackager::new(
&config,
Vec::new(),
false,
AndroidBindingMode::KotlinMultiplatform,
);
assert_eq!(legacy_packager.android_library_name(), "configured-library");
assert_eq!(kmp_packager.android_library_name(), "configured-library");
}
#[test]
fn android_jni_glue_path_uses_kmp_android_glue_for_kmp_bindings() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
let root = std::env::temp_dir().join(format!("boltffi-android-kmp-jni-path-test-{unique}"));
let kmp_output = root.join("kmp");
let expected_glue = kmp_output.join("src/androidMain/c/jni_glue.c");
fs::create_dir_all(expected_glue.parent().expect("jni glue parent"))
.expect("create kmp jni dir");
fs::write(&expected_glue, []).expect("write kmp jni glue");
let config = parse_config(&format!(
r#"
experimental = ["kotlin_multiplatform"]
[package]
name = "demo"
[targets.kotlin_multiplatform]
enabled = true
output = "{}"
"#,
kmp_output.display()
));
let packager = AndroidPackager::new(
&config,
Vec::new(),
false,
AndroidBindingMode::KotlinMultiplatform,
);
assert_eq!(
packager.android_jni_glue_path().expect("jni glue path"),
expected_glue
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn android_jnilibs_path_uses_kmp_android_main_jnilibs_for_kmp_bindings() {
let root = std::env::temp_dir().join("boltffi-android-kmp-jni-output-test");
let kmp_output = root.join("kmp");
let android_pack_output = root.join("legacy-jniLibs");
let config = parse_config(&format!(
r#"
experimental = ["kotlin_multiplatform"]
[package]
name = "demo"
[targets.android.pack]
output = "{}"
[targets.kotlin_multiplatform]
enabled = true
output = "{}"
"#,
android_pack_output.display(),
kmp_output.display()
));
let packager = AndroidPackager::new(
&config,
Vec::new(),
false,
AndroidBindingMode::KotlinMultiplatform,
);
assert_eq!(
packager.android_jnilibs_path(),
kmp_output.join("src/androidMain/jniLibs")
);
}
#[test]
fn kmp_android_packaging_does_not_write_legacy_debug_symbols() {
let config = parse_config(
r#"
[package]
name = "demo"
[targets.android.debug_symbols]
enabled = true
"#,
);
let legacy_packager =
AndroidPackager::new(&config, Vec::new(), false, AndroidBindingMode::Kotlin);
let kmp_packager = AndroidPackager::new(
&config,
Vec::new(),
false,
AndroidBindingMode::KotlinMultiplatform,
);
assert!(legacy_packager.android_debug_symbols_enabled());
assert!(!kmp_packager.android_debug_symbols_enabled());
}
#[test]
fn android_linker_uses_export_map_and_collects_unused_sections() {
let args = android_shared_link_args(
Path::new("/tmp/out/libdemo.so"),
Path::new("/tmp/out/jni_glue.o"),
Path::new("/tmp/out/libdemo.a"),
Path::new("/tmp/out/exports.map"),
);
assert!(!args.contains(&OsString::from("-Wl,--exclude-libs,ALL")));
assert!(args.contains(&OsString::from("--version-script")));
assert!(args.contains(&OsString::from("/tmp/out/exports.map")));
assert!(args.contains(&OsString::from("-Wl,--gc-sections")));
}
#[test]
fn android_export_map_keeps_public_jni_and_boltffi_symbols() {
let script = android_export_version_script();
assert!(script.contains("Java_*;"));
assert!(script.contains("JNI_OnLoad*;"));
assert!(script.contains("JNI_OnUnload*;"));
assert!(script.contains("boltffi_*;"));
assert!(script.contains("local:"));
assert!(script.contains("*;"));
}
#[test]
fn android_jni_compile_args_include_debug_info_when_requested() {
let args = android_jni_compile_args(
Path::new("/tmp/out/jni_glue.o"),
Path::new("/tmp/include"),
Path::new("/tmp/jni/jni_glue.c"),
true,
true,
);
assert!(args.contains(&OsString::from("-g")));
}
#[cfg(unix)]
#[test]
fn android_linker_preserves_non_utf8_paths() {
let dest_path = PathBuf::from(OsString::from_vec(b"/tmp/out-\xFF.so".to_vec()));
let object_path = PathBuf::from(OsString::from_vec(b"/tmp/jni-\xFE.o".to_vec()));
let library_path = PathBuf::from(OsString::from_vec(b"/tmp/lib-\xFD.a".to_vec()));
let export_script_path =
PathBuf::from(OsString::from_vec(b"/tmp/exports-\xFC.map".to_vec()));
let args =
android_shared_link_args(&dest_path, &object_path, &library_path, &export_script_path);
assert_eq!(
args[2].as_os_str().as_bytes(),
dest_path.as_os_str().as_bytes()
);
assert_eq!(
args[3].as_os_str().as_bytes(),
object_path.as_os_str().as_bytes()
);
assert_eq!(
args[5].as_os_str().as_bytes(),
library_path.as_os_str().as_bytes()
);
assert_eq!(
args[10].as_os_str().as_bytes(),
export_script_path.as_os_str().as_bytes()
);
}
}