app

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

commit 3f38899f5a918f16be78ecea652c4c0b85a4787a
parent 107f02f8994b390963d96723d70658ab0fe996dc
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 11:54:22 +0000

android: add async reverse lookup backend

- add a dedicated android reverse-lookup tracker that runs geocoder queries off the ui thread
- expose begin and poll hooks from the android backend without changing the current home lookup ui yet
- keep query results typed and one-shot so the core consumer can adopt the new poll contract cleanly
- cover the tracker behavior with focused tests and verify the android host build stays green

Diffstat:
Mcrates/android/src/lib.rs | 37+++++++++++++++++++++++++++++++++++--
Acrates/android/src/reverse_lookup.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 148 insertions(+), 2 deletions(-)

diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs @@ -11,7 +11,7 @@ use radroots_app_core::{ HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, ImportActionState, RadrootsLocationCountry, RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, RadrootsOfflineGeocoderState, RadrootsResolvedLocation, - SetupActionState, + RadrootsReverseLocationLookupResult, SetupActionState, }; #[cfg(any(target_os = "android", test))] use radroots_identity::RadrootsIdentity; @@ -31,6 +31,8 @@ use zeroize::Zeroizing; #[cfg(any(target_os = "android", test))] mod offline_geocoder; #[cfg(any(target_os = "android", test))] +mod reverse_lookup; +#[cfg(any(target_os = "android", test))] mod security; #[cfg(any(target_os = "android", test))] mod storage; @@ -41,6 +43,7 @@ mod vault; #[cfg_attr(not(target_os = "android"), allow(dead_code))] struct AndroidBackend { offline_geocoder: offline_geocoder::AndroidOfflineGeocoder, + reverse_lookup: reverse_lookup::AndroidReverseLookup, } #[cfg(any(target_os = "android", test))] @@ -87,6 +90,33 @@ impl RadrootsAppBackend for AndroidBackend { } } + fn request_reverse_location_lookup( + &self, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + #[cfg(target_os = "android")] + { + return self.reverse_lookup.begin( + self.offline_geocoder.current_state(), + point, + options, + ); + } + + #[cfg(not(target_os = "android"))] + { + let _ = (point, options); + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn poll_reverse_location_lookup_result( + &self, + ) -> Result<Option<RadrootsReverseLocationLookupResult>, String> { + Ok(self.reverse_lookup.take_update()) + } + fn list_location_countries( &self, ) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { @@ -265,7 +295,10 @@ impl AndroidBackend { ), ); - Self { offline_geocoder } + Self { + offline_geocoder, + reverse_lookup: reverse_lookup::AndroidReverseLookup::new(), + } } #[cfg(target_os = "android")] diff --git a/crates/android/src/reverse_lookup.rs b/crates/android/src/reverse_lookup.rs @@ -0,0 +1,113 @@ +#![cfg_attr(not(target_os = "android"), allow(dead_code))] + +#[cfg(target_os = "android")] +use crate::offline_geocoder; +use radroots_app_core::{ + RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, + RadrootsOfflineGeocoderState, RadrootsReverseLocationLookupResult, +}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +pub(crate) struct AndroidReverseLookup { + result: Arc<Mutex<Option<RadrootsReverseLocationLookupResult>>>, + changed: Arc<AtomicBool>, + pending: Arc<AtomicBool>, +} + +impl AndroidReverseLookup { + pub(crate) fn new() -> Self { + Self::default() + } + + #[cfg(target_os = "android")] + pub(crate) fn begin( + &self, + geocoder_state: RadrootsOfflineGeocoderState, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + if self.pending.swap(true, Ordering::AcqRel) { + return Err(RadrootsLocationResolverError::QueryFailed { + message: "offline location query is already running".to_owned(), + }); + } + + if let Ok(mut slot) = self.result.lock() { + *slot = None; + } + + let result = Arc::clone(&self.result); + let changed = Arc::clone(&self.changed); + let pending = Arc::clone(&self.pending); + std::thread::spawn(move || { + let lookup_result = offline_geocoder::reverse_location(&geocoder_state, point, options); + if let Ok(mut slot) = result.lock() { + *slot = Some(lookup_result); + changed.store(true, Ordering::Release); + } + pending.store(false, Ordering::Release); + }); + + Ok(()) + } + + #[cfg(not(target_os = "android"))] + pub(crate) fn begin( + &self, + _geocoder_state: RadrootsOfflineGeocoderState, + _point: RadrootsLocationPoint, + _options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + + pub(crate) fn take_update(&self) -> Option<RadrootsReverseLocationLookupResult> { + if !self.changed.swap(false, Ordering::AcqRel) { + return None; + } + + match self.result.lock() { + Ok(mut slot) => slot.take(), + Err(_) => Some(Err(RadrootsLocationResolverError::QueryFailed { + message: "android reverse lookup result lock poisoned".to_owned(), + })), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_app_core::RadrootsResolvedLocation; + + fn sample_result() -> RadrootsReverseLocationLookupResult { + Ok(vec![RadrootsResolvedLocation { + id: 7, + name: "example".to_owned(), + admin1_id: None, + admin1_name: None, + country_id: "US".to_owned(), + country_name: Some("United States".to_owned()), + point: RadrootsLocationPoint { lat: 1.0, lng: 2.0 }, + }]) + } + + #[test] + fn take_update_is_none_until_tracker_changes() { + let tracker = AndroidReverseLookup::new(); + + assert_eq!(tracker.take_update(), None); + } + + #[test] + fn take_update_returns_queued_result_once() { + let tracker = AndroidReverseLookup::new(); + *tracker.result.lock().unwrap() = Some(sample_result()); + tracker.changed.store(true, Ordering::Release); + + assert!(matches!(tracker.take_update(), Some(Ok(results)) if results.len() == 1)); + assert_eq!(tracker.take_update(), None); + } +}