app

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

commit 2e502ee1339c279b4111a154ea95c9b9292e57ca
parent 8149f9916c0bb84ef972c464df81b64ce67f8502
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 13:12:55 +0000

android: add async country lookup backend

- add an android country lookup tracker for async country list and center requests
- wire the android backend into the new core country lookup request and poll contract
- reuse the staged android offline geocoder asset for bounded country queries without blocking startup
- add android tests for queued country list and center updates

Diffstat:
Acrates/android/src/country_lookup.rs | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/android/src/lib.rs | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 229 insertions(+), 1 deletion(-)

diff --git a/crates/android/src/country_lookup.rs b/crates/android/src/country_lookup.rs @@ -0,0 +1,179 @@ +#![cfg_attr(not(target_os = "android"), allow(dead_code))] + +#[cfg(target_os = "android")] +use crate::offline_geocoder; +use radroots_app_core::{ + RadrootsLocationCountryCenterLookupResult, RadrootsLocationCountryListResult, + RadrootsLocationResolverError, RadrootsOfflineGeocoderState, +}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +pub(crate) struct AndroidCountryLookup { + country_list_result: Arc<Mutex<Option<RadrootsLocationCountryListResult>>>, + country_list_changed: Arc<AtomicBool>, + country_list_pending: Arc<AtomicBool>, + country_center_result: Arc<Mutex<Option<RadrootsLocationCountryCenterLookupResult>>>, + country_center_changed: Arc<AtomicBool>, + country_center_pending: Arc<AtomicBool>, +} + +impl AndroidCountryLookup { + pub(crate) fn new() -> Self { + Self::default() + } + + #[cfg(target_os = "android")] + pub(crate) fn begin_list( + &self, + geocoder_state: RadrootsOfflineGeocoderState, + ) -> Result<(), RadrootsLocationResolverError> { + if self.country_list_pending.swap(true, Ordering::AcqRel) { + return Err(RadrootsLocationResolverError::QueryFailed { + message: "offline country list query is already running".to_owned(), + }); + } + + if let Ok(mut slot) = self.country_list_result.lock() { + *slot = None; + } + + let result = Arc::clone(&self.country_list_result); + let changed = Arc::clone(&self.country_list_changed); + let pending = Arc::clone(&self.country_list_pending); + std::thread::spawn(move || { + let lookup_result = offline_geocoder::list_countries(&geocoder_state); + 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_list( + &self, + _geocoder_state: RadrootsOfflineGeocoderState, + ) -> Result<(), RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + + #[cfg(target_os = "android")] + pub(crate) fn begin_center( + &self, + geocoder_state: RadrootsOfflineGeocoderState, + country_id: String, + ) -> Result<(), RadrootsLocationResolverError> { + if self.country_center_pending.swap(true, Ordering::AcqRel) { + return Err(RadrootsLocationResolverError::QueryFailed { + message: "offline country center query is already running".to_owned(), + }); + } + + if let Ok(mut slot) = self.country_center_result.lock() { + *slot = None; + } + + let result = Arc::clone(&self.country_center_result); + let changed = Arc::clone(&self.country_center_changed); + let pending = Arc::clone(&self.country_center_pending); + std::thread::spawn(move || { + let lookup_result = offline_geocoder::country_center(&geocoder_state, &country_id); + 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_center( + &self, + _geocoder_state: RadrootsOfflineGeocoderState, + _country_id: String, + ) -> Result<(), RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + + pub(crate) fn take_list_update(&self) -> Option<RadrootsLocationCountryListResult> { + if !self.country_list_changed.swap(false, Ordering::AcqRel) { + return None; + } + + match self.country_list_result.lock() { + Ok(mut slot) => slot.take(), + Err(_) => Some(Err(RadrootsLocationResolverError::QueryFailed { + message: "android country list result lock poisoned".to_owned(), + })), + } + } + + pub(crate) fn take_center_update(&self) -> Option<RadrootsLocationCountryCenterLookupResult> { + if !self.country_center_changed.swap(false, Ordering::AcqRel) { + return None; + } + + match self.country_center_result.lock() { + Ok(mut slot) => slot.take(), + Err(_) => Some(Err(RadrootsLocationResolverError::QueryFailed { + message: "android country center result lock poisoned".to_owned(), + })), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_app_core::{RadrootsLocationCountry, RadrootsLocationPoint}; + + fn sample_countries() -> RadrootsLocationCountryListResult { + Ok(vec![RadrootsLocationCountry { + country_id: "BR".to_owned(), + country_name: Some("Brazil".to_owned()), + center: RadrootsLocationPoint { + lat: -14.235, + lng: -51.9253, + }, + }]) + } + + #[test] + fn take_list_update_is_none_until_tracker_changes() { + let tracker = AndroidCountryLookup::new(); + + assert_eq!(tracker.take_list_update(), None); + } + + #[test] + fn take_list_update_returns_queued_result_once() { + let tracker = AndroidCountryLookup::new(); + *tracker.country_list_result.lock().unwrap() = Some(sample_countries()); + tracker.country_list_changed.store(true, Ordering::Release); + + assert!(matches!(tracker.take_list_update(), Some(Ok(results)) if results.len() == 1)); + assert_eq!(tracker.take_list_update(), None); + } + + #[test] + fn take_center_update_returns_queued_result_once() { + let tracker = AndroidCountryLookup::new(); + *tracker.country_center_result.lock().unwrap() = Some(Ok(RadrootsLocationPoint { + lat: -14.235, + lng: -51.9253, + })); + tracker + .country_center_changed + .store(true, Ordering::Release); + + assert!(matches!(tracker.take_center_update(), Some(Ok(point)) if point.lat == -14.235)); + assert_eq!(tracker.take_center_update(), None); + } +} diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs @@ -9,7 +9,8 @@ use radroots_app_core::{APP_NAME, RadrootsApp}; #[cfg(any(target_os = "android", test))] use radroots_app_core::{ HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, ImportActionState, - RadrootsLocationCountry, RadrootsLocationPoint, RadrootsLocationResolverError, + RadrootsLocationCountry, RadrootsLocationCountryCenterLookupResult, + RadrootsLocationCountryListResult, RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, RadrootsOfflineGeocoderState, RadrootsResolvedLocation, RadrootsReverseLocationLookupResult, SetupActionState, }; @@ -29,6 +30,8 @@ use winit::platform::android::activity::AndroidApp; use zeroize::Zeroizing; #[cfg(any(target_os = "android", test))] +mod country_lookup; +#[cfg(any(target_os = "android", test))] mod offline_geocoder; #[cfg(any(target_os = "android", test))] mod reverse_lookup; @@ -42,6 +45,7 @@ mod vault; #[cfg(any(target_os = "android", test))] #[cfg_attr(not(target_os = "android"), allow(dead_code))] struct AndroidBackend { + country_lookup: country_lookup::AndroidCountryLookup, offline_geocoder: offline_geocoder::AndroidOfflineGeocoder, reverse_lookup: reverse_lookup::AndroidReverseLookup, } @@ -117,6 +121,50 @@ impl RadrootsAppBackend for AndroidBackend { Ok(self.reverse_lookup.take_update()) } + fn request_location_country_list(&self) -> Result<(), RadrootsLocationResolverError> { + #[cfg(target_os = "android")] + { + return self + .country_lookup + .begin_list(self.offline_geocoder.current_state()); + } + + #[cfg(not(target_os = "android"))] + { + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn poll_location_country_list_result( + &self, + ) -> Result<Option<RadrootsLocationCountryListResult>, String> { + Ok(self.country_lookup.take_list_update()) + } + + fn request_location_country_center_lookup( + &self, + country_id: &str, + ) -> Result<(), RadrootsLocationResolverError> { + #[cfg(target_os = "android")] + { + return self + .country_lookup + .begin_center(self.offline_geocoder.current_state(), country_id.to_owned()); + } + + #[cfg(not(target_os = "android"))] + { + let _ = country_id; + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn poll_location_country_center_lookup_result( + &self, + ) -> Result<Option<RadrootsLocationCountryCenterLookupResult>, String> { + Ok(self.country_lookup.take_center_update()) + } + fn list_location_countries( &self, ) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { @@ -296,6 +344,7 @@ impl AndroidBackend { ); Self { + country_lookup: country_lookup::AndroidCountryLookup::new(), offline_geocoder, reverse_lookup: reverse_lookup::AndroidReverseLookup::new(), }