app

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

commit 580651c5218e9d19f5efd83d68da06baa142c679
parent 9e35760e5941576cacd8951a4ad2b75f7a6c416b
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 18:25:26 +0000

android: add recovery-key backup for local identities

- add the android home action to back up the selected local recovery key
- export the selected local secret through the existing accounts manager and convert it to nsec
- reveal the recovery key through the shared backup action result without changing other android identity flows
- add android coverage for selected local recovery-key export

Diffstat:
Mcrates/android/src/lib.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 52 insertions(+), 2 deletions(-)

diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs @@ -12,7 +12,7 @@ use radroots_app_core::{APP_NAME, RadrootsApp}; use radroots_app_core::{ HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, SetupActionState, }; -#[cfg(test)] +#[cfg(any(target_os = "android", test))] use radroots_identity::RadrootsIdentity; #[cfg(test)] use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; @@ -24,6 +24,8 @@ use radroots_nostr_accounts::prelude::RadrootsNostrSelectedAccountStatus; use std::path::Path; #[cfg(target_os = "android")] use winit::platform::android::activity::AndroidApp; +#[cfg(any(target_os = "android", test))] +use zeroize::Zeroizing; #[cfg(any(target_os = "android", test))] mod security; @@ -80,6 +82,12 @@ impl RadrootsAppBackend for AndroidBackend { { return vec![ HomeActionState { + kind: HomeActionKind::BackupRecoveryKey, + label: "Back Up Recovery Key".to_owned(), + enabled: true, + pending: false, + }, + HomeActionState { kind: HomeActionKind::RemoveLocalKey, label: "Remove Key From This Device".to_owned(), enabled: true, @@ -105,7 +113,10 @@ impl RadrootsAppBackend for AndroidBackend { { let manager = Self::accounts_manager()?; return match action { - HomeActionKind::BackupRecoveryKey => Ok(HomeActionResult::None), + HomeActionKind::BackupRecoveryKey => { + Self::export_selected_local_recovery_key(&manager) + .map(|nsec| HomeActionResult::RevealRecoveryKey { nsec }) + } HomeActionKind::RemoveLocalKey => Self::remove_selected_local_identity(&manager) .map(HomeActionResult::IdentityState), HomeActionKind::ResetDevice => { @@ -188,6 +199,29 @@ impl AndroidBackend { Self::identity_state_from_manager(manager) } + fn export_selected_local_recovery_key( + manager: &RadrootsNostrAccountsManager, + ) -> Result<String, String> { + let Some(account_id) = manager + .selected_account_id() + .map_err(|source| source.to_string())? + else { + return Err("no selected local identity is available to back up".to_owned()); + }; + + let Some(secret_key_hex) = manager + .export_secret_hex(&account_id) + .map_err(|source| source.to_string())? + else { + return Err("selected local identity does not have an exportable secret".to_owned()); + }; + + let secret_key_hex = Zeroizing::new(secret_key_hex); + let identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str()) + .map_err(|source| source.to_string())?; + Ok(identity.nsec()) + } + fn remove_selected_local_identity( manager: &RadrootsNostrAccountsManager, ) -> Result<IdentityGateState, String> { @@ -404,6 +438,22 @@ mod tests { } #[test] + fn export_selected_local_recovery_key_returns_nsec() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let identity = RadrootsIdentity::generate(); + + manager + .upsert_identity(&identity, Some("primary".into()), true) + .expect("store identity"); + + let nsec = + AndroidBackend::export_selected_local_recovery_key(&manager).expect("export recovery"); + + assert_eq!(nsec, identity.nsec()); + assert!(nsec.starts_with("nsec1")); + } + + #[test] fn remove_accounts_file_if_present_deletes_existing_file() { let unique = format!( "radroots-android-reset-{}-{}.json",