1use crate::backend::{QtThread, Shared, json, load_config, say, tr};
11use cxx_qt_lib::QString;
12use serde::Serialize;
13use sioul_core::config::{Config, expand_home, state_dir};
14use sioul_sync::share;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex};
17
18static KEY: Mutex<Option<[u8; 32]>> = Mutex::new(None);
20static BUSY: Mutex<()> = Mutex::new(());
22static HURRY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
26static HERE: Mutex<()> = Mutex::new(());
28static LAST: Mutex<Option<(i64, Vec<String>)>> = Mutex::new(None);
30static SAID: Mutex<Vec<(i64, String)>> = Mutex::new(Vec::new());
33static WITHHELD: Mutex<Option<Vec<share::Withheld>>> = Mutex::new(None);
36
37fn hex(key: &[u8; 32]) -> String {
38 key.iter().map(|b| format!("{b:02x}")).collect()
39}
40
41fn unhex(text: &str) -> Option<[u8; 32]> {
42 let bytes: Option<Vec<u8>> = (0..text.len()).step_by(2).map(|i| text.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect();
43 <[u8; 32]>::try_from(bytes?).ok()
44}
45
46fn key() -> Option<[u8; 32]> {
47 let mut held = KEY.lock().ok()?;
48 if held.is_none() {
49 *held = sioul_sync::secret::named(share::KEY_NAME).and_then(|k| unhex(&k));
50 }
51 *held
52}
53
54fn sealed(folder: Option<PathBuf>) -> Option<(PathBuf, [u8; 32])> {
59 let folder = folder?;
60 Some((folder, key()?))
61}
62
63pub(crate) fn memory_path() -> PathBuf {
64 state_dir().join("share").join("memory.json")
65}
66
67fn here() -> share::Here {
71 let state = state_dir();
72 let mut here = share::Here::load(&state);
73 if here.folder_path().is_some() && !here.parts.contains_key("projects") {
74 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
75 here = share::Here::load(&state);
76 if !here.parts.contains_key("projects") {
77 here.parts.insert("projects".into(), load_config().share_projects);
78 let _ = here.save(&state);
79 }
80 }
81 if here.notes_carried.is_some() && here.notes_carried_for.is_none() {
84 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
85 here = share::Here::load(&state);
86 if here.notes_carried.is_some() && here.notes_carried_for.is_none()
87 && let Some(notes) = load_config().notes_root_path()
88 {
89 here.notes_carried_for = Some(notes.display().to_string());
90 let _ = here.save(&state);
91 }
92 }
93 here
94}
95
96fn files_readable() -> bool {
100 #[cfg(target_os = "android")]
101 {
102 unsafe extern "C" {
104 fn sioul_android_files_access() -> bool;
106 }
107 unsafe { sioul_android_files_access() }
109 }
110 #[cfg(not(target_os = "android"))]
111 true
112}
113
114fn quietly<T>(work: impl FnOnce() -> T) -> T {
117 use std::sync::atomic::Ordering;
118 HURRY.store(true, Ordering::Relaxed);
119 let busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
120 HURRY.store(false, Ordering::Relaxed);
121 let done = work();
122 drop(busy);
123 done
124}
125
126fn between_exchanges<T>(work: impl FnOnce() -> T) -> T {
129 quietly(|| {
130 let running = share::exchange_lock(&memory_path(), true);
131 let done = work();
132 drop(running);
133 done
134 })
135}
136
137fn after_exchange<T>(work: impl FnOnce() -> T) -> T {
145 use std::sync::atomic::Ordering;
146 HURRY.store(true, Ordering::Relaxed);
147 let busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
148 HURRY.store(false, Ordering::Relaxed);
149 drop(busy);
150 work()
151}
152
153fn notes_carried(config: &Config, here: &share::Here) -> Option<PathBuf> {
162 let store = config.notes_root_path()?;
163 let carried = if cfg!(target_os = "android") { carried_on_phone(here.notes_said(&store), here.folder_path().is_some_and(|folder| same_top(&on_storage(&store), &on_storage(&folder), Path::new(STORAGE)))) } else { carried(&store, &synced_roots()) };
164 carried.then_some(store)
165}
166
167fn carried_on_phone(said: Option<bool>, guessed: bool) -> bool {
169 said.unwrap_or(guessed)
170}
171
172pub(crate) fn set_notes_carried(carried: bool) -> String {
176 between_exchanges(|| {
177 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
178 let mut here = share::Here::load(&state_dir());
180 here.notes_carried = Some(carried);
181 here.notes_carried_for = load_config().notes_root_path().map(|notes| notes.display().to_string());
183 if carried {
184 for part in ["notes", "papers"] {
185 if here.parts.get(part) == Some(&true) {
186 here.parts.insert(part.to_string(), false);
187 }
188 }
189 }
190 here.save(&state_dir()).err().unwrap_or_default()
191 })
192}
193
194const STORAGE: &str = "/storage/emulated/0";
196
197fn on_storage(path: &Path) -> PathBuf {
200 ["/sdcard", "/storage/self/primary", "/mnt/sdcard", "/mnt/user/0/primary"].iter().find_map(|alias| path.strip_prefix(alias).ok()).map_or_else(|| path.to_path_buf(), |rest| Path::new(STORAGE).join(rest))
201}
202
203fn same_top(a: &Path, b: &Path, storage: &Path) -> bool {
205 let top = |path: &Path| path.strip_prefix(storage).ok().and_then(|rest| rest.components().next()).map(|c| c.as_os_str().to_owned());
206 top(a).is_some() && top(a) == top(b)
207}
208
209fn refused(part: &str, config: &Config, here: &share::Here) -> Option<String> {
211 if !matches!(part, "notes" | "papers") {
212 return None;
213 }
214 notes_carried(config, here).map(|store| say("share-part-carried", &[("store", shorten(&store))]))
215}
216
217fn stores_here(here: &share::Here) -> Vec<share::Store> {
219 let config = load_config();
220 let carried = notes_carried(&config, here).is_some();
221 share::stores_of(&config, &share::Roots::here(), &|part| here.shares(part, &config) && !(carried && matches!(part, "notes" | "papers")))
222}
223
224fn part_words(part: &str) -> (String, String) {
226 let text = |id: &str| tr().text(id, None);
227 match part {
228 "settings" => (text("share-part-settings"), text("share-part-settings-carries")),
229 "senders" => (text("share-part-senders"), text("share-part-senders-carries")),
230 "calls" => (text("share-part-calls"), text("share-part-calls-carries")),
231 "phone-messages" => (text("share-part-phone-messages"), text("share-part-phone-messages-carries")),
232 "texts" => (text("share-part-texts"), text("share-part-texts-carries")),
234 "spam" => (text("share-part-spam"), text("share-part-spam-carries")),
235 "health" => (text("share-part-health"), text("share-part-health-carries")),
236 "time" => (text("share-part-time"), text("share-part-time-carries")),
237 "drafts" => (text("share-part-drafts"), text("share-part-drafts-carries")),
238 "projects" => (text("share-part-projects"), text("share-part-projects-carries")),
239 "lists" => (text("share-part-lists"), text("share-part-lists-carries")),
240 "notes" => (text("share-part-notes"), text("share-part-notes-carries")),
241 "papers" => (text("share-part-papers"), text("share-part-papers-carries")),
242 sioul_sync::history::ACCOUNTS => (text("share-part-accounts"), String::new()),
244 _ => (part.to_string(), String::new()),
245 }
246}
247
248fn synced_roots() -> Vec<PathBuf> {
250 let home = expand_home("~");
251 let mut roots: Vec<PathBuf> = ["Nextcloud", "Dropbox", "ownCloud", "Sync", "Syncthing", "OneDrive", "pCloudDrive", "Seafile"].iter().map(|n| home.join(n)).filter(|p| p.is_dir()).collect();
252 let data = |name: &str, file: &str| std::env::var_os(name).map(|a| PathBuf::from(a).join(file)).unwrap_or_default();
253 let read = |paths: &[PathBuf]| paths.iter().filter_map(|c| std::fs::read_to_string(c).ok()).collect::<Vec<_>>();
254 let mut found: Vec<PathBuf> = Vec::new();
255 for text in read(&[home.join(".config/Nextcloud/nextcloud.cfg"), home.join("Library/Preferences/Nextcloud/nextcloud.cfg"), data("APPDATA", "Nextcloud/nextcloud.cfg")]) {
256 found.extend(text.lines().filter_map(|line| line.split_once("localPath=")).map(|(_, path)| PathBuf::from(path.trim().trim_end_matches('/'))));
257 }
258 for text in read(&[home.join(".config/syncthing/config.xml"), home.join(".local/state/syncthing/config.xml"), home.join("Library/Application Support/Syncthing/config.xml"), data("LOCALAPPDATA", "Syncthing/config.xml")]) {
260 found.extend(syncthing_folders(&text).iter().map(|path| expand_home(path)));
261 }
262 for text in read(&[home.join(".dropbox/info.json"), data("APPDATA", "Dropbox/info.json"), data("LOCALAPPDATA", "Dropbox/info.json")]) {
264 found.extend(dropbox_folders(&text).into_iter().map(PathBuf::from));
265 }
266 for path in found {
267 if path.is_dir() && !roots.contains(&path) {
268 roots.push(path);
269 }
270 }
271 roots
272}
273
274fn syncthing_folders(xml: &str) -> Vec<String> {
276 xml.split("<folder ")
277 .skip(1)
278 .filter_map(|element| {
279 let tag = element.split('>').next()?;
280 let (_, rest) = tag.split_once(" path=\"").or_else(|| tag.strip_prefix("path=\"").map(|rest| ("", rest)))?;
281 let path = rest.split('"').next()?;
282 Some(path.replace("&", "&").replace("'", "'").replace(""", "\"").replace("<", "<").replace(">", ">"))
283 })
284 .collect()
285}
286
287fn dropbox_folders(json: &str) -> Vec<String> {
289 let Ok(serde_json::Value::Object(accounts)) = serde_json::from_str::<serde_json::Value>(json) else { return Vec::new() };
290 accounts.values().filter_map(|account| account.get("path").and_then(serde_json::Value::as_str).map(str::to_string)).collect()
291}
292
293pub(crate) fn candidates() -> Vec<String> {
297 let roots: Vec<PathBuf> = if cfg!(target_os = "android") { vec![PathBuf::from("/storage/emulated/0")] } else { synced_roots() };
298 let mut found = Vec::new();
299 for root in &roots {
300 look_for_seals(root, 0, &mut found);
301 }
302 found.sort();
303 found.dedup();
304 found.into_iter().map(|path| shorten(&path)).collect()
305}
306
307pub(crate) fn folders_in(path: &str) -> String {
312 #[derive(Serialize)]
313 struct Folder {
314 name: String,
315 path: String,
316 sealed: bool,
318 }
319 #[derive(Serialize)]
320 struct Listing {
321 path: String,
322 parent: String,
323 folders: Vec<Folder>,
324 readable: bool,
325 }
326 let start = if cfg!(target_os = "android") { PathBuf::from("/storage/emulated/0") } else { expand_home("~") };
327 let dir = if path.trim().is_empty() { start } else { expand_home(path.trim()) };
328 let entries = std::fs::read_dir(&dir);
329 let readable = entries.is_ok();
330 let mut folders: Vec<Folder> = entries
331 .into_iter()
332 .flatten()
333 .filter_map(Result::ok)
334 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
335 .map(|e| (e.file_name().to_string_lossy().to_string(), e.path()))
336 .filter(|(name, _)| !name.starts_with('.'))
337 .map(|(name, path)| Folder { sealed: path.join("seal.toml").is_file(), path: path.display().to_string(), name })
338 .collect();
339 folders.sort_by_key(|f| f.name.to_lowercase());
340 let top = cfg!(target_os = "android") && dir == Path::new("/storage/emulated/0");
342 let parent = dir.parent().filter(|_| !top).map(|p| p.display().to_string()).unwrap_or_default();
343 json(&Listing { path: dir.display().to_string(), parent, folders, readable })
344}
345
346fn look_for_seals(dir: &Path, depth: usize, found: &mut Vec<PathBuf>) {
347 if depth > 6 || found.len() >= 10 {
349 return;
350 }
351 if dir.join("seal.toml").is_file() {
352 found.push(dir.to_path_buf());
353 return;
354 }
355 let Ok(entries) = std::fs::read_dir(dir) else { return };
356 for entry in entries.filter_map(Result::ok) {
357 let name = entry.file_name().to_string_lossy().to_string();
358 let skip = name.starts_with('.') || matches!(name.as_str(), "DCIM" | "Pictures" | "Movies" | "Music" | "Podcasts" | "Ringtones" | "Alarms" | "Notifications" | "data" | "obb" | "node_modules" | "target");
359 if !skip && entry.file_type().is_ok_and(|t| t.is_dir()) {
360 look_for_seals(&entry.path(), depth + 1, found);
361 }
362 }
363}
364
365fn carried(path: &Path, roots: &[PathBuf]) -> bool {
366 roots.iter().any(|root| path.starts_with(root))
367}
368
369fn suggested(roots: &[PathBuf]) -> String {
374 let store = load_config().notes_root_path();
375 let documents = roots.iter().map(|root| root.join("Documents")).find(|d| d.is_dir());
376 let folder = match (&store, &documents, roots.first()) {
377 (Some(store), _, _) if carried(store, roots) && in_documents(store) => store.join("sioul-shared"),
378 (_, Some(documents), _) => documents.join("Sioul"),
379 (Some(store), _, _) if carried(store, roots) => store.join("sioul-shared"),
380 (_, _, Some(root)) => root.join("Sioul"),
381 (Some(store), _, None) => store.join("sioul-shared"),
382 (None, _, None) => return String::new(),
383 };
384 shorten(&folder)
385}
386
387fn in_documents(path: &Path) -> bool {
390 path.components().any(|part| part.as_os_str().eq_ignore_ascii_case("Documents"))
391}
392
393fn beside_documents(path: &Path, roots: &[PathBuf]) -> bool {
396 roots.iter().any(|root| path.starts_with(root) && root.join("Documents").is_dir()) && !in_documents(path)
397}
398
399fn shorten(path: &Path) -> String {
401 let home = expand_home("~");
402 path.strip_prefix(&home).map_or_else(|_| path.display().to_string(), |rest| format!("~/{}", rest.display()))
403}
404
405fn when(seconds: i64) -> String {
407 let Ok(at) = jiff::Timestamp::from_second(seconds) else { return String::new() };
408 let at = at.to_zoned(jiff::tz::TimeZone::system());
409 if at.date() == jiff::Zoned::now().date() { at.strftime("%H:%M").to_string() } else { tr().date(&at, true) }
410}
411
412#[derive(Serialize)]
413struct Status {
414 on: bool,
415 folder: String,
416 sealed: bool,
418 projects: bool,
420 lines: Vec<String>,
421 problems: Vec<String>,
422 parts: Vec<Part>,
424 vanished: Vec<Vanished>,
426 withheld: Vec<WithheldShown>,
428 gone_devices: Vec<GoneRow>,
430 devices: Vec<crate::health::DeviceRow>,
432 backup: Backup,
434 servers: Vec<ServerAccount>,
436 mirrored: bool,
438 build: String,
440 can_again: bool,
442 again: String,
444 carried_ask: String,
448 replace: Option<crate::join::Replacing>,
450 add_device: Vec<String>,
452}
453
454#[derive(Serialize)]
456struct Vanished {
457 store: String,
459 text: String,
460}
461
462#[derive(Serialize)]
464struct Part {
465 id: &'static str,
466 name: String,
467 carries: String,
469 on: bool,
470 refused: String,
472 guessed: bool,
475 last: String,
477}
478
479pub(crate) fn status(folder: &str) -> String {
481 let here = here();
482 let roots = synced_roots();
483 let on = here.folder_path().is_some() && key().is_some();
484 let saved = here.folder.clone().filter(|f| !f.is_empty());
486 let chosen = match (&saved, folder.is_empty()) {
487 (Some(saved), _) if on => saved.clone(),
488 (_, false) => folder.to_string(),
489 (Some(saved), true) => saved.clone(),
490 (None, true) => suggested(&roots),
491 };
492 let path = expand_home(&chosen);
493 let mut lines = Vec::new();
494 let mut problems = Vec::new();
495 let mut vanished = Vec::new();
496 let withheld = if on { withheld(true) } else { Vec::new() };
500 let held_stores: Vec<String> = withheld_now(false).into_iter().map(|w| w.store).collect();
501 let mirror = mirrored();
502 let formats = if on { key().map(|key| share::formats(&path, &key, &memory_path(), &here.id, now_ms())) } else { None };
504 if on {
505 match &mirror {
507 Some(state) => {
508 lines.push(say("share-on-server", &[("host", state.host()), ("place", state.place.clone())]));
509 lines.extend(Some(mirror_line(state)).filter(|l| !l.is_empty()));
510 }
511 None => lines.push(say("share-on", &[("folder", chosen.clone())])),
512 }
513 attached(&here);
514 let others = match key() {
516 Some(key) => share::others_heard(&path, &key, &here.id),
517 None => share::others(&path, &here.id),
518 };
519 match others.iter().map(|o| o.heard).max() {
520 Some(heard) => {
521 let mut args = sioul_core::i18n::args();
522 args.set("count", others.len() as i64);
523 args.set("when", when(heard));
524 lines.push(tr().text("share-others", Some(&args)));
525 }
526 None => lines.push(tr().text("share-alone", None)),
527 }
528 if let Some((at, said)) = LAST.lock().ok().and_then(|l| l.clone()) {
529 lines.push(say("share-last", &[("when", when(at))]));
530 problems.extend(said.iter().filter(|p| !kept_a_day(p) && !p.starts_with("share-vanished:") && !p.starts_with("share-busy") && !p.starts_with("share-newer:")).map(|p| problem_text(p)));
531 vanished.extend(said.iter().filter(|p| !p.strip_prefix("share-vanished:").and_then(|rest| rest.rsplit_once(':')).is_some_and(|(folder, _)| held_stores.iter().any(|store| store == folder))).filter_map(|p| vanished_of(p)));
532 }
533 if let Some(formats) = &formats {
538 for (part, _, build) in &formats.newer {
539 let name = part_words(part).0;
540 problems.push(if build.is_empty() { say("share-newer-plain", &[("part", name)]) } else { say("share-newer", &[("part", name), ("build", build.clone())]) });
541 }
542 if formats.holders.iter().any(|(_, holds)| *holds != share::Holds::Unheard) {
543 lines.push(tr().text("share-format-held", None));
544 }
545 if !formats.older.is_empty() {
547 problems.push(tr().text("share-format-older", None));
548 }
549 }
550 let day_ago = jiff::Timestamp::now().as_second() - 86_400;
552 if let Ok(mut said) = SAID.lock() {
553 said.retain(|(at, _)| *at > day_ago);
554 problems.extend(said.iter().map(|(_, p)| problem_text(p)));
555 }
556 } else if here.folder_path().is_some() {
557 problems.push(tr().text("share-key-missing", None));
558 } else {
559 lines.push(tr().text("share-off", None));
560 }
561 if !cfg!(target_os = "android") && beside_documents(&path, &roots) {
563 lines.push(tr().text("share-phones", None));
564 }
565 let config = load_config();
568 if let Some(store) = config.notes_root_path()
569 && !here.shares("notes", &config)
570 && carried(&path, &roots)
571 && !carried(&store, &roots)
572 {
573 problems.push(say("share-outside", &[("store", shorten(&store))]));
574 }
575 let traffic = share::traffic(&memory_path(), &here.id);
576 let parts = share::PARTS
577 .iter()
578 .map(|&id| {
579 let (name, carries) = part_words(id);
580 let refused = refused(id, &config, &here).unwrap_or_default();
581 let shared = here.shares(id, &config) && refused.is_empty();
582 let last = match traffic.get(id).copied().unwrap_or_default() {
583 _ if !on || !shared => String::new(),
584 (0, 0) => tr().text("share-part-quiet", None),
585 (sent, received) => [(sent, "share-part-sent"), (received, "share-part-received")].iter().filter(|(at, _)| *at > 0).map(|(at, id)| say(id, &[("when", when(*at))])).collect::<Vec<_>>().join(" "),
586 };
587 let last = match (id, last.is_empty()) {
589 ("texts", false) => [last, crate::texts::kept_words()].join(" ").trim().to_string(),
590 _ => last,
591 };
592 let guessed = cfg!(target_os = "android") && !refused.is_empty() && config.notes_root_path().is_none_or(|notes| here.notes_said(¬es).is_none());
593 Part { id, name, carries, on: shared, refused, guessed, last }
594 })
595 .collect();
596 let holders: Vec<(String, i64, bool)> = formats.as_ref().map(|f| f.holders.iter().map(|(id, _)| (id, true)).chain(f.older.iter().map(|id| (id, false))).map(|(id, holds)| (id.clone(), f.heard.get(id).copied().unwrap_or(0) / 1000, holds)).collect()).unwrap_or_default();
598 let devices = if on { crate::health::device_rows(&holders) } else { Vec::new() };
599 let backup = backup(on && mirror.is_none(), &path);
600 let build = say("share-build", &[("build", sioul_core::build::DESCRIBED.to_string())]);
601 let can_again = on;
603 let again = if on { again_line(&sioul_sync::remote::Sending::load(&memory_path()).again, &sioul_sync::remote::State::load(&memory_path()).host()) } else { String::new() };
604 let carried_ask = match config.notes_root_path() {
605 Some(store) if cfg!(target_os = "android") && here.notes_said(&store).is_none() => say("share-carried-ask", &[("store", shorten(&store))]),
606 _ => String::new(),
607 };
608 let replace = if on { crate::join::replacing() } else { None };
609 let add_device = if on && !cfg!(target_os = "android") { crate::join::add_device(server_place(mirror.as_ref(), &path)) } else { Vec::new() };
610 let gone_devices = if on { gone_rows(&path) } else { Vec::new() };
611 json(&Status { on, folder: chosen, sealed: share::sealed(&path), projects: here.shares("projects", &config), lines, problems, parts, vanished, withheld, gone_devices, devices, backup, servers: server_accounts(&config), mirrored: mirror.is_some(), build, can_again, again, carried_ask, replace, add_device })
612}
613
614fn server_place(mirror: Option<&sioul_sync::remote::State>, folder: &Path) -> Option<(String, String)> {
619 if let Some(state) = mirror {
620 return Some((state.host(), state.place.clone()));
621 }
622 let state = sioul_sync::remote::State::load(&memory_path());
623 if !state.confirmed_for(folder) {
624 return None;
625 }
626 let (_, rest) = state.url.split_once("/remote.php/dav/files/")?;
628 let (_, place) = rest.split_once('/')?;
629 let place = place.trim_matches('/');
630 (!place.is_empty()).then(|| (state.host(), crate::join::decoded_path(place)))
631}
632
633fn problem_text(code: &str) -> String {
634 let file = |file: &str| share::shown(file).to_string();
635 match code.split_once(':') {
636 Some(("share-other-seal", _)) => tr().text("share-other-seal", None),
637 Some((other @ ("share-other-gap" | "share-other-cut" | "share-other-line"), _)) => tr().text(other, None),
639 Some(("share-unreadable", path)) => say("share-unreadable", &[("file", path.to_string())]),
640 Some(("share-conflict", copy)) => say("share-conflict", &[("copy", copy.to_string())]),
641 Some(("share-conflict-gone", copy)) => say("share-conflict-gone", &[("copy", copy.to_string())]),
642 Some(("share-older-copy", copy)) => say("share-older-copy", &[("copy", copy.to_string())]),
643 Some(("share-damaged", name)) => say("share-damaged", &[("file", file(name))]),
644 Some(("share-too-big", name)) => say("share-too-big", &[("file", file(name))]),
645 Some(("share-missing", name)) => say("share-missing", &[("file", file(name))]),
646 Some(("share-no-room", name)) => say("share-no-room", &[("file", file(name))]),
647 Some(("share-name-clash", name)) => say("share-name-clash", &[("file", file(name))]),
648 Some(("share-refused", name)) => say("share-refused", &[("file", file(name))]),
649 Some(("share-not-text", name)) => say("share-not-text", &[("file", file(name))]),
650 Some(("share-emptied", name)) => say("share-emptied", &[("file", file(name))]),
651 Some(("share-format-wait", name)) => say("share-format-wait", &[("file", file(name))]),
652 None if code == "share-files-unreadable" => tr().text("share-files-unreadable", None),
653 None if matches!(code, "share-no-seal" | "share-sealed-otherwise" | "share-twin" | "share-own-ahead" | "share-own-cut" | "share-device-counts" | "share-device-unknown") => tr().text(code, None),
654 _ => code.to_string(),
655 }
656}
657
658fn kept_a_day(code: &str) -> bool {
660 code.starts_with("share-conflict") || code.starts_with("share-older-copy")
661}
662
663fn vanished_of(code: &str) -> Option<Vanished> {
665 let (folder, count) = code.strip_prefix("share-vanished:")?.rsplit_once(':')?;
666 let store = folder;
668 let shown = match share::shown(folder) {
669 "" => load_config().notes_root_path().map(|notes| shorten(¬es)).unwrap_or_default(),
670 inside => inside.trim_end_matches('/').to_string(),
671 };
672 let words = if folder.ends_with('/') { "share-vanished" } else { "share-vanished-link" };
674 Some(Vanished { store: store.to_string(), text: say(words, &[("folder", shown), ("count", count.to_string())]) })
675}
676
677#[derive(Serialize)]
681pub(crate) struct WithheldShown {
682 store: String,
683 text: String,
684 leaves: String,
685 key: String,
686}
687
688fn day(ms: i64) -> String {
690 jiff::Timestamp::from_millisecond(ms).map(|at| tr().day(at.to_zoned(jiff::tz::TimeZone::system()).date())).unwrap_or_default()
691}
692
693fn withheld_now(read: bool) -> Vec<share::Withheld> {
697 let mut kept = WITHHELD.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
698 if kept.is_none() && read {
699 *kept = Some(share::withheld(&memory_path(), &here().id));
700 }
701 kept.clone().unwrap_or_default()
702}
703
704pub(crate) fn withheld(read: bool) -> Vec<WithheldShown> {
709 let here = here();
710 if here.folder_path().is_none() {
711 return Vec::new();
712 }
713 let held = withheld_now(read);
714 if held.is_empty() {
715 return Vec::new();
716 }
717 let stores = stores_here(&here);
718 held.into_iter()
719 .filter(|w| w.store == "files/notes/" || w.store == "files/papers/")
721 .map(|w| {
722 let root = stores.iter().find(|s| s.name == w.store).map(|s| s.path.clone());
723 withheld_words(w, root.as_deref())
724 })
725 .collect()
726}
727
728fn withheld_words(w: share::Withheld, root: Option<&Path>) -> WithheldShown {
732 let part = if w.store == "files/papers/" { tr().text("porch-withheld-of-papers", None) } else { tr().text("porch-withheld-of-notes", None) };
733 let place = |folder: &String| match root {
734 Some(root) if folder.is_empty() => shorten(root),
735 Some(root) => shorten(&root.join(folder)),
736 None => folder.clone(),
737 };
738 let folders = w.folders.iter().map(place).filter(|f| !f.is_empty()).collect::<Vec<_>>().join(", ");
739 let text = if day(w.first) == day(w.last) {
741 say("porch-withheld", &[("count", w.count.to_string()), ("part", part), ("day", day(w.first)), ("folders", folders)])
742 } else {
743 say("porch-withheld-days", &[("count", w.count.to_string()), ("part", part), ("first", day(w.first)), ("last", day(w.last)), ("folders", folders)])
744 };
745 let leaves = say("porch-withheld-leave", &[("count", w.count.to_string()), ("day", day(w.leaves)), ("days", share::GONE_GRACE_DAYS.to_string())]);
746 WithheldShown { key: format!("porch-withheld-said:{}:{}", w.store, w.last), store: w.store, text, leaves }
747}
748
749pub(crate) fn put_back_withheld(store: &str) -> String {
755 let here = here();
756 let Some((folder, key)) = sealed(attached(&here)) else { return tr().text("share-off", None) };
757 let stores = stores_here(&here);
758 match share::put_back_withheld(&folder, &key, &memory_path(), &here.id, &stores, store) {
759 Ok(put) => put_back_words(put),
760 Err(e) => problem_text(&e),
761 }
762}
763
764fn put_back_words(put: share::PutBack) -> String {
769 if put.back >= put.held {
770 String::new()
771 } else if put.back == 0 {
772 tr().text("porch-withheld-none-back", None)
773 } else {
774 say("porch-withheld-some-back", &[("back", put.back.to_string()), ("count", put.held.to_string()), ("rest", (put.held - put.back).to_string())])
775 }
776}
777
778pub(crate) fn take_out_withheld(store: &str) -> String {
782 if !matches!(store, "files/notes/" | "files/papers/") {
783 return format!("{store}?");
784 }
785 let here = here();
786 quietly(|| share::confirm_withheld(&memory_path(), &here.id, store).err().unwrap_or_default())
787}
788
789#[derive(Serialize)]
791struct GoneRow {
792 id: String,
793 name: String,
794 state: String,
795}
796
797fn device_named(name: &str, kind: &str) -> String {
799 let capital = |text: String| -> String {
800 let mut chars = text.chars();
801 chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
802 };
803 match (kind, name) {
804 (sioul_sync::devices::PHONE, name) if !name.is_empty() && name != sioul_sync::devices::PHONE => format!("{} ({name})", capital(tr().text("device-the-phone", None))),
805 (_, name) if !name.is_empty() => name.to_string(),
806 _ => tr().text("share-device-unnamed", None),
807 }
808}
809
810fn gone_rows(folder: &Path) -> Vec<GoneRow> {
813 let here = here();
814 let Some(key) = key() else { return Vec::new() };
815 share::departed_devices(folder, &key, &memory_path(), &here.id, now_ms())
816 .into_iter()
817 .map(|gone| {
818 let size = sioul_core::view::size(tr(), usize::try_from(gone.bytes).unwrap_or(usize::MAX));
819 let state = say(&format!("share-device-gone-{}", gone.why), &[("size", size)]);
820 GoneRow { name: device_named(&gone.name, &gone.kind), id: gone.id, state }
821 })
822 .collect()
823}
824
825pub(crate) fn device_files(id: &str) -> String {
828 let none = || json(&serde_json::json!({ "text": "", "live": false }));
829 let here = here();
830 let Some((folder, key)) = sealed(attached(&here)) else { return none() };
831 let stores = stores_here(&here);
832 let Some(files) = share::device_files(&folder, &key, &memory_path(), &here.id, &stores, id, now_ms()) else { return none() };
833 json(&serde_json::json!({ "text": removal_words(&files, heard_lately(id, files.heard)), "live": files.counts }))
834}
835
836fn heard_lately(id: &str, heard: i64) -> bool {
840 let last = crate::health::quiet_since(id).unwrap_or(0).max(heard / 1000);
841 last > 0 && jiff::Timestamp::now().as_second() - last < crate::health::FORGET_NOW_AFTER
842}
843
844fn removal_words(files: &share::DeviceFiles, lately: bool) -> String {
848 let size = |bytes: u64| sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX));
849 let mut said = Vec::new();
850 if files.counts && lately {
851 said.push(say("share-remove-in-use", &[("when", when(files.heard / 1000))]));
852 } else if files.counts {
853 said.push(tr().text("share-remove-live", None));
854 }
855 said.push(say("share-remove-rounds", &[("count", files.rounds.to_string()), ("size", size(files.round_bytes + files.small_bytes))]));
856 if files.sealed > 0 {
857 said.push(say("share-remove-sealed", &[("count", files.sealed.to_string()), ("size", size(files.sealed_bytes))]));
858 } else if !files.sealed_known {
859 said.push(tr().text("share-remove-sealed-unknown", None));
860 }
861 if files.sealed_later > 0 {
862 said.push(say("share-remove-sealed-later", &[("count", files.sealed_later.to_string()), ("days", files.later_days.max(1).to_string())]));
863 }
864 if files.notes_stay {
866 said.push(tr().text("share-remove-small-stay", None));
867 } else if files.entry_stays {
868 said.push(tr().text("share-remove-entry-stays", None));
869 }
870 said.push(tr().text("share-remove-stays", None));
871 if !files.unread_by.is_empty() {
872 let names = files.unread_by.iter().map(|(_, name, kind)| device_named(name, kind)).collect::<Vec<_>>().join(", ");
873 said.push(say("share-remove-unread", &[("devices", names), ("count", files.unread_by.len().to_string())]));
874 }
875 said.join(" ")
876}
877
878pub(crate) fn remove_device_files(id: &str) -> String {
887 let here = here();
888 let Some((folder, key)) = sealed(attached(&here)) else { return tr().text("share-off", None) };
889 let stores = stores_here(&here);
890 let Some(files) = share::device_files(&folder, &key, &memory_path(), &here.id, &stores, id, now_ms()) else { return problem_text("share-device-unknown") };
891 let marked = match (files.counts, heard_lately(id, files.heard)) {
892 (true, true) => {
893 forget_device(id);
894 String::new()
895 }
896 (true, false) => crate::health::device_forget(id),
897 (false, true) => String::new(),
898 (false, false) => crate::health::device_forget_doses(id),
899 };
900 if !marked.is_empty() {
901 return marked;
902 }
903 after_exchange(|| match share::remove_device_files(&folder, &key, &memory_path(), &here.id, &stores, id, now_ms()) {
904 Ok(_) => {
905 crate::everywhere::gone_soon();
906 String::new()
907 }
908 Err(e) => problem_text(&e),
909 })
910}
911
912pub(crate) fn start(folder: &str, passphrase: &str, again: &str) -> String {
914 let folder = folder.trim();
915 if folder.is_empty() {
916 return tr().text("share-no-folder", None);
917 }
918 let path = expand_home(folder);
919 let first = !share::sealed(&path);
920 if passphrase.chars().count() < 12 {
921 return tr().text("share-short", None);
922 }
923 if first && passphrase != again {
924 return tr().text("share-differ", None);
925 }
926 let key = match share::key_for(&path, passphrase) {
927 Ok(key) => key,
928 Err(share::Refused::WrongPassphrase) => return tr().text("share-wrong", None),
929 Err(share::Refused::Other(e)) => return e,
930 };
931 if let Err(e) = sioul_sync::secret::save_named(share::KEY_NAME, &hex(&key)) {
932 return e.sentence(tr(), "Sioul");
933 }
934 if let Ok(mut held) = KEY.lock() {
935 *held = Some(key);
936 }
937 let state = state_dir();
938 let mut here = share::Here::load(&state);
939 here.folder = Some(folder.to_string());
940 here.save(&state).err().unwrap_or_default()
941}
942
943pub(crate) fn set_projects(on: bool) -> String {
945 set_part("projects", on)
946}
947
948pub(crate) fn set_part(part: &str, on: bool) -> String {
952 let Some(part) = share::PARTS.iter().find(|p| **p == part) else { return format!("{part}?") };
953 if on && let Some(why) = refused(part, &load_config(), &here()) {
954 return why;
955 }
956 between_exchanges(|| {
958 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
959 let mut here = share::Here::load(&state_dir());
961 here.parts.insert((*part).to_string(), on);
962 here.save(&state_dir()).err().unwrap_or_default()
963 })
964}
965
966#[derive(Serialize)]
968struct Kept {
969 part: &'static str,
970 name: String,
971 files: Vec<KeptFile>,
972 more: String,
974}
975
976#[derive(Serialize)]
977struct KeptFile {
978 file: String,
979 shown: String,
980 count: String,
981 versions: Vec<KeptVersion>,
982}
983
984#[derive(Serialize)]
985struct KeptVersion {
986 stamp: String,
987 when: String,
988 size: String,
989}
990
991pub(crate) fn history(filter: &str) -> String {
995 let root = sioul_sync::history::root(&memory_path());
996 let needle = filter.trim().to_lowercase();
997 let kept: Vec<Kept> = share::PARTS
999 .iter()
1000 .chain(std::iter::once(&sioul_sync::history::ACCOUNTS))
1001 .filter_map(|&part| {
1002 let all: Vec<_> = sioul_sync::history::files(&root, part).into_iter().filter(|(file, versions)| shown_kept(&root, part, file, versions).to_lowercase().contains(&needle)).collect();
1003 let more = if all.len() > 100 { say("share-history-more", &[("count", (all.len() - 100).to_string())]) } else { String::new() };
1004 let files: Vec<KeptFile> = all
1005 .into_iter()
1006 .take(100)
1007 .map(|(file, versions)| KeptFile {
1008 shown: shown_kept(&root, part, &file, &versions),
1009 count: say("share-versions", &[("count", versions.len().to_string())]),
1010 versions: versions.into_iter().map(|v| KeptVersion { when: when(v.at / 1000), size: sioul_core::view::size(tr(), usize::try_from(v.size).unwrap_or(usize::MAX)), stamp: v.stamp }).collect(),
1011 file,
1012 })
1013 .collect();
1014 (!files.is_empty()).then(|| Kept { part, name: part_words(part).0, files, more })
1015 })
1016 .collect();
1017 json(&kept)
1018}
1019
1020fn shown_kept(root: &Path, part: &str, file: &str, versions: &[sioul_sync::history::Version]) -> String {
1024 if part != sioul_sync::history::ACCOUNTS {
1025 return share::shown(file).to_string();
1026 }
1027 let text = versions.first().and_then(|v| match sioul_sync::history::version(root, part, file, &v.stamp) {
1028 Some(sioul_sync::history::Kept::Copy(path)) => std::fs::read_to_string(path).ok(),
1029 _ => None,
1030 });
1031 let named = text.as_deref().and_then(|text| sioul_core::lines::unfold(text).iter().find(|l| matches!(sioul_core::lines::name(l).as_str(), "SUMMARY" | "FN")).map(|l| sioul_core::lines::value(l).trim().to_string())).filter(|n| !n.is_empty());
1032 named.unwrap_or_else(|| share::shown(file).to_string())
1033}
1034
1035fn account_item(file: &str) -> Option<PathBuf> {
1039 let relative = file.strip_prefix("data/")?;
1040 if relative.split('/').any(|piece| piece.is_empty() || piece == "." || piece == "..") {
1041 return None;
1042 }
1043 let path = sioul_core::config::data_dir().join(relative);
1044 crate::pim::account_of(&path).map(|_| path)
1045}
1046
1047pub(crate) fn put_back_item(file: &str, stamp: &str) -> Result<String, String> {
1052 let root = sioul_sync::history::root(&memory_path());
1053 let path = account_item(file).ok_or_else(|| format!("{file}?"))?;
1054 let Some(sioul_sync::history::Kept::Copy(version)) = sioul_sync::history::version(&root, sioul_sync::history::ACCOUNTS, file, stamp) else { return Err(format!("{file}: {stamp}: not kept")) };
1055 let bytes = std::fs::read(&version).map_err(|e| e.to_string())?;
1056 if path.is_file() {
1057 sioul_sync::history::keep(&root, sioul_sync::history::ACCOUNTS, file, &path, jiff::Timestamp::now().as_millisecond())?;
1058 }
1059 share::write_atomically(&path, &bytes)?;
1060 crate::pim::account_of(&path).ok_or_else(|| format!("{file}?"))
1061}
1062
1063pub(crate) fn put_back(part: &str, file: &str, stamp: &str) -> String {
1067 let here = here();
1068 let sealed = sealed(here.folder_path());
1069 let vault = sealed.as_ref().map(|(folder, key)| (folder.as_path(), key));
1070 let stores = share::stores_of(&load_config(), &share::Roots::here(), &|_| true);
1072 after_exchange(|| share::put_back(&memory_path(), vault, &stores, part, file, stamp, jiff::Timestamp::now().as_millisecond()).err().map(|e| problem_text(&e)).unwrap_or_default())
1075}
1076
1077pub(crate) fn store_of(file: &str) -> String {
1079 share::stores_of(&load_config(), &share::Roots::here(), &|_| true).into_iter().filter(|s| if s.folder { file.starts_with(s.name.as_str()) } else { s.name == file }).map(|s| s.name).max_by_key(String::len).unwrap_or_default()
1080}
1081
1082pub(crate) fn put_back_preview(part: &str, file: &str, stamp: &str) -> String {
1084 let stores = share::stores_of(&load_config(), &share::Roots::here(), &|_| true);
1085 let kept = sioul_sync::history::kept_at(stamp).map(|at| when(at / 1000)).unwrap_or_default();
1086 let shown = share::shown(file).to_string();
1087 if part == sioul_sync::history::ACCOUNTS {
1089 return say("share-put-back-whole", &[("file", shown), ("when", kept)]);
1090 }
1091 match share::put_back_preview(&memory_path(), &stores, part, file, stamp) {
1092 Ok(back) if back.whole => say("share-put-back-whole", &[("file", shown), ("when", kept)]),
1093 Ok(back) if back.changed + back.returning == 0 => say("share-put-back-nothing", &[("file", shown)]),
1094 Ok(back) => say("share-put-back-entries", &[("file", shown), ("when", kept), ("changed", back.changed.to_string()), ("returning", back.returning.to_string()), ("kept", back.kept.to_string())]),
1095 Err(e) => e,
1096 }
1097}
1098
1099pub(crate) fn confirm_gone(store: &str) -> String {
1101 let named = ["files/notes/", "files/papers/", "files/spam/"].iter().any(|root| store.starts_with(root)) && !store.split('/').any(|piece| piece == "." || piece == "..") && !store.contains("//");
1104 if !named {
1105 return format!("{store}?");
1106 }
1107 let here = here();
1108 quietly(|| share::confirm_gone(&memory_path(), &here.id, store).err().unwrap_or_default())
1109}
1110
1111pub(crate) fn estimate(part: &str) -> String {
1113 let stores = share::stores_of(&load_config(), &share::Roots::here(), &|p| p == part);
1114 let (count, bytes, big) = share::estimate(&stores);
1115 let size = sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX));
1116 let text = say("share-estimate", &[("count", count.to_string()), ("size", size), ("big", big.to_string())]);
1117 json(&serde_json::json!({ "part": part, "text": text }))
1118}
1119
1120pub(crate) fn put_back_said(file: &str, stamp: &str) -> String {
1122 let kept = sioul_sync::history::kept_at(stamp).map(|at| when(at / 1000)).unwrap_or_default();
1123 say("share-put-back-done", &[("file", share::shown(file).to_string()), ("when", kept)])
1124}
1125
1126pub(crate) fn stop() -> String {
1128 between_exchanges(|| {
1130 crate::devices::left();
1132 if let Ok(mut held) = KEY.lock() {
1133 *held = None;
1134 }
1135 let _ = sioul_sync::secret::forget_named(share::KEY_NAME);
1136 let state = state_dir();
1137 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1138 let mut here = share::Here::load(&state);
1139 if let Some(folder) = here.folder_path() {
1143 if let Some(state) = mirrored() {
1144 let (memory, id) = (memory_path(), here.id.clone());
1145 std::thread::spawn(move || {
1146 mirror_step_for(&folder, &id, false, false);
1147 crate::join::let_go(&state);
1149 sioul_sync::remote::forget(&memory, &folder);
1150 let _ = std::fs::remove_dir_all(sioul_sync::remote::mirror_of(&memory));
1151 });
1152 } else if sending_on(&here) {
1153 let (memory, leaving) = (memory_path(), here.clone());
1155 std::thread::spawn(move || {
1156 send_to_server(&leaving, false);
1157 sioul_sync::remote::forget(&memory, &folder);
1158 });
1159 } else {
1160 sioul_sync::remote::forget(&memory_path(), &folder);
1161 }
1162 }
1163 here.folder = None;
1164 let memory = memory_path();
1166 let _ = std::fs::remove_file(memory.with_file_name("files.json"));
1167 let _ = std::fs::remove_file(&memory);
1168 here.save(&state).err().unwrap_or_default()
1169 })
1170}
1171
1172pub(crate) fn forget_device(id: &str) {
1176 let here = here();
1177 if let Some((folder, key)) = sealed(attached(&here))
1178 && let Err(e) = share::forget_device(&folder, &key, &memory_path(), &here.id, id)
1179 {
1180 eprintln!("sioul: sharing: {e}");
1181 }
1182 crate::everywhere::gone_soon();
1183}
1184
1185pub(crate) fn holds_doses() -> bool {
1189 on() && share::holds(&memory_path(), "health")
1190}
1191
1192pub(crate) fn writes_format_2() -> bool {
1195 on() && share::writes(&memory_path()) >= 2
1196}
1197
1198pub(crate) fn on() -> bool {
1200 share::Here::load(&state_dir()).folder_path().is_some() && key().is_some()
1201}
1202
1203pub(crate) fn configured() -> bool {
1205 share::Here::load(&state_dir()).folder_path().is_some()
1206}
1207
1208pub(crate) fn last_exchange() -> Option<(i64, bool)> {
1210 LAST.lock().ok().and_then(|l| l.as_ref().map(|(at, problems)| (*at, problems.is_empty())))
1211}
1212
1213pub(crate) fn keeper(part: &str, rule: sioul_sync::lease::Rule, active: i64, take: bool) -> (sioul_sync::lease::Keeper, bool) {
1218 if !renews(cfg!(target_os = "android"), crate::backend::AWAY.load(std::sync::atomic::Ordering::SeqCst)) {
1223 return (looked(part, rule), false);
1224 }
1225 let _claiming = CLAIMING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1226 let here = share::Here::load(&state_dir());
1227 let alone = sioul_sync::lease::Keeper::alone(&here.id);
1228 let Some((folder, key)) = sealed(attached(&here)) else { return (alone, false) };
1229 let wrote = share::written(&memory_path(), &here.id);
1231 match sioul_sync::lease::renew(&folder, &key, part, &here.id, jiff::Timestamp::now().as_second(), active, take, rule, wrote) {
1232 Ok(keeper) => (keeper, false),
1233 Err(_) => (alone, true),
1234 }
1235}
1236
1237fn renews(phone: bool, away: bool) -> bool {
1239 !(phone && away)
1240}
1241
1242pub(crate) fn looked(part: &str, rule: sioul_sync::lease::Rule) -> sioul_sync::lease::Keeper {
1244 let here = share::Here::load(&state_dir());
1245 let Some((folder, key)) = sealed(attached(&here)) else { return sioul_sync::lease::Keeper::alone(&here.id) };
1246 sioul_sync::lease::look(&folder, &key, part, &here.id, jiff::Timestamp::now().as_second(), rule)
1247}
1248
1249static CLAIMING: Mutex<()> = Mutex::new(());
1252
1253const PARTS: [&str; 3] = ["health", "notices", crate::projects::INVOICES];
1255
1256pub(crate) fn closing() {
1261 let ended = || !cfg!(target_os = "android") || crate::backend::AWAY.load(std::sync::atomic::Ordering::SeqCst);
1263 let here = here();
1264 let Some((folder, key)) = sealed(here.folder_path()) else {
1265 crate::devices::window_closed(ended);
1266 return;
1267 };
1268 let memory = memory_path();
1269 {
1270 HURRY.store(true, std::sync::atomic::Ordering::Relaxed);
1272 let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1273 HURRY.store(false, std::sync::atomic::Ordering::Relaxed);
1274 let stores = stores_here(&here);
1275 let sharing = share::Sharing { folder: &folder, computer: &here.id, key: &key, memory: &memory, files: false, hurry: None };
1276 if let Ok(outcome) = share::exchange(&sharing, &stores, jiff::Timestamp::now().as_millisecond()) {
1277 crate::devices::exported(&outcome);
1278 }
1279 }
1280 {
1281 let _claiming = CLAIMING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1282 if ended() {
1284 let wrote = share::written(&memory, &here.id);
1285 for part in PARTS {
1286 let _ = sioul_sync::lease::close(&folder, &key, part, &here.id, jiff::Timestamp::now().as_second(), wrote);
1287 }
1288 }
1289 }
1290 crate::devices::window_closed(ended);
1292 let going = if mirrored().is_some() {
1296 Some(std::thread::spawn(move || mirror_step(&here, false, false)))
1297 } else if sending_on(&here) {
1298 Some(std::thread::spawn(move || {
1300 send_to_server(&here, false);
1301 }))
1302 } else {
1303 None
1304 };
1305 if let Some(going) = going {
1306 let until = std::time::Instant::now() + std::time::Duration::from_secs(if cfg!(target_os = "android") { 0 } else { 5 });
1307 while !going.is_finished() && std::time::Instant::now() < until {
1308 std::thread::sleep(std::time::Duration::from_millis(50));
1309 }
1310 }
1311}
1312
1313pub(crate) struct Others {
1315 pub claims: Vec<sioul_sync::lease::Claim>,
1317 pub heard: share::Heard,
1319 pub others: Vec<share::Other>,
1321 pub entries: Vec<sioul_sync::devices::Entry>,
1323 pub unread: Vec<String>,
1324 pub news: Option<bool>,
1330}
1331
1332pub(crate) fn others_on_health() -> Option<Others> {
1336 let here = share::Here::load(&state_dir());
1337 let Some((folder, key)) = sealed(attached(&here)) else { return None };
1338 let claims = sioul_sync::lease::claims(&folder, &key, "health").into_iter().filter(|c| c.computer != here.id).collect();
1339 let (mut entries, mut unread) = sioul_sync::devices::all(&folder, &key);
1340 entries.retain(|e| e.id != here.id);
1341 unread.retain(|id| *id != here.id);
1342 let pulls = sioul_sync::remote::State::load(&memory_path());
1343 let now = jiff::Timestamp::now().as_second();
1344 let news = (pulls.fetching() && pulls.confirmed > 0 && !pulls.url.is_empty()).then(|| pulls.said.is_empty() && (now - 2 * 60..=now).contains(&pulls.last));
1345 Some(Others { claims, heard: share::heard(&memory_path(), &here.id), others: share::others_heard(&folder, &key, &here.id), entries, unread, news })
1347}
1348
1349pub(crate) fn vault() -> Option<(String, Option<(PathBuf, [u8; 32])>)> {
1352 let here = share::Here::load(&state_dir());
1353 if here.id.is_empty() {
1354 return None;
1355 }
1356 let vault = sealed(attached(&here));
1357 Some((here.id, vault))
1358}
1359
1360pub(crate) fn shares(part: &str) -> bool {
1362 here().shares(part, &load_config())
1363}
1364
1365pub(crate) fn nudge_carriers() {
1368 ask_carriers();
1369}
1370
1371pub(crate) fn rebuild_health_record<T>(then: impl FnOnce() -> T) -> Result<T, String> {
1380 rebuild_then(&["state/health-state.toml"], then)
1381}
1382
1383pub(crate) fn rebuild_dose_records<T>(then: impl FnOnce() -> T) -> Result<T, String> {
1386 rebuild_then(&["state/health-doses.toml"], then)
1387}
1388
1389pub(crate) fn rebuild_dose_reminders<T>(then: impl FnOnce() -> T) -> Result<T, String> {
1393 rebuild_then(&["state/health-reminders.toml"], then)
1394}
1395
1396fn rebuild_then<T>(files: &[&str], then: impl FnOnce() -> T) -> Result<T, String> {
1397 let here = share::Here::load(&state_dir());
1398 let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1399 share::rebuild_then(&memory_path(), &here.id, files, then)
1400}
1401
1402#[cfg(target_os = "android")]
1410const CARRIERS: &[(&str, &str, &str)] = &[
1411 ("foundation.e.drive", "foundation.e.drive.receivers.DebugCmdReceiver", "foundation.e.drive.action.FORCE_SCAN"),
1414];
1415
1416fn ask_carriers() {
1418 ask_carriers_with(false);
1419}
1420
1421fn ask_carriers_with(always: bool) {
1424 if mirrored().is_some() {
1426 std::thread::spawn(|| mirror_step(&here(), false, false));
1427 return;
1428 }
1429 let here = here();
1434 if sending_on(&here) {
1435 std::thread::spawn(move || {
1436 send_to_server(&here, false);
1437 if always || !here.folder_path().is_some_and(|folder| pulls_alone(&folder)) {
1439 ask_carriers_only();
1440 }
1441 });
1442 return;
1443 }
1444 ask_carriers_only();
1445}
1446
1447fn pulls_alone(folder: &Path) -> bool {
1454 cfg!(target_os = "android") && sioul_sync::remote::State::load(&memory_path()).pulls_well(folder)
1455}
1456
1457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1459enum News {
1460 None,
1463 Pull,
1467 Alarm,
1473 SyncApp,
1476 Computer,
1478}
1479
1480fn news(fetch_first: bool, phone: bool, mirror: bool, pulls_well: bool, found: bool, alarm: bool) -> News {
1485 match (fetch_first && !mirror, phone) {
1486 (false, _) => News::None,
1487 (true, false) => News::Computer,
1488 (true, true) if alarm && found => News::Alarm,
1489 (true, true) if pulls_well && !alarm => News::Pull,
1490 (true, true) => News::SyncApp,
1491 }
1492}
1493
1494thread_local! {
1495 static NEWS_MISSED: std::cell::Cell<Option<(i64, std::collections::BTreeSet<String>)>> = const { std::cell::Cell::new(None) };
1499}
1500
1501pub(crate) fn news_missed() -> Option<(i64, std::collections::BTreeSet<String>)> {
1507 NEWS_MISSED.with(std::cell::Cell::take)
1508}
1509
1510fn ask_sync_app() -> std::time::Duration {
1515 use std::sync::atomic::Ordering;
1516 let now = jiff::Timestamp::now().as_second();
1517 let last = NUDGED.load(Ordering::Relaxed);
1518 let waited = if now - last < 20 {
1519 now - last
1520 } else {
1521 NUDGED.store(now, Ordering::Relaxed);
1522 ask_carriers_with(true);
1523 0
1524 };
1525 std::time::Duration::from_secs((20 - waited).max(0) as u64)
1526}
1527
1528fn ask_carriers_only() {
1530 #[cfg(target_os = "android")]
1531 for (package, receiver, action) in CARRIERS {
1532 let text = |s: &str| std::ffi::CString::new(s).unwrap_or_default();
1533 let (package, receiver, action) = (text(package), text(receiver), text(action));
1534 unsafe { crate::backend::sioul_android_broadcast(package.as_ptr(), receiver.as_ptr(), action.as_ptr()) };
1536 }
1537}
1538
1539static NUDGED: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1541static ALONE: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1543static SHOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
1544
1545pub(crate) fn set_shown(shown: bool) {
1547 SHOWN.store(shown, std::sync::atomic::Ordering::Relaxed);
1548}
1549
1550pub(crate) fn nudge(qt: &QtThread, shared: &Arc<Shared>, every: i64, then_read: bool) {
1555 use std::sync::atomic::Ordering;
1556 let now = jiff::Timestamp::now().as_second();
1557 let last = NUDGED.load(Ordering::Relaxed);
1558 if !cfg!(target_os = "android") || now - last < every.max(60) {
1559 return;
1560 }
1561 let here = share::Here::load(&state_dir());
1562 let Some(folder) = here.folder_path() else { return };
1563 if pulls_alone(&folder) {
1569 let alone = ALONE.load(Ordering::Relaxed);
1570 if now - alone < every.max(60) || ALONE.compare_exchange(alone, now, Ordering::Relaxed, Ordering::Relaxed).is_err() {
1571 return;
1572 }
1573 if then_read && every == 0 {
1574 exchange(qt, shared);
1575 }
1576 if then_read || sending_on(&here) {
1577 return;
1578 }
1579 }
1580 if NUDGED.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed).is_err() {
1582 return;
1583 }
1584 ask_carriers();
1585 if then_read {
1586 let (qt, shared) = (qt.clone(), Arc::clone(shared));
1587 std::thread::spawn(move || {
1588 std::thread::sleep(std::time::Duration::from_secs(20));
1589 exchange(&qt, &shared);
1590 });
1591 }
1592}
1593
1594pub(crate) fn nudge_tick(qt: &QtThread, shared: &Arc<Shared>) {
1596 if SHOWN.load(std::sync::atomic::Ordering::Relaxed) {
1597 nudge(qt, shared, 5 * 60, true);
1598 }
1599}
1600
1601pub(crate) fn exchange_here(fetch_first: bool) -> Option<Result<share::Outcome, String>> {
1609 let here = here();
1610 let Some((folder, key)) = sealed(here.folder_path()) else { return None };
1611 crate::calls::before_exchange();
1613 crate::phonemsgs::before_exchange();
1614 crate::replies::before_exchange();
1616 crate::texts::before_exchange(None);
1618 let mirror = mirrored().is_some();
1619 if mirror && fetch_first {
1621 mirror_step(&here, true, true);
1622 }
1623 let pull_alone = !mirror && pulls_alone(&folder);
1628 let alarm = fetch_first && !crate::steps::in_service();
1630 let found = {
1631 let state = sioul_sync::remote::State::load(&memory_path());
1632 state.fetching() && state.confirmed_for(&folder)
1633 };
1634 let waits = news(fetch_first, cfg!(target_os = "android"), mirror, pull_alone, found, alarm);
1635 NEWS_MISSED.with(|missed| missed.set(None));
1636 let fetching = (fetch_first && !mirror && waits != News::Alarm).then(|| {
1639 let here = here.clone();
1640 std::thread::spawn(move || fetch_from_server(&here, true))
1641 });
1642 if !fetch_first && crate::steps::in_service() {
1646 if mirror { mirror_step(&here, true, false) } else { fetch_from_server(&here, false) }
1647 }
1648 if waits == News::SyncApp {
1651 std::thread::sleep(ask_sync_app());
1652 }
1653 if waits == News::Alarm {
1656 let memory = memory_path();
1657 let since = jiff::Timestamp::now().as_second();
1658 match attached(&here) {
1659 Some(at) => {
1660 let state = sioul_sync::remote::State::load(&memory);
1661 let login = load_config().accounts.iter().find(|a| a.id == state.account).and_then(login_of);
1662 let came = sioul_sync::remote::alarm_news(&memory, &at, &here.id, key, login, &FETCHING, since, std::sync::Arc::new(|| jiff::Timestamp::now().as_second()), &mut ask_sync_app);
1663 match came {
1664 sioul_sync::remote::AlarmNews::Pulled => log_backup("alarm: the others' news pulled since it began".into()),
1665 sioul_sync::remote::AlarmNews::SyncApp { heard } => {
1666 log_backup(format!("alarm: no pull went through in time; the sync app asked and waited for, news of {} device(s) came: the others said in doubt where known closed", heard.len()));
1667 NEWS_MISSED.with(|missed| missed.set(Some((since, heard))));
1668 }
1669 }
1670 }
1671 None => std::thread::sleep(ask_sync_app()),
1672 }
1673 }
1674 if let Some(fetching) = fetching {
1679 let until = std::time::Instant::now() + std::time::Duration::from_secs(match waits {
1680 News::Pull => 25,
1681 News::SyncApp => 5,
1682 News::Computer | News::None | News::Alarm => 15,
1683 });
1684 while !fetching.is_finished() && std::time::Instant::now() < until {
1685 std::thread::sleep(std::time::Duration::from_millis(100));
1686 }
1687 }
1688 HURRY.store(true, std::sync::atomic::Ordering::Relaxed);
1691 let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1692 HURRY.store(false, std::sync::atomic::Ordering::Relaxed);
1693 let _ = sioul_core::securitykey::share_cards_at_exchange();
1695 let stores = stores_here(&here);
1700 let memory = memory_path();
1701 share::set_importing(&memory, crate::texts::importing_in_bulk());
1703 let sharing = share::Sharing { folder: &folder, computer: &here.id, key: &key, memory: &memory, files: false, hurry: None };
1704 let outcome = share::exchange(&sharing, &stores, jiff::Timestamp::now().as_millisecond());
1705 if let Ok(outcome) = &outcome {
1706 crate::devices::exported(outcome);
1707 if mirror {
1711 if fetch_first || crate::steps::in_service() {
1712 mirror_step(&here, false, false);
1713 } else {
1714 let here = here.clone();
1715 std::thread::spawn(move || mirror_step(&here, false, false));
1716 }
1717 }
1718 remember(&outcome.problems, jiff::Timestamp::now().as_second());
1719 if cfg!(target_os = "android") {
1722 let codes: Vec<&str> = outcome.problems.iter().map(|p| p.split(':').next().unwrap_or_default()).collect();
1723 match outcome.quiet {
1724 true => eprintln!("sioul: sharing: nothing new, nothing read"),
1725 false => eprintln!("sioul: sharing: {} sent, {} received, {} waiting{}{}{}", outcome.sent, outcome.received, outcome.pending, waits_said(&outcome.waits), if codes.is_empty() { "" } else { "; " }, codes.join("; ")),
1726 }
1727 }
1728 let ask = outcome.sent > 0 && !crate::devices::in_receiver();
1730 if sending_on(&here) {
1735 let here = here.clone();
1736 let go = move || {
1737 send_to_server(&here, false);
1738 if ask && !pull_alone {
1739 ask_carriers_only();
1740 }
1741 };
1742 if fetch_first || crate::steps::in_service() {
1743 go();
1744 } else {
1745 std::thread::spawn(go);
1746 }
1747 } else if ask {
1748 ask_carriers();
1749 }
1750 }
1751 Some(outcome)
1752}
1753
1754fn remember(problems: &[String], now: i64) {
1756 if let Ok(mut kept) = SAID.lock() {
1757 for problem in problems.iter().filter(|p| kept_a_day(p)) {
1758 kept.retain(|(_, p)| p != problem);
1759 kept.push((now, problem.clone()));
1760 }
1761 }
1762}
1763
1764pub(crate) fn exchange(qt: &QtThread, shared: &Arc<Shared>) {
1769 start_worker(qt, shared);
1770 want(|soon, now| soon.ask(now));
1771}
1772
1773pub(crate) fn exchange_tick(qt: &QtThread, shared: &Arc<Shared>) {
1776 start_worker(qt, shared);
1777 want(|soon, now| soon.tick(now));
1778}
1779
1780fn exchange_now(qt: &QtThread, shared: &Arc<Shared>) {
1785 let (qt, shared) = (qt.clone(), Arc::clone(shared));
1786 {
1787 let mirror = mirrored().is_some();
1790 if mirror {
1791 mirror_step(&here(), true, false);
1792 } else {
1793 fetch_from_server(&here(), false);
1794 }
1795 share::set_frugal(&memory_path(), frugal_now());
1798 share::set_importing(&memory_path(), crate::texts::importing_in_bulk());
1799 crate::crypto::seal_clear_drafts();
1806 {
1809 let here = here();
1810 if let Some((folder, key)) = sealed(here.folder_path()) {
1811 crate::everywhere::note_gone(&folder, &key, &here.id);
1812 }
1813 }
1814 let busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1816 let here = here();
1817 let Some((folder, key)) = sealed(here.folder_path()) else { return };
1818 crate::calls::before_exchange();
1820 crate::phonemsgs::before_exchange();
1821 crate::replies::before_exchange();
1823 crate::texts::before_exchange(Some(&qt));
1826 let _ = sioul_core::securitykey::share_cards_at_exchange();
1828 let stores = stores_here(&here);
1829 let now = jiff::Timestamp::now();
1830 let memory = memory_path();
1831 let readable = files_readable();
1833 let sharing = share::Sharing { folder: &folder, computer: &here.id, key: &key, memory: &memory, files: readable, hurry: Some(&HURRY) };
1834 let (mut received, mut pending, mut waits, mut no_blob) = (0, 0, String::new(), false);
1835 let (written, accounts, problems, sent) = match share::exchange(&sharing, &stores, now.as_millisecond()) {
1836 Ok(outcome) => {
1837 crate::devices::exported(&outcome);
1839 (received, pending, waits) = (outcome.received, outcome.pending, waits_said(&outcome.waits));
1840 no_blob = outcome.waits.contains_key("no-blob");
1841 if let Some(held) = &outcome.withheld
1843 && let Ok(mut kept) = WITHHELD.lock()
1844 {
1845 *kept = Some(held.clone());
1846 }
1847 if !outcome.written.is_empty() {
1848 if let Ok(mut cache) = shared.loaded.lock() {
1852 *cache = None;
1853 }
1854 crate::work::show_work(&qt, &shared);
1855 let names = outcome.written.iter().cloned().collect::<Vec<_>>().join("\n");
1856 let _ = qt.queue(move |mut sioul| sioul.as_mut().shared_in(QString::from(&names)));
1857 }
1858 if outcome.written.contains("data/health.toml") || outcome.written.contains("data/health-days.toml") {
1860 crate::wake::schedule();
1861 }
1862 if ["state/do-not-disturb.toml", "state/quiet.toml", "data/time/running.toml", "config/config.toml"].iter().any(|f| outcome.written.contains(*f)) {
1864 std::thread::spawn(crate::everywhere::apply);
1865 }
1866 (!outcome.written.is_empty(), outcome.written.contains("config/config.toml"), outcome.problems, outcome.sent)
1867 }
1868 Err(e) => (false, false, vec![e], 0),
1869 };
1870 if let Ok(mut watched) = WATCHED.lock() {
1874 *watched = Some(sioul_sync::trigger::Stamps::of(&stores));
1875 }
1876 drop(busy);
1877 if mirror {
1882 send_texts(&here);
1883 mirror_step(&here, false, false);
1884 let (folder, id) = (folder.clone(), here.id.clone());
1888 std::thread::spawn(move || {
1889 if mirror_sealed(&folder, &id, &key) > 0 {
1890 want(|soon, now| soon.ask(now));
1891 }
1892 });
1893 crate::join::agenda_later(&qt, &shared);
1896 } else {
1897 send_to_server(&here, false);
1898 if sent > 0 {
1899 nudge(&qt, &shared, 60, false);
1900 }
1901 }
1902 if cfg!(target_os = "android") {
1905 let codes: Vec<&str> = problems.iter().map(|p| p.split(':').next().unwrap_or_default()).collect();
1907 eprintln!("sioul: sharing: {sent} sent, {received} received, {pending} waiting{waits}{}{}", if codes.is_empty() { "" } else { "; " }, codes.join("; "));
1908 }
1909 for part in problems.iter().filter_map(|p| p.strip_prefix("share-newer:")) {
1913 crate::backend::set_status(&qt, say("share-newer-status", &[("part", part_words(part).0)]));
1914 }
1915 let mut said = problems;
1916 if !readable && stores.iter().any(|s| matches!(s.shape, share::Shape::Files)) {
1918 said.push("share-files-unreadable".into());
1919 }
1920 said.dedup();
1921 remember(&said, now.as_second());
1922 if let Ok(mut last) = LAST.lock() {
1923 *last = Some((now.as_second(), said));
1924 }
1925 if accounts {
1928 crate::backend::start_new_watchers(&qt, &shared);
1929 }
1930 if written {
1931 crate::backend::show(&qt, &shared);
1932 crate::pim::show_pim(&qt, &shared);
1933 crate::work::show_work(&qt, &shared);
1934 }
1935 if !mirror && no_blob {
1941 let folder = folder.clone();
1942 std::thread::spawn(move || {
1943 if fetch_wanted(&folder, &key) > 0 {
1944 want(|soon, now| soon.ask(now));
1945 }
1946 });
1947 }
1948 }
1949}
1950
1951fn waits_said(waits: &std::collections::BTreeMap<String, usize>) -> String {
1955 if waits.is_empty() {
1956 return String::new();
1957 }
1958 format!(" ({})", waits.iter().map(|(why, n)| format!("{why} {n}")).collect::<Vec<_>>().join(", "))
1959}
1960
1961static SOON: Mutex<sioul_sync::trigger::Soon> = Mutex::new(sioul_sync::trigger::Soon::new());
1963static SOON_WAKE: std::sync::Condvar = std::sync::Condvar::new();
1964static WORKER: std::sync::OnceLock<(QtThread, Arc<Shared>)> = std::sync::OnceLock::new();
1966static WATCHED: Mutex<Option<sioul_sync::trigger::Stamps>> = Mutex::new(None);
1968
1969fn now_ms() -> i64 {
1970 jiff::Timestamp::now().as_millisecond()
1971}
1972
1973fn want(change: impl FnOnce(&mut sioul_sync::trigger::Soon, i64)) {
1975 let mut soon = SOON.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1976 change(&mut soon, now_ms());
1977 SOON_WAKE.notify_all();
1978}
1979
1980fn start_worker(qt: &QtThread, shared: &Arc<Shared>) {
1982 if WORKER.set((qt.clone(), Arc::clone(shared))).is_ok() {
1983 std::thread::spawn(worker);
1984 std::thread::spawn(watcher);
1985 }
1986}
1987
1988fn worker() {
1994 let Some((qt, shared)) = WORKER.get() else { return };
1995 loop {
1996 {
1997 let mut soon = SOON.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1998 loop {
1999 let now = now_ms();
2000 match soon.due() {
2001 Some(due) if due <= now => {
2002 soon.take(now);
2003 break;
2004 }
2005 Some(due) => soon = SOON_WAKE.wait_timeout(soon, std::time::Duration::from_millis((due - now) as u64)).unwrap_or_else(std::sync::PoisonError::into_inner).0,
2006 None => soon = SOON_WAKE.wait(soon).unwrap_or_else(std::sync::PoisonError::into_inner),
2007 }
2008 }
2009 }
2010 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| exchange_now(qt, shared)));
2011 give_back_memory();
2012 }
2013}
2014
2015fn give_back_memory() {
2020 #[cfg(all(target_os = "linux", target_env = "gnu"))]
2021 {
2022 unsafe extern "C" {
2024 fn malloc_trim(pad: usize) -> std::ffi::c_int;
2025 }
2026 unsafe {
2028 malloc_trim(0);
2029 }
2030 }
2031}
2032
2033fn watcher() {
2040 loop {
2041 std::thread::sleep(std::time::Duration::from_secs(2));
2042 let shown = SHOWN.load(std::sync::atomic::Ordering::Relaxed);
2043 let here = share::Here::load(&state_dir());
2044 if !shown || here.folder_path().is_none() || key().is_none() {
2045 if let Ok(mut watched) = WATCHED.lock() {
2046 *watched = None;
2047 }
2048 continue;
2049 }
2050 let _quiet = match BUSY.try_lock() {
2051 Ok(held) => held,
2052 Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
2053 Err(std::sync::TryLockError::WouldBlock) => continue,
2054 };
2055 let now = sioul_sync::trigger::Stamps::of(&stores_here(&here));
2056 let Ok(mut watched) = WATCHED.lock() else { continue };
2057 if watched.as_ref().is_some_and(|before| !now.changed(before).is_empty()) {
2058 want(|soon, at| soon.saved(at));
2059 }
2060 *watched = Some(now);
2061 }
2062}
2063
2064fn sending_on(here: &share::Here) -> bool {
2069 let Some(folder) = here.folder_path() else { return false };
2070 let state = sioul_sync::remote::State::load(&memory_path());
2071 state.mode != sioul_sync::remote::MIRROR && state.confirmed_for(&folder) && state.sending()
2072}
2073
2074fn send_to_server(here: &share::Here, again: bool) -> Option<sioul_sync::remote::Pulled> {
2080 let folder = here.folder_path()?;
2081 if mirrored().is_some() || here.id.is_empty() {
2082 return None;
2083 }
2084 let memory = memory_path();
2085 let now = jiff::Timestamp::now().as_second();
2086 let state = sioul_sync::remote::State::load(&memory);
2087 if !state.confirmed_for(&folder) {
2088 if again {
2089 sioul_sync::remote::Sending::note_again(&memory, now, "not-confirmed");
2090 }
2091 return None;
2092 }
2093 let config = load_config();
2094 let Some(login) = config.accounts.iter().find(|a| a.id == state.account).and_then(login_of) else {
2095 if again {
2096 sioul_sync::remote::Sending::note_again(&memory, now, &format!("no-account-for:{}", state.host()));
2097 }
2098 return None;
2099 };
2100 let sealed = share::own_sealed(&memory, &here.id);
2101 send_texts(here);
2103 let sent = sioul_sync::remote::send(&memory, &folder, &here.id, &login, &sealed, now, again, metered());
2104 if sent.sent > 0 || sent.held > 0 || sent.taken_out > 0 || again || sent.problem.as_deref().is_some_and(|p| p != "off" && !p.starts_with("waiting:")) {
2105 let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
2106 let detail = sent.failure.as_ref().map(|f| format!("; {}", failure_words(f))).unwrap_or_default();
2107 log_send(match &sent.problem {
2108 None => format!("{at} sent to {}: {} sent, {} there already, {} records longer there left as they are, {} paced, {} taken out, {} bytes{}", state.host(), sent.sent, sent.same, sent.longer, sent.paced, sent.taken_out, sent.bytes, if sent.differ.is_empty() { String::new() } else { format!("; otherwise there: {}", sent.differ.join("; ")) }),
2109 Some(why) => format!("{at} send to {} stopped: {}{detail}", state.host(), backup_words(why)),
2110 });
2111 }
2112 Some(sent)
2113}
2114
2115fn failure_words(f: &sioul_sync::remote::Failure) -> String {
2118 let file = if f.file.is_empty() { String::new() } else { format!("{}: ", f.file) };
2119 let bytes = if f.total > 0 { format!(", {} of {} bytes handed to the network", f.sent, f.total) } else { String::new() };
2120 format!("{file}{} after {} s{bytes} ({})", f.kind, f.seconds, f.words)
2121}
2122
2123fn log_send(line: String) {
2126 static SAID: Mutex<String> = Mutex::new(String::new());
2127 if let Ok(mut said) = SAID.lock() {
2128 if *said == line {
2129 return;
2130 }
2131 said.clone_from(&line);
2132 }
2133 eprintln!("sioul: backup: {line}");
2134}
2135
2136fn metered() -> bool {
2147 if cfg!(target_os = "android") {
2148 return false;
2149 }
2150 static WAITED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2151 crate::devices::watch_system();
2152 let limit = if WAITED.swap(true, std::sync::atomic::Ordering::SeqCst) { std::time::Duration::ZERO } else { FIRST_READING };
2153 if !sioul_sync::power::read_once_within(limit) {
2154 return true;
2155 }
2156 sioul_sync::power::kept_power().is_some_and(|power| power.metered == Some(true))
2157}
2158
2159const FIRST_READING: std::time::Duration = std::time::Duration::from_secs(2);
2162
2163fn frugal_now() -> bool {
2166 metered() || sioul_sync::remote::Sending::load(&memory_path()).slow()
2167}
2168
2169fn send_texts(here: &share::Here) {
2176 let Some(folder) = here.folder_path() else { return };
2177 let config = load_config();
2178 let (texts, replies) = (here.shares(sioul_core::texts::PART, &config), here.shares(sioul_core::phonemsgs::PART, &config));
2179 if here.id.is_empty() || !(texts || replies) {
2180 return;
2181 }
2182 if texts {
2183 let source = sioul_core::texts::own_file(&sioul_core::texts::folder(), sioul_core::texts::SEND, &here.id);
2184 if let Err(e) = sioul_sync::remote::place_texts(&folder, &here.id, &source) {
2185 eprintln!("sioul: texts: {e}");
2186 }
2187 }
2188 if replies {
2189 let source = sioul_core::calls::own_file(&sioul_core::phonemsgs::folder(), sioul_core::replies::ASK, &here.id);
2190 if let Err(e) = sioul_sync::remote::place_asks(&folder, &here.id, &source) {
2191 eprintln!("sioul: replies: {e}");
2192 }
2193 }
2194 let memory = memory_path();
2195 let state = sioul_sync::remote::State::load(&memory);
2196 if !state.confirmed_for(&folder) || !(state.mode == sioul_sync::remote::MIRROR || state.sending()) {
2197 return;
2198 }
2199 let Some(login) = server_login(&state, &load_config()) else { return };
2200 let media = if texts { crate::texts::media_to_send() } else { std::collections::BTreeMap::new() };
2202 let sent = sioul_sync::remote::send_urgent(&memory, &folder, &here.id, &login, jiff::Timestamp::now().as_second(), &media);
2203 if sent.sent > 0 || sent.failure.is_some() {
2204 let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
2205 log_send(match &sent.failure {
2206 None => format!("{at} texts or replies to send sent to {}", state.host()),
2207 Some(f) => format!("{at} texts or replies to send not sent to {}: {}", state.host(), failure_words(f)),
2208 });
2209 }
2210}
2211
2212pub(crate) fn texts_now() {
2215 std::thread::spawn(|| send_texts(&here()));
2216}
2217
2218pub(crate) fn replies_now() {
2221 texts_now();
2222}
2223
2224pub(crate) fn replies_copies() -> Vec<PathBuf> {
2229 let here = share::Here::load(&state_dir());
2230 let Some(folder) = attached(&here) else { return Vec::new() };
2231 let mut out = vec![folder.join(sioul_core::phonemsgs::FOLDER)];
2232 out.extend(sioul_sync::remote::overlay(&folder).map(|cache| cache.join(sioul_core::phonemsgs::FOLDER)));
2233 out
2234}
2235
2236pub(crate) fn texts_copies() -> Vec<PathBuf> {
2241 let here = share::Here::load(&state_dir());
2242 let Some(folder) = attached(&here) else { return Vec::new() };
2243 let mut out = vec![folder.join(sioul_core::texts::FOLDER)];
2244 out.extend(sioul_sync::remote::overlay(&folder).map(|cache| cache.join(sioul_core::texts::FOLDER)));
2245 out
2246}
2247
2248pub(crate) fn send_again(qt: &QtThread, shared: &Arc<Shared>) {
2254 let (qt, shared) = (qt.clone(), Arc::clone(shared));
2255 std::thread::spawn(move || {
2256 let here = here();
2257 let memory = memory_path();
2258 let now = jiff::Timestamp::now().as_second();
2259 match (mirrored(), here.folder_path()) {
2260 (Some(state), Some(folder)) => match server_login(&state, &load_config()) {
2261 Some(login) => {
2262 sioul_sync::remote::again_mirror(&memory, &folder, &here.id, &login, now);
2263 }
2264 None => sioul_sync::remote::Sending::note_again(&memory, now, &format!("no-account-for:{}", state.host())),
2265 },
2266 (None, Some(_)) => {
2267 send_to_server(&here, true);
2268 fetch_from_server(&here, true);
2269 }
2270 _ => {}
2271 }
2272 exchange(&qt, &shared);
2273 });
2274}
2275
2276pub(crate) fn set_send(on: bool) -> String {
2279 sioul_sync::remote::State::choose(&memory_path(), |state| state.send = (!on).then_some(false)).err().unwrap_or_default()
2280}
2281
2282pub(crate) fn keeps_itself() -> bool {
2287 mirrored().is_some()
2288}
2289
2290fn mirrored() -> Option<sioul_sync::remote::State> {
2291 let state = sioul_sync::remote::State::load(&memory_path());
2292 (state.mode == sioul_sync::remote::MIRROR).then_some(state)
2293}
2294
2295fn server_login(state: &sioul_sync::remote::State, config: &Config) -> Option<sioul_sync::remote::Login> {
2301 if !state.user.is_empty() {
2302 let password = sioul_sync::loginflow::kept(&state.user, &state.host());
2303 return Some(sioul_sync::remote::Login { account: state.account.clone(), url: state.url.clone(), user: state.user.clone(), password });
2304 }
2305 config.accounts.iter().find(|a| a.id == state.account).and_then(login_of)
2306}
2307
2308fn mirror_step(here: &share::Here, whole: bool, urgent: bool) {
2313 if let Some(folder) = here.folder_path() {
2314 mirror_step_for(&folder, &here.id, whole, urgent);
2315 }
2316}
2317
2318fn mirror_step_for(folder: &Path, own: &str, whole: bool, urgent: bool) {
2319 let Some(state) = mirrored().filter(|s| s.confirmed_for(folder)) else { return };
2320 let now = jiff::Timestamp::now().as_second();
2321 let whole = whole && (urgent || now - state.tried >= 20);
2322 let Some(login) = server_login(&state, &load_config()) else { return };
2323 let stepped = sioul_sync::remote::step(&memory_path(), folder, own, &login, now, whole);
2324 if cfg!(target_os = "android") && (stepped.fetched + stepped.sent > 0 || stepped.listed_bytes >= 64 << 10 || stepped.problem.is_some()) {
2330 let code = stepped.problem.as_deref().map(|p| format!("; {}", p.split(':').next().unwrap_or_default())).unwrap_or_default();
2331 eprintln!("sioul: sharing: {} fetched ({} bytes), {} sent by Sioul itself; listed {} ({} bytes read){code}", stepped.fetched, stepped.bytes, stepped.sent, stepped.listed, stepped.listed_bytes);
2332 }
2333}
2334
2335fn mirror_sealed(folder: &Path, own: &str, key: &[u8; 32]) -> usize {
2341 static SEALING: Mutex<()> = Mutex::new(());
2342 let _one = match SEALING.try_lock() {
2343 Ok(held) => held,
2344 Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
2345 Err(std::sync::TryLockError::WouldBlock) => return 0,
2346 };
2347 let Some(state) = mirrored().filter(|s| s.confirmed_for(folder)) else { return 0 };
2348 let (memory, now) = (memory_path(), jiff::Timestamp::now().as_second());
2349 if !sioul_sync::remote::sealed_due(&memory, folder, own, now) {
2350 return 0;
2351 }
2352 let Some(login) = server_login(&state, &load_config()) else { return 0 };
2353 let pulled = sioul_sync::remote::sealed_step(&memory, folder, own, &login, key, now, frugal_now());
2355 if cfg!(target_os = "android") && (pulled.fetched + pulled.held + pulled.paced + pulled.let_go > 0 || pulled.listed_bytes > 0 || pulled.problem.is_some()) {
2356 let code = pulled.problem.as_deref().map(|p| format!("; {}", p.split(':').next().unwrap_or_default())).unwrap_or_default();
2357 let failed = if pulled.listing_failed { "; blobs listing failed" } else { "" };
2358 eprintln!("sioul: sharing: sealed files: {} fetched ({} bytes), {} did not open whole, {} left for later on this connection; listed {} ({} bytes read); let go of {} ({} bytes){failed}{code}", pulled.fetched, pulled.bytes, pulled.held, pulled.paced, pulled.listed, pulled.listed_bytes, pulled.let_go, pulled.freed);
2359 }
2360 pulled.fetched
2361}
2362
2363#[derive(Serialize)]
2365struct ServerAccount {
2366 id: String,
2367 label: String,
2368}
2369
2370fn server_accounts(config: &Config) -> Vec<ServerAccount> {
2373 config
2374 .accounts
2375 .iter()
2376 .filter(|a| a.is_dav() && a.auth.as_deref() != Some("google") && a.url.as_deref().is_some_and(|u| u.contains("/remote.php/")))
2377 .map(|a| {
2378 let host = sioul_sync::remote::host_of(a.url.as_deref().unwrap_or_default());
2379 let who = a.address.clone().unwrap_or_else(|| a.id.clone());
2380 ServerAccount { id: a.id.clone(), label: if who.ends_with(&host) { who } else { format!("{who} ({host})") } }
2381 })
2382 .collect()
2383}
2384
2385fn mirror_line(state: &sioul_sync::remote::State) -> String {
2387 let host = state.host();
2388 let (code, _) = state.said.split_once(':').unwrap_or((state.said.as_str(), ""));
2389 if state.confirmed == 0 {
2390 return say("share-server-unconfirmed", &[("host", host)]);
2391 }
2392 let why = || {
2393 tr().text(
2394 match code {
2395 "login" => "share-backup-why-login",
2396 "tls" => "share-backup-why-tls",
2397 "network" => "share-backup-why-network",
2398 "disk" => "share-backup-why-disk",
2399 "quota" => "share-backup-why-quota",
2400 _ => "share-backup-why-server",
2401 },
2402 None,
2403 )
2404 };
2405 let last = state.last.max(state.pushed);
2406 match (code, last) {
2407 ("", 0) => String::new(),
2408 ("", last) => say("share-server-last", &[("when", when(last))]),
2409 (_, 0) => say("share-server-failing-never", &[("why", why())]),
2410 (_, last) => say("share-server-failing", &[("when", when(last)), ("why", why())]),
2411 }
2412}
2413
2414pub(crate) fn server_problem(code: &str) -> String {
2416 let (kind, about) = code.split_once(':').unwrap_or((code, ""));
2417 let about = || about.to_string();
2418 match kind {
2419 "no-place" => tr().text("share-server-no-place", None),
2420 "no-files" => say("share-server-no-files", &[("host", about())]),
2421 "no-password" => tr().text("share-server-no-password", None),
2422 "login" => say("share-server-refused", &[("host", about())]),
2423 "tls" => say("share-server-tls", &[("host", about())]),
2424 "network" => say("share-server-unreachable", &[("host", about())]),
2425 "sealed-meanwhile" => tr().text("share-server-sealed-meanwhile", None),
2426 _ => say("share-server-error", &[("host", about())]),
2427 }
2428}
2429
2430pub(crate) fn start_on_server(qt: &QtThread, shared: &Arc<Shared>, account: String, place: String, passphrase: String, again: String) {
2436 let (qt, shared) = (qt.clone(), Arc::clone(shared));
2437 std::thread::spawn(move || {
2438 let started = (|| -> Result<(), String> {
2439 if passphrase.chars().count() < 12 {
2440 return Err(tr().text("share-short", None));
2441 }
2442 let config = load_config();
2443 let login = config.accounts.iter().find(|a| a.id == account).and_then(login_of).ok_or_else(|| tr().text("share-server-none", None))?;
2444 if login.password.is_none() {
2445 return Err(tr().text("share-server-no-password", None));
2446 }
2447 begin_on_server(&login, &place, &passphrase, &again, false, true).map(drop)
2448 })();
2449 let problem = started.err().unwrap_or_default();
2450 if problem.is_empty() {
2452 exchange(&qt, &shared);
2453 }
2454 let _ = qt.queue(move |mut sioul| sioul.as_mut().share_started(QString::from(&problem)));
2455 });
2456}
2457
2458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2461pub(crate) enum Begun {
2462 Joined,
2463 Started,
2464}
2465
2466pub(crate) fn begin_on_server(login: &sioul_sync::remote::Login, place: &str, passphrase: &str, again: &str, signed_in: bool, may_start: bool) -> Result<Begun, String> {
2475 if passphrase.chars().count() < 12 {
2476 return Err(tr().text("share-short", None));
2477 }
2478 let opened = sioul_sync::remote::open(login, place).map_err(|code| server_problem(&code))?;
2479 if opened.seal.is_none() && !may_start {
2480 return Err(say("join-not-there", &[("place", opened.place.clone()), ("host", sioul_sync::remote::host_of(&opened.url))]));
2481 }
2482 let memory = memory_path();
2483 let mirror = sioul_sync::remote::mirror_of(&memory);
2484 let _ = std::fs::remove_dir_all(&mirror);
2486 share::private_dirs(&mirror).map_err(|e| format!("{}: {e}", mirror.display()))?;
2487 let key = match &opened.seal {
2488 Some(seal) => {
2490 share::write_atomically(&mirror.join("seal.toml"), seal)?;
2491 share::key_for(&mirror, passphrase)
2492 }
2493 None if passphrase != again => return Err(tr().text("share-differ", None)),
2494 None => share::key_for(&mirror, passphrase),
2495 };
2496 let key = match key {
2497 Ok(key) => key,
2498 Err(share::Refused::WrongPassphrase) => {
2499 let _ = std::fs::remove_dir_all(&mirror);
2500 return Err(tr().text("share-wrong", None));
2501 }
2502 Err(share::Refused::Other(e)) => return Err(e),
2503 };
2504 if opened.seal.is_none() {
2505 sioul_sync::remote::create(login, &opened, &mirror).map_err(|code| {
2506 let _ = std::fs::remove_dir_all(&mirror);
2507 server_problem(&code)
2508 })?;
2509 }
2510 sioul_sync::secret::save_named(share::KEY_NAME, &hex(&key)).map_err(|e| e.sentence(tr(), "Sioul"))?;
2511 if let Ok(mut held) = KEY.lock() {
2512 *held = Some(key);
2513 }
2514 let now = jiff::Timestamp::now().as_second();
2515 if signed_in {
2516 sioul_sync::remote::begin_signed_in(&memory, &mirror, login, &opened, now)?;
2517 } else {
2518 sioul_sync::remote::begin(&memory, &mirror, login, &opened, now)?;
2519 }
2520 let state = state_dir();
2521 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2522 let mut here = share::Here::load(&state);
2523 here.folder = Some(mirror.display().to_string());
2524 here.save(&state)?;
2525 Ok(if opened.seal.is_some() { Begun::Joined } else { Begun::Started })
2526}
2527
2528pub(crate) fn exchange_at_once(qt: &QtThread, shared: &Arc<Shared>) {
2533 exchange_now(qt, shared);
2534}
2535
2536pub(crate) fn heard_of(device: &str) -> i64 {
2538 let here = here();
2539 let Some((folder, key)) = sealed(attached(&here)) else { return 0 };
2540 share::heard_of(&folder, &key, &memory_path(), &here.id, device)
2541}
2542
2543static FETCHING: Mutex<()> = Mutex::new(());
2547
2548fn attached(here: &share::Here) -> Option<PathBuf> {
2551 let folder = here.folder_path()?;
2552 sioul_sync::remote::attach(&folder, &memory_path());
2553 Some(folder)
2554}
2555
2556fn logins(config: &Config) -> Vec<sioul_sync::remote::Login> {
2560 config.accounts.iter().filter(|a| a.auth.as_deref() != Some("google")).filter_map(login_of).collect()
2561}
2562
2563fn login_of(account: &sioul_core::config::Account) -> Option<sioul_sync::remote::Login> {
2564 let url = account.url.clone().filter(|u| account.is_dav() && u.contains("/remote.php/"))?;
2565 Some(sioul_sync::remote::Login { account: account.id.clone(), url, user: account.login()?.to_string(), password: sioul_sync::secret::password(account).ok() })
2566}
2567
2568fn nextcloud_configs() -> Vec<String> {
2570 if cfg!(target_os = "android") {
2571 return Vec::new();
2572 }
2573 let home = expand_home("~");
2574 let windows = std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("Nextcloud/nextcloud.cfg"));
2575 [Some(home.join(".config/Nextcloud/nextcloud.cfg")), Some(home.join("Library/Preferences/Nextcloud/nextcloud.cfg")), windows].into_iter().flatten().filter_map(|p| std::fs::read_to_string(p).ok()).collect()
2576}
2577
2578fn log_backup(line: String) {
2582 static SAID: Mutex<String> = Mutex::new(String::new());
2583 if !cfg!(target_os = "android") {
2584 return;
2585 }
2586 if let Ok(mut said) = SAID.lock() {
2587 if *said == line {
2588 return;
2589 }
2590 said.clone_from(&line);
2591 }
2592 eprintln!("sioul: backup: {line}");
2593}
2594
2595fn backup_words(said: &str) -> String {
2597 let (code, about) = said.split_once(':').unwrap_or((said, ""));
2598 let why = match code {
2599 "network" => "could not be reached",
2600 "login" => "refused the password kept for the account",
2601 "tls" => "its certificate could not be checked",
2602 "server" => "answered with an error",
2603 "quota" => "its space is full",
2604 "disk" => "could not be written here",
2605 "no-password" => "no password kept on this device for the account",
2606 "not-found" => "the folder is not there",
2607 "seal-differs" => "a folder sealed otherwise, never read",
2608 "seal-gone" => "the folder holds no seal any more",
2609 "no-account" => "no account with a Nextcloud server",
2610 "no-account-for" => "no account on that server",
2611 "no-seal" => "no seal in the folder here",
2612 "off" => "switched off here",
2613 other => other,
2614 };
2615 if about.is_empty() { why.to_string() } else { format!("{about}: {why}") }
2616}
2617
2618fn fetch_from_server(here: &share::Here, urgent: bool) {
2626 let Some(folder) = attached(here) else { return };
2627 if mirrored().is_some() {
2628 return;
2629 }
2630 let _fetching = match FETCHING.try_lock() {
2632 Ok(held) => held,
2633 Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
2634 Err(std::sync::TryLockError::WouldBlock) => return,
2635 };
2636 let memory = memory_path();
2637 let now = jiff::Timestamp::now().as_second();
2638 let mut state = sioul_sync::remote::State::load(&memory);
2639 if !state.fetching() || here.id.is_empty() {
2640 sioul_sync::remote::tidy(&memory, &folder, &here.id);
2641 log_backup("switched off here".into());
2642 return;
2643 }
2644 let config = load_config();
2645 let login = |account: &str| config.accounts.iter().find(|a| a.id == account).and_then(login_of);
2646 if state.look_due(&folder, now) || (state.confirmed_for(&folder) && login(&state.account).is_none_or(|l| l.password.is_none())) {
2648 let places = sioul_sync::remote::places(&folder, &nextcloud_configs());
2649 let logins = logins(&config);
2650 state = sioul_sync::remote::find(&memory, &folder, &logins, &places, now);
2651 log_backup(if state.confirmed_for(&folder) { format!("the folder found on {}", state.host()) } else { format!("the folder not found on {} account(s): {}", logins.len(), backup_words(&state.said)) });
2652 }
2653 if !state.confirmed_for(&folder) {
2654 return;
2655 }
2656 let phone = cfg!(target_os = "android");
2657 let asleep = phone && crate::everywhere::rest_now() == (true, false);
2658 let pace = sioul_sync::remote::Pace {
2659 phone,
2660 shown: !crate::steps::in_service() && SHOWN.load(std::sync::atomic::Ordering::Relaxed),
2662 in_use: phone && crate::devices::others_in_use(asleep),
2663 urgent,
2664 step: if asleep { crate::steps::STEP_ASLEEP } else { crate::steps::STEP_AWAKE },
2665 };
2666 if !sioul_sync::remote::due(&state, now, pace) {
2667 let pace_said = match (pace.shown, pace.in_use, asleep) {
2668 (true, _, _) => "Sioul on the screen: once a minute",
2669 (_, true, _) => "another device in use: once a minute",
2670 (_, false, true) => "in the background, nobody at another device, at night: every fifteen minutes",
2671 _ => "in the background, nobody at another device: every five minutes",
2672 };
2673 log_backup(format!("next pull at its pace ({pace_said})"));
2674 return;
2675 }
2676 let Some(login) = login(&state.account) else { return };
2677 let pulled = sioul_sync::remote::pull(&memory, &folder, &here.id, &login, now);
2678 let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
2679 log_backup(match &pulled.problem {
2680 None => format!("{at} pulled from {}: {} listed, {} fetched, {} bytes{}", state.host(), pulled.listed, pulled.fetched, pulled.bytes, if pulled.left > 0 { format!("; {} records too large left to the sync app", pulled.left) } else { String::new() }),
2681 Some(why) => format!("{at} pull from {} stopped: {}", state.host(), backup_words(why)),
2682 });
2683}
2684
2685pub(crate) fn fetch_wanted_here() {
2690 let here = here();
2691 let Some(folder) = attached(&here) else { return };
2692 if mirrored().is_some() {
2694 if sioul_sync::remote::sealed_due(&memory_path(), &folder, &here.id, jiff::Timestamp::now().as_second())
2695 && let Some((folder, key)) = sealed(Some(folder))
2696 {
2697 mirror_sealed(&folder, &here.id, &key);
2698 }
2699 return;
2700 }
2701 if !sioul_sync::remote::wanted_due(&memory_path(), &folder, jiff::Timestamp::now().as_second()) {
2702 return;
2703 }
2704 if let Some((folder, key)) = sealed(Some(folder)) {
2705 fetch_wanted(&folder, &key);
2706 }
2707}
2708
2709fn fetch_wanted(folder: &Path, key: &[u8; 32]) -> usize {
2717 if mirrored().is_some() {
2718 return 0;
2719 }
2720 static WANTING: Mutex<()> = Mutex::new(());
2722 let _one = match WANTING.try_lock() {
2723 Ok(held) => held,
2724 Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
2725 Err(std::sync::TryLockError::WouldBlock) => return 0,
2726 };
2727 let memory = memory_path();
2728 let now = jiff::Timestamp::now().as_second();
2729 let state = sioul_sync::remote::State::load(&memory);
2730 if !state.fetching() || !state.confirmed_for(folder) || !sioul_sync::remote::wanted_due(&memory, folder, now) {
2731 return 0;
2732 }
2733 let Some(login) = load_config().accounts.iter().find(|a| a.id == state.account).and_then(login_of) else { return 0 };
2734 let pulled = sioul_sync::remote::fetch_wanted(&memory, folder, &login, key, now, frugal_now());
2735 let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
2736 log_backup(match &pulled.problem {
2737 None => format!(
2738 "{at} sealed files waited for: {} fetched from {}, {} bytes{}{}",
2739 pulled.fetched,
2740 state.host(),
2741 pulled.bytes,
2742 if pulled.paced > 0 { format!("; {} large left for later on this connection", pulled.paced) } else { String::new() },
2743 if pulled.held > 0 { format!("; {} did not come whole, asked again later", pulled.held) } else { String::new() }
2744 ),
2745 Some(why) => format!("{at} sealed files waited for: fetching from {} stopped: {}", state.host(), backup_words(why)),
2746 });
2747 pulled.fetched
2748}
2749
2750#[derive(Serialize)]
2752struct Backup {
2753 shown: bool,
2755 line: String,
2757 on: bool,
2759 given: String,
2761 found: bool,
2763 send: bool,
2765 send_switch: String,
2767 send_line: String,
2769}
2770
2771fn backup(on: bool, folder: &Path) -> Backup {
2772 let state = sioul_sync::remote::State::load(&memory_path());
2773 let line = if on { backup_line(&state, folder) } else { String::new() };
2774 let host = state.host();
2775 let send_switch = if host.is_empty() { tr().text("share-send-switch-none", None) } else { say("share-send-switch", &[("host", host)]) };
2776 let send_line = if on && state.fetching() { send_line(&state, &sioul_sync::remote::Sending::load(&memory_path()), folder) } else { String::new() };
2777 Backup { shown: on, line, on: state.fetching(), given: state.given.clone(), found: state.confirmed_for(folder), send: state.sending(), send_switch, send_line }
2778}
2779
2780fn why_words(code: &str) -> String {
2782 tr().text(
2783 match code {
2784 "login" => "share-backup-why-login",
2785 "tls" => "share-backup-why-tls",
2786 "network" => "share-backup-why-network",
2787 "disk" => "share-backup-why-disk",
2788 "quota" => "share-backup-why-quota",
2789 _ => "share-backup-why-server",
2790 },
2791 None,
2792 )
2793}
2794
2795fn send_line(state: &sioul_sync::remote::State, sending: &sioul_sync::remote::Sending, folder: &Path) -> String {
2797 let host = state.host();
2798 if !state.sending() {
2799 return if host.is_empty() { String::new() } else { say("share-send-off", &[("host", host)]) };
2800 }
2801 if !state.confirmed_for(folder) {
2802 return tr().text("share-send-waiting", None);
2803 }
2804 let (code, _) = sending.said.split_once(':').unwrap_or((sending.said.as_str(), ""));
2805 let line = match (code, sending.last) {
2806 ("", 0) => say("share-send-soon", &[("host", host)]),
2807 ("", last) => say("share-send-on", &[("host", host), ("when", when(last))]),
2808 (code, 0) => say("share-send-failing-never", &[("host", host), ("why", why_words(code))]),
2809 (code, last) => say("share-send-failing", &[("host", host), ("when", when(last)), ("why", why_words(code))]),
2810 };
2811 match sending.failure.as_ref().filter(|_| !sending.said.is_empty()) {
2813 Some(failure) => [line, failure_line(failure, sending.next_try())].join(" "),
2814 None => line,
2815 }
2816}
2817
2818fn failure_line(failure: &sioul_sync::remote::Failure, next: i64) -> String {
2821 let size = |bytes: u64| sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX));
2822 let detail = match failure.kind.as_str() {
2823 "timeout" => say("share-send-detail-timeout", &[("total", size(failure.total)), ("seconds", failure.seconds.to_string())]),
2824 "stalled" => say("share-send-detail-stalled", &[("total", size(failure.total))]),
2825 "network" if failure.sent > 0 => say("share-send-detail-cut", &[("total", size(failure.total))]),
2826 "out-of-time" => tr().text("share-send-detail-out-of-time", None),
2827 "unreachable" | "network" => tr().text("share-send-detail-unreachable", None),
2828 kind => format!("{}.", why_words(kind)),
2829 };
2830 let file = match failure.file.as_str() {
2831 "records" => "share-send-file-records",
2832 "entry" => "share-send-file-entry",
2833 "notes" => "share-send-file-notes",
2834 "claim" => "share-send-file-claim",
2835 "texts" => "share-send-file-texts",
2836 "sealed" => "share-send-file-sealed",
2837 _ => "",
2838 };
2839 let next = if next > 0 { say("share-send-next", &[("when", when(next))]) } else { String::new() };
2840 let said = if file.is_empty() { say("share-send-failure", &[("when", when(failure.at)), ("detail", detail)]) } else { say("share-send-failure-file", &[("file", tr().text(file, None)), ("when", when(failure.at)), ("detail", detail)]) };
2841 [said, next].join(" ").trim().to_string()
2842}
2843
2844fn again_line(again: &sioul_sync::remote::Again, host: &str) -> String {
2846 if again.at == 0 {
2847 return String::new();
2848 }
2849 let (code, about) = again.said.split_once(':').unwrap_or((again.said.as_str(), ""));
2850 let host = if about.is_empty() { host.to_string() } else { about.to_string() };
2851 let at = when(again.at);
2852 let counted = |id: &str, count: usize| {
2853 let mut args = sioul_core::i18n::args();
2854 args.set("count", count as i64);
2855 args.set("host", host.clone());
2856 args.set("when", at.clone());
2857 tr().text(id, Some(&args))
2858 };
2859 match code {
2860 "" if again.sent > 0 => counted("share-send-again-sent", again.sent),
2861 "" => counted("share-send-again-same", again.same),
2862 "off" => tr().text("share-send-again-off", None),
2863 "not-confirmed" | "mirror" | "no-seal" | "seal-gone" | "seal-differs" => tr().text("share-send-again-not-found", None),
2864 "no-account-for" => say("share-send-again-no-account", &[("host", host)]),
2865 code => say("share-send-again-failed", &[("host", host), ("when", at), ("why", why_words(code))]),
2866 }
2867}
2868
2869fn backup_line(state: &sioul_sync::remote::State, folder: &Path) -> String {
2871 let host = state.host();
2872 let (code, about) = state.said.split_once(':').unwrap_or((state.said.as_str(), ""));
2873 let about = || about.to_string();
2874 if !state.fetching() {
2875 return if host.is_empty() { tr().text("share-backup-off-none", None) } else { say("share-backup-off", &[("host", host)]) };
2876 }
2877 if state.confirmed_for(folder) {
2878 let why = |code: &str| {
2879 tr().text(
2880 match code {
2881 "login" => "share-backup-why-login",
2882 "tls" => "share-backup-why-tls",
2883 "network" => "share-backup-why-network",
2884 "disk" => "share-backup-why-disk",
2885 _ => "share-backup-why-server",
2886 },
2887 None,
2888 )
2889 };
2890 return match (code, state.last) {
2891 ("", 0) => say("share-backup-soon", &[("host", host)]),
2892 ("", last) if !state.left.is_empty() => format!("{} {}", say("share-backup-on", &[("host", host), ("when", when(last))]), tr().text("share-backup-left", None)),
2894 ("", last) => say("share-backup-on", &[("host", host), ("when", when(last))]),
2895 (code, 0) => say("share-backup-failing-never", &[("host", host), ("why", why(code))]),
2896 (code, last) => say("share-backup-failing", &[("host", host), ("when", when(last)), ("why", why(code))]),
2897 };
2898 }
2899 if state.looked == 0 || state.folder != folder.display().to_string() || state.asked > state.looked {
2900 return tr().text("share-backup-looking", None);
2901 }
2902 match code {
2903 "no-account" => tr().text("share-backup-no-account", None),
2904 "no-account-for" => say("share-backup-no-account-for", &[("host", about())]),
2905 "not-found" => say("share-backup-not-found", &[("hosts", about())]),
2906 "seal-differs" => say("share-backup-seal-differs", &[("host", about())]),
2907 "seal-gone" => say("share-backup-seal-gone", &[("host", about())]),
2908 "login" => say("share-backup-login", &[("host", about())]),
2909 "no-password" => say("share-backup-no-password", &[("account", about())]),
2910 "no-seal" => String::new(),
2911 _ => say("share-backup-unreachable", &[("host", about())]),
2912 }
2913}
2914
2915pub(crate) fn set_backup(on: bool) -> String {
2918 let now = jiff::Timestamp::now().as_second();
2919 let chosen = sioul_sync::remote::State::choose(&memory_path(), |state| {
2920 state.on = (!on).then_some(false);
2921 if on {
2923 state.asked = now;
2924 }
2925 });
2926 chosen.err().unwrap_or_default()
2927}
2928
2929pub(crate) fn set_backup_place(place: &str) -> String {
2933 let now = jiff::Timestamp::now().as_second();
2934 let chosen = sioul_sync::remote::State::choose(&memory_path(), |state| {
2935 state.given = place.trim().to_string();
2936 state.asked = now;
2937 });
2938 chosen.err().unwrap_or_default()
2939}
2940
2941#[cfg(test)]
2942mod tests {
2943 use super::*;
2944
2945 #[test]
2956 #[ignore]
2957 fn measure_the_alarm_path_with_a_look_pending() {
2958 let state = state_dir();
2959 let folder = state.join("measure-folder");
2960 let set_up = state.join("measure-set-up");
2961 let empty = std::env::var_os("SIOUL_MEASURE_EMPTY").is_some();
2962 let copies = !empty && std::env::var_os("SIOUL_MEASURE_NO_COPIES").is_none();
2963 std::fs::create_dir_all(&folder).unwrap();
2964 let key = share::key_for(&folder, "four words make a passphrase").unwrap();
2965 if let Ok(mut held) = KEY.lock() {
2966 *held = Some(key);
2967 }
2968 let history = sioul_sync::history::root(&memory_path()).join("drafts").join("data").join("drafts");
2969 let clear = format!("From: Jane <jane@example.org>\r\nTo: Me <me@example.org>\r\nSubject: The lease\r\nMessage-ID: <lease@example.org>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{}\r\n", "The secret clause is on page four. ".repeat(2_900));
2970 if !set_up.exists() {
2971 let mut here = share::Here::load(&state);
2972 here.folder = Some(folder.display().to_string());
2973 here.save(&state).unwrap();
2974 if copies {
2975 for n in 0..150 {
2976 let dir = history.join(format!("c{n}.eml"));
2977 std::fs::create_dir_all(&dir).unwrap();
2978 std::fs::write(dir.join("2026-10-18T10-00-00.000Z"), &clear).unwrap();
2979 }
2980 }
2981 if !empty {
2982 let others = sioul_core::config::data_dir().join("pgp").join("others");
2983 std::fs::create_dir_all(&others).unwrap();
2984 for n in 0..300 {
2985 std::fs::write(others.join(format!("{n}.asc")), sioul_core::pgp::stand_in_key(&format!("Someone {n} <someone{n}@example.org>")).unwrap()).unwrap();
2986 }
2987 }
2988 assert!(exchange_here(false).is_some_and(|o| o.is_ok()));
2990 std::fs::write(&set_up, "").unwrap();
2991 println!("set up: run it again with the same folders to measure");
2992 return;
2993 }
2994 let started = std::time::Instant::now();
2995 let outcome = exchange_here(false);
2996 let took = started.elapsed();
2997 assert!(outcome.is_some_and(|o| o.is_ok()));
2998 let pending = (0..150).filter(|n| std::fs::read(history.join(format!("c{n}.eml")).join("2026-10-18T10-00-00.000Z")).is_ok_and(|b| b == clear.as_bytes())).count();
2999 println!("the alarm path's exchange in a fresh process: {took:?} ({}; copies still pending: {pending})", if empty { "nothing kept" } else if copies { "150 copies, 300 keys" } else { "300 keys" });
3000 assert_eq!(pending, if copies { 150 } else { 0 }, "the alarm path never looks at the copies");
3001 }
3002
3003 #[test]
3007 fn a_put_back_is_said_as_it_went() {
3008 use sioul_sync::share::PutBack;
3009 assert_eq!(put_back_words(PutBack { back: 12, held: 12 }), "");
3010 assert_eq!(put_back_words(PutBack { back: 0, held: 0 }), "", "nothing held any more: nothing to say");
3011 let some = put_back_words(PutBack { back: 11, held: 12 });
3012 assert!(some.contains("11") && some.contains("12"), "{some}");
3013 assert!(!put_back_words(PutBack { back: 0, held: 12 }).is_empty());
3014 }
3015
3016 #[test]
3019 fn the_log_says_why_notes_wait() {
3020 assert_eq!(waits_said(&std::collections::BTreeMap::new()), "");
3021 let waits: std::collections::BTreeMap<String, usize> = [("no-blob".to_string(), 2), ("backoff".to_string(), 1)].into();
3022 assert_eq!(waits_said(&waits), " (backoff 1, no-blob 2)");
3023 }
3024
3025 #[test]
3034 fn a_doses_alarm_waits_for_the_pull_alone_while_it_works() {
3035 assert_eq!(news(true, true, false, true, true, true), News::Alarm, "an alarm: a pull since it began, else the sync app (`remote::alarm_news`)");
3036 assert_eq!(news(true, true, false, false, true, true), News::Alarm, "the last pull failed: an alarm tries one all the same, the sync app asked when it fails");
3037 assert_eq!(news(true, true, false, true, true, false), News::Pull, "the background step: its pull alone");
3038 assert_eq!(news(true, true, false, false, true, false), News::SyncApp, "the background step, its pull failing: as before");
3039 assert_eq!(news(true, true, false, false, false, true), News::SyncApp, "no folder found on a server: the sync app, as before");
3040 assert_eq!(news(true, true, true, false, false, true), News::None, "Sioul keeping the folder: the server looked through already");
3041 assert_eq!(news(true, false, false, false, false, true), News::Computer);
3042 assert_eq!(news(false, true, false, true, true, false), News::None, "a button pressed: nothing waited for");
3043 }
3044
3045 #[test]
3048 fn a_phone_put_away_renews_no_claim() {
3049 assert!(!renews(true, true));
3050 assert!(renews(true, false) && renews(false, true) && renews(false, false));
3051 }
3052
3053 #[test]
3054 fn keeping_the_folder_says_in_words_where_it_stands() {
3055 use sioul_sync::remote::{MIRROR, State};
3056 let kept = State { mode: MIRROR.into(), place: "Documents/Sioul".into(), url: "https://murena.io/remote.php/dav/files/jane/Documents/Sioul/".into(), confirmed: 1, ..State::default() };
3057 assert_eq!(mirror_line(&kept), "", "nothing to say before its first step");
3058 let stepped = State { last: jiff::Timestamp::now().as_second(), ..kept.clone() };
3059 assert!(!mirror_line(&stepped).is_empty());
3060 for said in ["network:murena.io", "login:murena.io", "tls:murena.io", "server:murena.io", "quota:murena.io", "disk:murena.io"] {
3061 let line = mirror_line(&State { said: said.into(), ..stepped.clone() });
3062 assert!(!line.is_empty() && !line.contains("share-"), "{said}: {line}");
3063 }
3064 assert!(mirror_line(&State { confirmed: 0, said: "seal-differs:murena.io".into(), ..kept }).contains("murena.io"));
3065 for code in ["no-place", "no-files:murena.io", "no-password:murena", "login:murena.io", "tls:murena.io", "network:murena.io", "server:murena.io", "sealed-meanwhile"] {
3066 let words = server_problem(code);
3067 assert!(!words.is_empty() && !words.contains("share-"), "{code}: {words}");
3068 }
3069 }
3070
3071 #[test]
3074 fn a_failed_send_is_said_in_words() {
3075 use sioul_sync::remote::Failure;
3076 let at = jiff::Timestamp::now().as_second();
3077 let timeout = Failure { kind: "timeout".into(), file: "records".into(), seconds: 216, sent: 1_081_344, total: 2_530_934, words: "timeout: global".into(), at };
3078 let line = failure_line(&timeout, at + 60);
3079 assert!(line.contains("216") && !line.contains("share-"), "{line}");
3080 for kind in ["stalled", "network", "unreachable", "out-of-time", "tls", "login", "quota", "server"] {
3081 for file in ["records", "entry", "notes", "claim", "texts", "sealed", ""] {
3082 let line = failure_line(&Failure { kind: kind.into(), file: file.into(), ..timeout.clone() }, 0);
3083 assert!(!line.is_empty() && !line.contains("share-"), "{kind} {file}: {line}");
3084 }
3085 }
3086 assert_eq!(failure_words(&timeout), "records: timeout after 216 s, 1081344 of 2530934 bytes handed to the network (timeout: global)");
3087 }
3088
3089 #[test]
3090 fn the_backup_says_in_words_what_it_did() {
3091 assert_eq!(backup_words("network:murena.io"), "murena.io: could not be reached");
3092 assert_eq!(backup_words("no-account"), "no account with a Nextcloud server");
3093 assert_eq!(backup_words("seal-differs:murena.io"), "murena.io: a folder sealed otherwise, never read");
3094 }
3095
3096 #[test]
3097 fn where_the_backup_stands_in_words() {
3098 use sioul_sync::remote::State;
3099 let folder = Path::new("/storage/emulated/0/Documents/Sioul");
3100 let here = folder.display().to_string();
3101 let found = State { folder: here.clone(), url: "https://murena.io/remote.php/dav/files/jane/Documents/Sioul/".into(), account: "murena".into(), confirmed: 1, looked: 1, ..State::default() };
3102 assert!(backup_line(&found, folder).contains("murena.io"));
3103 let fetched = State { last: jiff::Timestamp::now().as_second(), ..found.clone() };
3104 assert!(backup_line(&fetched, folder).contains("murena.io"));
3105 let failing = State { said: "network:murena.io".into(), ..fetched.clone() };
3106 assert_ne!(backup_line(&failing, folder), backup_line(&fetched, folder));
3107 let left = State { left: [("desk-9.jsonl".to_string(), 70 << 20)].into(), ..fetched.clone() };
3108 assert!(backup_line(&left, folder).ends_with(&tr().text("share-backup-left", None)), "a round left to the sync app, said");
3109 let off = State { on: Some(false), ..fetched.clone() };
3110 assert!(backup_line(&off, folder).contains("murena.io"));
3111 let looking = State { folder: here.clone(), ..State::default() };
3112 assert_eq!(backup_line(&looking, folder), tr().text("share-backup-looking", None));
3113 for said in ["no-account", "not-found:murena.io", "seal-differs:murena.io", "seal-gone:murena.io", "login:murena.io", "no-password:murena", "network:murena.io", "no-account-for:cloud.example.org"] {
3114 let state = State { folder: here.clone(), looked: 1, said: said.into(), ..State::default() };
3115 let line = backup_line(&state, folder);
3116 assert!(!line.is_empty() && !line.contains("share-backup"), "{said}: {line}");
3117 }
3118 }
3119
3120 #[test]
3121 fn documents_folders() {
3122 let base = std::env::temp_dir().join(format!("sioul-share-documents-{}", std::process::id()));
3123 let root = base.join("Nextcloud");
3124 std::fs::create_dir_all(root.join("Documents")).unwrap();
3125 let roots = vec![root.clone()];
3126 assert!(in_documents(&root.join("Documents").join("Sioul")));
3127 assert!(in_documents(&root.join("documents").join("Sioul")));
3128 assert!(!in_documents(&root.join("Sioul")));
3129 assert!(beside_documents(&root.join("Sioul"), &roots));
3130 assert!(!beside_documents(&root.join("Documents").join("Sioul"), &roots));
3131 let other = base.join("Sync");
3133 std::fs::create_dir_all(&other).unwrap();
3134 assert!(!beside_documents(&other.join("Sioul"), &[other.clone()]));
3135 let _ = std::fs::remove_dir_all(&base);
3136 }
3137
3138 #[test]
3142 fn your_calendars_and_contacts_set_aside_are_named_and_kept_inside() {
3143 let root = std::env::temp_dir().join(format!("sioul-kept-items-{}", std::process::id()));
3144 let _ = std::fs::remove_dir_all(&root);
3145 let event = "data/calendars/work/personal/dentist.ics";
3146 sioul_sync::history::keep_bytes(&root, sioul_sync::history::ACCOUNTS, event, b"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Dentist\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", 1_790_000_000_000).unwrap();
3147 let card = "data/contacts/work/friends/jane.vcf";
3148 sioul_sync::history::keep_bytes(&root, sioul_sync::history::ACCOUNTS, card, b"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane Doe\r\nEND:VCARD\r\n", 1_790_000_000_000).unwrap();
3149 let named = |file: &str| shown_kept(&root, sioul_sync::history::ACCOUNTS, file, &sioul_sync::history::versions(&root, sioul_sync::history::ACCOUNTS, file));
3150 assert_eq!((named(event).as_str(), named(card).as_str()), ("Dentist", "Jane Doe"));
3151 assert_eq!(shown_kept(&root, "settings", "config/config.toml", &[]), "config.toml");
3152 assert!(account_item(event).is_some_and(|p| p.ends_with("calendars/work/personal/dentist.ics")));
3153 assert!(account_item("data/../config/config.toml").is_none() && account_item("data/drafts/reply.eml").is_none() && account_item("config/config.toml").is_none());
3154 let _ = std::fs::remove_dir_all(&root);
3155 }
3156
3157 #[test]
3160 fn notes_held_are_said_in_words() {
3161 let day_ms = 86_400_000;
3162 let went = 1_791_000_000_000;
3163 let held = share::Withheld { store: "files/notes/".into(), count: 23, first: went, last: went + 60_000, leaves: went + 60_000 + 14 * day_ms, folders: vec!["archive/2026".into(), String::new()] };
3164 let root = Path::new("/srv/notes");
3165 let shown = withheld_words(held.clone(), Some(root));
3166 assert!(shown.text.contains("23") && shown.text.contains("/srv/notes/archive/2026") && shown.text.contains(&day(went)), "{}", shown.text);
3167 assert!(shown.leaves.contains(&day(went + 60_000 + 14 * day_ms)) && shown.leaves.contains("14"), "{}", shown.leaves);
3168 assert_eq!(shown.key, format!("porch-withheld-said:files/notes/:{}", went + 60_000));
3169 let spread = withheld_words(share::Withheld { first: went - 2 * day_ms, ..held.clone() }, None);
3170 assert!(spread.text.contains(&day(went - 2 * day_ms)) && spread.text.contains(&day(went)), "between two days: {}", spread.text);
3171 let one = withheld_words(share::Withheld { count: 1, folders: vec![String::new()], ..held }, Some(root));
3172 for words in [&shown.text, &shown.leaves, &spread.text, &one.text, &one.leaves] {
3173 assert!(!words.contains("porch-") && !words.contains('{'), "{words}");
3174 }
3175 }
3176
3177 #[test]
3181 fn a_folder_gone_is_confirmed_in_its_own_store() {
3182 for (code, store) in [("share-vanished:files/notes/archive/:20", "files/notes/archive/"), ("share-vanished:files/papers/:3", "files/papers/"), ("share-vanished:files/spam/corpus/:40", "files/spam/corpus/"), ("share-vanished:files/notes/:6", "files/notes/")] {
3183 assert_eq!(vanished_of(code).map(|v| v.store), Some(store.to_string()), "{code}");
3184 }
3185 for wrong in ["files/notes/../config/", "config/", "files/notes"] {
3186 assert!(confirm_gone(wrong).ends_with('?'), "{wrong}");
3187 }
3188 let link = vanished_of("share-vanished:files/notes/single.md:1").unwrap();
3190 assert_eq!(link.store, "files/notes/single.md");
3191 assert_eq!(link.text, say("share-vanished-link", &[("folder", "single.md".to_string()), ("count", "1".to_string())]));
3192 }
3193
3194 #[test]
3197 fn what_remove_its_files_takes_out_is_said_first() {
3198 let left = share::DeviceFiles { id: "old".into(), rounds: 17, round_bytes: 16_100_000, small: 3, small_bytes: 900, sealed: 2, sealed_bytes: 40_000, sealed_known: true, entry_stays: true, last_round: 28, ..share::DeviceFiles::default() };
3199 let said = removal_words(&left, false);
3200 assert!(said.contains("17") && said.contains(&sioul_core::view::size(tr(), 16_100_900)), "{said}");
3201 let live = share::DeviceFiles { counts: true, sealed: 0, sealed_known: false, sealed_later: 3, notes_stay: true, unread_by: vec![("phone".into(), "Model 7".into(), sioul_sync::devices::PHONE.into())], ..left.clone() };
3202 let warned = removal_words(&live, false);
3203 assert!(warned.starts_with(&tr().text("share-remove-live", None)) && warned.contains("Model 7") && warned.contains(&tr().text("share-remove-sealed-unknown", None)), "{warned}");
3204 let bare = removal_words(&share::DeviceFiles { rounds: 0, round_bytes: 0, sealed: 0, ..share::DeviceFiles::default() }, false);
3205 let in_use = removal_words(&share::DeviceFiles { heard: 1_791_000_000_000, ..live.clone() }, true);
3207 assert!(in_use.starts_with(&say("share-remove-in-use", &[("when", when(1_791_000_000))])) && !in_use.contains(&tr().text("share-remove-live", None)), "{in_use}");
3208 assert!(said.contains(&tr().text("share-remove-entry-stays", None)), "{said}");
3210 assert!(warned.contains(&tr().text("share-remove-small-stay", None)), "{warned}");
3211 for words in [&said, &warned, &bare, &in_use] {
3212 assert!(!words.contains("share-") && !words.contains('{'), "{words}");
3213 }
3214 assert_eq!(device_named("", ""), tr().text("share-device-unnamed", None));
3215 assert_eq!(device_named("desk", sioul_sync::devices::COMPUTER), "desk");
3216 assert!(device_named("Model 7", sioul_sync::devices::PHONE).ends_with("(Model 7)"));
3217 }
3218
3219 #[test]
3222 fn on_a_phone_your_word_on_the_notes_folder_wins_over_the_guess() {
3223 assert!(carried_on_phone(None, true) && !carried_on_phone(None, false));
3224 assert!(!carried_on_phone(Some(false), true), "not carried, as you said, though it looked so");
3225 assert!(carried_on_phone(Some(true), false), "carried, as you said, though it did not look so");
3226 }
3227
3228 #[test]
3233 fn the_answer_on_the_notes_folder_is_for_that_folder() {
3234 let notes = Path::new(STORAGE).join("Notes");
3235 let moved = Path::new(STORAGE).join("Documents/Notes");
3236 let mut here = share::Here { notes_carried: Some(false), ..share::Here::default() };
3237 assert_eq!(here.notes_said(¬es), Some(false), "an answer from before: for the notes of then");
3238 here.notes_carried_for = Some(notes.display().to_string());
3239 assert_eq!(here.notes_said(¬es), Some(false));
3240 assert_eq!(here.notes_said(&moved), None, "moved: asked again");
3241 assert!(carried_on_phone(here.notes_said(&moved), true), "the guess meanwhile: in the sharing folder's top folder, carried");
3242 assert!(!carried_on_phone(here.notes_said(¬es), true), "where it was said: as said");
3243 }
3244
3245 #[test]
3246 fn notes_carried_twice_are_refused() {
3247 let home = std::env::temp_dir().join(format!("sioul-share-carried-{}", std::process::id()));
3248 let roots = vec![home.join("Nextcloud")];
3249 assert!(carried(&home.join("Nextcloud").join("Notes"), &roots));
3250 assert!(!carried(&home.join("Notes"), &roots));
3251 let storage = Path::new(STORAGE);
3253 assert!(same_top(&storage.join("Documents/Notes"), &storage.join("Documents/Sioul"), storage));
3254 assert!(!same_top(&storage.join("Notes"), &storage.join("Documents/Sioul"), storage));
3255 assert!(!same_top(Path::new("/data/user/0/sioul/files/Notes"), &storage.join("Documents/Sioul"), storage));
3256 assert!(same_top(&on_storage(Path::new("/sdcard/Documents/Notes")), &on_storage(&storage.join("Documents/Sioul")), storage));
3257 assert!(same_top(&on_storage(Path::new("/storage/self/primary/Documents/Notes")), &on_storage(Path::new("/sdcard/Documents/Sioul")), storage));
3258 let xml = "<configuration>\n<folder id=\"a\" label=\"Notes\" path=\"~/Writing & Notes\" type=\"sendreceive\">\n<device id=\"X\"></device>\n</folder>\n<folder path=\"/data/Sync\" id=\"b\"></folder>\n<folderish/>\n</configuration>";
3260 assert_eq!(syncthing_folders(xml), ["~/Writing & Notes", "/data/Sync"]);
3261 let mut boxes = dropbox_folders(r#"{"personal": {"path": "/home/me/Boxes/Dropbox", "host": 1}, "business": {"path": "/home/me/Work"}}"#);
3262 boxes.sort();
3263 assert_eq!(boxes, ["/home/me/Boxes/Dropbox", "/home/me/Work"]);
3264 assert!(dropbox_folders("not json").is_empty());
3265 }
3266
3267 #[test]
3268 fn folders_listed() {
3269 let base = std::env::temp_dir().join(format!("sioul-folders-in-{}", std::process::id()));
3270 for dir in ["Documents/Sioul", "Documents/.hidden", "Documents/archive", "Documents/Bills"] {
3271 std::fs::create_dir_all(base.join(dir)).unwrap();
3272 }
3273 std::fs::write(base.join("Documents/Sioul/seal.toml"), "").unwrap();
3274 std::fs::write(base.join("Documents/notes.txt"), "").unwrap();
3275 let listed: serde_json::Value = serde_json::from_str(&folders_in(&base.join("Documents").display().to_string())).unwrap();
3276 let names: Vec<&str> = listed["folders"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
3277 assert_eq!(names, ["archive", "Bills", "Sioul"]);
3278 assert_eq!(listed["folders"][2]["sealed"], true);
3279 assert_eq!(listed["folders"][0]["sealed"], false);
3280 assert_eq!(listed["parent"], base.display().to_string());
3281 assert_eq!(listed["readable"], true);
3282 let missing: serde_json::Value = serde_json::from_str(&folders_in(&base.join("nowhere").display().to_string())).unwrap();
3283 assert_eq!(missing["readable"], false);
3284 let _ = std::fs::remove_dir_all(&base);
3285 }
3286}