Skip to main content

sioul_app/
attachments.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Mail attachments on a phone (docs/client.md, "Antivirus"). A phone has no
5//! antivirus Sioul can call, so none is asked and none is claimed: the words
6//! say the file was not checked. The attachment opens in the app you choose,
7//! which Android lets read that one file and nothing else of Sioul's, and
8//! "Save…" asks Android where (android/package/src/com/aurelienpierre/sioul/
9//! Attachments.java, AttachmentSave.java). Sioul's own refusals stand as on a
10//! computer: mail set aside keeps its attachments closed, and programs,
11//! scripts and installers, Android's among them, are never opened.
12//!
13//! A computer is unchanged (`backend::checked_attachment`): its antivirus is
14//! asked, or you are, when it has none.
15
16use std::path::{Path, PathBuf};
17use std::sync::Mutex;
18
19use sioul_core::config;
20
21use crate::backend::{QtThread, say, set_status};
22
23/// Whether attachments go the phone's way: on Android; and in a demo's
24/// pictures of a phone's screen (`SIOUL_GRAB_PHONE` with `SIOUL_DEMO`), so
25/// that they show its words. There nothing reaches another app: Android is
26/// not there to answer.
27pub(crate) fn phone() -> bool {
28    cfg!(target_os = "android") || (crate::backend::offline() && std::env::var_os("SIOUL_GRAB_PHONE").is_some())
29}
30
31/// What stands between an attachment and where it goes.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub(crate) enum Check {
34    /// A computer: its antivirus is asked.
35    Scan,
36    /// A computer, its antivirus not asked again: the same bytes were found
37    /// clean earlier in this session, or you chose to go on without one.
38    Skip,
39    /// A phone: no antivirus can be called, and none is claimed.
40    Unchecked,
41}
42
43/// The check an attachment gets, from where Sioul runs.
44pub(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
54/// Whether an attachment is kept from opening (it may still be saved): a
55/// program, a script, an installer, a shortcut, a disk image
56/// (`links::is_program`, as on a computer); on a phone, Android's
57/// installers too (`links::is_phone_program`).
58pub(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
62/// The type an app is asked to open, from the name's extension, as Sioul
63/// names the attachments it sends (`compose::mime_of`): never the type the
64/// message claims. Java asks Android's own table for those this one does not
65/// know, and never hands an installer.
66pub(crate) fn type_of(name: &str) -> &'static str {
67    sioul_core::compose::mime_of(name)
68}
69
70/// The folder attachments are written in to be opened:
71/// `<cache>/sioul/attachments`, which Android's provider may lend from
72/// (android/package/res/xml/qtprovider_paths.xml).
73#[cfg_attr(not(target_os = "android"), allow(dead_code))]
74pub(crate) fn folder() -> PathBuf {
75    config::cache_dir().join("attachments")
76}
77
78/// What Java's answer to "open" says, as the status line.
79pub(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/// What AttachmentSave said once Android's question was answered, as the status line.
93#[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
105/// The attachment written at `file` handed to the app you choose (Android
106/// asks which, when none is set for its type); the line to show. A copy no
107/// app took leaves the cache at once.
108pub(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
117/// The window waiting for Android's question where to save to be answered.
118static SAVING: Mutex<Option<QtThread>> = Mutex::new(None);
119
120/// Android asked where to save the attachment written at `file`; the line to
121/// show meanwhile. AttachmentSave copies it there, takes it out of the cache
122/// and says what became of it (`sioul_attachment_saved`).
123pub(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/// AttachmentSave's word (android/main.cpp), once the copy is made or not:
138/// {path, name, saved, shown} or {path, name, saved: false, problem}; the
139/// status line says it. The copy in the cache goes, saved or not: Java took
140/// it out, and here it is taken out again if it stayed. On a thread of Java's.
141#[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/// AttachmentSave.nativeSaved's side here (android/main.cpp).
156///
157/// # Safety
158/// `json` is null, or a zero-terminated UTF-8 text valid for the call.
159#[cfg(target_os = "android")]
160// SAFETY: no other symbol of the program has this name.
161#[unsafe(no_mangle)]
162pub unsafe extern "C" fn sioul_attachment_saved(json: *const std::ffi::c_char) {
163    // SAFETY: as the caller promises.
164    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        // A phone: never a scan, whatever was found or chosen before.
175        for (known, chosen) in [(false, false), (true, false), (false, true), (true, true)] {
176            assert_eq!(check(true, known, chosen), Check::Unchecked);
177        }
178        // A computer: the antivirus, unless the same bytes passed it, or you chose to go on without one.
179        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        // Android's installers, on a phone; a computer's list is as it was.
190        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        // An installer is never named so, whatever its name: it is kept closed before.
206        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        // No answer from Java (a computer's demo): said, never claimed opened.
218        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        // Each says it was not checked, in English, and none claims a scan.
225        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        // Where Rust writes attachments, under the cache Android gives (android/main.cpp: XDG_CACHE_HOME = getCacheDir()).
235        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        // The provider's paths name that folder, and no wider one of the cache or the device.
239        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}