commit 67ba7c42812f4e2161844188bf80f4c0aae55769
parent 8b5efed0a1f1597961c4fb8f11c8f7711197c365
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 12:22:42 +0000
android: enable local nostr key generation
- add the android keystore vault adapter, jni bridge, and no-backup metadata storage wiring
- enable local nostr key generation in the android launcher and map configured state to the shared home flow
- add the rust-side android account manager integration for loading and resolving selected local identities
- harden the android emulator runner for apple silicon by defaulting to swiftshader and disabling snapshot reuse
Diffstat:
8 files changed, 768 insertions(+), 15 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2775,10 +2775,15 @@ version = "0.1.0"
dependencies = [
"android_logger",
"eframe",
+ "jni 0.21.1",
"log",
+ "ndk-context",
"radroots-app-core",
+ "radroots-identity",
+ "radroots-nostr-accounts",
"wgpu",
"winit",
+ "zeroize",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
@@ -24,7 +24,9 @@ android_logger = "0.15.1"
directories = "6"
eframe = { version = "0.33.3", default-features = false, features = ["android-game-activity", "default_fonts", "glow", "wgpu", "wayland", "x11"] }
egui = { version = "0.33.3", features = ["serde"] }
+jni = "0.21.1"
log = "0.4.28"
+ndk-context = "0.1.1"
nostr = { version = "0.44.1", default-features = false, features = ["std"] }
nostr-browser-signer = "0.44.1"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std"] }
diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml
@@ -18,9 +18,14 @@ crate-type = ["cdylib", "rlib"]
eframe.workspace = true
log.workspace = true
radroots-app-core = { path = "../core" }
+radroots-identity.workspace = true
+radroots-nostr-accounts.workspace = true
+zeroize.workspace = true
[target.'cfg(target_os = "android")'.dependencies]
android_logger.workspace = true
+jni.workspace = true
+ndk-context.workspace = true
wgpu = { workspace = true, features = ["vulkan", "gles", "wgsl"] }
winit.workspace = true
diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs
@@ -4,25 +4,93 @@
use android_logger::Config;
#[cfg(target_os = "android")]
use eframe::egui::ViewportBuilder;
+#[cfg(any(target_os = "android", test))]
+use radroots_app_core::RadrootsAppBackend;
#[cfg(target_os = "android")]
use radroots_app_core::{APP_NAME, RadrootsApp};
#[cfg(any(target_os = "android", test))]
-use radroots_app_core::{IdentityGateState, RadrootsAppBackend, SetupActionState};
+use radroots_app_core::{IdentityGateState, SetupActionState};
+#[cfg(test)]
+use radroots_identity::RadrootsIdentity;
+#[cfg(test)]
+use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord;
+#[cfg(any(target_os = "android", test))]
+use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager;
+#[cfg(any(target_os = "android", test))]
+use radroots_nostr_accounts::prelude::RadrootsNostrSelectedAccountStatus;
#[cfg(target_os = "android")]
use winit::platform::android::activity::AndroidApp;
#[cfg(any(target_os = "android", test))]
+mod security;
+#[cfg(any(target_os = "android", test))]
+mod storage;
+#[cfg(any(target_os = "android", test))]
+mod vault;
+
+#[cfg(any(target_os = "android", test))]
struct AndroidBackend;
#[cfg(any(target_os = "android", test))]
impl RadrootsAppBackend for AndroidBackend {
fn load_identity_state(&self) -> Result<IdentityGateState, String> {
- Ok(IdentityGateState::Unsupported {
- reason: "Secure onboarding is not yet available on Android.".to_owned(),
- })
+ #[cfg(target_os = "android")]
+ {
+ let manager = Self::accounts_manager()?;
+ return Self::identity_state_from_manager(&manager);
+ }
+
+ #[cfg(not(target_os = "android"))]
+ {
+ Ok(Self::unsupported_identity_state())
+ }
}
fn setup_action_state(&self) -> SetupActionState {
+ #[cfg(target_os = "android")]
+ {
+ return Self::enabled_setup_action_state();
+ }
+
+ #[cfg(not(target_os = "android"))]
+ {
+ Self::unsupported_setup_action_state()
+ }
+ }
+
+ fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> {
+ #[cfg(target_os = "android")]
+ {
+ let manager = Self::accounts_manager()?;
+ return Self::generate_local_identity(&manager).map(Some);
+ }
+
+ #[cfg(not(target_os = "android"))]
+ {
+ Ok(Some(Self::unsupported_identity_state()))
+ }
+ }
+}
+
+#[cfg(any(target_os = "android", test))]
+impl AndroidBackend {
+ #[cfg(target_os = "android")]
+ fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> {
+ #[cfg(target_os = "android")]
+ {
+ return storage::accounts_manager();
+ }
+ }
+
+ #[cfg(test)]
+ fn unsupported_identity_state() -> IdentityGateState {
+ IdentityGateState::Unsupported {
+ reason: ANDROID_SETUP_UNAVAILABLE_REASON.to_owned(),
+ }
+ }
+
+ #[cfg(test)]
+ fn unsupported_setup_action_state() -> SetupActionState {
SetupActionState {
label: "Generate New Key".to_owned(),
enabled: false,
@@ -30,13 +98,48 @@ impl RadrootsAppBackend for AndroidBackend {
}
}
- fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> {
- Ok(Some(IdentityGateState::Unsupported {
- reason: "Secure onboarding is not yet available on Android.".to_owned(),
- }))
+ fn enabled_setup_action_state() -> SetupActionState {
+ SetupActionState {
+ label: "Generate New Key".to_owned(),
+ enabled: true,
+ pending: false,
+ }
+ }
+
+ fn map_status(status: RadrootsNostrSelectedAccountStatus) -> IdentityGateState {
+ match status {
+ RadrootsNostrSelectedAccountStatus::Ready { account } => IdentityGateState::Ready {
+ account_id: account.account_id.to_string(),
+ npub: account.public_identity.public_key_npub,
+ },
+ RadrootsNostrSelectedAccountStatus::NotConfigured
+ | RadrootsNostrSelectedAccountStatus::PublicOnly { .. } => IdentityGateState::Missing,
+ }
+ }
+
+ fn identity_state_from_manager(
+ manager: &RadrootsNostrAccountsManager,
+ ) -> Result<IdentityGateState, String> {
+ let status = manager
+ .selected_account_status()
+ .map_err(|source| source.to_string())?;
+ Ok(Self::map_status(status))
+ }
+
+ fn generate_local_identity(
+ manager: &RadrootsNostrAccountsManager,
+ ) -> Result<IdentityGateState, String> {
+ manager
+ .generate_identity(Some("local".to_owned()), true)
+ .map_err(|source| source.to_string())?;
+ Self::identity_state_from_manager(manager)
}
}
+#[cfg(any(target_os = "android", test))]
+#[cfg(test)]
+const ANDROID_SETUP_UNAVAILABLE_REASON: &str = "Secure onboarding is not yet available on Android.";
+
#[cfg(target_os = "android")]
fn native_options(android_app: AndroidApp) -> eframe::NativeOptions {
eframe::NativeOptions {
@@ -72,15 +175,15 @@ mod tests {
use super::*;
#[test]
- fn android_backend_reports_unsupported_onboarding() {
+ fn android_backend_reports_android_disabled_state_off_target() {
assert_eq!(
- AndroidBackend.load_identity_state(),
- Ok(IdentityGateState::Unsupported {
- reason: "Secure onboarding is not yet available on Android.".to_owned(),
- })
+ AndroidBackend::unsupported_identity_state(),
+ IdentityGateState::Unsupported {
+ reason: ANDROID_SETUP_UNAVAILABLE_REASON.to_owned(),
+ }
);
assert_eq!(
- AndroidBackend.setup_action_state(),
+ AndroidBackend::unsupported_setup_action_state(),
SetupActionState {
label: "Generate New Key".to_owned(),
enabled: false,
@@ -88,4 +191,76 @@ mod tests {
}
);
}
+
+ #[test]
+ fn android_backend_enables_setup_action_when_android_keygen_is_wired() {
+ assert_eq!(
+ AndroidBackend::enabled_setup_action_state(),
+ SetupActionState {
+ label: "Generate New Key".to_owned(),
+ enabled: true,
+ pending: false,
+ }
+ );
+ }
+
+ #[test]
+ fn android_backend_maps_ready_account_to_ready_state() {
+ let identity = RadrootsIdentity::generate();
+ let account =
+ RadrootsNostrAccountRecord::new(identity.to_public(), Some("local".into()), 0);
+
+ let state = AndroidBackend::map_status(RadrootsNostrSelectedAccountStatus::Ready {
+ account: account.clone(),
+ });
+
+ assert_eq!(
+ state,
+ IdentityGateState::Ready {
+ account_id: account.account_id.to_string(),
+ npub: account.public_identity.public_key_npub,
+ }
+ );
+ }
+
+ #[test]
+ fn android_backend_maps_fresh_and_public_only_accounts_to_missing() {
+ let public_only_identity = RadrootsIdentity::generate();
+ let public_only_account =
+ RadrootsNostrAccountRecord::new(public_only_identity.to_public(), None, 0);
+
+ assert_eq!(
+ AndroidBackend::map_status(RadrootsNostrSelectedAccountStatus::NotConfigured),
+ IdentityGateState::Missing
+ );
+ assert_eq!(
+ AndroidBackend::map_status(RadrootsNostrSelectedAccountStatus::PublicOnly {
+ account: public_only_account,
+ }),
+ IdentityGateState::Missing
+ );
+ }
+
+ #[test]
+ fn fresh_android_manager_starts_in_setup_state() {
+ let manager = RadrootsNostrAccountsManager::new_in_memory();
+
+ assert_eq!(
+ AndroidBackend::identity_state_from_manager(&manager),
+ Ok(IdentityGateState::Missing)
+ );
+ }
+
+ #[test]
+ fn local_identity_generation_transitions_android_to_ready() {
+ let manager = RadrootsNostrAccountsManager::new_in_memory();
+
+ let state = AndroidBackend::generate_local_identity(&manager).expect("generate identity");
+ let IdentityGateState::Ready { account_id, npub } = state else {
+ panic!("expected ready identity state");
+ };
+
+ assert!(!account_id.is_empty());
+ assert!(npub.starts_with("npub1"));
+ }
}
diff --git a/crates/android/src/security.rs b/crates/android/src/security.rs
@@ -0,0 +1,363 @@
+use radroots_nostr_accounts::prelude::RadrootsNostrAccountsError;
+use std::path::PathBuf;
+
+pub(crate) const ANDROID_NOSTR_SERVICE: &str = "org.radroots.app.nostr";
+pub(crate) const ANDROID_NOSTR_NAMESPACE: &str = "nostr";
+
+#[cfg(target_os = "android")]
+use jni::objects::{JByteArray, JClass, JObject, JString, JValue};
+#[cfg(target_os = "android")]
+use jni::sys::{jboolean, jobject};
+#[cfg(target_os = "android")]
+use jni::{JNIEnv, JavaVM};
+
+#[cfg(target_os = "android")]
+const ANDROID_SECURITY_BRIDGE_CLASS: &str =
+ "org.radroots.app.android.security.RadRootsAndroidSecurityBridge";
+
+#[cfg(target_os = "android")]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum AndroidSecretStatus {
+ Success,
+ NotFound,
+ InvalidInput,
+ Error,
+}
+
+#[cfg(target_os = "android")]
+impl AndroidSecretStatus {
+ fn from_raw(value: i32) -> Result<Self, RadrootsNostrAccountsError> {
+ match value {
+ 0 => Ok(Self::Success),
+ 1 => Ok(Self::NotFound),
+ 2 => Ok(Self::InvalidInput),
+ 3 => Ok(Self::Error),
+ other => Err(RadrootsNostrAccountsError::Vault(format!(
+ "unknown android security bridge status {other}"
+ ))),
+ }
+ }
+}
+
+#[cfg(target_os = "android")]
+pub(crate) fn store_secret(
+ service: &str,
+ namespace: &str,
+ name: &str,
+ value: &[u8],
+ device_local_only: bool,
+ user_presence_required: bool,
+ prefer_strong_box: bool,
+) -> Result<(), RadrootsNostrAccountsError> {
+ let java_vm = android_java_vm()?;
+ let mut env = java_vm.attach_current_thread().map_err(jni_error)?;
+ let bridge_class = bridge_class(&mut env)?;
+ let service = java_string_arg(&mut env, service)?;
+ let namespace = java_string_arg(&mut env, namespace)?;
+ let name = java_string_arg(&mut env, name)?;
+ let value = env.byte_array_from_slice(value).map_err(jni_error)?;
+ let value = JObject::from(value);
+
+ let status = env
+ .call_static_method(
+ &bridge_class,
+ "putSecret",
+ "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZZZ)I",
+ &[
+ JValue::Object(&service),
+ JValue::Object(&namespace),
+ JValue::Object(&name),
+ JValue::Object(&value),
+ JValue::Bool(bool_to_jboolean(device_local_only)),
+ JValue::Bool(bool_to_jboolean(user_presence_required)),
+ JValue::Bool(bool_to_jboolean(prefer_strong_box)),
+ ],
+ )
+ .and_then(|value| value.i())
+ .map_err(jni_error)?;
+
+ match AndroidSecretStatus::from_raw(status)? {
+ AndroidSecretStatus::Success => Ok(()),
+ AndroidSecretStatus::NotFound => Err(bridge_vault_error(
+ &mut env,
+ &bridge_class,
+ "android security bridge reported not found during store",
+ )),
+ AndroidSecretStatus::InvalidInput => Err(bridge_vault_error(
+ &mut env,
+ &bridge_class,
+ "android security bridge rejected the store request",
+ )),
+ AndroidSecretStatus::Error => Err(bridge_vault_error(
+ &mut env,
+ &bridge_class,
+ "android keystore store failed",
+ )),
+ }
+}
+
+#[cfg(not(target_os = "android"))]
+pub(crate) fn store_secret(
+ service: &str,
+ namespace: &str,
+ name: &str,
+ value: &[u8],
+ device_local_only: bool,
+ user_presence_required: bool,
+ prefer_strong_box: bool,
+) -> Result<(), RadrootsNostrAccountsError> {
+ let _ = (
+ service,
+ namespace,
+ name,
+ value,
+ device_local_only,
+ user_presence_required,
+ prefer_strong_box,
+ );
+ Err(RadrootsNostrAccountsError::Vault(
+ "android keystore storage is only available on android".to_owned(),
+ ))
+}
+
+#[cfg(target_os = "android")]
+pub(crate) fn load_secret(
+ service: &str,
+ namespace: &str,
+ name: &str,
+) -> Result<Option<Vec<u8>>, RadrootsNostrAccountsError> {
+ let java_vm = android_java_vm()?;
+ let mut env = java_vm.attach_current_thread().map_err(jni_error)?;
+ let bridge_class = bridge_class(&mut env)?;
+ let service = java_string_arg(&mut env, service)?;
+ let namespace = java_string_arg(&mut env, namespace)?;
+ let name = java_string_arg(&mut env, name)?;
+
+ let value = env
+ .call_static_method(
+ &bridge_class,
+ "getSecret",
+ "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)[B",
+ &[
+ JValue::Object(&service),
+ JValue::Object(&namespace),
+ JValue::Object(&name),
+ ],
+ )
+ .and_then(|value| value.l())
+ .map_err(jni_error)?;
+
+ if value.is_null() {
+ let Some(message) = take_last_error_message(&mut env, &bridge_class)? else {
+ return Ok(None);
+ };
+ return Err(RadrootsNostrAccountsError::Vault(message));
+ }
+
+ let value = JByteArray::from(value);
+ env.convert_byte_array(&value).map(Some).map_err(jni_error)
+}
+
+#[cfg(not(target_os = "android"))]
+pub(crate) fn load_secret(
+ service: &str,
+ namespace: &str,
+ name: &str,
+) -> Result<Option<Vec<u8>>, RadrootsNostrAccountsError> {
+ let _ = (service, namespace, name);
+ Err(RadrootsNostrAccountsError::Vault(
+ "android keystore storage is only available on android".to_owned(),
+ ))
+}
+
+#[cfg(target_os = "android")]
+pub(crate) fn remove_secret(
+ service: &str,
+ namespace: &str,
+ name: &str,
+) -> Result<(), RadrootsNostrAccountsError> {
+ let java_vm = android_java_vm()?;
+ let mut env = java_vm.attach_current_thread().map_err(jni_error)?;
+ let bridge_class = bridge_class(&mut env)?;
+ let service = java_string_arg(&mut env, service)?;
+ let namespace = java_string_arg(&mut env, namespace)?;
+ let name = java_string_arg(&mut env, name)?;
+
+ let status = env
+ .call_static_method(
+ &bridge_class,
+ "deleteSecret",
+ "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
+ &[
+ JValue::Object(&service),
+ JValue::Object(&namespace),
+ JValue::Object(&name),
+ ],
+ )
+ .and_then(|value| value.i())
+ .map_err(jni_error)?;
+
+ match AndroidSecretStatus::from_raw(status)? {
+ AndroidSecretStatus::Success | AndroidSecretStatus::NotFound => Ok(()),
+ AndroidSecretStatus::InvalidInput => Err(bridge_vault_error(
+ &mut env,
+ &bridge_class,
+ "android security bridge rejected the delete request",
+ )),
+ AndroidSecretStatus::Error => Err(bridge_vault_error(
+ &mut env,
+ &bridge_class,
+ "android keystore delete failed",
+ )),
+ }
+}
+
+#[cfg(not(target_os = "android"))]
+pub(crate) fn remove_secret(
+ service: &str,
+ namespace: &str,
+ name: &str,
+) -> Result<(), RadrootsNostrAccountsError> {
+ let _ = (service, namespace, name);
+ Err(RadrootsNostrAccountsError::Vault(
+ "android keystore storage is only available on android".to_owned(),
+ ))
+}
+
+#[cfg(target_os = "android")]
+pub(crate) fn resolve_nostr_storage_root() -> Result<PathBuf, RadrootsNostrAccountsError> {
+ let java_vm = android_java_vm()?;
+ let mut env = java_vm.attach_current_thread().map_err(jni_error)?;
+ let bridge_class = bridge_class(&mut env)?;
+ let value = env
+ .call_static_method(
+ &bridge_class,
+ "resolveNostrStorageRoot",
+ "()Ljava/lang/String;",
+ &[],
+ )
+ .and_then(|value| value.l())
+ .map_err(jni_error)?;
+
+ if value.is_null() {
+ return Err(bridge_store_error(
+ &mut env,
+ &bridge_class,
+ "android security bridge returned no storage root",
+ ));
+ }
+
+ let value = JString::from(value);
+ let path: String = env.get_string(&value).map_err(jni_error)?.into();
+ Ok(PathBuf::from(path))
+}
+
+#[cfg(not(target_os = "android"))]
+#[allow(dead_code)]
+pub(crate) fn resolve_nostr_storage_root() -> Result<PathBuf, RadrootsNostrAccountsError> {
+ Err(RadrootsNostrAccountsError::Store(
+ "android no-backup storage is only available on android".to_owned(),
+ ))
+}
+
+#[cfg(target_os = "android")]
+fn android_java_vm() -> Result<JavaVM, RadrootsNostrAccountsError> {
+ let context = ndk_context::android_context();
+ // SAFETY: ndk_context is initialized by the Android runtime before this code runs and
+ // returns a stable JavaVM pointer for the current process.
+ unsafe { JavaVM::from_raw(context.vm().cast()) }.map_err(jni_error)
+}
+
+#[cfg(target_os = "android")]
+fn bridge_class<'local>(
+ env: &mut JNIEnv<'local>,
+) -> Result<JClass<'local>, RadrootsNostrAccountsError> {
+ let context = ndk_context::android_context();
+ // SAFETY: ndk_context stores a live process-wide Context jobject for the active Android app.
+ let context = unsafe { JObject::from_raw(context.context() as jobject) };
+ let context = env.new_local_ref(&context).map_err(jni_error)?;
+ let class_loader = env
+ .call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
+ .and_then(|value| value.l())
+ .map_err(jni_error)?;
+ let class_name = env
+ .new_string(ANDROID_SECURITY_BRIDGE_CLASS)
+ .map_err(jni_error)?;
+ let class_name = JObject::from(class_name);
+ let bridge_class = env
+ .call_method(
+ &class_loader,
+ "loadClass",
+ "(Ljava/lang/String;)Ljava/lang/Class;",
+ &[JValue::Object(&class_name)],
+ )
+ .and_then(|value| value.l())
+ .map_err(jni_error)?;
+ Ok(JClass::from(bridge_class))
+}
+
+#[cfg(target_os = "android")]
+fn java_string_arg<'local>(
+ env: &mut JNIEnv<'local>,
+ value: &str,
+) -> Result<JObject<'local>, RadrootsNostrAccountsError> {
+ env.new_string(value).map(JObject::from).map_err(jni_error)
+}
+
+#[cfg(target_os = "android")]
+fn take_last_error_message(
+ env: &mut JNIEnv<'_>,
+ bridge_class: &JClass<'_>,
+) -> Result<Option<String>, RadrootsNostrAccountsError> {
+ let value = env
+ .call_static_method(
+ bridge_class,
+ "takeLastErrorMessage",
+ "()Ljava/lang/String;",
+ &[],
+ )
+ .and_then(|value| value.l())
+ .map_err(jni_error)?;
+ if value.is_null() {
+ return Ok(None);
+ }
+ let value = JString::from(value);
+ let value: String = env.get_string(&value).map_err(jni_error)?.into();
+ Ok(Some(value))
+}
+
+#[cfg(target_os = "android")]
+fn bridge_vault_error(
+ env: &mut JNIEnv<'_>,
+ bridge_class: &JClass<'_>,
+ fallback: &str,
+) -> RadrootsNostrAccountsError {
+ let message = take_last_error_message(env, bridge_class)
+ .ok()
+ .flatten()
+ .unwrap_or_else(|| fallback.to_owned());
+ RadrootsNostrAccountsError::Vault(message)
+}
+
+#[cfg(target_os = "android")]
+fn bridge_store_error(
+ env: &mut JNIEnv<'_>,
+ bridge_class: &JClass<'_>,
+ fallback: &str,
+) -> RadrootsNostrAccountsError {
+ let message = take_last_error_message(env, bridge_class)
+ .ok()
+ .flatten()
+ .unwrap_or_else(|| fallback.to_owned());
+ RadrootsNostrAccountsError::Store(message)
+}
+
+#[cfg(target_os = "android")]
+fn jni_error(error: jni::errors::Error) -> RadrootsNostrAccountsError {
+ RadrootsNostrAccountsError::Vault(format!("android jni error: {error}"))
+}
+
+#[cfg(target_os = "android")]
+fn bool_to_jboolean(value: bool) -> jboolean {
+ if value { 1 } else { 0 }
+}
diff --git a/crates/android/src/storage.rs b/crates/android/src/storage.rs
@@ -0,0 +1,59 @@
+#[cfg(target_os = "android")]
+use crate::security::{ANDROID_NOSTR_SERVICE, resolve_nostr_storage_root};
+#[cfg(target_os = "android")]
+use crate::vault::RadrootsAndroidKeystoreVault;
+#[cfg(target_os = "android")]
+use radroots_nostr_accounts::prelude::{
+ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore,
+};
+use std::path::Path;
+use std::path::PathBuf;
+#[cfg(target_os = "android")]
+use std::sync::Arc;
+
+#[cfg(target_os = "android")]
+pub(crate) fn accounts_path() -> Result<PathBuf, String> {
+ let root = resolve_nostr_storage_root().map_err(|source| source.to_string())?;
+ let accounts_path = accounts_path_from_root(root.as_path());
+ if let Some(parent) = accounts_path.parent() {
+ ensure_directory_tree(parent)?;
+ }
+ Ok(accounts_path)
+}
+
+#[cfg(target_os = "android")]
+pub(crate) fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> {
+ let store = Arc::new(RadrootsNostrFileAccountStore::new(accounts_path()?));
+ let vault = Arc::new(RadrootsAndroidKeystoreVault::new(ANDROID_NOSTR_SERVICE));
+ RadrootsNostrAccountsManager::new(store, vault).map_err(|source| source.to_string())
+}
+
+pub(crate) fn accounts_path_from_root(root: &Path) -> PathBuf {
+ root.join("accounts.json")
+}
+
+#[cfg(target_os = "android")]
+fn ensure_directory_tree(path: &Path) -> Result<(), String> {
+ std::fs::create_dir_all(path)
+ .map_err(|source| format!("failed to create android accounts directory: {source}"))?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn accounts_path_uses_android_no_backup_layout() {
+ let root = PathBuf::from(
+ "/data/user/0/org.radroots.app.android/no_backup/RadRoots/app/android/nostr",
+ );
+
+ assert_eq!(
+ accounts_path_from_root(root.as_path()),
+ PathBuf::from(
+ "/data/user/0/org.radroots.app.android/no_backup/RadRoots/app/android/nostr/accounts.json"
+ )
+ );
+ }
+}
diff --git a/crates/android/src/vault.rs b/crates/android/src/vault.rs
@@ -0,0 +1,116 @@
+use crate::security::{ANDROID_NOSTR_NAMESPACE, load_secret, remove_secret, store_secret};
+use radroots_identity::RadrootsIdentityId;
+use radroots_nostr_accounts::prelude::{RadrootsNostrAccountsError, RadrootsNostrSecretVault};
+use zeroize::Zeroizing;
+
+#[derive(Debug, Clone)]
+pub(crate) struct RadrootsAndroidKeystoreVault {
+ service_name: String,
+}
+
+impl RadrootsAndroidKeystoreVault {
+ pub(crate) fn new(service_name: impl Into<String>) -> Self {
+ Self {
+ service_name: service_name.into(),
+ }
+ }
+
+ fn account_name(account_id: &RadrootsIdentityId) -> &str {
+ account_id.as_str()
+ }
+}
+
+impl RadrootsNostrSecretVault for RadrootsAndroidKeystoreVault {
+ fn store_secret_hex(
+ &self,
+ account_id: &RadrootsIdentityId,
+ secret_key_hex: &str,
+ ) -> Result<(), RadrootsNostrAccountsError> {
+ let secret_key_hex = Zeroizing::new(secret_key_hex.to_owned());
+ store_secret(
+ self.service_name.as_str(),
+ ANDROID_NOSTR_NAMESPACE,
+ Self::account_name(account_id),
+ secret_key_hex.as_bytes(),
+ true,
+ false,
+ true,
+ )
+ }
+
+ fn load_secret_hex(
+ &self,
+ account_id: &RadrootsIdentityId,
+ ) -> Result<Option<String>, RadrootsNostrAccountsError> {
+ let Some(secret) = load_secret(
+ self.service_name.as_str(),
+ ANDROID_NOSTR_NAMESPACE,
+ Self::account_name(account_id),
+ )?
+ else {
+ return Ok(None);
+ };
+
+ let secret = Zeroizing::new(secret);
+ let secret = std::str::from_utf8(secret.as_slice()).map_err(|source| {
+ RadrootsNostrAccountsError::Vault(format!(
+ "android keystore secret was not valid utf-8: {source}"
+ ))
+ })?;
+ Ok(Some(secret.to_owned()))
+ }
+
+ fn remove_secret(
+ &self,
+ account_id: &RadrootsIdentityId,
+ ) -> Result<(), RadrootsNostrAccountsError> {
+ remove_secret(
+ self.service_name.as_str(),
+ ANDROID_NOSTR_NAMESPACE,
+ Self::account_name(account_id),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn account_name_uses_account_id_string() {
+ let account_id = RadrootsIdentityId::parse(
+ "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606",
+ )
+ .expect("account id");
+
+ assert_eq!(
+ RadrootsAndroidKeystoreVault::account_name(&account_id),
+ "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606"
+ );
+ }
+
+ #[cfg(not(target_os = "android"))]
+ #[test]
+ fn vault_operations_report_unavailable_off_android() {
+ let vault = RadrootsAndroidKeystoreVault::new(crate::security::ANDROID_NOSTR_SERVICE);
+ let account_id = RadrootsIdentityId::parse(
+ "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606",
+ )
+ .expect("account id");
+
+ let load = vault
+ .load_secret_hex(&account_id)
+ .expect_err("load off android");
+ assert!(load.to_string().starts_with("vault error:"));
+
+ let store = vault
+ .store_secret_hex(&account_id, "deadbeef")
+ .expect_err("store off android");
+ assert!(store.to_string().starts_with("vault error:"));
+
+ let remove = vault
+ .remove_secret(&account_id)
+ .expect_err("remove off android");
+ assert!(remove.to_string().starts_with("vault error:"));
+ }
+}
diff --git a/scripts/run-android-emulator.sh b/scripts/run-android-emulator.sh
@@ -17,6 +17,28 @@ require_command() {
exit 1
}
+host_os() {
+ uname -s
+}
+
+host_arch() {
+ uname -m
+}
+
+android_emulator_gpu_mode() {
+ if [[ -n "${RADROOTS_ANDROID_EMULATOR_GPU_MODE:-}" ]]; then
+ printf '%s\n' "${RADROOTS_ANDROID_EMULATOR_GPU_MODE}"
+ return
+ fi
+
+ if [[ "$(host_os)" == "Darwin" && "$(host_arch)" == "arm64" ]]; then
+ printf '%s\n' "swiftshader"
+ return
+ fi
+
+ printf '%s\n' "auto"
+}
+
running_emulator_serial() {
local target_avd="$1"
while read -r serial state _; do
@@ -53,15 +75,21 @@ wait_for_boot_complete() {
launch_emulator_if_needed() {
local avd_name="$1"
local serial
+ local gpu_mode
serial="$(running_emulator_serial "${avd_name}" || true)"
if [[ -n "${serial}" ]]; then
printf '%s\n' "${serial}"
return
fi
+ gpu_mode="$(android_emulator_gpu_mode)"
ANDROID_AVD_HOME="${android_avd_home}" \
ANDROID_EMULATOR_HOME="${android_emulator_home}" \
- nohup "${android_emulator_bin}" -avd "${avd_name}" -no-snapshot-save >/tmp/radroots-android-emulator.log 2>&1 &
+ nohup "${android_emulator_bin}" \
+ -avd "${avd_name}" \
+ -gpu "${gpu_mode}" \
+ -no-snapshot-load \
+ -no-snapshot-save >/tmp/radroots-android-emulator.log 2>&1 &
for _ in $(seq 1 60); do
serial="$(running_emulator_serial "${avd_name}" || true)"