Skip to main content

sioul_app/
access.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What Android withholds from Sioul on this phone that it needs for what is
5//! on here (docs/android.md, "What Android withholds"): the texts'
6//! permissions while the part Texts is on; notification access, while other
7//! apps' notifications are held or the phone's messages go to your
8//! computers; the contacts while calls are screened or the phone's messages
9//! go to your computers. Each said in a line, with what Android wants.
10//! Whether Android keeps restricted settings from Sioul installed from a
11//! file (Android 13 and later: notification access; 15 and later: the texts
12//! too) no app can read, so the lines say the steps that hold either way,
13//! in their order: Android's question or page once (which, refused for
14//! restricted settings, makes App info's ⋮ offer Allow restricted
15//! settings), then Sioul's app info. Said in the join's result
16//! (JoinPanel.qml), with the part Texts' switch where it is off, and on the
17//! Porch until you put it away (AccessCard.qml); never counted, never red,
18//! never again once put away.
19
20use crate::backend::tr;
21use serde_json::{Value, json};
22use sioul_core::i18n::Translator;
23
24/// Android's version from which an app installed from a file is kept from restricted settings (13): notification access.
25const GUARDED: i64 = 33;
26/// The same for the texts' permissions, kept so from Android 15 only: on 13 and 14 a refusal is a plain one.
27const GUARDED_TEXTS: i64 = 35;
28
29/// What Android says on this phone, as the lines read it.
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub(crate) struct Said {
32    /// Android's version (its API level).
33    pub api: i64,
34    /// The part Texts on here; the phone able to read texts (it has an SMS app); Android's three texts permissions all given.
35    pub texts_on: bool,
36    pub texts_could: bool,
37    pub texts_given: bool,
38    /// The texts' question asked here once at least, and its last answer at
39    /// once with none of the three given (no question shown).
40    pub asked: bool,
41    pub at_once: bool,
42    /// Notification access needed here (other apps' notifications held, or the phone's messages for your computers), and given.
43    pub notifications_needed: bool,
44    pub notifications_given: bool,
45    /// The contacts needed here (calls screened, the phone's messages for your computers), and READ_CONTACTS given.
46    pub contacts_needed: bool,
47    pub contacts_given: bool,
48}
49
50/// One line for each thing withheld: {what ("texts", "notifications",
51/// "contacts", and with `join` "texts-part"), said, info (Sioul's app info
52/// offered beside), switch (the part Texts')}. Pure: tested below.
53pub(crate) fn lines(s: &Said, tr: &Translator, join: bool) -> Vec<Value> {
54    let mut out = Vec::new();
55    if join && !s.texts_on && s.texts_could {
56        out.push(json!({ "what": "texts-part", "said": tr.text("access-texts-off", None), "info": false, "switch": true }));
57    }
58    if s.texts_on && !s.texts_given {
59        let (words, info) = if s.asked && s.api >= GUARDED_TEXTS {
60            ("access-texts-guarded", true)
61        } else if s.at_once {
62            ("access-texts-at-once", true)
63        } else {
64            ("access-texts", false)
65        };
66        out.push(json!({ "what": "texts", "said": tr.text(words, None), "info": info, "switch": false }));
67    }
68    if s.notifications_needed && !s.notifications_given {
69        let guarded = s.api >= GUARDED;
70        out.push(json!({ "what": "notifications", "said": tr.text(if guarded { "access-notifications-guarded" } else { "access-notifications" }, None), "info": guarded, "switch": false }));
71    }
72    if s.contacts_needed && !s.contacts_given {
73        out.push(json!({ "what": "contacts", "said": tr.text("access-contacts", None), "info": false, "switch": false }));
74    }
75    out
76}
77
78/// What Android says now on this phone; nothing elsewhere.
79fn said_here() -> Option<Said> {
80    if !cfg!(target_os = "android") {
81        return None;
82    }
83    let state = crate::steps::java("texts-state", "{}");
84    let phone_messages = crate::share::shares(sioul_core::phonemsgs::PART);
85    Some(Said {
86        api: state["api"].as_i64().unwrap_or(0),
87        texts_on: crate::share::shares(sioul_core::texts::PART),
88        texts_could: state["sms_app"].as_str().is_some_and(|app| !app.is_empty()),
89        texts_given: state["read"] == true && state["receive"] == true && state["send"] == true,
90        asked: state["asked"] == true,
91        at_once: state["at_once"] == true,
92        notifications_needed: sioul_core::appnotes::Choices::load(&sioul_core::appnotes::Choices::default_path()).hold || phone_messages,
93        notifications_given: crate::appnotes::access_here() != Some(false),
94        contacts_needed: crate::calls::screens_here() || phone_messages,
95        contacts_given: crate::steps::java("contacts-allowed", "{}") != Value::Bool(false),
96    })
97}
98
99/// The lines of this phone (with `join`, the part Texts' too); none elsewhere.
100pub(crate) fn withheld(join: bool) -> Vec<Value> {
101    said_here().map(|s| lines(&s, tr(), join)).unwrap_or_default()
102}
103
104/// What the window asks (`texts::call`'s "access…" verbs): "access" {join},
105/// the lines; "access-ask" {what, join}: Android's question, or its page
106/// for notification access; "access-info" {join}: Sioul's app info. The
107/// lines again, as JSON {lines}. The Porch puts a line away with a view
108/// flag of its own (`porch-access-left-<what>`), in the window; the part
109/// Texts' switch is `texts::call`'s "part".
110pub(crate) fn call(verb: &str, asked: &Value) -> Value {
111    match verb {
112        "access-ask" => match asked["what"].as_str().unwrap_or_default() {
113            "texts" => {
114                crate::steps::java("texts-ask", "{}");
115            }
116            "notifications" => {
117                crate::appnotes::change("open-access", "{}");
118            }
119            "contacts" => crate::steps::ask_contacts(),
120            _ => {}
121        },
122        "access-info" => {
123            crate::steps::java("texts-info", "{}");
124        }
125        _ => {}
126    }
127    json!({ "lines": withheld(asked["join"] == true) })
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    /// What each state says, in the steps' order: nothing withheld, nothing
135    /// said; the texts' line only while the part is on, the part's own line
136    /// only in the join's result; once asked on Android 15 and later, the
137    /// steps that hold whether restricted settings keep them or not, with
138    /// Sioul's app info (notification access from Android 13); on Android 12
139    /// to 14 a question refused for good, app info too; notification access
140    /// and the contacts only where they are needed.
141    #[test]
142    fn what_android_withholds_is_said_with_what_it_wants() {
143        let tr = Translator::new("en");
144        let all = Said { api: 36, texts_on: true, texts_could: true, texts_given: true, asked: true, at_once: false, notifications_needed: true, notifications_given: true, contacts_needed: true, contacts_given: true };
145        assert!(lines(&all, &tr, true).is_empty(), "all given: nothing said");
146        let whats = |s: &Said, join: bool| lines(s, &tr, join).iter().map(|l| (l["what"].as_str().unwrap().to_string(), l["info"] == true)).collect::<Vec<_>>();
147        // The owner's new phone, 16 October 2026: Android 16, Sioul installed from a file, the texts asked and refused.
148        let refused = Said { texts_given: false, notifications_given: false, contacts_given: false, ..all.clone() };
149        assert_eq!(whats(&refused, false), [("texts".to_string(), true), ("notifications".into(), true), ("contacts".into(), false)]);
150        let said = lines(&refused, &tr, false);
151        let texts = said[0]["said"].as_str().unwrap();
152        let (once, info, again) = (texts.find("press Allow in Android… once").unwrap(), texts.find("Allow restricted settings").unwrap(), texts.find("Allow in Android… again").unwrap());
153        assert!(once < info && info < again, "in their order: {texts}");
154        assert!(said[1]["said"].as_str().unwrap().contains("tap Sioul once"), "{said:?}");
155        // Not asked yet: Allow in Android… alone.
156        assert_eq!(whats(&Said { texts_given: false, asked: false, ..all.clone() }, false), [("texts".to_string(), false)]);
157        // Android 12: no restricted settings; refused for good (at once), app info; refused after the question, asked again.
158        assert_eq!(whats(&Said { api: 31, texts_given: false, at_once: true, ..all.clone() }, false), [("texts".to_string(), true)]);
159        assert_eq!(whats(&Said { api: 31, texts_given: false, ..all.clone() }, false), [("texts".to_string(), false)]);
160        assert_eq!(whats(&Said { api: 31, notifications_given: false, ..all.clone() }, false), [("notifications".to_string(), false)]);
161        // Android 13 and 14: notification access guarded, the texts' permissions not (from 15 only): a plain refusal.
162        assert_eq!(whats(&Said { api: 34, texts_given: false, notifications_given: false, ..all.clone() }, false), [("texts".to_string(), false), ("notifications".into(), true)]);
163        assert_eq!(whats(&Said { api: 33, texts_given: false, at_once: true, ..all.clone() }, false), [("texts".to_string(), true)]);
164        assert!(!lines(&Said { api: 34, texts_given: false, ..all.clone() }, &tr, false)[0]["said"].as_str().unwrap().contains("restricted settings"), "on 14, never the restricted settings' steps for the texts");
165        // The part off: no permission line; in the join's result, the part's own line with its switch.
166        let off = Said { texts_on: false, texts_given: false, ..all.clone() };
167        assert!(whats(&off, false).is_empty());
168        assert_eq!(lines(&off, &tr, true)[0]["switch"], true);
169        assert!(whats(&Said { texts_could: false, ..off.clone() }, true).is_empty(), "a phone with no texts to read: nothing");
170        // Only where they are needed.
171        assert!(whats(&Said { notifications_needed: false, notifications_given: false, contacts_needed: false, contacts_given: false, ..all.clone() }, false).is_empty());
172        for language in ["en", "fr"] {
173            let tr = Translator::new(language);
174            for line in lines(&refused, &tr, false).into_iter().chain(lines(&off, &tr, true)).chain(lines(&Said { api: 31, texts_given: false, at_once: true, ..all.clone() }, &tr, false)) {
175                assert!(!line["said"].as_str().unwrap().starts_with("access-"), "{language}: {line}");
176            }
177        }
178    }
179}