app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 5+++++
MCargo.toml | 2++
Mcrates/android/Cargo.toml | 5+++++
Mcrates/android/src/lib.rs | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Acrates/android/src/security.rs | 363+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/android/src/storage.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/android/src/vault.rs | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/run-android-emulator.sh | 30+++++++++++++++++++++++++++++-
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)"