app

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

commit e186e039495c230ff08fdaabbf57e3884fea4756
parent 7e66e9a039bed7a760bed8a0a8d7728139bf41e2
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 22:19:52 +0000

android: add non-blocking offline geocoder init

- add the radroots geocoder dependency to the android launcher slice and verify the staged db on a background thread
- package the optional copied geocoder db into apk assets and copy it into no-backup storage through a dedicated app bridge
- surface ready or unavailable offline geocoder runtime state without blocking android startup or the existing identity flows
- add focused android tests for unavailable-state messaging alongside rust and gradle validation of the packaged asset path

Diffstat:
MCargo.lock | 1+
Mcrates/android/Cargo.toml | 1+
Mcrates/android/src/lib.rs | 37++++++++++++++++++++++++++++++++++---
Acrates/android/src/offline_geocoder.rs | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/android/src/security.rs | 2++
Mplatforms/android/app/build.gradle.kts | 1+
Mplatforms/android/app/src/main/kotlin/org/radroots/app/android/MainActivity.kt | 1+
Aplatforms/android/app/src/main/kotlin/org/radroots/app/android/RadRootsAndroidAppBridge.kt | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 317 insertions(+), 3 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2851,6 +2851,7 @@ dependencies = [ "log", "ndk-context", "radroots-app-core", + "radroots-geocoder", "radroots-identity", "radroots-nostr-accounts", "wgpu", diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml @@ -18,6 +18,7 @@ crate-type = ["cdylib", "rlib"] eframe = { workspace = true, features = ["android-game-activity", "glow"] } log.workspace = true radroots-app-core = { path = "../core" } +radroots-geocoder.workspace = true radroots-identity.workspace = true radroots-nostr-accounts.workspace = true zeroize.workspace = true diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs @@ -11,7 +11,7 @@ use radroots_app_core::{APP_NAME, RadrootsApp}; #[cfg(any(target_os = "android", test))] use radroots_app_core::{ HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, ImportActionState, - SetupActionState, + RadrootsOfflineGeocoderState, SetupActionState, }; #[cfg(any(target_os = "android", test))] use radroots_identity::RadrootsIdentity; @@ -29,6 +29,8 @@ use winit::platform::android::activity::AndroidApp; use zeroize::Zeroizing; #[cfg(any(target_os = "android", test))] +mod offline_geocoder; +#[cfg(any(target_os = "android", test))] mod security; #[cfg(any(target_os = "android", test))] mod storage; @@ -36,7 +38,10 @@ mod storage; mod vault; #[cfg(any(target_os = "android", test))] -struct AndroidBackend; +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +struct AndroidBackend { + offline_geocoder: offline_geocoder::AndroidOfflineGeocoder, +} #[cfg(any(target_os = "android", test))] impl RadrootsAppBackend for AndroidBackend { @@ -53,6 +58,14 @@ impl RadrootsAppBackend for AndroidBackend { } } + fn offline_geocoder_state(&self) -> Option<RadrootsOfflineGeocoderState> { + Some(self.offline_geocoder.current_state()) + } + + fn poll_offline_geocoder_state(&self) -> Result<Option<RadrootsOfflineGeocoderState>, String> { + Ok(self.offline_geocoder.take_update()) + } + fn setup_action_state(&self) -> SetupActionState { #[cfg(target_os = "android")] { @@ -183,7 +196,25 @@ impl RadrootsAppBackend for AndroidBackend { } #[cfg(any(target_os = "android", test))] +#[cfg_attr(not(target_os = "android"), allow(dead_code))] impl AndroidBackend { + fn new() -> Self { + #[cfg(target_os = "android")] + let offline_geocoder = offline_geocoder::AndroidOfflineGeocoder::start(); + + #[cfg(not(target_os = "android"))] + let offline_geocoder = offline_geocoder::AndroidOfflineGeocoder::from_state( + RadrootsOfflineGeocoderState::Unavailable { + user_message: "Offline geocoder is not available in this android build.".to_owned(), + debug_message: + "android offline geocoder initialization is only wired on android targets" + .to_owned(), + }, + ); + + Self { offline_geocoder } + } + #[cfg(target_os = "android")] fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> { #[cfg(target_os = "android")] @@ -397,7 +428,7 @@ fn run_android_app(android_app: AndroidApp) -> Result<(), String> { eframe::run_native( APP_NAME, native_options(android_app), - Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(AndroidBackend))))), + Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(AndroidBackend::new()))))), ) .map_err(|err| err.to_string()) } diff --git a/crates/android/src/offline_geocoder.rs b/crates/android/src/offline_geocoder.rs @@ -0,0 +1,217 @@ +#![cfg_attr(not(target_os = "android"), allow(dead_code))] + +use radroots_app_core::RadrootsOfflineGeocoderState; +#[cfg(target_os = "android")] +use radroots_geocoder::Geocoder; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[cfg(target_os = "android")] +use jni::objects::{JClass, JObject, JString}; +#[cfg(target_os = "android")] +use jni::sys::jobject; +#[cfg(target_os = "android")] +use jni::{JNIEnv, JavaVM}; +#[cfg(target_os = "android")] +use radroots_nostr_accounts::prelude::RadrootsNostrAccountsError; + +#[cfg(target_os = "android")] +const ANDROID_APP_BRIDGE_CLASS: &str = "org.radroots.app.android.RadRootsAndroidAppBridge"; + +#[derive(Clone)] +pub(crate) struct AndroidOfflineGeocoder { + current: Arc<Mutex<RadrootsOfflineGeocoderState>>, + changed: Arc<AtomicBool>, +} + +impl AndroidOfflineGeocoder { + pub(crate) fn from_state(state: RadrootsOfflineGeocoderState) -> Self { + Self { + current: Arc::new(Mutex::new(state)), + changed: Arc::new(AtomicBool::new(false)), + } + } + + #[cfg(target_os = "android")] + pub(crate) fn start() -> Self { + let tracker = Self::from_state(RadrootsOfflineGeocoderState::Initializing); + let current = Arc::clone(&tracker.current); + let changed = Arc::clone(&tracker.changed); + + std::thread::spawn(move || { + let state = initialize_offline_geocoder(); + if let RadrootsOfflineGeocoderState::Unavailable { debug_message, .. } = &state { + log::warn!("android offline geocoder unavailable: {debug_message}"); + } + if let Ok(mut slot) = current.lock() { + *slot = state; + changed.store(true, Ordering::Release); + } + }); + + tracker + } + + pub(crate) fn current_state(&self) -> RadrootsOfflineGeocoderState { + self.current + .lock() + .map(|state| state.clone()) + .unwrap_or_else(|_| RadrootsOfflineGeocoderState::Unavailable { + user_message: "Offline geocoder is unavailable on this device.".to_owned(), + debug_message: "android offline geocoder state lock poisoned".to_owned(), + }) + } + + pub(crate) fn take_update(&self) -> Option<RadrootsOfflineGeocoderState> { + if self.changed.swap(false, Ordering::AcqRel) { + Some(self.current_state()) + } else { + None + } + } +} + +#[cfg(target_os = "android")] +fn initialize_offline_geocoder() -> RadrootsOfflineGeocoderState { + match initialize_offline_geocoder_inner() { + Ok(()) => RadrootsOfflineGeocoderState::Ready, + Err(debug_message) => classify_initialize_error(debug_message), + } +} + +#[cfg(target_os = "android")] +fn initialize_offline_geocoder_inner() -> Result<(), String> { + let staged_path = stage_offline_geocoder_asset()?; + Geocoder::open_path(staged_path.as_str()) + .map(|_| ()) + .map_err(|source| format!("failed to open staged android geocoder db: {source}")) +} + +#[cfg(target_os = "android")] +fn stage_offline_geocoder_asset() -> Result<String, String> { + let java_vm = android_java_vm().map_err(|source| source.to_string())?; + let mut env = java_vm + .attach_current_thread() + .map_err(jni_error) + .map_err(|source| source.to_string())?; + let bridge_class = bridge_class(&mut env).map_err(|source| source.to_string())?; + let value = env + .call_static_method( + &bridge_class, + "stageOfflineGeocoderAsset", + "()Ljava/lang/String;", + &[], + ) + .and_then(|value| value.l()) + .map_err(jni_error) + .map_err(|source| source.to_string())?; + + if value.is_null() { + return Err(take_last_error_message(&mut env, &bridge_class) + .map_err(|source| source.to_string())? + .unwrap_or_else(|| "android app bridge returned no staged geocoder path".to_owned())); + } + + let value = JString::from(value); + env.get_string(&value) + .map(|value| value.into()) + .map_err(|source| jni_error(source).to_string()) +} + +fn classify_initialize_error(debug_message: String) -> RadrootsOfflineGeocoderState { + let user_message = if debug_message.contains("asset missing") { + "Offline geocoder is not available in this build.".to_owned() + } else { + "Offline geocoder could not be initialized on this device.".to_owned() + }; + + RadrootsOfflineGeocoderState::Unavailable { + user_message, + debug_message, + } +} + +#[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_APP_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;", + &[jni::objects::JValue::Object(&class_name)], + ) + .and_then(|value| value.l()) + .map_err(jni_error)?; + Ok(JClass::from(bridge_class)) +} + +#[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 jni_error(error: jni::errors::Error) -> RadrootsNostrAccountsError { + RadrootsNostrAccountsError::Store(format!("android jni error: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_asset_maps_to_build_unavailable_message() { + let state = classify_initialize_error( + "android bundled geocoder asset missing at assets/geocoder/geonames.db".to_owned(), + ); + + assert_eq!( + state, + RadrootsOfflineGeocoderState::Unavailable { + user_message: "Offline geocoder is not available in this build.".to_owned(), + debug_message: + "android bundled geocoder asset missing at assets/geocoder/geonames.db" + .to_owned(), + } + ); + } +} diff --git a/crates/android/src/security.rs b/crates/android/src/security.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(target_os = "android"), allow(dead_code))] + use radroots_nostr_accounts::prelude::RadrootsNostrAccountsError; use std::path::PathBuf; diff --git a/platforms/android/app/build.gradle.kts b/platforms/android/app/build.gradle.kts @@ -49,6 +49,7 @@ android { sourceSets { getByName("main") { jniLibs.srcDir(rustJniLibsDir) + assets.srcDir("../../../assets") } } } diff --git a/platforms/android/app/src/main/kotlin/org/radroots/app/android/MainActivity.kt b/platforms/android/app/src/main/kotlin/org/radroots/app/android/MainActivity.kt @@ -6,6 +6,7 @@ import org.radroots.app.android.security.RadRootsAndroidSecurityBridge class MainActivity : GameActivity() { override fun onCreate(savedInstanceState: Bundle?) { + RadRootsAndroidAppBridge.initialize(this) RadRootsAndroidSecurityBridge.initialize(this) super.onCreate(savedInstanceState) } diff --git a/platforms/android/app/src/main/kotlin/org/radroots/app/android/RadRootsAndroidAppBridge.kt b/platforms/android/app/src/main/kotlin/org/radroots/app/android/RadRootsAndroidAppBridge.kt @@ -0,0 +1,60 @@ +package org.radroots.app.android + +import android.content.Context +import java.io.File +import java.io.FileNotFoundException + +object RadRootsAndroidAppBridge { + private const val GEOCODER_ASSET_PATH = "geocoder/geonames.db" + private const val GEOCODER_FILE_NAME = "geonames.db" + + @Volatile + private var appContext: Context? = null + + @Volatile + private var lastErrorMessage: String? = null + + @JvmStatic + fun initialize(context: Context) { + appContext = context.applicationContext + } + + @JvmStatic + @Synchronized + fun stageOfflineGeocoderAsset(): String? { + val context = appContext ?: return fail("android app bridge is not initialized") + val targetDir = File(context.noBackupFilesDir, "RadRoots/app/android/geocoder") + if (!targetDir.exists() && !targetDir.mkdirs()) { + return fail("failed to create android geocoder directory: ${targetDir.absolutePath}") + } + + val targetFile = File(targetDir, GEOCODER_FILE_NAME) + return try { + context.assets.open(GEOCODER_ASSET_PATH).use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + lastErrorMessage = null + targetFile.absolutePath + } catch (_: FileNotFoundException) { + fail("android bundled geocoder asset missing at assets/$GEOCODER_ASSET_PATH") + } catch (source: Exception) { + fail("failed to stage android geocoder asset: ${source.message ?: source.javaClass.simpleName}") + } + } + + @JvmStatic + @Synchronized + fun takeLastErrorMessage(): String? { + val value = lastErrorMessage + lastErrorMessage = null + return value + } + + @Synchronized + private fun fail(message: String): String? { + lastErrorMessage = message + return null + } +}