app

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

commit 107f02f8994b390963d96723d70658ab0fe996dc
parent 267c083d8889c91848f030da6ee8726c56ee3486
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 11:50:31 +0000

ios: add async reverse lookup backend

- add a dedicated ios reverse-lookup tracker that runs geocoder queries off the ui thread
- expose begin and poll hooks from the ios 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 ios host build stays green

Diffstat:
Mcrates/ios/src/lib.rs | 32++++++++++++++++++++++++++++++--
Acrates/ios/src/reverse_lookup.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 152 insertions(+), 2 deletions(-)

diff --git a/crates/ios/src/lib.rs b/crates/ios/src/lib.rs @@ -10,7 +10,8 @@ use radroots_app_core::{ PasteActionState, RadrootsApp, RadrootsAppBackend, RadrootsLocationCountry, RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, RadrootsOfflineGeocoderPlatform, RadrootsOfflineGeocoderState, - RadrootsOfflineGeocoderUnavailableKind, RadrootsResolvedLocation, SetupActionState, + RadrootsOfflineGeocoderUnavailableKind, RadrootsResolvedLocation, + RadrootsReverseLocationLookupResult, SetupActionState, }; #[cfg(any(target_os = "ios", test))] use radroots_identity::RadrootsIdentity; @@ -26,12 +27,15 @@ use zeroize::Zeroizing; #[cfg(any(target_os = "ios", test))] mod offline_geocoder; #[cfg(any(target_os = "ios", test))] +mod reverse_lookup; +#[cfg(any(target_os = "ios", test))] mod storage; #[cfg(any(target_os = "ios", test))] #[cfg_attr(not(target_os = "ios"), allow(dead_code))] struct IosBackend { offline_geocoder: offline_geocoder::IosOfflineGeocoder, + reverse_lookup: reverse_lookup::IosReverseLookup, } #[cfg(target_os = "ios")] @@ -56,7 +60,10 @@ impl IosBackend { ), }; - Self { offline_geocoder } + Self { + offline_geocoder, + reverse_lookup: reverse_lookup::IosReverseLookup::new(), + } } #[cfg(target_os = "ios")] @@ -266,6 +273,27 @@ impl RadrootsAppBackend for IosBackend { } } + fn request_reverse_location_lookup( + &self, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + let app_data_root = storage::app_data_root() + .map_err(|message| RadrootsLocationResolverError::QueryFailed { message })?; + self.reverse_lookup.begin( + app_data_root, + self.offline_geocoder.current_state(), + point, + options, + ) + } + + 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> { diff --git a/crates/ios/src/reverse_lookup.rs b/crates/ios/src/reverse_lookup.rs @@ -0,0 +1,122 @@ +#![cfg_attr(not(target_os = "ios"), allow(dead_code))] + +#[cfg(target_os = "ios")] +use crate::offline_geocoder; +use radroots_app_core::{ + RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, + RadrootsOfflineGeocoderState, RadrootsReverseLocationLookupResult, +}; +#[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 IosReverseLookup { + result: Arc<Mutex<Option<RadrootsReverseLocationLookupResult>>>, + changed: Arc<AtomicBool>, + pending: Arc<AtomicBool>, +} + +impl IosReverseLookup { + pub(crate) fn new() -> Self { + Self::default() + } + + #[cfg(target_os = "ios")] + pub(crate) fn begin( + &self, + app_data_root: PathBuf, + 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( + app_data_root.as_path(), + &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 = "ios"))] + pub(crate) fn begin( + &self, + _app_data_root: std::path::PathBuf, + _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: "ios 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 = IosReverseLookup::new(); + + assert_eq!(tracker.take_update(), None); + } + + #[test] + fn take_update_returns_queued_result_once() { + let tracker = IosReverseLookup::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); + } +}