Privacy-preserving location sharing with end-to-end encryption
coord.is
1use aes_gcm::{
2 aead::{Aead, KeyInit},
3 Aes256Gcm, Nonce,
4};
5use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
6use curve25519_dalek::edwards::CompressedEdwardsY;
7use ed25519_dalek::{Signer, SigningKey};
8use rand::rngs::OsRng;
9use std::collections::HashMap;
10
11/// Blob format version for future compatibility
12/// v2: JSON-encoded location
13/// v3: Binary-encoded location (20 bytes)
14const BLOB_VERSION: u8 = 0x03;
15use std::fs;
16use std::path::PathBuf;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::{Mutex, OnceLock};
19use std::time::{SystemTime, UNIX_EPOCH};
20use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
21
22/// Global storage directory path, set once at app startup
23static STORAGE_PATH: OnceLock<PathBuf> = OnceLock::new();
24
25/// In-memory friends list, persisted to disk on changes
26static FRIENDS: Mutex<Vec<Friend>> = Mutex::new(Vec::new());
27
28/// Track the last successfully uploaded location timestamp to avoid uploading stale data
29/// Persisted to disk to survive app restarts
30static LAST_UPLOADED_TIMESTAMP: AtomicU64 = AtomicU64::new(0);
31
32uniffi::setup_scaffolding!();
33
34/// Get the version of the transponder core library
35#[uniffi::export]
36pub fn get_version() -> String {
37 env!("CARGO_PKG_VERSION").to_string()
38}
39
40/// License information grouped by license type
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
42pub struct LicenseGroup {
43 pub id: String,
44 pub name: String,
45 pub text: String,
46 pub packages: Vec<String>,
47}
48
49/// Get license information for all dependencies, grouped by license type
50#[uniffi::export]
51pub fn get_licenses() -> Vec<LicenseGroup> {
52 let json = include_str!("../licenses.json");
53 serde_json::from_str(json).unwrap_or_default()
54}
55
56#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
57pub struct Location {
58 pub latitude: f64,
59 pub longitude: f64,
60 #[serde(default)]
61 pub altitude: f64,
62 pub accuracy: f32,
63 pub timestamp: u64,
64}
65
66// =============================================================================
67// Wire Format Conversion Utilities
68// =============================================================================
69
70/// Convert degrees to microdegrees (i32)
71/// 1 microdegree ≈ 11cm at the equator
72fn degrees_to_micro(degrees: f64) -> i32 {
73 (degrees * 1_000_000.0).round() as i32
74}
75
76/// Convert microdegrees (i32) to degrees
77fn micro_to_degrees(micro: i32) -> f64 {
78 micro as f64 / 1_000_000.0
79}
80
81/// Wire format for location (20 bytes total)
82/// All integers are big-endian
83struct WireLocation {
84 lat_micro: i32, // 4 bytes - microdegrees
85 long_micro: i32, // 4 bytes - microdegrees
86 alt: i16, // 2 bytes - meters
87 accuracy: u16, // 2 bytes - meters
88 timestamp: u64, // 8 bytes - ms since epoch
89}
90
91impl WireLocation {
92 fn from_location(loc: &Location) -> Self {
93 Self {
94 lat_micro: degrees_to_micro(loc.latitude),
95 long_micro: degrees_to_micro(loc.longitude),
96 alt: loc.altitude.clamp(-32768.0, 32767.0) as i16,
97 accuracy: (loc.accuracy as u32).min(65535) as u16,
98 timestamp: loc.timestamp,
99 }
100 }
101
102 fn to_location(&self) -> Location {
103 Location {
104 latitude: micro_to_degrees(self.lat_micro),
105 longitude: micro_to_degrees(self.long_micro),
106 altitude: self.alt as f64,
107 accuracy: self.accuracy as f32,
108 timestamp: self.timestamp,
109 }
110 }
111
112 fn encode(&self) -> [u8; 20] {
113 let mut buf = [0u8; 20];
114 buf[0..4].copy_from_slice(&self.lat_micro.to_be_bytes());
115 buf[4..8].copy_from_slice(&self.long_micro.to_be_bytes());
116 buf[8..10].copy_from_slice(&self.alt.to_be_bytes());
117 buf[10..12].copy_from_slice(&self.accuracy.to_be_bytes());
118 buf[12..20].copy_from_slice(&self.timestamp.to_be_bytes());
119 buf
120 }
121
122 fn decode(buf: &[u8; 20]) -> Self {
123 Self {
124 lat_micro: i32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]),
125 long_micro: i32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]),
126 alt: i16::from_be_bytes([buf[8], buf[9]]),
127 accuracy: u16::from_be_bytes([buf[10], buf[11]]),
128 timestamp: u64::from_be_bytes([buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19]]),
129 }
130 }
131}
132
133#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)]
134pub struct Friend {
135 pub pubkey: String,
136 pub server: String,
137 pub name: String,
138 pub share_with: bool,
139 pub fetch_from: bool,
140 pub location: Option<Location>,
141 pub fetched_at: Option<u64>,
142 /// Computed color for this friend's marker (hex string like "#4A90D9")
143 /// Not persisted - derived from pubkey on load
144 #[serde(skip, default)]
145 pub color: String,
146}
147
148/// Derive a consistent color from a pubkey string.
149/// Returns a hex color string like "#4A90D9".
150/// Uses HSL with fixed saturation/lightness for good visibility.
151#[uniffi::export]
152pub fn color_from_pubkey(pubkey: String) -> String {
153 color_from_pubkey_internal(&pubkey)
154}
155
156/// Get the color for an identity (what others see when viewing you)
157#[uniffi::export]
158pub fn get_identity_color(identity: &Identity) -> String {
159 let pubkey = URL_SAFE_NO_PAD.encode(&identity.ed25519_public);
160 color_from_pubkey_internal(&pubkey)
161}
162
163/// Internal color derivation function
164fn color_from_pubkey_internal(pubkey: &str) -> String {
165 // Hash the pubkey to get a consistent number
166 let hash: u64 = pubkey.bytes().fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
167
168 // Map to hue (0-360), keeping saturation and lightness fixed
169 let hue = (hash % 360) as f64;
170 let saturation = 0.65;
171 let lightness = 0.45;
172
173 // Convert HSL to RGB
174 let (r, g, b) = hsl_to_rgb(hue, saturation, lightness);
175
176 format!("#{:02X}{:02X}{:02X}", r, g, b)
177}
178
179/// Convert HSL to RGB values (0-255)
180fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
181 let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
182 let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
183 let m = l - c / 2.0;
184
185 let (r1, g1, b1) = if h < 60.0 {
186 (c, x, 0.0)
187 } else if h < 120.0 {
188 (x, c, 0.0)
189 } else if h < 180.0 {
190 (0.0, c, x)
191 } else if h < 240.0 {
192 (0.0, x, c)
193 } else if h < 300.0 {
194 (x, 0.0, c)
195 } else {
196 (c, 0.0, x)
197 };
198
199 let r = ((r1 + m) * 255.0) as u8;
200 let g = ((g1 + m) * 255.0) as u8;
201 let b = ((b1 + m) * 255.0) as u8;
202
203 (r, g, b)
204}
205
206#[derive(Debug, Clone, uniffi::Record)]
207pub struct Identity {
208 pub ed25519_private: Vec<u8>,
209 pub ed25519_public: Vec<u8>,
210 pub x25519_private: Vec<u8>,
211 pub x25519_public: Vec<u8>,
212}
213
214#[derive(Debug, thiserror::Error, uniffi::Error)]
215pub enum CoreError {
216 #[error("Invalid key")]
217 InvalidKey,
218 #[error("Encryption failed")]
219 EncryptionFailed,
220 #[error("Decryption failed")]
221 DecryptionFailed,
222 #[error("Invalid data")]
223 InvalidData,
224 #[error("Storage not initialized")]
225 StorageNotInitialized,
226 #[error("Storage error: {details}")]
227 StorageError { details: String },
228 #[error("Invalid link format")]
229 InvalidLink,
230 #[error("Friend not found")]
231 FriendNotFound,
232 #[error("Location is older than last upload")]
233 StaleLocation,
234}
235
236/// Derive X25519 private key bytes from Ed25519 seed
237/// Uses SHA-512 like Ed25519, but returns raw bytes without mod l reduction
238/// (x25519_dalek::StaticSecret::from() will apply clamping when used)
239fn derive_x25519_scalar(ed25519_seed: &[u8; 32]) -> [u8; 32] {
240 use sha2::{Sha512, Digest};
241 let hash = Sha512::digest(ed25519_seed);
242 let mut scalar = [0u8; 32];
243 scalar.copy_from_slice(&hash[..32]);
244 scalar
245}
246
247/// Generate a new identity with Ed25519 (signing) and X25519 (key exchange) keypairs
248/// X25519 keys are derived from Ed25519 for simplicity - only Ed25519 pubkey needed in friend links
249#[uniffi::export]
250pub fn generate_identity() -> Identity {
251 // Generate Ed25519 signing keypair
252 let ed_signing = SigningKey::generate(&mut OsRng);
253 let ed_public = ed_signing.verifying_key();
254
255 // Derive X25519 scalar from Ed25519 (raw scalar, no additional clamping)
256 let x_scalar = derive_x25519_scalar(&ed_signing.to_bytes());
257
258 // Derive X25519 public by converting Ed25519 public (consistent with encryption)
259 let x_public_bytes = ed25519_pubkey_to_x25519(&ed_public.to_bytes())
260 .expect("Valid Ed25519 pubkey should convert to X25519");
261
262 Identity {
263 ed25519_private: ed_signing.to_bytes().to_vec(),
264 ed25519_public: ed_public.to_bytes().to_vec(),
265 x25519_private: x_scalar.to_vec(),
266 x25519_public: x_public_bytes.to_vec(),
267 }
268}
269
270/// Derive shared secret using X25519 ECDH
271#[uniffi::export]
272pub fn derive_shared_secret(
273 my_x25519_private: Vec<u8>,
274 their_x25519_public: Vec<u8>,
275) -> Result<Vec<u8>, CoreError> {
276 let private_bytes: [u8; 32] = my_x25519_private
277 .try_into()
278 .map_err(|_| CoreError::InvalidKey)?;
279 let public_bytes: [u8; 32] = their_x25519_public
280 .try_into()
281 .map_err(|_| CoreError::InvalidKey)?;
282
283 let private = StaticSecret::from(private_bytes);
284 let public = X25519PublicKey::from(public_bytes);
285
286 let shared = private.diffie_hellman(&public);
287 Ok(shared.as_bytes().to_vec())
288}
289
290/// Encrypt location data with AES-256-GCM using shared secret
291#[uniffi::export]
292pub fn encrypt_location(location: Location, shared_secret: Vec<u8>) -> Result<Vec<u8>, CoreError> {
293 let key_bytes: [u8; 32] = shared_secret
294 .try_into()
295 .map_err(|_| CoreError::InvalidKey)?;
296
297 let cipher = Aes256Gcm::new_from_slice(&key_bytes).map_err(|_| CoreError::InvalidKey)?;
298
299 // Serialize location to JSON
300 let plaintext = serde_json::to_vec(&LocationJson {
301 latitude: location.latitude,
302 longitude: location.longitude,
303 altitude: location.altitude,
304 accuracy: location.accuracy,
305 timestamp: location.timestamp,
306 })
307 .map_err(|_| CoreError::InvalidData)?;
308
309 // Generate random nonce
310 let mut nonce_bytes = [0u8; 12];
311 rand::RngCore::fill_bytes(&mut OsRng, &mut nonce_bytes);
312 let nonce = Nonce::from_slice(&nonce_bytes);
313
314 // Encrypt
315 let ciphertext = cipher
316 .encrypt(nonce, plaintext.as_ref())
317 .map_err(|_| CoreError::EncryptionFailed)?;
318
319 // Prepend nonce to ciphertext
320 let mut result = nonce_bytes.to_vec();
321 result.extend(ciphertext);
322 Ok(result)
323}
324
325/// Decrypt location data with AES-256-GCM using shared secret
326#[uniffi::export]
327pub fn decrypt_location(
328 encrypted: Vec<u8>,
329 shared_secret: Vec<u8>,
330) -> Result<Option<Location>, CoreError> {
331 if encrypted.len() < 12 {
332 return Err(CoreError::InvalidData);
333 }
334
335 let key_bytes: [u8; 32] = shared_secret
336 .try_into()
337 .map_err(|_| CoreError::InvalidKey)?;
338
339 let cipher = Aes256Gcm::new_from_slice(&key_bytes).map_err(|_| CoreError::InvalidKey)?;
340
341 // Split nonce and ciphertext
342 let (nonce_bytes, ciphertext) = encrypted.split_at(12);
343 let nonce = Nonce::from_slice(nonce_bytes);
344
345 // Decrypt
346 let plaintext = cipher
347 .decrypt(nonce, ciphertext)
348 .map_err(|_| CoreError::DecryptionFailed)?;
349
350 // Deserialize
351 let json: LocationJson =
352 serde_json::from_slice(&plaintext).map_err(|_| CoreError::InvalidData)?;
353
354 Ok(Some(Location {
355 latitude: json.latitude,
356 longitude: json.longitude,
357 altitude: json.altitude,
358 accuracy: json.accuracy,
359 timestamp: json.timestamp,
360 }))
361}
362
363// Internal JSON representation for legacy single-recipient encryption
364#[derive(serde::Serialize, serde::Deserialize)]
365struct LocationJson {
366 latitude: f64,
367 longitude: f64,
368 #[serde(default)]
369 altitude: f64,
370 accuracy: f32,
371 timestamp: u64,
372}
373
374/// HTTP request prepared by Rust core for native layer to execute
375#[derive(Debug, Clone, uniffi::Record)]
376pub struct PreparedRequest {
377 pub url: String,
378 pub method: String,
379 pub headers: HashMap<String, String>,
380 pub body: Vec<u8>,
381}
382
383/// Convert Ed25519 public key to X25519 public key
384/// Both are points on Curve25519, just different representations (Edwards vs Montgomery)
385fn ed25519_pubkey_to_x25519(ed_pubkey: &[u8; 32]) -> Result<[u8; 32], CoreError> {
386 let compressed = CompressedEdwardsY::from_slice(ed_pubkey).map_err(|_| CoreError::InvalidKey)?;
387 let edwards_point = compressed.decompress().ok_or(CoreError::InvalidKey)?;
388 Ok(edwards_point.to_montgomery().to_bytes())
389}
390
391/// Calculate padded entry count for privacy (hides exact friend count)
392/// Power of 2 up to 64, then nearest 50 above that
393fn padded_entry_count(n: usize) -> usize {
394 if n == 0 {
395 return 0;
396 }
397 if n <= 64 {
398 n.next_power_of_two()
399 } else {
400 ((n + 49) / 50) * 50
401 }
402}
403
404/// Create multi-recipient encrypted blob with trial decryption support
405/// Format: version(1) | ephemeral_pubkey(32) | entry_count(2) | [nonce(12) | encrypted_dek(48)]... | data_nonce(12) | ciphertext
406/// Entry count includes padding entries (random bytes that fail auth for everyone)
407fn create_encrypted_blob(
408 location: &Location,
409 recipient_ed25519_pubkeys: &[[u8; 32]],
410) -> Result<Vec<u8>, CoreError> {
411 // Generate ephemeral X25519 keypair for this blob
412 let ephemeral_private = StaticSecret::random_from_rng(OsRng);
413 let ephemeral_public = X25519PublicKey::from(&ephemeral_private);
414
415 // Generate random data encryption key (DEK)
416 let mut dek = [0u8; 32];
417 rand::RngCore::fill_bytes(&mut OsRng, &mut dek);
418
419 // Encode location to binary wire format (20 bytes)
420 let wire_location = WireLocation::from_location(location);
421 let plaintext = wire_location.encode();
422
423 // Encrypt location with DEK
424 let data_cipher = Aes256Gcm::new_from_slice(&dek).map_err(|_| CoreError::InvalidKey)?;
425 let mut data_nonce_bytes = [0u8; 12];
426 rand::RngCore::fill_bytes(&mut OsRng, &mut data_nonce_bytes);
427 let data_nonce = Nonce::from_slice(&data_nonce_bytes);
428 let ciphertext = data_cipher
429 .encrypt(data_nonce, plaintext.as_ref())
430 .map_err(|_| CoreError::EncryptionFailed)?;
431
432 // Build blob
433 let mut blob = Vec::new();
434
435 // Version byte for future compatibility
436 blob.push(BLOB_VERSION);
437
438 // Ephemeral public key (32 bytes)
439 blob.extend_from_slice(ephemeral_public.as_bytes());
440
441 // Calculate padded entry count (real entries + padding)
442 let real_count = recipient_ed25519_pubkeys.len();
443 let padded_count = padded_entry_count(real_count);
444
445 // Entry count (2 bytes, big-endian) - includes padding
446 blob.extend_from_slice(&(padded_count as u16).to_be_bytes());
447
448 // Real entries: encrypt DEK for each recipient (no pubkey stored - trial decryption)
449 for ed_pubkey in recipient_ed25519_pubkeys {
450 let x_pubkey_bytes = ed25519_pubkey_to_x25519(ed_pubkey)?;
451 let x_pubkey = X25519PublicKey::from(x_pubkey_bytes);
452
453 // ECDH to get shared secret
454 let shared_secret = ephemeral_private.diffie_hellman(&x_pubkey);
455
456 // Encrypt DEK with shared secret
457 let key_cipher =
458 Aes256Gcm::new_from_slice(shared_secret.as_bytes()).map_err(|_| CoreError::InvalidKey)?;
459 let mut key_nonce_bytes = [0u8; 12];
460 rand::RngCore::fill_bytes(&mut OsRng, &mut key_nonce_bytes);
461 let key_nonce = Nonce::from_slice(&key_nonce_bytes);
462 let encrypted_dek = key_cipher
463 .encrypt(key_nonce, dek.as_ref())
464 .map_err(|_| CoreError::EncryptionFailed)?;
465
466 // Append: nonce (12) | encrypted DEK (48 = 32 + 16 tag)
467 blob.extend_from_slice(&key_nonce_bytes);
468 blob.extend_from_slice(&encrypted_dek);
469 }
470
471 // Padding entries: random bytes that will fail AES-GCM auth for everyone
472 for _ in real_count..padded_count {
473 let mut dummy = [0u8; 60]; // 12-byte nonce + 48-byte "encrypted" DEK
474 rand::RngCore::fill_bytes(&mut OsRng, &mut dummy);
475 blob.extend_from_slice(&dummy);
476 }
477
478 // Append encrypted location: nonce (12) | ciphertext
479 blob.extend_from_slice(&data_nonce_bytes);
480 blob.extend_from_slice(&ciphertext);
481
482 Ok(blob)
483}
484
485/// Prepare a location upload request for the native HTTP layer to execute
486/// Returns StaleLocation error if the location timestamp is older than the last successful upload
487#[uniffi::export]
488pub fn prepare_location_upload(
489 identity: &Identity,
490 location: Location,
491 friends: Vec<Friend>,
492 my_server: String,
493) -> Result<PreparedRequest, CoreError> {
494 // Check if this location is stale (only if storage is initialized)
495 if STORAGE_PATH.get().is_some() {
496 let last_ts = LAST_UPLOADED_TIMESTAMP.load(Ordering::Relaxed);
497 if location.timestamp <= last_ts {
498 return Err(CoreError::StaleLocation);
499 }
500 }
501
502 // Collect recipient pubkeys: all friends + self (so we can fetch our own location)
503 let mut recipient_pubkeys: Vec<[u8; 32]> = Vec::new();
504
505 // Add self
506 let my_ed_pubkey: [u8; 32] = identity
507 .ed25519_public
508 .clone()
509 .try_into()
510 .map_err(|_| CoreError::InvalidKey)?;
511 recipient_pubkeys.push(my_ed_pubkey);
512
513 // Add friends
514 for friend in &friends {
515 let pubkey_bytes = URL_SAFE_NO_PAD
516 .decode(&friend.pubkey)
517 .map_err(|_| CoreError::InvalidKey)?;
518 let pubkey_array: [u8; 32] = pubkey_bytes.try_into().map_err(|_| CoreError::InvalidKey)?;
519 recipient_pubkeys.push(pubkey_array);
520 }
521
522 // Create encrypted blob
523 let blob = create_encrypted_blob(&location, &recipient_pubkeys)?;
524 let blob_b64 = URL_SAFE_NO_PAD.encode(&blob);
525
526 // Get current timestamp
527 let timestamp = SystemTime::now()
528 .duration_since(UNIX_EPOCH)
529 .map_err(|_| CoreError::InvalidData)?
530 .as_secs();
531
532 // Sign: blob || timestamp (as 8-byte big-endian)
533 let signing_key_bytes: [u8; 32] = identity
534 .ed25519_private
535 .clone()
536 .try_into()
537 .map_err(|_| CoreError::InvalidKey)?;
538 let signing_key = SigningKey::from_bytes(&signing_key_bytes);
539
540 let mut message_to_sign = blob.clone();
541 message_to_sign.extend_from_slice(×tamp.to_be_bytes());
542 let signature = signing_key.sign(&message_to_sign);
543 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
544
545 // Build request body
546 let pubkey_b64 = URL_SAFE_NO_PAD.encode(&identity.ed25519_public);
547 let body = serde_json::to_vec(&serde_json::json!({
548 "pubkey": pubkey_b64,
549 "timestamp": timestamp,
550 "blob": blob_b64,
551 "signature": signature_b64
552 }))
553 .map_err(|_| CoreError::InvalidData)?;
554
555 // Build URL
556 let url = format!("{}/api/location", my_server.trim_end_matches('/'));
557
558 // Headers
559 let mut headers = HashMap::new();
560 headers.insert("Content-Type".to_string(), "application/json".to_string());
561
562 Ok(PreparedRequest {
563 url,
564 method: "PUT".to_string(),
565 headers,
566 body,
567 })
568}
569
570/// Decrypt a multi-recipient blob using trial decryption
571/// Format: version(1) | ephemeral(32) | count(2) | [nonce(12) | encrypted_dek(48)]... | data_nonce(12) | ciphertext
572/// Returns the decrypted Location if we're a valid recipient
573#[uniffi::export]
574pub fn decrypt_blob(identity: &Identity, blob: Vec<u8>) -> Result<Location, CoreError> {
575 // Minimum blob size: version(1) + ephemeral(32) + count(2) + at least one entry(60) + nonce(12) + some ciphertext
576 if blob.len() < 1 + 32 + 2 + 60 + 12 + 16 {
577 return Err(CoreError::InvalidData);
578 }
579
580 // Check version byte
581 if blob[0] != BLOB_VERSION {
582 return Err(CoreError::InvalidData);
583 }
584
585 // Parse ephemeral public key bytes (after version byte)
586 let ephemeral_bytes: [u8; 32] = blob[1..33].try_into().map_err(|_| CoreError::InvalidData)?;
587 let ephemeral_public = X25519PublicKey::from(ephemeral_bytes);
588
589 // Parse entry count (includes padding)
590 let entry_count =
591 u16::from_be_bytes(blob[33..35].try_into().map_err(|_| CoreError::InvalidData)?) as usize;
592
593 // Calculate where entries end
594 // Each entry: nonce(12) + encrypted_dek(48) = 60 bytes
595 let entries_end = 35 + entry_count * 60;
596 if blob.len() < entries_end + 12 + 16 {
597 return Err(CoreError::InvalidData);
598 }
599
600 // Get our X25519 private key for ECDH
601 let my_x25519_private: [u8; 32] = identity
602 .x25519_private
603 .clone()
604 .try_into()
605 .map_err(|_| CoreError::InvalidKey)?;
606 let my_secret = StaticSecret::from(my_x25519_private);
607
608 // Compute shared secret once (same for all entries since ephemeral is fixed)
609 let shared_secret = my_secret.diffie_hellman(&ephemeral_public);
610 let cipher = Aes256Gcm::new_from_slice(shared_secret.as_bytes())
611 .map_err(|_| CoreError::InvalidKey)?;
612
613 // Trial decryption: try each entry until one succeeds
614 let mut dek: Option<[u8; 32]> = None;
615 for i in 0..entry_count {
616 let entry_start = 35 + i * 60;
617 let nonce_bytes: [u8; 12] = blob[entry_start..entry_start + 12]
618 .try_into()
619 .map_err(|_| CoreError::InvalidData)?;
620 let encrypted_dek = &blob[entry_start + 12..entry_start + 60];
621
622 let nonce = Nonce::from_slice(&nonce_bytes);
623
624 // Try to decrypt - will fail with auth error for wrong entries and padding
625 if let Ok(decrypted_dek) = cipher.decrypt(nonce, encrypted_dek) {
626 if let Ok(dek_array) = decrypted_dek.try_into() {
627 dek = Some(dek_array);
628 break;
629 }
630 }
631 }
632
633 let dek = dek.ok_or(CoreError::DecryptionFailed)?; // We're not a recipient
634
635 // Decrypt the location data
636 let data_nonce: [u8; 12] = blob[entries_end..entries_end + 12]
637 .try_into()
638 .map_err(|_| CoreError::InvalidData)?;
639 let ciphertext = &blob[entries_end + 12..];
640
641 let cipher = Aes256Gcm::new_from_slice(&dek).map_err(|_| CoreError::InvalidKey)?;
642 let nonce = Nonce::from_slice(&data_nonce);
643 let plaintext = cipher
644 .decrypt(nonce, ciphertext)
645 .map_err(|_| CoreError::DecryptionFailed)?;
646
647 // Decode binary wire format (20 bytes)
648 if plaintext.len() != 20 {
649 return Err(CoreError::InvalidData);
650 }
651 let wire_bytes: [u8; 20] = plaintext.try_into().map_err(|_| CoreError::InvalidData)?;
652 let wire_location = WireLocation::decode(&wire_bytes);
653
654 Ok(wire_location.to_location())
655}
656
657/// Result of fetching a friend's location
658#[derive(Debug, Clone, uniffi::Record)]
659pub struct FetchedLocation {
660 pub pubkey: String,
661 pub location: Option<Location>,
662 pub updated: Option<u64>,
663}
664
665/// Prepare location fetch requests, grouped by server for federation
666/// Returns one PreparedRequest per unique server
667#[uniffi::export]
668pub fn prepare_location_fetch(friends: Vec<Friend>) -> Vec<PreparedRequest> {
669 // Group friends by server
670 let mut by_server: HashMap<String, Vec<String>> = HashMap::new();
671 for friend in friends {
672 by_server
673 .entry(friend.server.clone())
674 .or_default()
675 .push(friend.pubkey.clone());
676 }
677
678 // Create one request per server
679 by_server
680 .into_iter()
681 .map(|(server, pubkeys)| {
682 let url = format!("{}/api/location", server.trim_end_matches('/'));
683 let body = serde_json::to_vec(&serde_json::json!({ "ids": pubkeys }))
684 .unwrap_or_default();
685
686 let mut headers = HashMap::new();
687 headers.insert("Content-Type".to_string(), "application/json".to_string());
688
689 PreparedRequest {
690 url,
691 method: "POST".to_string(),
692 headers,
693 body,
694 }
695 })
696 .collect()
697}
698
699/// Prepare a request to fetch our own location from the server
700/// Used to verify what the server has stored for us
701#[uniffi::export]
702pub fn prepare_self_location_fetch(identity: &Identity, server: String) -> PreparedRequest {
703 let pubkey_b64 = URL_SAFE_NO_PAD.encode(&identity.ed25519_public);
704 let url = format!("{}/api/location", server.trim_end_matches('/'));
705 let body = serde_json::to_vec(&serde_json::json!({ "ids": [pubkey_b64] })).unwrap_or_default();
706
707 let mut headers = HashMap::new();
708 headers.insert("Content-Type".to_string(), "application/json".to_string());
709
710 PreparedRequest {
711 url,
712 method: "POST".to_string(),
713 headers,
714 body,
715 }
716}
717
718/// Server response format for POST /location
719#[derive(serde::Deserialize)]
720struct StoredLocationResponse {
721 blob: String,
722 updated: u64,
723}
724
725/// Process a fetch response from the server and decrypt locations
726/// Returns decrypted locations for each friend we can decrypt
727#[uniffi::export]
728pub fn process_fetch_response(
729 identity: &Identity,
730 response_body: Vec<u8>,
731) -> Result<Vec<FetchedLocation>, CoreError> {
732 // Parse response JSON: { pubkey: { blob, updated } | null, ... }
733 let response: HashMap<String, Option<StoredLocationResponse>> =
734 serde_json::from_slice(&response_body).map_err(|_| CoreError::InvalidData)?;
735
736 let mut results = Vec::new();
737
738 for (pubkey, stored) in response {
739 let fetched = match stored {
740 Some(data) => {
741 // Decode and decrypt blob
742 let blob_bytes = URL_SAFE_NO_PAD
743 .decode(&data.blob)
744 .map_err(|_| CoreError::InvalidData)?;
745
746 match decrypt_blob(identity, blob_bytes) {
747 Ok(location) => FetchedLocation {
748 pubkey,
749 location: Some(location),
750 updated: Some(data.updated),
751 },
752 Err(_) => FetchedLocation {
753 pubkey,
754 location: None,
755 updated: Some(data.updated),
756 },
757 }
758 }
759 None => FetchedLocation {
760 pubkey,
761 location: None,
762 updated: None,
763 },
764 };
765 results.push(fetched);
766 }
767
768 Ok(results)
769}
770
771// =============================================================================
772// Storage & Friend Management
773// =============================================================================
774
775fn friends_file_path() -> Result<PathBuf, CoreError> {
776 STORAGE_PATH
777 .get()
778 .map(|p| p.join("friends.json"))
779 .ok_or(CoreError::StorageNotInitialized)
780}
781
782fn last_upload_file_path() -> Option<PathBuf> {
783 STORAGE_PATH.get().map(|p| p.join("last_upload_timestamp"))
784}
785
786fn save_last_upload_timestamp(timestamp: u64) {
787 if let Some(path) = last_upload_file_path() {
788 let _ = fs::write(&path, timestamp.to_string());
789 }
790}
791
792fn load_last_upload_timestamp() -> u64 {
793 last_upload_file_path()
794 .and_then(|path| fs::read_to_string(&path).ok())
795 .and_then(|content| content.trim().parse().ok())
796 .unwrap_or(0)
797}
798
799fn save_friends(friends: &[Friend]) -> Result<(), CoreError> {
800 let path = friends_file_path()?;
801 let json = serde_json::to_string_pretty(friends).map_err(|e| CoreError::StorageError {
802 details: e.to_string(),
803 })?;
804 fs::write(&path, json).map_err(|e| CoreError::StorageError {
805 details: e.to_string(),
806 })?;
807 Ok(())
808}
809
810fn load_friends() -> Result<Vec<Friend>, CoreError> {
811 let path = friends_file_path()?;
812 if !path.exists() {
813 return Ok(Vec::new());
814 }
815 let json = fs::read_to_string(&path).map_err(|e| CoreError::StorageError {
816 details: e.to_string(),
817 })?;
818 let mut friends: Vec<Friend> = serde_json::from_str(&json).map_err(|e| CoreError::StorageError {
819 details: e.to_string(),
820 })?;
821
822 // Compute colors for all friends (not stored in JSON)
823 for friend in &mut friends {
824 friend.color = color_from_pubkey_internal(&friend.pubkey);
825 }
826
827 Ok(friends)
828}
829
830/// Initialize storage with the app's data directory path.
831/// Must be called once at app startup before any friend operations.
832#[uniffi::export]
833pub fn init_storage(path: String) -> Result<(), CoreError> {
834 let storage_path = PathBuf::from(path);
835
836 // Create directory if it doesn't exist
837 if !storage_path.exists() {
838 fs::create_dir_all(&storage_path).map_err(|e| CoreError::StorageError {
839 details: e.to_string(),
840 })?;
841 }
842
843 // Set the global storage path (fails if already set)
844 STORAGE_PATH
845 .set(storage_path)
846 .map_err(|_| CoreError::StorageError {
847 details: "Storage already initialized".to_string(),
848 })?;
849
850 // Load friends from disk into memory
851 let loaded = load_friends()?;
852 let mut friends = FRIENDS.lock().unwrap();
853 *friends = loaded;
854
855 // Load last upload timestamp
856 let last_ts = load_last_upload_timestamp();
857 LAST_UPLOADED_TIMESTAMP.store(last_ts, Ordering::Relaxed);
858
859 Ok(())
860}
861
862/// Migrate friend server URLs from old domain to new domain.
863/// Returns the number of friends that were migrated.
864#[uniffi::export]
865pub fn migrate_server_urls(from_domain: String, to_domain: String) -> Result<u32, CoreError> {
866 let mut friends = FRIENDS.lock().unwrap();
867 let mut migrated = 0u32;
868
869 for friend in friends.iter_mut() {
870 if friend.server.contains(&from_domain) {
871 friend.server = friend.server.replace(&from_domain, &to_domain);
872 migrated += 1;
873 }
874 }
875
876 if migrated > 0 {
877 save_friends(&friends)?;
878 }
879
880 Ok(migrated)
881}
882
883/// Add a new friend. If a friend with the same pubkey exists, updates their info.
884#[uniffi::export]
885pub fn add_friend(
886 pubkey: String,
887 server: String,
888 name: String,
889 share_with: bool,
890 fetch_from: bool,
891) -> Result<(), CoreError> {
892 let mut friends = FRIENDS.lock().unwrap();
893
894 // Check if friend already exists
895 if let Some(existing) = friends.iter_mut().find(|f| f.pubkey == pubkey) {
896 existing.server = server;
897 existing.name = name;
898 existing.share_with = share_with;
899 existing.fetch_from = fetch_from;
900 } else {
901 let color = color_from_pubkey_internal(&pubkey);
902 friends.push(Friend {
903 pubkey,
904 server,
905 name,
906 share_with,
907 fetch_from,
908 location: None,
909 fetched_at: None,
910 color,
911 });
912 }
913
914 save_friends(&friends)
915}
916
917/// Update an existing friend's settings
918#[uniffi::export]
919pub fn update_friend(
920 pubkey: String,
921 share_with: Option<bool>,
922 fetch_from: Option<bool>,
923 name: Option<String>,
924) -> Result<(), CoreError> {
925 let mut friends = FRIENDS.lock().unwrap();
926
927 let friend = friends
928 .iter_mut()
929 .find(|f| f.pubkey == pubkey)
930 .ok_or(CoreError::FriendNotFound)?;
931
932 if let Some(sw) = share_with {
933 friend.share_with = sw;
934 }
935 if let Some(ff) = fetch_from {
936 friend.fetch_from = ff;
937 }
938 if let Some(n) = name {
939 friend.name = n;
940 }
941
942 save_friends(&friends)
943}
944
945/// Remove a friend by pubkey
946#[uniffi::export]
947pub fn remove_friend(pubkey: String) -> Result<(), CoreError> {
948 let mut friends = FRIENDS.lock().unwrap();
949 let initial_len = friends.len();
950 friends.retain(|f| f.pubkey != pubkey);
951
952 if friends.len() == initial_len {
953 return Err(CoreError::FriendNotFound);
954 }
955
956 save_friends(&friends)
957}
958
959/// List all friends
960#[uniffi::export]
961pub fn list_friends() -> Vec<Friend> {
962 FRIENDS.lock().unwrap().clone()
963}
964
965/// Generate mock friends for development/testing.
966/// Colors are computed automatically from pubkeys.
967#[uniffi::export]
968pub fn mock_friends() -> Vec<Friend> {
969 let now = SystemTime::now()
970 .duration_since(UNIX_EPOCH)
971 .unwrap()
972 .as_millis() as u64;
973
974 // (pubkey, name, Option<(lat, lng, alt, acc, ts_offset, fetch_offset)>)
975 let mock_data = [
976 ("abc123", "Alice", Some((37.7749, -122.4194, 50.0, 10.0, 300_000, 60_000))),
977 ("def456", "Bob", Some((37.7849, -122.4094, 25.0, 25.0, 3_600_000, 120_000))),
978 ("ghi789", "Charlie", None),
979 ("jkl012", "Diana", Some((37.7649, -122.4294, 100.0, 15.0, 1_800_000, 180_000))),
980 ("mno345", "Evan", Some((37.7949, -122.3994, 75.0, 50.0, 7_200_000, 300_000))),
981 ("pqr678", "Fiona", Some((37.7549, -122.4394, 10.0, 8.0, 120_000, 30_000))),
982 ("stu901", "George", Some((37.8049, -122.4094, 200.0, 100.0, 86_400_000, 600_000))),
983 ("vwx234", "Hannah", Some((37.7699, -122.4494, 30.0, 20.0, 900_000, 90_000))),
984 ("yza567", "Ivan", Some((37.7599, -122.3894, 45.0, 12.0, 600_000, 45_000))),
985 ("bcd890", "Julia", Some((37.7899, -122.4594, 60.0, 30.0, 2_700_000, 150_000))),
986 ("efg123", "Kevin", None),
987 ("hij456", "Laura", Some((37.7749, -122.4294, 80.0, 18.0, 450_000, 75_000))),
988 ];
989
990 mock_data
991 .into_iter()
992 .map(|(pubkey, name, loc_data)| {
993 let location = loc_data.map(|(lat, lng, alt, acc, ts_offset, _)| Location {
994 latitude: lat,
995 longitude: lng,
996 altitude: alt,
997 accuracy: acc as f32,
998 timestamp: now - ts_offset,
999 });
1000 let fetched_at = loc_data.map(|(_, _, _, _, _, fetch_offset)| now - fetch_offset);
1001
1002 Friend {
1003 pubkey: pubkey.to_string(),
1004 server: "https://example.com".to_string(),
1005 name: name.to_string(),
1006 share_with: true,
1007 fetch_from: true,
1008 location,
1009 fetched_at,
1010 color: color_from_pubkey_internal(pubkey),
1011 }
1012 })
1013 .collect()
1014}
1015
1016/// Get friends to share location with (share_with == true)
1017#[uniffi::export]
1018pub fn get_share_recipients() -> Vec<Friend> {
1019 FRIENDS
1020 .lock()
1021 .unwrap()
1022 .iter()
1023 .filter(|f| f.share_with)
1024 .cloned()
1025 .collect()
1026}
1027
1028/// Get friends to fetch location from (fetch_from == true)
1029#[uniffi::export]
1030pub fn get_fetch_targets() -> Vec<Friend> {
1031 FRIENDS
1032 .lock()
1033 .unwrap()
1034 .iter()
1035 .filter(|f| f.fetch_from)
1036 .cloned()
1037 .collect()
1038}
1039
1040/// Update a friend's cached location after fetching
1041#[uniffi::export]
1042pub fn update_friend_location(
1043 pubkey: String,
1044 location: Option<Location>,
1045 fetched_at: u64,
1046) -> Result<(), CoreError> {
1047 let mut friends = FRIENDS.lock().unwrap();
1048
1049 let friend = friends
1050 .iter_mut()
1051 .find(|f| f.pubkey == pubkey)
1052 .ok_or(CoreError::FriendNotFound)?;
1053
1054 friend.location = location;
1055 friend.fetched_at = Some(fetched_at);
1056
1057 save_friends(&friends)
1058}
1059
1060/// Mark a location upload as successful, updating the last uploaded timestamp
1061/// Call this after successfully uploading a location to prevent re-uploading stale data
1062#[uniffi::export]
1063pub fn mark_upload_success(timestamp: u64) {
1064 LAST_UPLOADED_TIMESTAMP.store(timestamp, Ordering::Relaxed);
1065 save_last_upload_timestamp(timestamp);
1066}
1067
1068// =============================================================================
1069// Friend Link Generation & Parsing
1070// =============================================================================
1071
1072/// Generate a friend link for sharing our identity
1073/// Format: coord://<server>/add/<pubkey>#<name>
1074#[uniffi::export]
1075pub fn generate_friend_link(identity: &Identity, server: String, name: String) -> String {
1076 let pubkey_b64 = URL_SAFE_NO_PAD.encode(&identity.ed25519_public);
1077 let encoded_name = urlencoding::encode(&name);
1078
1079 // Strip protocol from server for the link format
1080 let server_host = server
1081 .trim_start_matches("https://")
1082 .trim_start_matches("http://")
1083 .trim_end_matches('/');
1084
1085 format!("coord://{}/add/{}#{}", server_host, pubkey_b64, encoded_name)
1086}
1087
1088/// Parsed friend link data
1089#[derive(Debug, Clone, uniffi::Record)]
1090pub struct ParsedFriendLink {
1091 pub pubkey: String,
1092 pub server: String,
1093 pub name: String,
1094}
1095
1096/// Parse a friend link into its components
1097/// Returns None if the link format is invalid
1098#[uniffi::export]
1099pub fn parse_friend_link(url: String) -> Result<ParsedFriendLink, CoreError> {
1100 // Expected format: coord://<server>/add/<pubkey>#<name>
1101 let url = url.trim();
1102
1103 // Check protocol
1104 let rest = url
1105 .strip_prefix("coord://")
1106 .ok_or(CoreError::InvalidLink)?;
1107
1108 // Split off fragment (name)
1109 let (path_part, name) = rest.split_once('#').ok_or(CoreError::InvalidLink)?;
1110
1111 // Parse path: <server>/add/<pubkey>
1112 let add_idx = path_part.find("/add/").ok_or(CoreError::InvalidLink)?;
1113 let server_host = &path_part[..add_idx];
1114 let pubkey = &path_part[add_idx + 5..]; // skip "/add/"
1115
1116 if server_host.is_empty() || pubkey.is_empty() {
1117 return Err(CoreError::InvalidLink);
1118 }
1119
1120 // Validate pubkey is valid base64url and correct length (32 bytes for ed25519)
1121 let pubkey_bytes = URL_SAFE_NO_PAD
1122 .decode(pubkey)
1123 .map_err(|_| CoreError::InvalidLink)?;
1124 if pubkey_bytes.len() != 32 {
1125 return Err(CoreError::InvalidLink);
1126 }
1127
1128 // Decode name from URL encoding
1129 let decoded_name =
1130 urlencoding::decode(name).map_err(|_| CoreError::InvalidLink)?;
1131
1132 Ok(ParsedFriendLink {
1133 pubkey: pubkey.to_string(),
1134 server: format!("https://{}", server_host),
1135 name: decoded_name.into_owned(),
1136 })
1137}
1138
1139// =============================================================================
1140// City Database - Privacy-preserving local reverse geocoding
1141// =============================================================================
1142
1143/// City information for reverse geocoding
1144#[derive(Debug, Clone, serde::Deserialize, uniffi::Record)]
1145pub struct City {
1146 #[serde(rename = "n")]
1147 pub name: String,
1148 #[serde(rename = "la")]
1149 pub lat: f64,
1150 #[serde(rename = "lo")]
1151 pub lng: f64,
1152 #[serde(rename = "r", default)]
1153 pub region: String,
1154 #[serde(rename = "c", default)]
1155 pub country: String,
1156 #[serde(rename = "p", default)]
1157 pub population: u32,
1158}
1159
1160impl City {
1161 /// Returns a display string like "Toronto, ON" or "Paris, France"
1162 pub fn display_name(&self) -> String {
1163 if !self.region.is_empty() {
1164 format!("{}, {}", self.name, self.region)
1165 } else {
1166 format!("{}, {}", self.name, self.country)
1167 }
1168 }
1169}
1170
1171/// Lazily loaded city database
1172static CITIES: OnceLock<Vec<City>> = OnceLock::new();
1173
1174/// Load cities from embedded JSON (called lazily on first access)
1175fn get_cities() -> &'static Vec<City> {
1176 CITIES.get_or_init(|| {
1177 let json = include_str!("cities.json");
1178 serde_json::from_str(json).expect("Failed to parse embedded cities.json")
1179 })
1180}
1181
1182/// Find the nearest city to the given coordinates.
1183/// Uses a weighted score that prefers larger cities when distances are similar.
1184#[uniffi::export]
1185pub fn find_nearest_city(lat: f64, lng: f64) -> Option<City> {
1186 let cities = get_cities();
1187
1188 cities
1189 .iter()
1190 .min_by(|a, b| {
1191 let score_a = city_score(a, lat, lng);
1192 let score_b = city_score(b, lat, lng);
1193 score_a.partial_cmp(&score_b).unwrap_or(std::cmp::Ordering::Equal)
1194 })
1195 .cloned()
1196}
1197
1198/// Calculate score for a city (lower is better)
1199/// Score = distance - population bonus
1200fn city_score(city: &City, lat: f64, lng: f64) -> f64 {
1201 let distance = haversine_distance(lat, lng, city.lat, city.lng);
1202 let population_bonus = (city.population.max(1) as f64).ln() * 0.5;
1203 distance - population_bonus
1204}
1205
1206/// Haversine distance in kilometers between two points
1207fn haversine_distance(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
1208 const R: f64 = 6371.0; // Earth radius in km
1209 let d_lat = (lat2 - lat1).to_radians();
1210 let d_lng = (lng2 - lng1).to_radians();
1211 let a = (d_lat / 2.0).sin().powi(2)
1212 + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lng / 2.0).sin().powi(2);
1213 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
1214 R * c
1215}
1216
1217// =============================================================================
1218// Region Boundaries - Point-in-polygon lookup for accurate geocoding
1219// =============================================================================
1220
1221/// A region boundary polygon with bounding box for fast rejection
1222#[derive(Debug, Clone)]
1223struct RegionBoundary {
1224 name: String,
1225 country: String,
1226 /// Bounding box: (min_lng, min_lat, max_lng, max_lat)
1227 bbox: (f64, f64, f64, f64),
1228 /// Polygon rings: outer ring first, then holes. Each ring is vec of (lng, lat)
1229 rings: Vec<Vec<(f64, f64)>>,
1230}
1231
1232/// Lazily loaded region boundaries
1233static BOUNDARIES: OnceLock<Vec<RegionBoundary>> = OnceLock::new();
1234
1235/// Load boundaries from embedded GeoJSON
1236fn get_boundaries() -> &'static Vec<RegionBoundary> {
1237 BOUNDARIES.get_or_init(|| {
1238 let json = include_str!("boundaries.json");
1239 parse_boundaries_geojson(json)
1240 })
1241}
1242
1243/// Parse GeoJSON FeatureCollection into RegionBoundary structs
1244fn parse_boundaries_geojson(json: &str) -> Vec<RegionBoundary> {
1245 let parsed: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
1246 let features = parsed["features"].as_array();
1247
1248 let Some(features) = features else {
1249 return Vec::new();
1250 };
1251
1252 let mut boundaries = Vec::with_capacity(features.len());
1253
1254 for feature in features {
1255 let props = &feature["properties"];
1256 let name = props["name"].as_str().unwrap_or("").to_string();
1257 let country = props["admin"].as_str().unwrap_or("").to_string();
1258
1259 let geom = &feature["geometry"];
1260 let geom_type = geom["type"].as_str().unwrap_or("");
1261
1262 let polygons: Vec<Vec<Vec<(f64, f64)>>> = match geom_type {
1263 "Polygon" => {
1264 if let Some(rings) = parse_polygon_coords(&geom["coordinates"]) {
1265 vec![rings]
1266 } else {
1267 continue;
1268 }
1269 }
1270 "MultiPolygon" => {
1271 let Some(coords) = geom["coordinates"].as_array() else {
1272 continue;
1273 };
1274 coords.iter()
1275 .filter_map(|poly| parse_polygon_coords(poly))
1276 .collect()
1277 }
1278 _ => continue,
1279 };
1280
1281 // Create a boundary for each polygon in a MultiPolygon
1282 for rings in polygons {
1283 if rings.is_empty() || rings[0].is_empty() {
1284 continue;
1285 }
1286
1287 // Calculate bounding box from outer ring
1288 let outer = &rings[0];
1289 let mut min_lng = f64::MAX;
1290 let mut min_lat = f64::MAX;
1291 let mut max_lng = f64::MIN;
1292 let mut max_lat = f64::MIN;
1293
1294 for &(lng, lat) in outer {
1295 min_lng = min_lng.min(lng);
1296 min_lat = min_lat.min(lat);
1297 max_lng = max_lng.max(lng);
1298 max_lat = max_lat.max(lat);
1299 }
1300
1301 boundaries.push(RegionBoundary {
1302 name: name.clone(),
1303 country: country.clone(),
1304 bbox: (min_lng, min_lat, max_lng, max_lat),
1305 rings,
1306 });
1307 }
1308 }
1309
1310 boundaries
1311}
1312
1313/// Parse polygon coordinates from GeoJSON
1314fn parse_polygon_coords(coords: &serde_json::Value) -> Option<Vec<Vec<(f64, f64)>>> {
1315 let rings = coords.as_array()?;
1316 let mut result = Vec::with_capacity(rings.len());
1317
1318 for ring in rings {
1319 let points = ring.as_array()?;
1320 let mut ring_coords = Vec::with_capacity(points.len());
1321
1322 for point in points {
1323 let arr = point.as_array()?;
1324 let lng = arr.first()?.as_f64()?;
1325 let lat = arr.get(1)?.as_f64()?;
1326 ring_coords.push((lng, lat));
1327 }
1328
1329 result.push(ring_coords);
1330 }
1331
1332 Some(result)
1333}
1334
1335/// Region lookup result
1336#[derive(Debug, Clone, uniffi::Record)]
1337pub struct Region {
1338 pub name: String,
1339 pub country: String,
1340}
1341
1342/// Find which region contains the given coordinates using point-in-polygon tests.
1343/// Returns None if the point is not within any known region (e.g., ocean, coastal areas).
1344#[uniffi::export]
1345pub fn find_region(lat: f64, lng: f64) -> Option<Region> {
1346 let boundaries = get_boundaries();
1347
1348 for boundary in boundaries {
1349 // Fast bounding box rejection
1350 let (min_lng, min_lat, max_lng, max_lat) = boundary.bbox;
1351 if lng < min_lng || lng > max_lng || lat < min_lat || lat > max_lat {
1352 continue;
1353 }
1354
1355 // Point-in-polygon test
1356 if point_in_polygon(lng, lat, &boundary.rings) {
1357 return Some(Region {
1358 name: boundary.name.clone(),
1359 country: boundary.country.clone(),
1360 });
1361 }
1362 }
1363
1364 None
1365}
1366
1367/// Ray casting algorithm for point-in-polygon test.
1368/// Handles polygons with holes (first ring is outer, rest are holes).
1369fn point_in_polygon(x: f64, y: f64, rings: &[Vec<(f64, f64)>]) -> bool {
1370 if rings.is_empty() {
1371 return false;
1372 }
1373
1374 // Must be inside outer ring
1375 if !point_in_ring(x, y, &rings[0]) {
1376 return false;
1377 }
1378
1379 // Must not be inside any holes
1380 for hole in rings.iter().skip(1) {
1381 if point_in_ring(x, y, hole) {
1382 return false;
1383 }
1384 }
1385
1386 true
1387}
1388
1389/// Ray casting for a single ring
1390fn point_in_ring(x: f64, y: f64, ring: &[(f64, f64)]) -> bool {
1391 let n = ring.len();
1392 if n < 3 {
1393 return false;
1394 }
1395
1396 let mut inside = false;
1397 let mut j = n - 1;
1398
1399 for i in 0..n {
1400 let (xi, yi) = ring[i];
1401 let (xj, yj) = ring[j];
1402
1403 if ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
1404 inside = !inside;
1405 }
1406
1407 j = i;
1408 }
1409
1410 inside
1411}
1412
1413/// Find the nearest city to the given coordinates, filtered by region.
1414/// First determines which region the coordinates are in, then only considers
1415/// cities in that region. Falls back to unfiltered search if no region match.
1416#[uniffi::export]
1417pub fn find_nearest_city_in_region(lat: f64, lng: f64) -> Option<City> {
1418 let cities = get_cities();
1419
1420 // Try to find which region the point is in
1421 if let Some(region) = find_region(lat, lng) {
1422 // Filter cities to this region
1423 let regional_cities: Vec<&City> = cities
1424 .iter()
1425 .filter(|c| c.region == region.name && c.country == region.country)
1426 .collect();
1427
1428 if !regional_cities.is_empty() {
1429 return regional_cities
1430 .into_iter()
1431 .min_by(|a, b| {
1432 let score_a = city_score(a, lat, lng);
1433 let score_b = city_score(b, lat, lng);
1434 score_a.partial_cmp(&score_b).unwrap_or(std::cmp::Ordering::Equal)
1435 })
1436 .cloned();
1437 }
1438 }
1439
1440 // Fallback: no region match or no cities in region, use global search
1441 find_nearest_city(lat, lng)
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use super::*;
1447
1448 #[test]
1449 fn test_encrypt_decrypt_roundtrip() {
1450 let identity = generate_identity();
1451 let location = Location {
1452 latitude: 37.7749,
1453 longitude: -122.4194,
1454 altitude: 50.0,
1455 accuracy: 10.0,
1456 timestamp: 1234567890,
1457 };
1458
1459 // Encrypt for self only
1460 let my_ed_pubkey: [u8; 32] = identity.ed25519_public.clone().try_into().unwrap();
1461 let blob = create_encrypted_blob(&location, &[my_ed_pubkey]).unwrap();
1462
1463 // Decrypt
1464 let decrypted = decrypt_blob(&identity, blob).unwrap();
1465
1466 assert_eq!(decrypted.latitude, location.latitude);
1467 assert_eq!(decrypted.longitude, location.longitude);
1468 assert_eq!(decrypted.accuracy, location.accuracy);
1469 assert_eq!(decrypted.timestamp, location.timestamp);
1470 }
1471
1472 #[test]
1473 fn test_multi_recipient_encryption() {
1474 let alice = generate_identity();
1475 let bob = generate_identity();
1476
1477 let location = Location {
1478 latitude: 40.7128,
1479 longitude: -74.0060,
1480 altitude: 10.0,
1481 accuracy: 15.0,
1482 timestamp: 9876543210,
1483 };
1484
1485 // Encrypt for both Alice and Bob
1486 let alice_pubkey: [u8; 32] = alice.ed25519_public.clone().try_into().unwrap();
1487 let bob_pubkey: [u8; 32] = bob.ed25519_public.clone().try_into().unwrap();
1488 let blob = create_encrypted_blob(&location, &[alice_pubkey, bob_pubkey]).unwrap();
1489
1490 // Both can decrypt
1491 let alice_decrypted = decrypt_blob(&alice, blob.clone()).unwrap();
1492 let bob_decrypted = decrypt_blob(&bob, blob).unwrap();
1493
1494 assert_eq!(alice_decrypted.latitude, location.latitude);
1495 assert_eq!(bob_decrypted.latitude, location.latitude);
1496 }
1497
1498 #[test]
1499 fn test_non_recipient_cannot_decrypt() {
1500 let alice = generate_identity();
1501 let bob = generate_identity();
1502 let eve = generate_identity();
1503
1504 let location = Location {
1505 latitude: 51.5074,
1506 longitude: -0.1278,
1507 altitude: 25.0,
1508 accuracy: 20.0,
1509 timestamp: 1111111111,
1510 };
1511
1512 // Encrypt for Alice and Bob only
1513 let alice_pubkey: [u8; 32] = alice.ed25519_public.clone().try_into().unwrap();
1514 let bob_pubkey: [u8; 32] = bob.ed25519_public.clone().try_into().unwrap();
1515 let blob = create_encrypted_blob(&location, &[alice_pubkey, bob_pubkey]).unwrap();
1516
1517 // Eve cannot decrypt
1518 let result = decrypt_blob(&eve, blob);
1519 assert!(result.is_err());
1520 }
1521
1522 #[test]
1523 fn test_friend_link_roundtrip() {
1524 let identity = generate_identity();
1525 let server = "https://coord.is".to_string();
1526 let name = "Alice".to_string();
1527
1528 let link = generate_friend_link(&identity, server.clone(), name.clone());
1529 let parsed = parse_friend_link(link).unwrap();
1530
1531 assert_eq!(parsed.server, server);
1532 assert_eq!(parsed.name, name);
1533
1534 // Verify pubkey matches
1535 let expected_pubkey = URL_SAFE_NO_PAD.encode(&identity.ed25519_public);
1536 assert_eq!(parsed.pubkey, expected_pubkey);
1537 }
1538
1539 #[test]
1540 fn test_friend_link_with_special_characters() {
1541 let identity = generate_identity();
1542 let server = "https://relay.example.com".to_string();
1543 let name = "Bob & Alice's Friend".to_string();
1544
1545 let link = generate_friend_link(&identity, server.clone(), name.clone());
1546 let parsed = parse_friend_link(link).unwrap();
1547
1548 assert_eq!(parsed.name, name);
1549 assert_eq!(parsed.server, server);
1550 }
1551
1552 #[test]
1553 fn test_friend_link_strips_protocol() {
1554 let identity = generate_identity();
1555
1556 // With https://
1557 let link1 = generate_friend_link(&identity, "https://example.com".to_string(), "Test".to_string());
1558 assert!(link1.starts_with("coord://example.com/add/"));
1559
1560 // With http://
1561 let link2 = generate_friend_link(&identity, "http://example.com".to_string(), "Test".to_string());
1562 assert!(link2.starts_with("coord://example.com/add/"));
1563
1564 // Without protocol (shouldn't happen, but handle gracefully)
1565 let link3 = generate_friend_link(&identity, "example.com".to_string(), "Test".to_string());
1566 assert!(link3.starts_with("coord://example.com/add/"));
1567 }
1568
1569 #[test]
1570 fn test_parse_invalid_links() {
1571 // Wrong protocol
1572 assert!(parse_friend_link("https://example.com/add/abc#Name".to_string()).is_err());
1573
1574 // Missing fragment
1575 assert!(parse_friend_link("coord://example.com/add/abc".to_string()).is_err());
1576
1577 // Missing /add/
1578 assert!(parse_friend_link("coord://example.com/abc#Name".to_string()).is_err());
1579
1580 // Empty pubkey
1581 assert!(parse_friend_link("coord://example.com/add/#Name".to_string()).is_err());
1582
1583 // Empty server
1584 assert!(parse_friend_link("coord:///add/abc#Name".to_string()).is_err());
1585 }
1586
1587 #[test]
1588 fn test_stale_location_rejected_when_storage_initialized() {
1589 // Set up a temporary storage directory
1590 let temp_dir = std::env::temp_dir().join(format!("transponder_test_{}", std::process::id()));
1591 let _ = fs::create_dir_all(&temp_dir);
1592
1593 // We can't easily test with init_storage since it uses OnceLock,
1594 // but we can test the atomic and file operations directly
1595
1596 // Test mark_upload_success updates the atomic
1597 LAST_UPLOADED_TIMESTAMP.store(0, Ordering::Relaxed);
1598 assert_eq!(LAST_UPLOADED_TIMESTAMP.load(Ordering::Relaxed), 0);
1599
1600 LAST_UPLOADED_TIMESTAMP.store(1000, Ordering::Relaxed);
1601 assert_eq!(LAST_UPLOADED_TIMESTAMP.load(Ordering::Relaxed), 1000);
1602
1603 // Clean up
1604 let _ = fs::remove_dir_all(&temp_dir);
1605 }
1606
1607 #[test]
1608 fn test_stale_location_skipped_when_storage_uninitialized() {
1609 // When STORAGE_PATH is not set, prepare_location_upload should NOT check timestamps
1610 // This test verifies the behavior without storage initialized
1611
1612 // Reset timestamp to simulate fresh state
1613 LAST_UPLOADED_TIMESTAMP.store(5000, Ordering::Relaxed);
1614
1615 let identity = generate_identity();
1616 let location = Location {
1617 latitude: 37.7749,
1618 longitude: -122.4194,
1619 altitude: 0.0,
1620 accuracy: 10.0,
1621 timestamp: 1000, // Older than LAST_UPLOADED_TIMESTAMP
1622 };
1623
1624 // If storage is NOT initialized (STORAGE_PATH.get() returns None),
1625 // prepare_location_upload should succeed even with old timestamp
1626 // Note: This test may behave differently if storage was already initialized
1627 // in another test (OnceLock limitation)
1628 if STORAGE_PATH.get().is_none() {
1629 let result = prepare_location_upload(
1630 &identity,
1631 location,
1632 vec![],
1633 "https://example.com".to_string(),
1634 );
1635 // Should succeed because storage check is skipped
1636 assert!(result.is_ok());
1637 }
1638 }
1639
1640 #[test]
1641 fn test_timestamp_comparison_logic() {
1642 // Test the comparison: timestamp <= last_ts should be rejected
1643
1644 // Equal timestamps should be rejected (already uploaded this exact location)
1645 LAST_UPLOADED_TIMESTAMP.store(1000, Ordering::Relaxed);
1646 let last = LAST_UPLOADED_TIMESTAMP.load(Ordering::Relaxed);
1647 assert!(1000u64 <= last); // Would be rejected
1648 assert!(999u64 <= last); // Would be rejected (older)
1649 assert!(!(1001u64 <= last)); // Would be accepted (newer)
1650 }
1651
1652 #[test]
1653 fn test_load_save_timestamp_helpers() {
1654 // Test the helper functions work correctly when storage path exists
1655 // Note: These functions gracefully handle missing storage path
1656
1657 let loaded = load_last_upload_timestamp();
1658 // Should return 0 or existing value if storage is initialized
1659 assert!(loaded == 0 || loaded > 0);
1660
1661 // save_last_upload_timestamp silently does nothing if path not set
1662 save_last_upload_timestamp(12345);
1663 // Can't easily verify without storage initialized
1664 }
1665
1666 #[test]
1667 fn test_padded_entry_count() {
1668 // Power of 2 up to 64
1669 assert_eq!(padded_entry_count(0), 0);
1670 assert_eq!(padded_entry_count(1), 1);
1671 assert_eq!(padded_entry_count(2), 2);
1672 assert_eq!(padded_entry_count(3), 4);
1673 assert_eq!(padded_entry_count(4), 4);
1674 assert_eq!(padded_entry_count(5), 8);
1675 assert_eq!(padded_entry_count(8), 8);
1676 assert_eq!(padded_entry_count(9), 16);
1677 assert_eq!(padded_entry_count(17), 32);
1678 assert_eq!(padded_entry_count(33), 64);
1679 assert_eq!(padded_entry_count(64), 64);
1680
1681 // Nearest 50 above 64
1682 assert_eq!(padded_entry_count(65), 100);
1683 assert_eq!(padded_entry_count(100), 100);
1684 assert_eq!(padded_entry_count(101), 150);
1685 assert_eq!(padded_entry_count(150), 150);
1686 assert_eq!(padded_entry_count(151), 200);
1687 }
1688
1689 #[test]
1690 fn test_blob_version_byte() {
1691 let identity = generate_identity();
1692 let location = Location {
1693 latitude: 37.7749,
1694 longitude: -122.4194,
1695 altitude: 100.0,
1696 accuracy: 10.0,
1697 timestamp: 1234567890,
1698 };
1699
1700 let my_pubkey: [u8; 32] = identity.ed25519_public.clone().try_into().unwrap();
1701 let blob = create_encrypted_blob(&location, &[my_pubkey]).unwrap();
1702
1703 // First byte should be version
1704 assert_eq!(blob[0], BLOB_VERSION);
1705
1706 // Blob should work with decrypt
1707 let decrypted = decrypt_blob(&identity, blob).unwrap();
1708 assert_eq!(decrypted.latitude, location.latitude);
1709 }
1710
1711 #[test]
1712 fn test_wire_format_roundtrip() {
1713 let location = Location {
1714 latitude: 37.7749295, // 7 decimal places
1715 longitude: -122.4194155,
1716 altitude: 123.0,
1717 accuracy: 15.5,
1718 timestamp: 1234567890123,
1719 };
1720
1721 let wire = WireLocation::from_location(&location);
1722 let decoded = wire.to_location();
1723
1724 // Microdegrees gives ~11cm precision, so 6 decimal places should match
1725 assert!((decoded.latitude - 37.774929).abs() < 0.000001);
1726 assert!((decoded.longitude - (-122.419415)).abs() < 0.000001);
1727 assert_eq!(decoded.altitude, 123.0);
1728 assert_eq!(decoded.accuracy, 15.0); // u16 truncates fractional part
1729 assert_eq!(decoded.timestamp, location.timestamp);
1730 }
1731
1732 #[test]
1733 fn test_wire_format_binary_size() {
1734 let location = Location {
1735 latitude: 0.0,
1736 longitude: 0.0,
1737 altitude: 0.0,
1738 accuracy: 0.0,
1739 timestamp: 0,
1740 };
1741
1742 let wire = WireLocation::from_location(&location);
1743 let encoded = wire.encode();
1744
1745 // Wire format should be exactly 20 bytes
1746 assert_eq!(encoded.len(), 20);
1747 }
1748
1749 #[test]
1750 fn test_blob_padding_applied() {
1751 let identity = generate_identity();
1752 let location = Location {
1753 latitude: 37.7749,
1754 longitude: -122.4194,
1755 altitude: 50.0,
1756 accuracy: 10.0,
1757 timestamp: 1234567890,
1758 };
1759
1760 // With 3 recipients, should pad to 4 entries
1761 let pubkey: [u8; 32] = identity.ed25519_public.clone().try_into().unwrap();
1762 let blob = create_encrypted_blob(&location, &[pubkey, pubkey, pubkey]).unwrap();
1763
1764 // Entry count at bytes 33-35 should be 4 (padded from 3)
1765 let entry_count = u16::from_be_bytes([blob[33], blob[34]]) as usize;
1766 assert_eq!(entry_count, 4);
1767
1768 // Decryption should still work
1769 let decrypted = decrypt_blob(&identity, blob).unwrap();
1770 assert_eq!(decrypted.latitude, location.latitude);
1771 }
1772}