1use std::path::{Path, PathBuf};
17use std::sync::Mutex;
18
19use sioul_core::config;
20
21use crate::backend::{QtThread, say, set_status};
22
23pub(crate) fn phone() -> bool {
28 cfg!(target_os = "android") || (crate::backend::offline() && std::env::var_os("SIOUL_GRAB_PHONE").is_some())
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub(crate) enum Check {
34 Scan,
36 Skip,
39 Unchecked,
41}
42
43pub(crate) fn check(phone: bool, known_clean: bool, unchecked: bool) -> Check {
45 if phone {
46 Check::Unchecked
47 } else if known_clean || unchecked {
48 Check::Skip
49 } else {
50 Check::Scan
51 }
52}
53
54pub(crate) fn kept_closed(name: &str, phone: bool) -> bool {
59 sioul_core::links::is_program(name) || (phone && sioul_core::links::is_phone_program(name))
60}
61
62pub(crate) fn type_of(name: &str) -> &'static str {
67 sioul_core::compose::mime_of(name)
68}
69
70#[cfg_attr(not(target_os = "android"), allow(dead_code))]
74pub(crate) fn folder() -> PathBuf {
75 config::cache_dir().join("attachments")
76}
77
78pub(crate) fn opened_line(answer: &serde_json::Value, name: &str) -> String {
80 if answer["opened"] == true {
81 return say("attachment-phone-opened", &[("name", name.to_string())]);
82 }
83 match answer["problem"].as_str() {
84 Some("no-app") => say("attachment-phone-no-app", &[("name", name.to_string())]),
85 problem => {
86 let detail = answer["detail"].as_str().or(problem).unwrap_or("no answer");
87 say("attachment-phone-not-opened", &[("name", name.to_string()), ("detail", detail.to_string())])
88 }
89 }
90}
91
92#[cfg_attr(not(target_os = "android"), allow(dead_code))]
94pub(crate) fn saved_line(answer: &serde_json::Value) -> String {
95 let shown = answer["shown"].as_str().filter(|s| !s.is_empty()).or_else(|| answer["name"].as_str()).unwrap_or("").to_string();
96 if answer["saved"] == true {
97 say("attachment-phone-saved", &[("name", shown)])
98 } else if let Some(problem) = answer["problem"].as_str() {
99 say("attachment-phone-save-failed", &[("name", shown), ("detail", problem.to_string())])
100 } else {
101 say("attachment-phone-not-saved", &[("name", shown)])
102 }
103}
104
105pub(crate) fn open(file: &Path, name: &str) -> String {
109 let asked = serde_json::json!({ "path": file.display().to_string(), "type": type_of(name) });
110 let answer = crate::steps::java("attachment-open", &asked.to_string());
111 if answer["opened"] != true {
112 let _ = std::fs::remove_file(file);
113 }
114 opened_line(&answer, name)
115}
116
117static SAVING: Mutex<Option<QtThread>> = Mutex::new(None);
119
120pub(crate) fn save(qt: &QtThread, file: &Path, name: &str) -> String {
124 if let Ok(mut waiting) = SAVING.lock() {
125 *waiting = Some(qt.clone());
126 }
127 let asked = serde_json::json!({ "path": file.display().to_string(), "name": name, "type": type_of(name) });
128 let answer = crate::steps::java("attachment-save", &asked.to_string());
129 if answer["asked"] == true {
130 return say("attachment-phone-where", &[("name", name.to_string())]);
131 }
132 let _ = std::fs::remove_file(file);
133 let detail = answer["detail"].as_str().or_else(|| answer["problem"].as_str()).unwrap_or("no answer");
134 say("attachment-phone-save-failed", &[("name", name.to_string()), ("detail", detail.to_string())])
135}
136
137#[cfg_attr(not(target_os = "android"), allow(dead_code))]
142fn saved(text: &str) {
143 let answer: serde_json::Value = serde_json::from_str(text).unwrap_or(serde_json::Value::Null);
144 if let Some(path) = answer["path"].as_str().map(Path::new)
145 && path.starts_with(folder())
146 {
147 let _ = std::fs::remove_file(path);
148 }
149 let waiting = SAVING.lock().ok().and_then(|w| w.clone());
150 if let Some(qt) = waiting {
151 set_status(&qt, saved_line(&answer));
152 }
153}
154
155#[cfg(target_os = "android")]
160#[unsafe(no_mangle)]
162pub unsafe extern "C" fn sioul_attachment_saved(json: *const std::ffi::c_char) {
163 let text = if json.is_null() { String::new() } else { unsafe { std::ffi::CStr::from_ptr(json) }.to_string_lossy().into_owned() };
165 let _ = std::panic::catch_unwind(|| saved(&text));
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn a_phone_asks_no_antivirus_and_a_computer_is_unchanged() {
174 for (known, chosen) in [(false, false), (true, false), (false, true), (true, true)] {
176 assert_eq!(check(true, known, chosen), Check::Unchecked);
177 }
178 assert_eq!(check(false, false, false), Check::Scan);
180 assert_eq!(check(false, true, false), Check::Skip);
181 assert_eq!(check(false, false, true), Check::Skip);
182 }
183
184 #[test]
185 fn programs_and_installers_stay_closed_on_a_phone_too() {
186 for name in ["setup.exe", "run.sh", "invoice.pdf.js", "Tool.app", "disk.iso", "launch.desktop"] {
187 assert!(kept_closed(name, false) && kept_closed(name, true), "{name}");
188 }
189 for name in ["app.apk", "Bundle.APKS", "game.xapk", "split.apkm", "app.apk."] {
191 assert!(kept_closed(name, true), "{name}");
192 assert!(!kept_closed(name, false), "{name}");
193 }
194 for name in ["invoice.pdf", "photo.jpeg", "notes.txt", "apk-guide.pdf", "report.docx"] {
195 assert!(!kept_closed(name, true) && !kept_closed(name, false), "{name}");
196 }
197 }
198
199 #[test]
200 fn the_type_comes_from_the_name_never_from_the_message() {
201 assert_eq!(type_of("Facture.PDF"), "application/pdf");
202 assert_eq!(type_of("photo.jpeg"), "image/jpeg");
203 assert_eq!(type_of("scan.png"), "image/png");
204 assert_eq!(type_of("no extension"), "application/octet-stream");
205 assert_eq!(type_of("app.apk"), "application/octet-stream");
207 }
208
209 #[test]
210 fn the_words_say_what_happened_and_what_was_not_checked() {
211 let name = "Facture mars.pdf";
212 let line = |id: &str| say(id, &[("name", name.to_string())]);
213 assert_eq!(opened_line(&serde_json::json!({ "opened": true }), name), line("attachment-phone-opened"));
214 assert_eq!(opened_line(&serde_json::json!({ "problem": "no-app" }), name), line("attachment-phone-no-app"));
215 let failed = say("attachment-phone-not-opened", &[("name", name.to_string()), ("detail", "SecurityException".to_string())]);
216 assert_eq!(opened_line(&serde_json::json!({ "problem": "failed", "detail": "SecurityException" }), name), failed);
217 assert!(opened_line(&serde_json::Value::Null, name).contains("no answer"));
219
220 assert_eq!(saved_line(&serde_json::json!({ "name": name, "saved": true, "shown": "Facture mars (1).pdf" })), say("attachment-phone-saved", &[("name", "Facture mars (1).pdf".to_string())]));
221 assert_eq!(saved_line(&serde_json::json!({ "name": name, "saved": false })), line("attachment-phone-not-saved"));
222 let not_saved = say("attachment-phone-save-failed", &[("name", name.to_string()), ("detail", "IOException".to_string())]);
223 assert_eq!(saved_line(&serde_json::json!({ "name": name, "saved": false, "problem": "IOException" })), not_saved);
224 let english = sioul_core::i18n::Translator::new("en");
226 for id in ["attachment-phone-opened", "attachment-phone-saved", "ui-attachments-phone"] {
227 let words = english.text(id, None);
228 assert!(words.contains("not checked"), "{id}: {words}");
229 }
230 }
231
232 #[test]
233 fn android_may_lend_the_attachments_folder_alone() {
234 let cache = config::cache_dir();
236 let under = folder().strip_prefix(cache.parent().expect("the cache's parent")).expect("under the cache").to_string_lossy().replace('\\', "/");
237 assert_eq!(under, "sioul/attachments");
238 let paths = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../android/package/res/xml/qtprovider_paths.xml")).expect("qtprovider_paths.xml");
240 let caches: Vec<&str> = paths.lines().map(str::trim).filter(|l| l.starts_with("<cache-path")).collect();
241 assert_eq!(caches, [format!("<cache-path name=\"attachments\" path=\"{under}/\"/>")]);
242 assert!(!paths.contains("<root-path"));
243 }
244}