1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
//! The wire format that carries Cranpose's semantics into
//! `CranposeActivity`'s `AccessibilityNodeProvider`.
//!
//! The whole visible tree crosses JNI as one `String` on the frames it
//! changes, rather than one call per node, for the same reason the Play
//! Billing snapshot does: a screen reader must never observe a half-built
//! tree. Encoding lives here, in safe Rust, and is unit-tested on the host —
//! the format is the contract between the two sides, and the Java parser
//! rejects any record whose field count does not match.
//!
//! ```text
//! <id>\t<role>\t<left>\t<top>\t<right>\t<bottom>\t<centerX>\t<centerY>
//! \t<clickable>\t<label>\t<value>\t<stateDescription>\t<clickLabel>
//! \t<selected>\t<toggled>\t<enabled>\t<customActions>
//! ```
//!
//! Bounds are physical pixels (Java draws them into `Rect`s); the two centre
//! coordinates stay logical because they are fed back through the pointer
//! pipeline as a synthetic tap. `selected` and `toggled` are `-1` when the app
//! said nothing, so "not a checkable control" is distinguishable from "off".
//! Text uses the same `%09`/`%0A`/`%0D`/`%25` escaping as the launch-argument
//! payload, plus `%1F` for the separator that packs custom action labels into
//! one field, because every one of these strings is app-authored.
use crate::accessibility::{element_ids, AccessibilityElement, AccessibilityRole};
/// Separator between the custom action labels packed into one wire field.
/// ASCII unit separator, escaped like every other delimiter so a label
/// containing one cannot split the field.
const ACTION_SEPARATOR: char = '\u{1f}';
pub(crate) fn encode_elements(elements: &[AccessibilityElement], density: f32) -> String {
let density = density.max(f32::EPSILON);
let ids = element_ids(elements);
elements
.iter()
.zip(ids)
.map(|(element, id)| {
let role = match element.role {
AccessibilityRole::Button => 1,
AccessibilityRole::StaticText => 2,
AccessibilityRole::TextField => 3,
AccessibilityRole::Checkbox => 4,
AccessibilityRole::Switch => 5,
AccessibilityRole::RadioButton => 6,
AccessibilityRole::Tab => 7,
AccessibilityRole::Image => 8,
AccessibilityRole::Header => 9,
};
let (center_x, center_y) = element.bounds.center();
let actions = element
.custom_actions
.iter()
.map(|label| escape(label))
.collect::<Vec<_>>()
.join(&ACTION_SEPARATOR.to_string());
format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
id,
role,
(element.bounds.x * density).round() as i32,
(element.bounds.y * density).round() as i32,
((element.bounds.x + element.bounds.width) * density).round() as i32,
((element.bounds.y + element.bounds.height) * density).round() as i32,
center_x,
center_y,
i32::from(element.clickable),
escape(&element.label),
escape(element.value.as_deref().unwrap_or("")),
escape(element.state_description.as_deref().unwrap_or("")),
escape(element.click_label.as_deref().unwrap_or("")),
tristate(element.selected),
tristate(element.toggled),
i32::from(element.enabled),
actions,
)
})
.collect::<Vec<_>>()
.join("\n")
}
/// `-1` when the app said nothing about the state, so Java can tell "this is
/// not a checkable control" from "this switch is off".
fn tristate(value: Option<bool>) -> i32 {
match value {
None => -1,
Some(false) => 0,
Some(true) => 1,
}
}
fn escape(value: &str) -> String {
value
.replace('%', "%25")
.replace('\t', "%09")
.replace('\n', "%0A")
.replace('\r', "%0D")
.replace(ACTION_SEPARATOR, "%1F")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::AccessibilityRect;
fn element(node_id: usize, canvas_key: Option<u64>) -> AccessibilityElement {
AccessibilityElement {
node_id,
canvas_key,
label: "Row".into(),
bounds: AccessibilityRect::new(0.0, 0.0, 10.0, 10.0),
..AccessibilityElement::default()
}
}
#[test]
fn android_accessibility_wire_values_escape_record_delimiters() {
assert_eq!(
escape(&format!("A%\tB\nC\r{ACTION_SEPARATOR}")),
"A%25%09B%0AC%0D%1F"
);
}
#[test]
fn tristate_distinguishes_unset_from_off() {
assert_eq!(tristate(None), -1);
assert_eq!(tristate(Some(false)), 0);
assert_eq!(tristate(Some(true)), 1);
}
/// The Java parser rejects a record whose field count is wrong, so the
/// count is part of the contract and is asserted here rather than left to
/// be discovered on a watch.
#[test]
fn every_encoded_record_carries_the_seventeen_fields_java_parses() {
let elements = vec![
AccessibilityElement {
node_id: 4,
label: "Haptics".into(),
state_description: Some("On".into()),
click_label: Some("Toggle".into()),
bounds: AccessibilityRect::new(1.0, 2.0, 30.0, 40.0),
role: AccessibilityRole::Switch,
clickable: true,
toggled: Some(true),
custom_actions: vec!["Pause".into(), "Resume".into()],
..AccessibilityElement::default()
},
element(5, Some(1)),
];
let payload = encode_elements(&elements, 2.0);
let records: Vec<_> = payload.split('\n').collect();
assert_eq!(records.len(), 2);
for record in &records {
assert_eq!(record.split('\t').count(), 17, "record: {record}");
}
let fields: Vec<_> = records[0].split('\t').collect();
assert_eq!(fields[1], "5", "Switch should encode as role 5");
// Density 2.0 turns logical 1,2 .. 31,42 into physical pixels.
assert_eq!(&fields[2..6], ["2", "4", "62", "84"]);
assert_eq!(fields[9], "Haptics");
assert_eq!(fields[11], "On");
assert_eq!(fields[12], "Toggle");
assert_eq!(fields[13], "-1", "selected was never set");
assert_eq!(fields[14], "1", "toggled on");
assert_eq!(fields[15], "1", "enabled by default");
assert_eq!(fields[16], format!("Pause{ACTION_SEPARATOR}Resume"));
}
}