app

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

commit 8149f9916c0bb84ef972c464df81b64ce67f8502
parent 3c1f80c47cc4351ad65a77a6726886c27338459c
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 13:08:57 +0000

ios: add async country lookup backend

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

Diffstat:
Acrates/ios/src/country_lookup.rs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/ios/src/lib.rs | 37+++++++++++++++++++++++++++++++++++++
2 files changed, 227 insertions(+), 0 deletions(-)

diff --git a/crates/ios/src/country_lookup.rs b/crates/ios/src/country_lookup.rs @@ -0,0 +1,190 @@ +#![cfg_attr(not(target_os = "ios"), allow(dead_code))] + +#[cfg(target_os = "ios")] +use crate::offline_geocoder; +use radroots_app_core::{ + RadrootsLocationCountryCenterLookupResult, RadrootsLocationCountryListResult, + RadrootsLocationResolverError, RadrootsOfflineGeocoderState, +}; +#[cfg(target_os = "ios")] +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +pub(crate) struct IosCountryLookup { + 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 IosCountryLookup { + pub(crate) fn new() -> Self { + Self::default() + } + + #[cfg(target_os = "ios")] + pub(crate) fn begin_list( + &self, + app_data_root: PathBuf, + 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(app_data_root.as_path(), &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 = "ios"))] + pub(crate) fn begin_list( + &self, + _app_data_root: std::path::PathBuf, + _geocoder_state: RadrootsOfflineGeocoderState, + ) -> Result<(), RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + + #[cfg(target_os = "ios")] + pub(crate) fn begin_center( + &self, + app_data_root: PathBuf, + 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( + app_data_root.as_path(), + &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 = "ios"))] + pub(crate) fn begin_center( + &self, + _app_data_root: std::path::PathBuf, + _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: "ios 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: "ios 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 = IosCountryLookup::new(); + + assert_eq!(tracker.take_list_update(), None); + } + + #[test] + fn take_list_update_returns_queued_result_once() { + let tracker = IosCountryLookup::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 = IosCountryLookup::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/ios/src/lib.rs b/crates/ios/src/lib.rs @@ -8,6 +8,7 @@ use radroots_app_core::IdentityGateState; use radroots_app_core::{ APP_NAME, HomeActionKind, HomeActionResult, HomeActionState, ImportActionState, PasteActionState, RadrootsApp, RadrootsAppBackend, RadrootsLocationCountry, + RadrootsLocationCountryCenterLookupResult, RadrootsLocationCountryListResult, RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, RadrootsOfflineGeocoderPlatform, RadrootsOfflineGeocoderState, RadrootsOfflineGeocoderUnavailableKind, RadrootsResolvedLocation, @@ -25,6 +26,8 @@ use std::path::Path; use zeroize::Zeroizing; #[cfg(any(target_os = "ios", test))] +mod country_lookup; +#[cfg(any(target_os = "ios", test))] mod offline_geocoder; #[cfg(any(target_os = "ios", test))] mod reverse_lookup; @@ -34,6 +37,7 @@ mod storage; #[cfg(any(target_os = "ios", test))] #[cfg_attr(not(target_os = "ios"), allow(dead_code))] struct IosBackend { + country_lookup: country_lookup::IosCountryLookup, offline_geocoder: offline_geocoder::IosOfflineGeocoder, reverse_lookup: reverse_lookup::IosReverseLookup, } @@ -61,6 +65,7 @@ impl IosBackend { }; Self { + country_lookup: country_lookup::IosCountryLookup::new(), offline_geocoder, reverse_lookup: reverse_lookup::IosReverseLookup::new(), } @@ -294,6 +299,38 @@ impl RadrootsAppBackend for IosBackend { Ok(self.reverse_lookup.take_update()) } + fn request_location_country_list(&self) -> Result<(), RadrootsLocationResolverError> { + let app_data_root = storage::app_data_root() + .map_err(|message| RadrootsLocationResolverError::QueryFailed { message })?; + self.country_lookup + .begin_list(app_data_root, self.offline_geocoder.current_state()) + } + + 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> { + let app_data_root = storage::app_data_root() + .map_err(|message| RadrootsLocationResolverError::QueryFailed { message })?; + self.country_lookup.begin_center( + app_data_root, + self.offline_geocoder.current_state(), + country_id.to_owned(), + ) + } + + 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> {