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:
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
+ }
+}