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());
33
34fn hex(key: &[u8; 32]) -> String {
35 key.iter().map(|b| format!("{b:02x}")).collect()
36}
37
38fn unhex(text: &str) -> Option<[u8; 32]> {
39 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();
40 <[u8; 32]>::try_from(bytes?).ok()
41}
42
43fn key() -> Option<[u8; 32]> {
44 let mut held = KEY.lock().ok()?;
45 if held.is_none() {
46 *held = sioul_sync::secret::named(share::KEY_NAME).and_then(|k| unhex(&k));
47 }
48 *held
49}
50
51fn sealed(folder: Option<PathBuf>) -> Option<(PathBuf, [u8; 32])> {
56 let folder = folder?;
57 Some((folder, key()?))
58}
59
60fn memory_path() -> PathBuf {
61 state_dir().join("share").join("memory.json")
62}
63
64fn here() -> share::Here {
68 let state = state_dir();
69 let mut here = share::Here::load(&state);
70 if here.folder_path().is_some() && !here.parts.contains_key("projects") {
71 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
72 here = share::Here::load(&state);
73 if !here.parts.contains_key("projects") {
74 here.parts.insert("projects".into(), load_config().share_projects);
75 let _ = here.save(&state);
76 }
77 }
78 if here.notes_carried.is_some() && here.notes_carried_for.is_none() {
81 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
82 here = share::Here::load(&state);
83 if here.notes_carried.is_some() && here.notes_carried_for.is_none()
84 && let Some(notes) = load_config().notes_root_path()
85 {
86 here.notes_carried_for = Some(notes.display().to_string());
87 let _ = here.save(&state);
88 }
89 }
90 here
91}
92
93fn files_readable() -> bool {
97 #[cfg(target_os = "android")]
98 {
99 unsafe extern "C" {
101 fn sioul_android_files_access() -> bool;
103 }
104 unsafe { sioul_android_files_access() }
106 }
107 #[cfg(not(target_os = "android"))]
108 true
109}
110
111fn quietly<T>(work: impl FnOnce() -> T) -> T {
114 use std::sync::atomic::Ordering;
115 HURRY.store(true, Ordering::Relaxed);
116 let busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
117 HURRY.store(false, Ordering::Relaxed);
118 let done = work();
119 drop(busy);
120 done
121}
122
123fn between_exchanges<T>(work: impl FnOnce() -> T) -> T {
126 quietly(|| {
127 let running = share::exchange_lock(&memory_path(), true);
128 let done = work();
129 drop(running);
130 done
131 })
132}
133
134fn notes_carried(config: &Config, here: &share::Here) -> Option<PathBuf> {
143 let store = config.notes_root_path()?;
144 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()) };
145 carried.then_some(store)
146}
147
148fn carried_on_phone(said: Option<bool>, guessed: bool) -> bool {
150 said.unwrap_or(guessed)
151}
152
153pub(crate) fn set_notes_carried(carried: bool) -> String {
157 between_exchanges(|| {
158 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
159 let mut here = share::Here::load(&state_dir());
161 here.notes_carried = Some(carried);
162 here.notes_carried_for = load_config().notes_root_path().map(|notes| notes.display().to_string());
164 if carried {
165 for part in ["notes", "papers"] {
166 if here.parts.get(part) == Some(&true) {
167 here.parts.insert(part.to_string(), false);
168 }
169 }
170 }
171 here.save(&state_dir()).err().unwrap_or_default()
172 })
173}
174
175const STORAGE: &str = "/storage/emulated/0";
177
178fn on_storage(path: &Path) -> PathBuf {
181 ["/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))
182}
183
184fn same_top(a: &Path, b: &Path, storage: &Path) -> bool {
186 let top = |path: &Path| path.strip_prefix(storage).ok().and_then(|rest| rest.components().next()).map(|c| c.as_os_str().to_owned());
187 top(a).is_some() && top(a) == top(b)
188}
189
190fn refused(part: &str, config: &Config, here: &share::Here) -> Option<String> {
192 if !matches!(part, "notes" | "papers") {
193 return None;
194 }
195 notes_carried(config, here).map(|store| say("share-part-carried", &[("store", shorten(&store))]))
196}
197
198fn stores_here(here: &share::Here) -> Vec<share::Store> {
200 let config = load_config();
201 let carried = notes_carried(&config, here).is_some();
202 share::stores_of(&config, &share::Roots::here(), &|part| here.shares(part, &config) && !(carried && matches!(part, "notes" | "papers")))
203}
204
205fn part_words(part: &str) -> (String, String) {
207 let text = |id: &str| tr().text(id, None);
208 match part {
209 "settings" => (text("share-part-settings"), text("share-part-settings-carries")),
210 "senders" => (text("share-part-senders"), text("share-part-senders-carries")),
211 "calls" => (text("share-part-calls"), text("share-part-calls-carries")),
212 "phone-messages" => (text("share-part-phone-messages"), text("share-part-phone-messages-carries")),
213 "texts" => (text("share-part-texts"), text("share-part-texts-carries")),
215 "spam" => (text("share-part-spam"), text("share-part-spam-carries")),
216 "health" => (text("share-part-health"), text("share-part-health-carries")),
217 "time" => (text("share-part-time"), text("share-part-time-carries")),
218 "drafts" => (text("share-part-drafts"), text("share-part-drafts-carries")),
219 "projects" => (text("share-part-projects"), text("share-part-projects-carries")),
220 "lists" => (text("share-part-lists"), text("share-part-lists-carries")),
221 "notes" => (text("share-part-notes"), text("share-part-notes-carries")),
222 "papers" => (text("share-part-papers"), text("share-part-papers-carries")),
223 sioul_sync::history::ACCOUNTS => (text("share-part-accounts"), String::new()),
225 _ => (part.to_string(), String::new()),
226 }
227}
228
229fn synced_roots() -> Vec<PathBuf> {
231 let home = expand_home("~");
232 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();
233 let data = |name: &str, file: &str| std::env::var_os(name).map(|a| PathBuf::from(a).join(file)).unwrap_or_default();
234 let read = |paths: &[PathBuf]| paths.iter().filter_map(|c| std::fs::read_to_string(c).ok()).collect::<Vec<_>>();
235 let mut found: Vec<PathBuf> = Vec::new();
236 for text in read(&[home.join(".config/Nextcloud/nextcloud.cfg"), home.join("Library/Preferences/Nextcloud/nextcloud.cfg"), data("APPDATA", "Nextcloud/nextcloud.cfg")]) {
237 found.extend(text.lines().filter_map(|line| line.split_once("localPath=")).map(|(_, path)| PathBuf::from(path.trim().trim_end_matches('/'))));
238 }
239 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")]) {
241 found.extend(syncthing_folders(&text).iter().map(|path| expand_home(path)));
242 }
243 for text in read(&[home.join(".dropbox/info.json"), data("APPDATA", "Dropbox/info.json"), data("LOCALAPPDATA", "Dropbox/info.json")]) {
245 found.extend(dropbox_folders(&text).into_iter().map(PathBuf::from));
246 }
247 for path in found {
248 if path.is_dir() && !roots.contains(&path) {
249 roots.push(path);
250 }
251 }
252 roots
253}
254
255fn syncthing_folders(xml: &str) -> Vec<String> {
257 xml.split("<folder ")
258 .skip(1)
259 .filter_map(|element| {
260 let tag = element.split('>').next()?;
261 let (_, rest) = tag.split_once(" path=\"").or_else(|| tag.strip_prefix("path=\"").map(|rest| ("", rest)))?;
262 let path = rest.split('"').next()?;
263 Some(path.replace("&", "&").replace("'", "'").replace(""", "\"").replace("<", "<").replace(">", ">"))
264 })
265 .collect()
266}
267
268fn dropbox_folders(json: &str) -> Vec<String> {
270 let Ok(serde_json::Value::Object(accounts)) = serde_json::from_str::<serde_json::Value>(json) else { return Vec::new() };
271 accounts.values().filter_map(|account| account.get("path").and_then(serde_json::Value::as_str).map(str::to_string)).collect()
272}
273
274pub(crate) fn candidates() -> Vec<String> {
278 let roots: Vec<PathBuf> = if cfg!(target_os = "android") { vec![PathBuf::from("/storage/emulated/0")] } else { synced_roots() };
279 let mut found = Vec::new();
280 for root in &roots {
281 look_for_seals(root, 0, &mut found);
282 }
283 found.sort();
284 found.dedup();
285 found.into_iter().map(|path| shorten(&path)).collect()
286}
287
288pub(crate) fn folders_in(path: &str) -> String {
293 #[derive(Serialize)]
294 struct Folder {
295 name: String,
296 path: String,
297 sealed: bool,
299 }
300 #[derive(Serialize)]
301 struct Listing {
302 path: String,
303 parent: String,
304 folders: Vec<Folder>,
305 readable: bool,
306 }
307 let start = if cfg!(target_os = "android") { PathBuf::from("/storage/emulated/0") } else { expand_home("~") };
308 let dir = if path.trim().is_empty() { start } else { expand_home(path.trim()) };
309 let entries = std::fs::read_dir(&dir);
310 let readable = entries.is_ok();
311 let mut folders: Vec<Folder> = entries
312 .into_iter()
313 .flatten()
314 .filter_map(Result::ok)
315 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
316 .map(|e| (e.file_name().to_string_lossy().to_string(), e.path()))
317 .filter(|(name, _)| !name.starts_with('.'))
318 .map(|(name, path)| Folder { sealed: path.join("seal.toml").is_file(), path: path.display().to_string(), name })
319 .collect();
320 folders.sort_by_key(|f| f.name.to_lowercase());
321 let top = cfg!(target_os = "android") && dir == Path::new("/storage/emulated/0");
323 let parent = dir.parent().filter(|_| !top).map(|p| p.display().to_string()).unwrap_or_default();
324 json(&Listing { path: dir.display().to_string(), parent, folders, readable })
325}
326
327fn look_for_seals(dir: &Path, depth: usize, found: &mut Vec<PathBuf>) {
328 if depth > 6 || found.len() >= 10 {
330 return;
331 }
332 if dir.join("seal.toml").is_file() {
333 found.push(dir.to_path_buf());
334 return;
335 }
336 let Ok(entries) = std::fs::read_dir(dir) else { return };
337 for entry in entries.filter_map(Result::ok) {
338 let name = entry.file_name().to_string_lossy().to_string();
339 let skip = name.starts_with('.') || matches!(name.as_str(), "DCIM" | "Pictures" | "Movies" | "Music" | "Podcasts" | "Ringtones" | "Alarms" | "Notifications" | "data" | "obb" | "node_modules" | "target");
340 if !skip && entry.file_type().is_ok_and(|t| t.is_dir()) {
341 look_for_seals(&entry.path(), depth + 1, found);
342 }
343 }
344}
345
346fn carried(path: &Path, roots: &[PathBuf]) -> bool {
347 roots.iter().any(|root| path.starts_with(root))
348}
349
350fn suggested(roots: &[PathBuf]) -> String {
355 let store = load_config().notes_root_path();
356 let documents = roots.iter().map(|root| root.join("Documents")).find(|d| d.is_dir());
357 let folder = match (&store, &documents, roots.first()) {
358 (Some(store), _, _) if carried(store, roots) && in_documents(store) => store.join("sioul-shared"),
359 (_, Some(documents), _) => documents.join("Sioul"),
360 (Some(store), _, _) if carried(store, roots) => store.join("sioul-shared"),
361 (_, _, Some(root)) => root.join("Sioul"),
362 (Some(store), _, None) => store.join("sioul-shared"),
363 (None, _, None) => return String::new(),
364 };
365 shorten(&folder)
366}
367
368fn in_documents(path: &Path) -> bool {
371 path.components().any(|part| part.as_os_str().eq_ignore_ascii_case("Documents"))
372}
373
374fn beside_documents(path: &Path, roots: &[PathBuf]) -> bool {
377 roots.iter().any(|root| path.starts_with(root) && root.join("Documents").is_dir()) && !in_documents(path)
378}
379
380fn shorten(path: &Path) -> String {
382 let home = expand_home("~");
383 path.strip_prefix(&home).map_or_else(|_| path.display().to_string(), |rest| format!("~/{}", rest.display()))
384}
385
386fn when(seconds: i64) -> String {
388 let Ok(at) = jiff::Timestamp::from_second(seconds) else { return String::new() };
389 let at = at.to_zoned(jiff::tz::TimeZone::system());
390 if at.date() == jiff::Zoned::now().date() { at.strftime("%H:%M").to_string() } else { tr().date(&at, true) }
391}
392
393#[derive(Serialize)]
394struct Status {
395 on: bool,
396 folder: String,
397 sealed: bool,
399 projects: bool,
401 lines: Vec<String>,
402 problems: Vec<String>,
403 parts: Vec<Part>,
405 vanished: Vec<Vanished>,
407 devices: Vec<crate::health::DeviceRow>,
409 backup: Backup,
411 servers: Vec<ServerAccount>,
413 mirrored: bool,
415 build: String,
417 can_again: bool,
419 again: String,
421 carried_ask: String,
425}
426
427#[derive(Serialize)]
429struct Vanished {
430 store: String,
432 text: String,
433}
434
435#[derive(Serialize)]
437struct Part {
438 id: &'static str,
439 name: String,
440 carries: String,
442 on: bool,
443 refused: String,
445 guessed: bool,
448 last: String,
450}
451
452pub(crate) fn status(folder: &str) -> String {
454 let here = here();
455 let roots = synced_roots();
456 let on = here.folder_path().is_some() && key().is_some();
457 let saved = here.folder.clone().filter(|f| !f.is_empty());
459 let chosen = match (&saved, folder.is_empty()) {
460 (Some(saved), _) if on => saved.clone(),
461 (_, false) => folder.to_string(),
462 (Some(saved), true) => saved.clone(),
463 (None, true) => suggested(&roots),
464 };
465 let path = expand_home(&chosen);
466 let mut lines = Vec::new();
467 let mut problems = Vec::new();
468 let mut vanished = Vec::new();
469 let mirror = mirrored();
470 let formats = if on { key().map(|key| share::formats(&path, &key, &memory_path(), &here.id, now_ms())) } else { None };
472 if on {
473 match &mirror {
475 Some(state) => {
476 lines.push(say("share-on-server", &[("host", state.host()), ("place", state.place.clone())]));
477 lines.extend(Some(mirror_line(state)).filter(|l| !l.is_empty()));
478 }
479 None => lines.push(say("share-on", &[("folder", chosen.clone())])),
480 }
481 attached(&here);
482 let others = match key() {
484 Some(key) => share::others_heard(&path, &key, &here.id),
485 None => share::others(&path, &here.id),
486 };
487 match others.iter().map(|o| o.heard).max() {
488 Some(heard) => {
489 let mut args = sioul_core::i18n::args();
490 args.set("count", others.len() as i64);
491 args.set("when", when(heard));
492 lines.push(tr().text("share-others", Some(&args)));
493 }
494 None => lines.push(tr().text("share-alone", None)),
495 }
496 if let Some((at, said)) = LAST.lock().ok().and_then(|l| l.clone()) {
497 lines.push(say("share-last", &[("when", when(at))]));
498 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)));
499 vanished.extend(said.iter().filter_map(|p| vanished_of(p)));
500 }
501 if let Some(formats) = &formats {
506 for (part, _, build) in &formats.newer {
507 let name = part_words(part).0;
508 problems.push(if build.is_empty() { say("share-newer-plain", &[("part", name)]) } else { say("share-newer", &[("part", name), ("build", build.clone())]) });
509 }
510 if formats.holders.iter().any(|(_, holds)| *holds != share::Holds::Unheard) {
511 lines.push(tr().text("share-format-held", None));
512 }
513 if !formats.older.is_empty() {
515 problems.push(tr().text("share-format-older", None));
516 }
517 }
518 let day_ago = jiff::Timestamp::now().as_second() - 86_400;
520 if let Ok(mut said) = SAID.lock() {
521 said.retain(|(at, _)| *at > day_ago);
522 problems.extend(said.iter().map(|(_, p)| problem_text(p)));
523 }
524 } else if here.folder_path().is_some() {
525 problems.push(tr().text("share-key-missing", None));
526 } else {
527 lines.push(tr().text("share-off", None));
528 }
529 if !cfg!(target_os = "android") && beside_documents(&path, &roots) {
531 lines.push(tr().text("share-phones", None));
532 }
533 let config = load_config();
536 if let Some(store) = config.notes_root_path()
537 && !here.shares("notes", &config)
538 && carried(&path, &roots)
539 && !carried(&store, &roots)
540 {
541 problems.push(say("share-outside", &[("store", shorten(&store))]));
542 }
543 let traffic = share::traffic(&memory_path(), &here.id);
544 let parts = share::PARTS
545 .iter()
546 .map(|&id| {
547 let (name, carries) = part_words(id);
548 let refused = refused(id, &config, &here).unwrap_or_default();
549 let shared = here.shares(id, &config) && refused.is_empty();
550 let last = match traffic.get(id).copied().unwrap_or_default() {
551 _ if !on || !shared => String::new(),
552 (0, 0) => tr().text("share-part-quiet", None),
553 (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(" "),
554 };
555 let last = match (id, last.is_empty()) {
557 ("texts", false) => [last, crate::texts::kept_words()].join(" ").trim().to_string(),
558 _ => last,
559 };
560 let guessed = cfg!(target_os = "android") && !refused.is_empty() && config.notes_root_path().is_none_or(|notes| here.notes_said(¬es).is_none());
561 Part { id, name, carries, on: shared, refused, guessed, last }
562 })
563 .collect();
564 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();
566 let devices = if on { crate::health::device_rows(&holders) } else { Vec::new() };
567 let backup = backup(on && mirror.is_none(), &path);
568 let build = say("share-build", &[("build", sioul_core::build::DESCRIBED.to_string())]);
569 let can_again = on;
571 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() };
572 let carried_ask = match config.notes_root_path() {
573 Some(store) if cfg!(target_os = "android") && here.notes_said(&store).is_none() => say("share-carried-ask", &[("store", shorten(&store))]),
574 _ => String::new(),
575 };
576 json(&Status { on, folder: chosen, sealed: share::sealed(&path), projects: here.shares("projects", &config), lines, problems, parts, vanished, devices, backup, servers: server_accounts(&config), mirrored: mirror.is_some(), build, can_again, again, carried_ask })
577}
578
579fn problem_text(code: &str) -> String {
580 let file = |file: &str| share::shown(file).to_string();
581 match code.split_once(':') {
582 Some(("share-other-seal", _)) => tr().text("share-other-seal", None),
583 Some((other @ ("share-other-gap" | "share-other-cut" | "share-other-line"), _)) => tr().text(other, None),
585 Some(("share-unreadable", path)) => say("share-unreadable", &[("file", path.to_string())]),
586 Some(("share-conflict", copy)) => say("share-conflict", &[("copy", copy.to_string())]),
587 Some(("share-conflict-gone", copy)) => say("share-conflict-gone", &[("copy", copy.to_string())]),
588 Some(("share-older-copy", copy)) => say("share-older-copy", &[("copy", copy.to_string())]),
589 Some(("share-damaged", name)) => say("share-damaged", &[("file", file(name))]),
590 Some(("share-too-big", name)) => say("share-too-big", &[("file", file(name))]),
591 Some(("share-missing", name)) => say("share-missing", &[("file", file(name))]),
592 Some(("share-no-room", name)) => say("share-no-room", &[("file", file(name))]),
593 Some(("share-name-clash", name)) => say("share-name-clash", &[("file", file(name))]),
594 Some(("share-refused", name)) => say("share-refused", &[("file", file(name))]),
595 Some(("share-not-text", name)) => say("share-not-text", &[("file", file(name))]),
596 Some(("share-emptied", name)) => say("share-emptied", &[("file", file(name))]),
597 Some(("share-format-wait", name)) => say("share-format-wait", &[("file", file(name))]),
598 None if code == "share-files-unreadable" => tr().text("share-files-unreadable", None),
599 None if matches!(code, "share-no-seal" | "share-sealed-otherwise" | "share-twin" | "share-own-ahead" | "share-own-cut") => tr().text(code, None),
600 _ => code.to_string(),
601 }
602}
603
604fn kept_a_day(code: &str) -> bool {
606 code.starts_with("share-conflict") || code.starts_with("share-older-copy")
607}
608
609fn vanished_of(code: &str) -> Option<Vanished> {
611 let (folder, count) = code.strip_prefix("share-vanished:")?.rsplit_once(':')?;
612 let store = if folder.starts_with("files/papers/") { "files/papers/" } else { "files/notes/" };
613 let shown = match share::shown(folder) {
614 "" => load_config().notes_root_path().map(|notes| shorten(¬es)).unwrap_or_default(),
615 inside => inside.trim_end_matches('/').to_string(),
616 };
617 Some(Vanished { store: store.to_string(), text: say("share-vanished", &[("folder", shown), ("count", count.to_string())]) })
618}
619
620pub(crate) fn start(folder: &str, passphrase: &str, again: &str) -> String {
622 let folder = folder.trim();
623 if folder.is_empty() {
624 return tr().text("share-no-folder", None);
625 }
626 let path = expand_home(folder);
627 let first = !share::sealed(&path);
628 if passphrase.chars().count() < 12 {
629 return tr().text("share-short", None);
630 }
631 if first && passphrase != again {
632 return tr().text("share-differ", None);
633 }
634 let key = match share::key_for(&path, passphrase) {
635 Ok(key) => key,
636 Err(share::Refused::WrongPassphrase) => return tr().text("share-wrong", None),
637 Err(share::Refused::Other(e)) => return e,
638 };
639 if let Err(e) = sioul_sync::secret::save_named(share::KEY_NAME, &hex(&key)) {
640 return e.sentence(tr(), "Sioul");
641 }
642 if let Ok(mut held) = KEY.lock() {
643 *held = Some(key);
644 }
645 let state = state_dir();
646 let mut here = share::Here::load(&state);
647 here.folder = Some(folder.to_string());
648 here.save(&state).err().unwrap_or_default()
649}
650
651pub(crate) fn set_projects(on: bool) -> String {
653 set_part("projects", on)
654}
655
656pub(crate) fn set_part(part: &str, on: bool) -> String {
660 let Some(part) = share::PARTS.iter().find(|p| **p == part) else { return format!("{part}?") };
661 if on && let Some(why) = refused(part, &load_config(), &here()) {
662 return why;
663 }
664 between_exchanges(|| {
666 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
667 let mut here = share::Here::load(&state_dir());
669 here.parts.insert((*part).to_string(), on);
670 here.save(&state_dir()).err().unwrap_or_default()
671 })
672}
673
674#[derive(Serialize)]
676struct Kept {
677 part: &'static str,
678 name: String,
679 files: Vec<KeptFile>,
680 more: String,
682}
683
684#[derive(Serialize)]
685struct KeptFile {
686 file: String,
687 shown: String,
688 count: String,
689 versions: Vec<KeptVersion>,
690}
691
692#[derive(Serialize)]
693struct KeptVersion {
694 stamp: String,
695 when: String,
696 size: String,
697}
698
699pub(crate) fn history(filter: &str) -> String {
703 let root = sioul_sync::history::root(&memory_path());
704 let needle = filter.trim().to_lowercase();
705 let kept: Vec<Kept> = share::PARTS
707 .iter()
708 .chain(std::iter::once(&sioul_sync::history::ACCOUNTS))
709 .filter_map(|&part| {
710 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();
711 let more = if all.len() > 100 { say("share-history-more", &[("count", (all.len() - 100).to_string())]) } else { String::new() };
712 let files: Vec<KeptFile> = all
713 .into_iter()
714 .take(100)
715 .map(|(file, versions)| KeptFile {
716 shown: shown_kept(&root, part, &file, &versions),
717 count: say("share-versions", &[("count", versions.len().to_string())]),
718 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(),
719 file,
720 })
721 .collect();
722 (!files.is_empty()).then(|| Kept { part, name: part_words(part).0, files, more })
723 })
724 .collect();
725 json(&kept)
726}
727
728fn shown_kept(root: &Path, part: &str, file: &str, versions: &[sioul_sync::history::Version]) -> String {
732 if part != sioul_sync::history::ACCOUNTS {
733 return share::shown(file).to_string();
734 }
735 let text = versions.first().and_then(|v| match sioul_sync::history::version(root, part, file, &v.stamp) {
736 Some(sioul_sync::history::Kept::Copy(path)) => std::fs::read_to_string(path).ok(),
737 _ => None,
738 });
739 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());
740 named.unwrap_or_else(|| share::shown(file).to_string())
741}
742
743fn account_item(file: &str) -> Option<PathBuf> {
747 let relative = file.strip_prefix("data/")?;
748 if relative.split('/').any(|piece| piece.is_empty() || piece == "." || piece == "..") {
749 return None;
750 }
751 let path = sioul_core::config::data_dir().join(relative);
752 crate::pim::account_of(&path).map(|_| path)
753}
754
755pub(crate) fn put_back_item(file: &str, stamp: &str) -> Result<String, String> {
760 let root = sioul_sync::history::root(&memory_path());
761 let path = account_item(file).ok_or_else(|| format!("{file}?"))?;
762 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")) };
763 let bytes = std::fs::read(&version).map_err(|e| e.to_string())?;
764 if path.is_file() {
765 sioul_sync::history::keep(&root, sioul_sync::history::ACCOUNTS, file, &path, jiff::Timestamp::now().as_millisecond())?;
766 }
767 share::write_atomically(&path, &bytes)?;
768 crate::pim::account_of(&path).ok_or_else(|| format!("{file}?"))
769}
770
771pub(crate) fn put_back(part: &str, file: &str, stamp: &str) -> String {
775 let here = here();
776 let sealed = sealed(here.folder_path());
777 let vault = sealed.as_ref().map(|(folder, key)| (folder.as_path(), key));
778 let stores = share::stores_of(&load_config(), &share::Roots::here(), &|_| true);
780 quietly(|| share::put_back(&memory_path(), vault, &stores, part, file, stamp, jiff::Timestamp::now().as_millisecond()).err().map(|e| problem_text(&e)).unwrap_or_default())
781}
782
783pub(crate) fn store_of(file: &str) -> String {
785 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()
786}
787
788pub(crate) fn put_back_preview(part: &str, file: &str, stamp: &str) -> String {
790 let stores = share::stores_of(&load_config(), &share::Roots::here(), &|_| true);
791 let kept = sioul_sync::history::kept_at(stamp).map(|at| when(at / 1000)).unwrap_or_default();
792 let shown = share::shown(file).to_string();
793 if part == sioul_sync::history::ACCOUNTS {
795 return say("share-put-back-whole", &[("file", shown), ("when", kept)]);
796 }
797 match share::put_back_preview(&memory_path(), &stores, part, file, stamp) {
798 Ok(back) if back.whole => say("share-put-back-whole", &[("file", shown), ("when", kept)]),
799 Ok(back) if back.changed + back.returning == 0 => say("share-put-back-nothing", &[("file", shown)]),
800 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())]),
801 Err(e) => e,
802 }
803}
804
805pub(crate) fn confirm_gone(store: &str) -> String {
807 if !matches!(store, "files/notes/" | "files/papers/") {
808 return format!("{store}?");
809 }
810 let here = here();
811 quietly(|| share::confirm_gone(&memory_path(), &here.id, store).err().unwrap_or_default())
812}
813
814pub(crate) fn estimate(part: &str) -> String {
816 let stores = share::stores_of(&load_config(), &share::Roots::here(), &|p| p == part);
817 let (count, bytes, big) = share::estimate(&stores);
818 let size = sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX));
819 let text = say("share-estimate", &[("count", count.to_string()), ("size", size), ("big", big.to_string())]);
820 json(&serde_json::json!({ "part": part, "text": text }))
821}
822
823pub(crate) fn put_back_said(file: &str, stamp: &str) -> String {
825 let kept = sioul_sync::history::kept_at(stamp).map(|at| when(at / 1000)).unwrap_or_default();
826 say("share-put-back-done", &[("file", share::shown(file).to_string()), ("when", kept)])
827}
828
829pub(crate) fn stop() -> String {
831 between_exchanges(|| {
833 crate::devices::left();
835 if let Ok(mut held) = KEY.lock() {
836 *held = None;
837 }
838 let _ = sioul_sync::secret::forget_named(share::KEY_NAME);
839 let state = state_dir();
840 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
841 let mut here = share::Here::load(&state);
842 if let Some(folder) = here.folder_path() {
846 if mirrored().is_some() {
847 let (memory, id) = (memory_path(), here.id.clone());
848 std::thread::spawn(move || {
849 mirror_step_for(&folder, &id, false, false);
850 sioul_sync::remote::forget(&memory, &folder);
851 let _ = std::fs::remove_dir_all(sioul_sync::remote::mirror_of(&memory));
852 });
853 } else if sending_on(&here) {
854 let (memory, leaving) = (memory_path(), here.clone());
856 std::thread::spawn(move || {
857 send_to_server(&leaving, false);
858 sioul_sync::remote::forget(&memory, &folder);
859 });
860 } else {
861 sioul_sync::remote::forget(&memory_path(), &folder);
862 }
863 }
864 here.folder = None;
865 let memory = memory_path();
867 let _ = std::fs::remove_file(memory.with_file_name("files.json"));
868 let _ = std::fs::remove_file(&memory);
869 here.save(&state).err().unwrap_or_default()
870 })
871}
872
873pub(crate) fn forget_device(id: &str) {
877 let here = here();
878 if let Some((folder, key)) = sealed(attached(&here))
879 && let Err(e) = share::forget_device(&folder, &key, &memory_path(), &here.id, id)
880 {
881 eprintln!("sioul: sharing: {e}");
882 }
883}
884
885pub(crate) fn holds_doses() -> bool {
889 on() && share::holds(&memory_path(), "health")
890}
891
892pub(crate) fn writes_format_2() -> bool {
895 on() && share::writes(&memory_path()) >= 2
896}
897
898pub(crate) fn on() -> bool {
900 share::Here::load(&state_dir()).folder_path().is_some() && key().is_some()
901}
902
903pub(crate) fn last_exchange() -> Option<(i64, bool)> {
905 LAST.lock().ok().and_then(|l| l.as_ref().map(|(at, problems)| (*at, problems.is_empty())))
906}
907
908pub(crate) fn keeper(part: &str, rule: sioul_sync::lease::Rule, active: i64, take: bool) -> (sioul_sync::lease::Keeper, bool) {
913 if !renews(cfg!(target_os = "android"), crate::backend::AWAY.load(std::sync::atomic::Ordering::SeqCst)) {
918 return (looked(part, rule), false);
919 }
920 let _claiming = CLAIMING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
921 let here = share::Here::load(&state_dir());
922 let alone = sioul_sync::lease::Keeper::alone(&here.id);
923 let Some((folder, key)) = sealed(attached(&here)) else { return (alone, false) };
924 let wrote = share::written(&memory_path(), &here.id);
926 match sioul_sync::lease::renew(&folder, &key, part, &here.id, jiff::Timestamp::now().as_second(), active, take, rule, wrote) {
927 Ok(keeper) => (keeper, false),
928 Err(_) => (alone, true),
929 }
930}
931
932fn renews(phone: bool, away: bool) -> bool {
934 !(phone && away)
935}
936
937pub(crate) fn looked(part: &str, rule: sioul_sync::lease::Rule) -> sioul_sync::lease::Keeper {
939 let here = share::Here::load(&state_dir());
940 let Some((folder, key)) = sealed(attached(&here)) else { return sioul_sync::lease::Keeper::alone(&here.id) };
941 sioul_sync::lease::look(&folder, &key, part, &here.id, jiff::Timestamp::now().as_second(), rule)
942}
943
944static CLAIMING: Mutex<()> = Mutex::new(());
947
948const PARTS: [&str; 3] = ["health", "notices", crate::projects::INVOICES];
950
951pub(crate) fn closing() {
956 let ended = || !cfg!(target_os = "android") || crate::backend::AWAY.load(std::sync::atomic::Ordering::SeqCst);
958 let here = here();
959 let Some((folder, key)) = sealed(here.folder_path()) else {
960 crate::devices::window_closed(ended);
961 return;
962 };
963 let memory = memory_path();
964 {
965 HURRY.store(true, std::sync::atomic::Ordering::Relaxed);
967 let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
968 HURRY.store(false, std::sync::atomic::Ordering::Relaxed);
969 let stores = stores_here(&here);
970 let sharing = share::Sharing { folder: &folder, computer: &here.id, key: &key, memory: &memory, files: false, hurry: None };
971 if let Ok(outcome) = share::exchange(&sharing, &stores, jiff::Timestamp::now().as_millisecond()) {
972 crate::devices::exported(&outcome);
973 }
974 }
975 {
976 let _claiming = CLAIMING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
977 if ended() {
979 let wrote = share::written(&memory, &here.id);
980 for part in PARTS {
981 let _ = sioul_sync::lease::close(&folder, &key, part, &here.id, jiff::Timestamp::now().as_second(), wrote);
982 }
983 }
984 }
985 crate::devices::window_closed(ended);
987 let going = if mirrored().is_some() {
991 Some(std::thread::spawn(move || mirror_step(&here, false, false)))
992 } else if sending_on(&here) {
993 Some(std::thread::spawn(move || {
995 send_to_server(&here, false);
996 }))
997 } else {
998 None
999 };
1000 if let Some(going) = going {
1001 let until = std::time::Instant::now() + std::time::Duration::from_secs(if cfg!(target_os = "android") { 0 } else { 5 });
1002 while !going.is_finished() && std::time::Instant::now() < until {
1003 std::thread::sleep(std::time::Duration::from_millis(50));
1004 }
1005 }
1006}
1007
1008pub(crate) struct Others {
1010 pub claims: Vec<sioul_sync::lease::Claim>,
1012 pub heard: share::Heard,
1014 pub others: Vec<share::Other>,
1016 pub entries: Vec<sioul_sync::devices::Entry>,
1018 pub unread: Vec<String>,
1019 pub news: Option<bool>,
1025}
1026
1027pub(crate) fn others_on_health() -> Option<Others> {
1031 let here = share::Here::load(&state_dir());
1032 let Some((folder, key)) = sealed(attached(&here)) else { return None };
1033 let claims = sioul_sync::lease::claims(&folder, &key, "health").into_iter().filter(|c| c.computer != here.id).collect();
1034 let (mut entries, mut unread) = sioul_sync::devices::all(&folder, &key);
1035 entries.retain(|e| e.id != here.id);
1036 unread.retain(|id| *id != here.id);
1037 let pulls = sioul_sync::remote::State::load(&memory_path());
1038 let now = jiff::Timestamp::now().as_second();
1039 let news = (pulls.fetching() && pulls.confirmed > 0 && !pulls.url.is_empty()).then(|| pulls.said.is_empty() && (now - 2 * 60..=now).contains(&pulls.last));
1040 Some(Others { claims, heard: share::heard(&memory_path(), &here.id), others: share::others_heard(&folder, &key, &here.id), entries, unread, news })
1042}
1043
1044pub(crate) fn vault() -> Option<(String, Option<(PathBuf, [u8; 32])>)> {
1047 let here = share::Here::load(&state_dir());
1048 if here.id.is_empty() {
1049 return None;
1050 }
1051 let vault = sealed(attached(&here));
1052 Some((here.id, vault))
1053}
1054
1055pub(crate) fn shares(part: &str) -> bool {
1057 here().shares(part, &load_config())
1058}
1059
1060pub(crate) fn nudge_carriers() {
1063 ask_carriers();
1064}
1065
1066pub(crate) fn rebuild_health_record() -> String {
1071 let here = share::Here::load(&state_dir());
1072 let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1073 share::rebuild(&memory_path(), &here.id, &["state/health-state.toml"]).err().unwrap_or_default()
1074}
1075
1076pub(crate) fn rebuild_dose_records() -> String {
1079 let here = share::Here::load(&state_dir());
1080 let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1081 share::rebuild(&memory_path(), &here.id, &["state/health-doses.toml"]).err().unwrap_or_default()
1082}
1083
1084#[cfg(target_os = "android")]
1092const CARRIERS: &[(&str, &str, &str)] = &[
1093 ("foundation.e.drive", "foundation.e.drive.receivers.DebugCmdReceiver", "foundation.e.drive.action.FORCE_SCAN"),
1096];
1097
1098fn ask_carriers() {
1100 ask_carriers_with(false);
1101}
1102
1103fn ask_carriers_with(always: bool) {
1106 if mirrored().is_some() {
1108 std::thread::spawn(|| mirror_step(&here(), false, false));
1109 return;
1110 }
1111 let here = here();
1116 if sending_on(&here) {
1117 std::thread::spawn(move || {
1118 send_to_server(&here, false);
1119 if always || !here.folder_path().is_some_and(|folder| pulls_alone(&folder)) {
1121 ask_carriers_only();
1122 }
1123 });
1124 return;
1125 }
1126 ask_carriers_only();
1127}
1128
1129fn pulls_alone(folder: &Path) -> bool {
1136 cfg!(target_os = "android") && sioul_sync::remote::State::load(&memory_path()).pulls_well(folder)
1137}
1138
1139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1141enum News {
1142 None,
1145 Pull,
1149 Alarm,
1155 SyncApp,
1158 Computer,
1160}
1161
1162fn news(fetch_first: bool, phone: bool, mirror: bool, pulls_well: bool, found: bool, alarm: bool) -> News {
1167 match (fetch_first && !mirror, phone) {
1168 (false, _) => News::None,
1169 (true, false) => News::Computer,
1170 (true, true) if alarm && found => News::Alarm,
1171 (true, true) if pulls_well && !alarm => News::Pull,
1172 (true, true) => News::SyncApp,
1173 }
1174}
1175
1176thread_local! {
1177 static NEWS_MISSED: std::cell::Cell<Option<(i64, std::collections::BTreeSet<String>)>> = const { std::cell::Cell::new(None) };
1181}
1182
1183pub(crate) fn news_missed() -> Option<(i64, std::collections::BTreeSet<String>)> {
1189 NEWS_MISSED.with(std::cell::Cell::take)
1190}
1191
1192fn ask_sync_app() -> std::time::Duration {
1197 use std::sync::atomic::Ordering;
1198 let now = jiff::Timestamp::now().as_second();
1199 let last = NUDGED.load(Ordering::Relaxed);
1200 let waited = if now - last < 20 {
1201 now - last
1202 } else {
1203 NUDGED.store(now, Ordering::Relaxed);
1204 ask_carriers_with(true);
1205 0
1206 };
1207 std::time::Duration::from_secs((20 - waited).max(0) as u64)
1208}
1209
1210fn ask_carriers_only() {
1212 #[cfg(target_os = "android")]
1213 for (package, receiver, action) in CARRIERS {
1214 let text = |s: &str| std::ffi::CString::new(s).unwrap_or_default();
1215 let (package, receiver, action) = (text(package), text(receiver), text(action));
1216 unsafe { crate::backend::sioul_android_broadcast(package.as_ptr(), receiver.as_ptr(), action.as_ptr()) };
1218 }
1219}
1220
1221static NUDGED: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1223static ALONE: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1225static SHOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
1226
1227pub(crate) fn set_shown(shown: bool) {
1229 SHOWN.store(shown, std::sync::atomic::Ordering::Relaxed);
1230}
1231
1232pub(crate) fn nudge(qt: &QtThread, shared: &Arc<Shared>, every: i64, then_read: bool) {
1237 use std::sync::atomic::Ordering;
1238 let now = jiff::Timestamp::now().as_second();
1239 let last = NUDGED.load(Ordering::Relaxed);
1240 if !cfg!(target_os = "android") || now - last < every.max(60) {
1241 return;
1242 }
1243 let here = share::Here::load(&state_dir());
1244 let Some(folder) = here.folder_path() else { return };
1245 if pulls_alone(&folder) {
1251 let alone = ALONE.load(Ordering::Relaxed);
1252 if now - alone < every.max(60) || ALONE.compare_exchange(alone, now, Ordering::Relaxed, Ordering::Relaxed).is_err() {
1253 return;
1254 }
1255 if then_read && every == 0 {
1256 exchange(qt, shared);
1257 }
1258 if then_read || sending_on(&here) {
1259 return;
1260 }
1261 }
1262 if NUDGED.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed).is_err() {
1264 return;
1265 }
1266 ask_carriers();
1267 if then_read {
1268 let (qt, shared) = (qt.clone(), Arc::clone(shared));
1269 std::thread::spawn(move || {
1270 std::thread::sleep(std::time::Duration::from_secs(20));
1271 exchange(&qt, &shared);
1272 });
1273 }
1274}
1275
1276pub(crate) fn nudge_tick(qt: &QtThread, shared: &Arc<Shared>) {
1278 if SHOWN.load(std::sync::atomic::Ordering::Relaxed) {
1279 nudge(qt, shared, 5 * 60, true);
1280 }
1281}
1282
1283pub(crate) fn exchange_here(fetch_first: bool) -> Option<Result<share::Outcome, String>> {
1291 let here = here();
1292 let Some((folder, key)) = sealed(here.folder_path()) else { return None };
1293 crate::calls::before_exchange();
1295 crate::phonemsgs::before_exchange();
1296 crate::texts::before_exchange();
1298 let mirror = mirrored().is_some();
1299 if mirror && fetch_first {
1301 mirror_step(&here, true, true);
1302 }
1303 let pull_alone = !mirror && pulls_alone(&folder);
1308 let alarm = fetch_first && !crate::steps::in_service();
1310 let found = {
1311 let state = sioul_sync::remote::State::load(&memory_path());
1312 state.fetching() && state.confirmed_for(&folder)
1313 };
1314 let waits = news(fetch_first, cfg!(target_os = "android"), mirror, pull_alone, found, alarm);
1315 NEWS_MISSED.with(|missed| missed.set(None));
1316 let fetching = (fetch_first && !mirror && waits != News::Alarm).then(|| {
1319 let here = here.clone();
1320 std::thread::spawn(move || fetch_from_server(&here, true))
1321 });
1322 if !fetch_first && crate::steps::in_service() {
1326 if mirror { mirror_step(&here, true, false) } else { fetch_from_server(&here, false) }
1327 }
1328 if waits == News::SyncApp {
1331 std::thread::sleep(ask_sync_app());
1332 }
1333 if waits == News::Alarm {
1336 let memory = memory_path();
1337 let since = jiff::Timestamp::now().as_second();
1338 match attached(&here) {
1339 Some(at) => {
1340 let state = sioul_sync::remote::State::load(&memory);
1341 let login = load_config().accounts.iter().find(|a| a.id == state.account).and_then(login_of);
1342 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);
1343 match came {
1344 sioul_sync::remote::AlarmNews::Pulled => log_backup("alarm: the others' news pulled since it began".into()),
1345 sioul_sync::remote::AlarmNews::SyncApp { heard } => {
1346 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()));
1347 NEWS_MISSED.with(|missed| missed.set(Some((since, heard))));
1348 }
1349 }
1350 }
1351 None => std::thread::sleep(ask_sync_app()),
1352 }
1353 }
1354 if let Some(fetching) = fetching {
1359 let until = std::time::Instant::now() + std::time::Duration::from_secs(match waits {
1360 News::Pull => 25,
1361 News::SyncApp => 5,
1362 News::Computer | News::None | News::Alarm => 15,
1363 });
1364 while !fetching.is_finished() && std::time::Instant::now() < until {
1365 std::thread::sleep(std::time::Duration::from_millis(100));
1366 }
1367 }
1368 HURRY.store(true, std::sync::atomic::Ordering::Relaxed);
1371 let _busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1372 HURRY.store(false, std::sync::atomic::Ordering::Relaxed);
1373 let stores = stores_here(&here);
1374 let memory = memory_path();
1375 share::set_importing(&memory, crate::texts::importing_in_bulk());
1377 let sharing = share::Sharing { folder: &folder, computer: &here.id, key: &key, memory: &memory, files: false, hurry: None };
1378 let outcome = share::exchange(&sharing, &stores, jiff::Timestamp::now().as_millisecond());
1379 if let Ok(outcome) = &outcome {
1380 crate::devices::exported(outcome);
1381 if mirror {
1385 if fetch_first || crate::steps::in_service() {
1386 mirror_step(&here, false, false);
1387 } else {
1388 let here = here.clone();
1389 std::thread::spawn(move || mirror_step(&here, false, false));
1390 }
1391 }
1392 remember(&outcome.problems, jiff::Timestamp::now().as_second());
1393 if cfg!(target_os = "android") {
1396 let codes: Vec<&str> = outcome.problems.iter().map(|p| p.split(':').next().unwrap_or_default()).collect();
1397 match outcome.quiet {
1398 true => eprintln!("sioul: sharing: nothing new, nothing read"),
1399 false => eprintln!("sioul: sharing: {} sent, {} received, {} waiting{}{}{}", outcome.sent, outcome.received, outcome.pending, waits_said(&outcome.waits), if codes.is_empty() { "" } else { "; " }, codes.join("; ")),
1400 }
1401 }
1402 let ask = outcome.sent > 0 && !crate::devices::in_receiver();
1404 if sending_on(&here) {
1409 let here = here.clone();
1410 let go = move || {
1411 send_to_server(&here, false);
1412 if ask && !pull_alone {
1413 ask_carriers_only();
1414 }
1415 };
1416 if fetch_first || crate::steps::in_service() {
1417 go();
1418 } else {
1419 std::thread::spawn(go);
1420 }
1421 } else if ask {
1422 ask_carriers();
1423 }
1424 }
1425 Some(outcome)
1426}
1427
1428fn remember(problems: &[String], now: i64) {
1430 if let Ok(mut kept) = SAID.lock() {
1431 for problem in problems.iter().filter(|p| kept_a_day(p)) {
1432 kept.retain(|(_, p)| p != problem);
1433 kept.push((now, problem.clone()));
1434 }
1435 }
1436}
1437
1438pub(crate) fn exchange(qt: &QtThread, shared: &Arc<Shared>) {
1443 start_worker(qt, shared);
1444 want(|soon, now| soon.ask(now));
1445}
1446
1447pub(crate) fn exchange_tick(qt: &QtThread, shared: &Arc<Shared>) {
1450 start_worker(qt, shared);
1451 want(|soon, now| soon.tick(now));
1452}
1453
1454fn exchange_now(qt: &QtThread, shared: &Arc<Shared>) {
1459 let (qt, shared) = (qt.clone(), Arc::clone(shared));
1460 {
1461 let mirror = mirrored().is_some();
1464 if mirror {
1465 mirror_step(&here(), true, false);
1466 } else {
1467 fetch_from_server(&here(), false);
1468 }
1469 share::set_frugal(&memory_path(), frugal_now());
1472 share::set_importing(&memory_path(), crate::texts::importing_in_bulk());
1473 let busy = BUSY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1475 let here = here();
1476 let Some((folder, key)) = sealed(here.folder_path()) else { return };
1477 crate::calls::before_exchange();
1479 crate::phonemsgs::before_exchange();
1480 crate::texts::before_exchange();
1482 let stores = stores_here(&here);
1483 let now = jiff::Timestamp::now();
1484 let memory = memory_path();
1485 let readable = files_readable();
1487 let sharing = share::Sharing { folder: &folder, computer: &here.id, key: &key, memory: &memory, files: readable, hurry: Some(&HURRY) };
1488 let (mut received, mut pending, mut waits, mut no_blob) = (0, 0, String::new(), false);
1489 let (written, accounts, problems, sent) = match share::exchange(&sharing, &stores, now.as_millisecond()) {
1490 Ok(outcome) => {
1491 crate::devices::exported(&outcome);
1493 (received, pending, waits) = (outcome.received, outcome.pending, waits_said(&outcome.waits));
1494 no_blob = outcome.waits.contains_key("no-blob");
1495 if !outcome.written.is_empty() {
1496 if let Ok(mut cache) = shared.loaded.lock() {
1500 *cache = None;
1501 }
1502 crate::work::show_work(&qt, &shared);
1503 let names = outcome.written.iter().cloned().collect::<Vec<_>>().join("\n");
1504 let _ = qt.queue(move |mut sioul| sioul.as_mut().shared_in(QString::from(&names)));
1505 }
1506 if outcome.written.contains("data/health.toml") || outcome.written.contains("data/health-days.toml") {
1508 crate::wake::schedule();
1509 }
1510 if ["state/do-not-disturb.toml", "state/quiet.toml", "data/time/running.toml", "config/config.toml"].iter().any(|f| outcome.written.contains(*f)) {
1512 std::thread::spawn(crate::everywhere::apply);
1513 }
1514 (!outcome.written.is_empty(), outcome.written.contains("config/config.toml"), outcome.problems, outcome.sent)
1515 }
1516 Err(e) => (false, false, vec![e], 0),
1517 };
1518 if let Ok(mut watched) = WATCHED.lock() {
1522 *watched = Some(sioul_sync::trigger::Stamps::of(&stores));
1523 }
1524 drop(busy);
1525 if mirror {
1530 send_texts(&here);
1531 mirror_step(&here, false, false);
1532 } else {
1533 send_to_server(&here, false);
1534 if sent > 0 {
1535 nudge(&qt, &shared, 60, false);
1536 }
1537 }
1538 if cfg!(target_os = "android") {
1541 let codes: Vec<&str> = problems.iter().map(|p| p.split(':').next().unwrap_or_default()).collect();
1543 eprintln!("sioul: sharing: {sent} sent, {received} received, {pending} waiting{waits}{}{}", if codes.is_empty() { "" } else { "; " }, codes.join("; "));
1544 }
1545 for part in problems.iter().filter_map(|p| p.strip_prefix("share-newer:")) {
1549 crate::backend::set_status(&qt, say("share-newer-status", &[("part", part_words(part).0)]));
1550 }
1551 let mut said = problems;
1552 if !readable && stores.iter().any(|s| matches!(s.shape, share::Shape::Files)) {
1554 said.push("share-files-unreadable".into());
1555 }
1556 said.dedup();
1557 remember(&said, now.as_second());
1558 if let Ok(mut last) = LAST.lock() {
1559 *last = Some((now.as_second(), said));
1560 }
1561 if accounts {
1564 crate::backend::start_new_watchers(&qt, &shared);
1565 }
1566 if written {
1567 crate::backend::show(&qt, &shared);
1568 crate::pim::show_pim(&qt, &shared);
1569 crate::work::show_work(&qt, &shared);
1570 }
1571 if !mirror && no_blob {
1577 let folder = folder.clone();
1578 std::thread::spawn(move || {
1579 if fetch_wanted(&folder, &key) > 0 {
1580 want(|soon, now| soon.ask(now));
1581 }
1582 });
1583 }
1584 }
1585}
1586
1587fn waits_said(waits: &std::collections::BTreeMap<String, usize>) -> String {
1591 if waits.is_empty() {
1592 return String::new();
1593 }
1594 format!(" ({})", waits.iter().map(|(why, n)| format!("{why} {n}")).collect::<Vec<_>>().join(", "))
1595}
1596
1597static SOON: Mutex<sioul_sync::trigger::Soon> = Mutex::new(sioul_sync::trigger::Soon::new());
1599static SOON_WAKE: std::sync::Condvar = std::sync::Condvar::new();
1600static WORKER: std::sync::OnceLock<(QtThread, Arc<Shared>)> = std::sync::OnceLock::new();
1602static WATCHED: Mutex<Option<sioul_sync::trigger::Stamps>> = Mutex::new(None);
1604
1605fn now_ms() -> i64 {
1606 jiff::Timestamp::now().as_millisecond()
1607}
1608
1609fn want(change: impl FnOnce(&mut sioul_sync::trigger::Soon, i64)) {
1611 let mut soon = SOON.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1612 change(&mut soon, now_ms());
1613 SOON_WAKE.notify_all();
1614}
1615
1616fn start_worker(qt: &QtThread, shared: &Arc<Shared>) {
1618 if WORKER.set((qt.clone(), Arc::clone(shared))).is_ok() {
1619 std::thread::spawn(worker);
1620 std::thread::spawn(watcher);
1621 }
1622}
1623
1624fn worker() {
1630 let Some((qt, shared)) = WORKER.get() else { return };
1631 loop {
1632 {
1633 let mut soon = SOON.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1634 loop {
1635 let now = now_ms();
1636 match soon.due() {
1637 Some(due) if due <= now => {
1638 soon.take(now);
1639 break;
1640 }
1641 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,
1642 None => soon = SOON_WAKE.wait(soon).unwrap_or_else(std::sync::PoisonError::into_inner),
1643 }
1644 }
1645 }
1646 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| exchange_now(qt, shared)));
1647 give_back_memory();
1648 }
1649}
1650
1651fn give_back_memory() {
1656 #[cfg(all(target_os = "linux", target_env = "gnu"))]
1657 {
1658 unsafe extern "C" {
1660 fn malloc_trim(pad: usize) -> std::ffi::c_int;
1661 }
1662 unsafe {
1664 malloc_trim(0);
1665 }
1666 }
1667}
1668
1669fn watcher() {
1676 loop {
1677 std::thread::sleep(std::time::Duration::from_secs(2));
1678 let shown = SHOWN.load(std::sync::atomic::Ordering::Relaxed);
1679 let here = share::Here::load(&state_dir());
1680 if !shown || here.folder_path().is_none() || key().is_none() {
1681 if let Ok(mut watched) = WATCHED.lock() {
1682 *watched = None;
1683 }
1684 continue;
1685 }
1686 let _quiet = match BUSY.try_lock() {
1687 Ok(held) => held,
1688 Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
1689 Err(std::sync::TryLockError::WouldBlock) => continue,
1690 };
1691 let now = sioul_sync::trigger::Stamps::of(&stores_here(&here));
1692 let Ok(mut watched) = WATCHED.lock() else { continue };
1693 if watched.as_ref().is_some_and(|before| !now.changed(before).is_empty()) {
1694 want(|soon, at| soon.saved(at));
1695 }
1696 *watched = Some(now);
1697 }
1698}
1699
1700fn sending_on(here: &share::Here) -> bool {
1705 let Some(folder) = here.folder_path() else { return false };
1706 let state = sioul_sync::remote::State::load(&memory_path());
1707 state.mode != sioul_sync::remote::MIRROR && state.confirmed_for(&folder) && state.sending()
1708}
1709
1710fn send_to_server(here: &share::Here, again: bool) -> Option<sioul_sync::remote::Pulled> {
1716 let folder = here.folder_path()?;
1717 if mirrored().is_some() || here.id.is_empty() {
1718 return None;
1719 }
1720 let memory = memory_path();
1721 let now = jiff::Timestamp::now().as_second();
1722 let state = sioul_sync::remote::State::load(&memory);
1723 if !state.confirmed_for(&folder) {
1724 if again {
1725 sioul_sync::remote::Sending::note_again(&memory, now, "not-confirmed");
1726 }
1727 return None;
1728 }
1729 let config = load_config();
1730 let Some(login) = config.accounts.iter().find(|a| a.id == state.account).and_then(login_of) else {
1731 if again {
1732 sioul_sync::remote::Sending::note_again(&memory, now, &format!("no-account-for:{}", state.host()));
1733 }
1734 return None;
1735 };
1736 let sealed = share::own_sealed(&memory, &here.id);
1737 send_texts(here);
1739 let sent = sioul_sync::remote::send(&memory, &folder, &here.id, &login, &sealed, now, again, metered());
1740 if sent.sent > 0 || sent.held > 0 || again || sent.problem.as_deref().is_some_and(|p| p != "off" && !p.starts_with("waiting:")) {
1741 let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
1742 let detail = sent.failure.as_ref().map(|f| format!("; {}", failure_words(f))).unwrap_or_default();
1743 log_send(match &sent.problem {
1744 None => format!("{at} sent to {}: {} sent, {} there already, {} records longer there left as they are, {} paced, {} bytes{}", state.host(), sent.sent, sent.same, sent.longer, sent.paced, sent.bytes, if sent.differ.is_empty() { String::new() } else { format!("; otherwise there: {}", sent.differ.join("; ")) }),
1745 Some(why) => format!("{at} send to {} stopped: {}{detail}", state.host(), backup_words(why)),
1746 });
1747 }
1748 Some(sent)
1749}
1750
1751fn failure_words(f: &sioul_sync::remote::Failure) -> String {
1754 let file = if f.file.is_empty() { String::new() } else { format!("{}: ", f.file) };
1755 let bytes = if f.total > 0 { format!(", {} of {} bytes handed to the network", f.sent, f.total) } else { String::new() };
1756 format!("{file}{} after {} s{bytes} ({})", f.kind, f.seconds, f.words)
1757}
1758
1759fn log_send(line: String) {
1762 static SAID: Mutex<String> = Mutex::new(String::new());
1763 if let Ok(mut said) = SAID.lock() {
1764 if *said == line {
1765 return;
1766 }
1767 said.clone_from(&line);
1768 }
1769 eprintln!("sioul: backup: {line}");
1770}
1771
1772fn metered() -> bool {
1776 static ASKED: Mutex<(i64, bool)> = Mutex::new((0, false));
1777 if cfg!(target_os = "android") {
1778 return false;
1779 }
1780 let now = jiff::Timestamp::now().as_second();
1781 let Ok(mut asked) = ASKED.lock() else { return false };
1782 if now - asked.0 >= 300 {
1783 *asked = (now, sioul_sync::power::read().metered == Some(true));
1784 }
1785 asked.1
1786}
1787
1788fn frugal_now() -> bool {
1791 metered() || sioul_sync::remote::Sending::load(&memory_path()).slow()
1792}
1793
1794fn send_texts(here: &share::Here) {
1800 let Some(folder) = here.folder_path() else { return };
1801 if here.id.is_empty() || !here.shares(sioul_core::texts::PART, &load_config()) {
1802 return;
1803 }
1804 let source = sioul_core::texts::own_file(&sioul_core::texts::folder(), sioul_core::texts::SEND, &here.id);
1805 if let Err(e) = sioul_sync::remote::place_texts(&folder, &here.id, &source) {
1806 eprintln!("sioul: texts: {e}");
1807 }
1808 let memory = memory_path();
1809 let state = sioul_sync::remote::State::load(&memory);
1810 if !state.confirmed_for(&folder) || !(state.mode == sioul_sync::remote::MIRROR || state.sending()) {
1811 return;
1812 }
1813 let config = load_config();
1814 let Some(login) = config.accounts.iter().find(|a| a.id == state.account).and_then(login_of) else { return };
1815 let sent = sioul_sync::remote::send_urgent(&memory, &folder, &here.id, &login, jiff::Timestamp::now().as_second());
1816 if sent.sent > 0 || sent.failure.is_some() {
1817 let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
1818 log_send(match &sent.failure {
1819 None => format!("{at} texts to send sent to {}", state.host()),
1820 Some(f) => format!("{at} texts to send not sent to {}: {}", state.host(), failure_words(f)),
1821 });
1822 }
1823}
1824
1825pub(crate) fn texts_now() {
1828 std::thread::spawn(|| send_texts(&here()));
1829}
1830
1831pub(crate) fn texts_copies() -> Vec<PathBuf> {
1836 let here = share::Here::load(&state_dir());
1837 let Some(folder) = attached(&here) else { return Vec::new() };
1838 let mut out = vec![folder.join(sioul_core::texts::FOLDER)];
1839 out.extend(sioul_sync::remote::overlay(&folder).map(|cache| cache.join(sioul_core::texts::FOLDER)));
1840 out
1841}
1842
1843pub(crate) fn send_again(qt: &QtThread, shared: &Arc<Shared>) {
1849 let (qt, shared) = (qt.clone(), Arc::clone(shared));
1850 std::thread::spawn(move || {
1851 let here = here();
1852 let memory = memory_path();
1853 let now = jiff::Timestamp::now().as_second();
1854 match (mirrored(), here.folder_path()) {
1855 (Some(state), Some(folder)) => match load_config().accounts.iter().find(|a| a.id == state.account).and_then(login_of) {
1856 Some(login) => {
1857 sioul_sync::remote::again_mirror(&memory, &folder, &here.id, &login, now);
1858 }
1859 None => sioul_sync::remote::Sending::note_again(&memory, now, &format!("no-account-for:{}", state.host())),
1860 },
1861 (None, Some(_)) => {
1862 send_to_server(&here, true);
1863 fetch_from_server(&here, true);
1864 }
1865 _ => {}
1866 }
1867 exchange(&qt, &shared);
1868 });
1869}
1870
1871pub(crate) fn set_send(on: bool) -> String {
1874 sioul_sync::remote::State::choose(&memory_path(), |state| state.send = (!on).then_some(false)).err().unwrap_or_default()
1875}
1876
1877fn mirrored() -> Option<sioul_sync::remote::State> {
1881 let state = sioul_sync::remote::State::load(&memory_path());
1882 (state.mode == sioul_sync::remote::MIRROR).then_some(state)
1883}
1884
1885fn mirror_step(here: &share::Here, whole: bool, urgent: bool) {
1890 if let Some(folder) = here.folder_path() {
1891 mirror_step_for(&folder, &here.id, whole, urgent);
1892 }
1893}
1894
1895fn mirror_step_for(folder: &Path, own: &str, whole: bool, urgent: bool) {
1896 let Some(state) = mirrored().filter(|s| s.confirmed_for(folder)) else { return };
1897 let now = jiff::Timestamp::now().as_second();
1898 let whole = whole && (urgent || now - state.tried >= 20);
1899 let config = load_config();
1900 let Some(login) = config.accounts.iter().find(|a| a.id == state.account).and_then(login_of) else { return };
1901 let stepped = sioul_sync::remote::step(&memory_path(), folder, own, &login, now, whole);
1902 if cfg!(target_os = "android") && (stepped.fetched + stepped.sent > 0 || stepped.problem.is_some()) {
1904 let code = stepped.problem.as_deref().map(|p| format!("; {}", p.split(':').next().unwrap_or_default())).unwrap_or_default();
1905 eprintln!("sioul: sharing: {} fetched, {} sent by Sioul itself{code}", stepped.fetched, stepped.sent);
1906 }
1907}
1908
1909#[derive(Serialize)]
1911struct ServerAccount {
1912 id: String,
1913 label: String,
1914}
1915
1916fn server_accounts(config: &Config) -> Vec<ServerAccount> {
1919 config
1920 .accounts
1921 .iter()
1922 .filter(|a| a.is_dav() && a.auth.as_deref() != Some("google") && a.url.as_deref().is_some_and(|u| u.contains("/remote.php/")))
1923 .map(|a| {
1924 let host = sioul_sync::remote::host_of(a.url.as_deref().unwrap_or_default());
1925 let who = a.address.clone().unwrap_or_else(|| a.id.clone());
1926 ServerAccount { id: a.id.clone(), label: if who.ends_with(&host) { who } else { format!("{who} ({host})") } }
1927 })
1928 .collect()
1929}
1930
1931fn mirror_line(state: &sioul_sync::remote::State) -> String {
1933 let host = state.host();
1934 let (code, _) = state.said.split_once(':').unwrap_or((state.said.as_str(), ""));
1935 if state.confirmed == 0 {
1936 return say("share-server-unconfirmed", &[("host", host)]);
1937 }
1938 let why = || {
1939 tr().text(
1940 match code {
1941 "login" => "share-backup-why-login",
1942 "tls" => "share-backup-why-tls",
1943 "network" => "share-backup-why-network",
1944 "disk" => "share-backup-why-disk",
1945 "quota" => "share-backup-why-quota",
1946 _ => "share-backup-why-server",
1947 },
1948 None,
1949 )
1950 };
1951 let last = state.last.max(state.pushed);
1952 match (code, last) {
1953 ("", 0) => String::new(),
1954 ("", last) => say("share-server-last", &[("when", when(last))]),
1955 (_, 0) => say("share-server-failing-never", &[("why", why())]),
1956 (_, last) => say("share-server-failing", &[("when", when(last)), ("why", why())]),
1957 }
1958}
1959
1960fn server_problem(code: &str) -> String {
1962 let (kind, about) = code.split_once(':').unwrap_or((code, ""));
1963 let about = || about.to_string();
1964 match kind {
1965 "no-place" => tr().text("share-server-no-place", None),
1966 "no-files" => say("share-server-no-files", &[("host", about())]),
1967 "no-password" => tr().text("share-server-no-password", None),
1968 "login" => say("share-server-refused", &[("host", about())]),
1969 "tls" => say("share-server-tls", &[("host", about())]),
1970 "network" => say("share-server-unreachable", &[("host", about())]),
1971 "sealed-meanwhile" => tr().text("share-server-sealed-meanwhile", None),
1972 _ => say("share-server-error", &[("host", about())]),
1973 }
1974}
1975
1976pub(crate) fn start_on_server(qt: &QtThread, shared: &Arc<Shared>, account: String, place: String, passphrase: String, again: String) {
1982 let (qt, shared) = (qt.clone(), Arc::clone(shared));
1983 std::thread::spawn(move || {
1984 let started = (|| -> Result<(), String> {
1985 if passphrase.chars().count() < 12 {
1986 return Err(tr().text("share-short", None));
1987 }
1988 let config = load_config();
1989 let login = config.accounts.iter().find(|a| a.id == account).and_then(login_of).ok_or_else(|| tr().text("share-server-none", None))?;
1990 if login.password.is_none() {
1991 return Err(tr().text("share-server-no-password", None));
1992 }
1993 let opened = sioul_sync::remote::open(&login, &place).map_err(|code| server_problem(&code))?;
1994 let memory = memory_path();
1995 let mirror = sioul_sync::remote::mirror_of(&memory);
1996 let _ = std::fs::remove_dir_all(&mirror);
1998 share::private_dirs(&mirror).map_err(|e| format!("{}: {e}", mirror.display()))?;
1999 let key = match &opened.seal {
2000 Some(seal) => {
2002 share::write_atomically(&mirror.join("seal.toml"), seal)?;
2003 share::key_for(&mirror, &passphrase)
2004 }
2005 None if passphrase != again => return Err(tr().text("share-differ", None)),
2006 None => share::key_for(&mirror, &passphrase),
2007 };
2008 let key = match key {
2009 Ok(key) => key,
2010 Err(share::Refused::WrongPassphrase) => {
2011 let _ = std::fs::remove_dir_all(&mirror);
2012 return Err(tr().text("share-wrong", None));
2013 }
2014 Err(share::Refused::Other(e)) => return Err(e),
2015 };
2016 if opened.seal.is_none() {
2017 sioul_sync::remote::create(&login, &opened, &mirror).map_err(|code| {
2018 let _ = std::fs::remove_dir_all(&mirror);
2019 server_problem(&code)
2020 })?;
2021 }
2022 sioul_sync::secret::save_named(share::KEY_NAME, &hex(&key)).map_err(|e| e.sentence(tr(), "Sioul"))?;
2023 if let Ok(mut held) = KEY.lock() {
2024 *held = Some(key);
2025 }
2026 sioul_sync::remote::begin(&memory, &mirror, &login, &opened, jiff::Timestamp::now().as_second())?;
2027 let state = state_dir();
2028 let _writing = HERE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2029 let mut here = share::Here::load(&state);
2030 here.folder = Some(mirror.display().to_string());
2031 here.save(&state)
2032 })();
2033 let problem = started.err().unwrap_or_default();
2034 if problem.is_empty() {
2036 exchange(&qt, &shared);
2037 }
2038 let _ = qt.queue(move |mut sioul| sioul.as_mut().share_started(QString::from(&problem)));
2039 });
2040}
2041
2042static FETCHING: Mutex<()> = Mutex::new(());
2046
2047fn attached(here: &share::Here) -> Option<PathBuf> {
2050 let folder = here.folder_path()?;
2051 sioul_sync::remote::attach(&folder, &memory_path());
2052 Some(folder)
2053}
2054
2055fn logins(config: &Config) -> Vec<sioul_sync::remote::Login> {
2059 config.accounts.iter().filter(|a| a.auth.as_deref() != Some("google")).filter_map(login_of).collect()
2060}
2061
2062fn login_of(account: &sioul_core::config::Account) -> Option<sioul_sync::remote::Login> {
2063 let url = account.url.clone().filter(|u| account.is_dav() && u.contains("/remote.php/"))?;
2064 Some(sioul_sync::remote::Login { account: account.id.clone(), url, user: account.login()?.to_string(), password: sioul_sync::secret::password(account).ok() })
2065}
2066
2067fn nextcloud_configs() -> Vec<String> {
2069 if cfg!(target_os = "android") {
2070 return Vec::new();
2071 }
2072 let home = expand_home("~");
2073 let windows = std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("Nextcloud/nextcloud.cfg"));
2074 [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()
2075}
2076
2077fn log_backup(line: String) {
2081 static SAID: Mutex<String> = Mutex::new(String::new());
2082 if !cfg!(target_os = "android") {
2083 return;
2084 }
2085 if let Ok(mut said) = SAID.lock() {
2086 if *said == line {
2087 return;
2088 }
2089 said.clone_from(&line);
2090 }
2091 eprintln!("sioul: backup: {line}");
2092}
2093
2094fn backup_words(said: &str) -> String {
2096 let (code, about) = said.split_once(':').unwrap_or((said, ""));
2097 let why = match code {
2098 "network" => "could not be reached",
2099 "login" => "refused the password kept for the account",
2100 "tls" => "its certificate could not be checked",
2101 "server" => "answered with an error",
2102 "quota" => "its space is full",
2103 "disk" => "could not be written here",
2104 "no-password" => "no password kept on this device for the account",
2105 "not-found" => "the folder is not there",
2106 "seal-differs" => "a folder sealed otherwise, never read",
2107 "seal-gone" => "the folder holds no seal any more",
2108 "no-account" => "no account with a Nextcloud server",
2109 "no-account-for" => "no account on that server",
2110 "no-seal" => "no seal in the folder here",
2111 "off" => "switched off here",
2112 other => other,
2113 };
2114 if about.is_empty() { why.to_string() } else { format!("{about}: {why}") }
2115}
2116
2117fn fetch_from_server(here: &share::Here, urgent: bool) {
2125 let Some(folder) = attached(here) else { return };
2126 if mirrored().is_some() {
2127 return;
2128 }
2129 let _fetching = match FETCHING.try_lock() {
2131 Ok(held) => held,
2132 Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
2133 Err(std::sync::TryLockError::WouldBlock) => return,
2134 };
2135 let memory = memory_path();
2136 let now = jiff::Timestamp::now().as_second();
2137 let mut state = sioul_sync::remote::State::load(&memory);
2138 if !state.fetching() || here.id.is_empty() {
2139 sioul_sync::remote::tidy(&memory, &folder, &here.id);
2140 log_backup("switched off here".into());
2141 return;
2142 }
2143 let config = load_config();
2144 let login = |account: &str| config.accounts.iter().find(|a| a.id == account).and_then(login_of);
2145 if state.look_due(&folder, now) || (state.confirmed_for(&folder) && login(&state.account).is_none_or(|l| l.password.is_none())) {
2147 let places = sioul_sync::remote::places(&folder, &nextcloud_configs());
2148 let logins = logins(&config);
2149 state = sioul_sync::remote::find(&memory, &folder, &logins, &places, now);
2150 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)) });
2151 }
2152 if !state.confirmed_for(&folder) {
2153 return;
2154 }
2155 let phone = cfg!(target_os = "android");
2156 let asleep = phone && crate::everywhere::rest_now() == (true, false);
2157 let pace = sioul_sync::remote::Pace {
2158 phone,
2159 shown: !crate::steps::in_service() && SHOWN.load(std::sync::atomic::Ordering::Relaxed),
2161 in_use: phone && crate::devices::others_in_use(),
2162 urgent,
2163 step: if asleep { crate::steps::STEP_ASLEEP } else { crate::steps::STEP_AWAKE },
2164 };
2165 if !sioul_sync::remote::due(&state, now, pace) {
2166 let pace_said = match (pace.shown, pace.in_use, asleep) {
2167 (true, _, _) => "Sioul on the screen: once a minute",
2168 (_, true, _) => "another device in use: once a minute",
2169 (_, false, true) => "in the background, nobody at another device, at night: every fifteen minutes",
2170 _ => "in the background, nobody at another device: every five minutes",
2171 };
2172 log_backup(format!("next pull at its pace ({pace_said})"));
2173 return;
2174 }
2175 let Some(login) = login(&state.account) else { return };
2176 let pulled = sioul_sync::remote::pull(&memory, &folder, &here.id, &login, now);
2177 let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
2178 log_backup(match &pulled.problem {
2179 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() }),
2180 Some(why) => format!("{at} pull from {} stopped: {}", state.host(), backup_words(why)),
2181 });
2182}
2183
2184pub(crate) fn fetch_wanted_here() {
2189 let here = here();
2190 let Some(folder) = attached(&here) else { return };
2191 if !sioul_sync::remote::wanted_due(&memory_path(), &folder, jiff::Timestamp::now().as_second()) {
2192 return;
2193 }
2194 if let Some((folder, key)) = sealed(Some(folder)) {
2195 fetch_wanted(&folder, &key);
2196 }
2197}
2198
2199fn fetch_wanted(folder: &Path, key: &[u8; 32]) -> usize {
2207 if mirrored().is_some() {
2208 return 0;
2209 }
2210 static WANTING: Mutex<()> = Mutex::new(());
2212 let _one = match WANTING.try_lock() {
2213 Ok(held) => held,
2214 Err(std::sync::TryLockError::Poisoned(held)) => held.into_inner(),
2215 Err(std::sync::TryLockError::WouldBlock) => return 0,
2216 };
2217 let memory = memory_path();
2218 let now = jiff::Timestamp::now().as_second();
2219 let state = sioul_sync::remote::State::load(&memory);
2220 if !state.fetching() || !state.confirmed_for(folder) || !sioul_sync::remote::wanted_due(&memory, folder, now) {
2221 return 0;
2222 }
2223 let Some(login) = load_config().accounts.iter().find(|a| a.id == state.account).and_then(login_of) else { return 0 };
2224 let pulled = sioul_sync::remote::fetch_wanted(&memory, folder, &login, key, now, frugal_now());
2225 let at = jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M:%S").to_string();
2226 log_backup(match &pulled.problem {
2227 None => format!(
2228 "{at} sealed files waited for: {} fetched from {}, {} bytes{}{}",
2229 pulled.fetched,
2230 state.host(),
2231 pulled.bytes,
2232 if pulled.paced > 0 { format!("; {} large left for later on this connection", pulled.paced) } else { String::new() },
2233 if pulled.held > 0 { format!("; {} did not come whole, asked again later", pulled.held) } else { String::new() }
2234 ),
2235 Some(why) => format!("{at} sealed files waited for: fetching from {} stopped: {}", state.host(), backup_words(why)),
2236 });
2237 pulled.fetched
2238}
2239
2240#[derive(Serialize)]
2242struct Backup {
2243 shown: bool,
2245 line: String,
2247 on: bool,
2249 given: String,
2251 found: bool,
2253 send: bool,
2255 send_switch: String,
2257 send_line: String,
2259}
2260
2261fn backup(on: bool, folder: &Path) -> Backup {
2262 let state = sioul_sync::remote::State::load(&memory_path());
2263 let line = if on { backup_line(&state, folder) } else { String::new() };
2264 let host = state.host();
2265 let send_switch = if host.is_empty() { tr().text("share-send-switch-none", None) } else { say("share-send-switch", &[("host", host)]) };
2266 let send_line = if on && state.fetching() { send_line(&state, &sioul_sync::remote::Sending::load(&memory_path()), folder) } else { String::new() };
2267 Backup { shown: on, line, on: state.fetching(), given: state.given.clone(), found: state.confirmed_for(folder), send: state.sending(), send_switch, send_line }
2268}
2269
2270fn why_words(code: &str) -> String {
2272 tr().text(
2273 match code {
2274 "login" => "share-backup-why-login",
2275 "tls" => "share-backup-why-tls",
2276 "network" => "share-backup-why-network",
2277 "disk" => "share-backup-why-disk",
2278 "quota" => "share-backup-why-quota",
2279 _ => "share-backup-why-server",
2280 },
2281 None,
2282 )
2283}
2284
2285fn send_line(state: &sioul_sync::remote::State, sending: &sioul_sync::remote::Sending, folder: &Path) -> String {
2287 let host = state.host();
2288 if !state.sending() {
2289 return if host.is_empty() { String::new() } else { say("share-send-off", &[("host", host)]) };
2290 }
2291 if !state.confirmed_for(folder) {
2292 return tr().text("share-send-waiting", None);
2293 }
2294 let (code, _) = sending.said.split_once(':').unwrap_or((sending.said.as_str(), ""));
2295 let line = match (code, sending.last) {
2296 ("", 0) => say("share-send-soon", &[("host", host)]),
2297 ("", last) => say("share-send-on", &[("host", host), ("when", when(last))]),
2298 (code, 0) => say("share-send-failing-never", &[("host", host), ("why", why_words(code))]),
2299 (code, last) => say("share-send-failing", &[("host", host), ("when", when(last)), ("why", why_words(code))]),
2300 };
2301 match sending.failure.as_ref().filter(|_| !sending.said.is_empty()) {
2303 Some(failure) => [line, failure_line(failure, sending.next_try())].join(" "),
2304 None => line,
2305 }
2306}
2307
2308fn failure_line(failure: &sioul_sync::remote::Failure, next: i64) -> String {
2311 let size = |bytes: u64| sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX));
2312 let detail = match failure.kind.as_str() {
2313 "timeout" => say("share-send-detail-timeout", &[("total", size(failure.total)), ("seconds", failure.seconds.to_string())]),
2314 "stalled" => say("share-send-detail-stalled", &[("total", size(failure.total))]),
2315 "network" if failure.sent > 0 => say("share-send-detail-cut", &[("total", size(failure.total))]),
2316 "out-of-time" => tr().text("share-send-detail-out-of-time", None),
2317 "unreachable" | "network" => tr().text("share-send-detail-unreachable", None),
2318 kind => format!("{}.", why_words(kind)),
2319 };
2320 let file = match failure.file.as_str() {
2321 "records" => "share-send-file-records",
2322 "entry" => "share-send-file-entry",
2323 "notes" => "share-send-file-notes",
2324 "claim" => "share-send-file-claim",
2325 "texts" => "share-send-file-texts",
2326 "sealed" => "share-send-file-sealed",
2327 _ => "",
2328 };
2329 let next = if next > 0 { say("share-send-next", &[("when", when(next))]) } else { String::new() };
2330 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)]) };
2331 [said, next].join(" ").trim().to_string()
2332}
2333
2334fn again_line(again: &sioul_sync::remote::Again, host: &str) -> String {
2336 if again.at == 0 {
2337 return String::new();
2338 }
2339 let (code, about) = again.said.split_once(':').unwrap_or((again.said.as_str(), ""));
2340 let host = if about.is_empty() { host.to_string() } else { about.to_string() };
2341 let at = when(again.at);
2342 let counted = |id: &str, count: usize| {
2343 let mut args = sioul_core::i18n::args();
2344 args.set("count", count as i64);
2345 args.set("host", host.clone());
2346 args.set("when", at.clone());
2347 tr().text(id, Some(&args))
2348 };
2349 match code {
2350 "" if again.sent > 0 => counted("share-send-again-sent", again.sent),
2351 "" => counted("share-send-again-same", again.same),
2352 "off" => tr().text("share-send-again-off", None),
2353 "not-confirmed" | "mirror" | "no-seal" | "seal-gone" | "seal-differs" => tr().text("share-send-again-not-found", None),
2354 "no-account-for" => say("share-send-again-no-account", &[("host", host)]),
2355 code => say("share-send-again-failed", &[("host", host), ("when", at), ("why", why_words(code))]),
2356 }
2357}
2358
2359fn backup_line(state: &sioul_sync::remote::State, folder: &Path) -> String {
2361 let host = state.host();
2362 let (code, about) = state.said.split_once(':').unwrap_or((state.said.as_str(), ""));
2363 let about = || about.to_string();
2364 if !state.fetching() {
2365 return if host.is_empty() { tr().text("share-backup-off-none", None) } else { say("share-backup-off", &[("host", host)]) };
2366 }
2367 if state.confirmed_for(folder) {
2368 let why = |code: &str| {
2369 tr().text(
2370 match code {
2371 "login" => "share-backup-why-login",
2372 "tls" => "share-backup-why-tls",
2373 "network" => "share-backup-why-network",
2374 "disk" => "share-backup-why-disk",
2375 _ => "share-backup-why-server",
2376 },
2377 None,
2378 )
2379 };
2380 return match (code, state.last) {
2381 ("", 0) => say("share-backup-soon", &[("host", host)]),
2382 ("", last) if !state.left.is_empty() => format!("{} {}", say("share-backup-on", &[("host", host), ("when", when(last))]), tr().text("share-backup-left", None)),
2384 ("", last) => say("share-backup-on", &[("host", host), ("when", when(last))]),
2385 (code, 0) => say("share-backup-failing-never", &[("host", host), ("why", why(code))]),
2386 (code, last) => say("share-backup-failing", &[("host", host), ("when", when(last)), ("why", why(code))]),
2387 };
2388 }
2389 if state.looked == 0 || state.folder != folder.display().to_string() || state.asked > state.looked {
2390 return tr().text("share-backup-looking", None);
2391 }
2392 match code {
2393 "no-account" => tr().text("share-backup-no-account", None),
2394 "no-account-for" => say("share-backup-no-account-for", &[("host", about())]),
2395 "not-found" => say("share-backup-not-found", &[("hosts", about())]),
2396 "seal-differs" => say("share-backup-seal-differs", &[("host", about())]),
2397 "seal-gone" => say("share-backup-seal-gone", &[("host", about())]),
2398 "login" => say("share-backup-login", &[("host", about())]),
2399 "no-password" => say("share-backup-no-password", &[("account", about())]),
2400 "no-seal" => String::new(),
2401 _ => say("share-backup-unreachable", &[("host", about())]),
2402 }
2403}
2404
2405pub(crate) fn set_backup(on: bool) -> String {
2408 let now = jiff::Timestamp::now().as_second();
2409 let chosen = sioul_sync::remote::State::choose(&memory_path(), |state| {
2410 state.on = (!on).then_some(false);
2411 if on {
2413 state.asked = now;
2414 }
2415 });
2416 chosen.err().unwrap_or_default()
2417}
2418
2419pub(crate) fn set_backup_place(place: &str) -> String {
2423 let now = jiff::Timestamp::now().as_second();
2424 let chosen = sioul_sync::remote::State::choose(&memory_path(), |state| {
2425 state.given = place.trim().to_string();
2426 state.asked = now;
2427 });
2428 chosen.err().unwrap_or_default()
2429}
2430
2431#[cfg(test)]
2432mod tests {
2433 use super::*;
2434
2435 #[test]
2438 fn the_log_says_why_notes_wait() {
2439 assert_eq!(waits_said(&std::collections::BTreeMap::new()), "");
2440 let waits: std::collections::BTreeMap<String, usize> = [("no-blob".to_string(), 2), ("backoff".to_string(), 1)].into();
2441 assert_eq!(waits_said(&waits), " (backoff 1, no-blob 2)");
2442 }
2443
2444 #[test]
2453 fn a_doses_alarm_waits_for_the_pull_alone_while_it_works() {
2454 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`)");
2455 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");
2456 assert_eq!(news(true, true, false, true, true, false), News::Pull, "the background step: its pull alone");
2457 assert_eq!(news(true, true, false, false, true, false), News::SyncApp, "the background step, its pull failing: as before");
2458 assert_eq!(news(true, true, false, false, false, true), News::SyncApp, "no folder found on a server: the sync app, as before");
2459 assert_eq!(news(true, true, true, false, false, true), News::None, "Sioul keeping the folder: the server looked through already");
2460 assert_eq!(news(true, false, false, false, false, true), News::Computer);
2461 assert_eq!(news(false, true, false, true, true, false), News::None, "a button pressed: nothing waited for");
2462 }
2463
2464 #[test]
2467 fn a_phone_put_away_renews_no_claim() {
2468 assert!(!renews(true, true));
2469 assert!(renews(true, false) && renews(false, true) && renews(false, false));
2470 }
2471
2472 #[test]
2473 fn keeping_the_folder_says_in_words_where_it_stands() {
2474 use sioul_sync::remote::{MIRROR, State};
2475 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() };
2476 assert_eq!(mirror_line(&kept), "", "nothing to say before its first step");
2477 let stepped = State { last: jiff::Timestamp::now().as_second(), ..kept.clone() };
2478 assert!(!mirror_line(&stepped).is_empty());
2479 for said in ["network:murena.io", "login:murena.io", "tls:murena.io", "server:murena.io", "quota:murena.io", "disk:murena.io"] {
2480 let line = mirror_line(&State { said: said.into(), ..stepped.clone() });
2481 assert!(!line.is_empty() && !line.contains("share-"), "{said}: {line}");
2482 }
2483 assert!(mirror_line(&State { confirmed: 0, said: "seal-differs:murena.io".into(), ..kept }).contains("murena.io"));
2484 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"] {
2485 let words = server_problem(code);
2486 assert!(!words.is_empty() && !words.contains("share-"), "{code}: {words}");
2487 }
2488 }
2489
2490 #[test]
2493 fn a_failed_send_is_said_in_words() {
2494 use sioul_sync::remote::Failure;
2495 let at = jiff::Timestamp::now().as_second();
2496 let timeout = Failure { kind: "timeout".into(), file: "records".into(), seconds: 216, sent: 1_081_344, total: 2_530_934, words: "timeout: global".into(), at };
2497 let line = failure_line(&timeout, at + 60);
2498 assert!(line.contains("216") && !line.contains("share-"), "{line}");
2499 for kind in ["stalled", "network", "unreachable", "out-of-time", "tls", "login", "quota", "server"] {
2500 for file in ["records", "entry", "notes", "claim", "texts", "sealed", ""] {
2501 let line = failure_line(&Failure { kind: kind.into(), file: file.into(), ..timeout.clone() }, 0);
2502 assert!(!line.is_empty() && !line.contains("share-"), "{kind} {file}: {line}");
2503 }
2504 }
2505 assert_eq!(failure_words(&timeout), "records: timeout after 216 s, 1081344 of 2530934 bytes handed to the network (timeout: global)");
2506 }
2507
2508 #[test]
2509 fn the_backup_says_in_words_what_it_did() {
2510 assert_eq!(backup_words("network:murena.io"), "murena.io: could not be reached");
2511 assert_eq!(backup_words("no-account"), "no account with a Nextcloud server");
2512 assert_eq!(backup_words("seal-differs:murena.io"), "murena.io: a folder sealed otherwise, never read");
2513 }
2514
2515 #[test]
2516 fn where_the_backup_stands_in_words() {
2517 use sioul_sync::remote::State;
2518 let folder = Path::new("/storage/emulated/0/Documents/Sioul");
2519 let here = folder.display().to_string();
2520 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() };
2521 assert!(backup_line(&found, folder).contains("murena.io"));
2522 let fetched = State { last: jiff::Timestamp::now().as_second(), ..found.clone() };
2523 assert!(backup_line(&fetched, folder).contains("murena.io"));
2524 let failing = State { said: "network:murena.io".into(), ..fetched.clone() };
2525 assert_ne!(backup_line(&failing, folder), backup_line(&fetched, folder));
2526 let left = State { left: [("desk-9.jsonl".to_string(), 70 << 20)].into(), ..fetched.clone() };
2527 assert!(backup_line(&left, folder).ends_with(&tr().text("share-backup-left", None)), "a round left to the sync app, said");
2528 let off = State { on: Some(false), ..fetched.clone() };
2529 assert!(backup_line(&off, folder).contains("murena.io"));
2530 let looking = State { folder: here.clone(), ..State::default() };
2531 assert_eq!(backup_line(&looking, folder), tr().text("share-backup-looking", None));
2532 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"] {
2533 let state = State { folder: here.clone(), looked: 1, said: said.into(), ..State::default() };
2534 let line = backup_line(&state, folder);
2535 assert!(!line.is_empty() && !line.contains("share-backup"), "{said}: {line}");
2536 }
2537 }
2538
2539 #[test]
2540 fn documents_folders() {
2541 let base = std::env::temp_dir().join(format!("sioul-share-documents-{}", std::process::id()));
2542 let root = base.join("Nextcloud");
2543 std::fs::create_dir_all(root.join("Documents")).unwrap();
2544 let roots = vec![root.clone()];
2545 assert!(in_documents(&root.join("Documents").join("Sioul")));
2546 assert!(in_documents(&root.join("documents").join("Sioul")));
2547 assert!(!in_documents(&root.join("Sioul")));
2548 assert!(beside_documents(&root.join("Sioul"), &roots));
2549 assert!(!beside_documents(&root.join("Documents").join("Sioul"), &roots));
2550 let other = base.join("Sync");
2552 std::fs::create_dir_all(&other).unwrap();
2553 assert!(!beside_documents(&other.join("Sioul"), &[other.clone()]));
2554 let _ = std::fs::remove_dir_all(&base);
2555 }
2556
2557 #[test]
2561 fn your_calendars_and_contacts_set_aside_are_named_and_kept_inside() {
2562 let root = std::env::temp_dir().join(format!("sioul-kept-items-{}", std::process::id()));
2563 let _ = std::fs::remove_dir_all(&root);
2564 let event = "data/calendars/work/personal/dentist.ics";
2565 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();
2566 let card = "data/contacts/work/friends/jane.vcf";
2567 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();
2568 let named = |file: &str| shown_kept(&root, sioul_sync::history::ACCOUNTS, file, &sioul_sync::history::versions(&root, sioul_sync::history::ACCOUNTS, file));
2569 assert_eq!((named(event).as_str(), named(card).as_str()), ("Dentist", "Jane Doe"));
2570 assert_eq!(shown_kept(&root, "settings", "config/config.toml", &[]), "config.toml");
2571 assert!(account_item(event).is_some_and(|p| p.ends_with("calendars/work/personal/dentist.ics")));
2572 assert!(account_item("data/../config/config.toml").is_none() && account_item("data/drafts/reply.eml").is_none() && account_item("config/config.toml").is_none());
2573 let _ = std::fs::remove_dir_all(&root);
2574 }
2575
2576 #[test]
2579 fn on_a_phone_your_word_on_the_notes_folder_wins_over_the_guess() {
2580 assert!(carried_on_phone(None, true) && !carried_on_phone(None, false));
2581 assert!(!carried_on_phone(Some(false), true), "not carried, as you said, though it looked so");
2582 assert!(carried_on_phone(Some(true), false), "carried, as you said, though it did not look so");
2583 }
2584
2585 #[test]
2590 fn the_answer_on_the_notes_folder_is_for_that_folder() {
2591 let notes = Path::new(STORAGE).join("Notes");
2592 let moved = Path::new(STORAGE).join("Documents/Notes");
2593 let mut here = share::Here { notes_carried: Some(false), ..share::Here::default() };
2594 assert_eq!(here.notes_said(¬es), Some(false), "an answer from before: for the notes of then");
2595 here.notes_carried_for = Some(notes.display().to_string());
2596 assert_eq!(here.notes_said(¬es), Some(false));
2597 assert_eq!(here.notes_said(&moved), None, "moved: asked again");
2598 assert!(carried_on_phone(here.notes_said(&moved), true), "the guess meanwhile: in the sharing folder's top folder, carried");
2599 assert!(!carried_on_phone(here.notes_said(¬es), true), "where it was said: as said");
2600 }
2601
2602 #[test]
2603 fn notes_carried_twice_are_refused() {
2604 let home = std::env::temp_dir().join(format!("sioul-share-carried-{}", std::process::id()));
2605 let roots = vec![home.join("Nextcloud")];
2606 assert!(carried(&home.join("Nextcloud").join("Notes"), &roots));
2607 assert!(!carried(&home.join("Notes"), &roots));
2608 let storage = Path::new(STORAGE);
2610 assert!(same_top(&storage.join("Documents/Notes"), &storage.join("Documents/Sioul"), storage));
2611 assert!(!same_top(&storage.join("Notes"), &storage.join("Documents/Sioul"), storage));
2612 assert!(!same_top(Path::new("/data/user/0/sioul/files/Notes"), &storage.join("Documents/Sioul"), storage));
2613 assert!(same_top(&on_storage(Path::new("/sdcard/Documents/Notes")), &on_storage(&storage.join("Documents/Sioul")), storage));
2614 assert!(same_top(&on_storage(Path::new("/storage/self/primary/Documents/Notes")), &on_storage(Path::new("/sdcard/Documents/Sioul")), storage));
2615 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>";
2617 assert_eq!(syncthing_folders(xml), ["~/Writing & Notes", "/data/Sync"]);
2618 let mut boxes = dropbox_folders(r#"{"personal": {"path": "/home/me/Boxes/Dropbox", "host": 1}, "business": {"path": "/home/me/Work"}}"#);
2619 boxes.sort();
2620 assert_eq!(boxes, ["/home/me/Boxes/Dropbox", "/home/me/Work"]);
2621 assert!(dropbox_folders("not json").is_empty());
2622 }
2623
2624 #[test]
2625 fn folders_listed() {
2626 let base = std::env::temp_dir().join(format!("sioul-folders-in-{}", std::process::id()));
2627 for dir in ["Documents/Sioul", "Documents/.hidden", "Documents/archive", "Documents/Bills"] {
2628 std::fs::create_dir_all(base.join(dir)).unwrap();
2629 }
2630 std::fs::write(base.join("Documents/Sioul/seal.toml"), "").unwrap();
2631 std::fs::write(base.join("Documents/notes.txt"), "").unwrap();
2632 let listed: serde_json::Value = serde_json::from_str(&folders_in(&base.join("Documents").display().to_string())).unwrap();
2633 let names: Vec<&str> = listed["folders"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
2634 assert_eq!(names, ["archive", "Bills", "Sioul"]);
2635 assert_eq!(listed["folders"][2]["sealed"], true);
2636 assert_eq!(listed["folders"][0]["sealed"], false);
2637 assert_eq!(listed["parent"], base.display().to_string());
2638 assert_eq!(listed["readable"], true);
2639 let missing: serde_json::Value = serde_json::from_str(&folders_in(&base.join("nowhere").display().to_string())).unwrap();
2640 assert_eq!(missing["readable"], false);
2641 let _ = std::fs::remove_dir_all(&base);
2642 }
2643}