Skip to main content

ddc_dec/
platform.rs

1//! Platform symbols: render IntDef/LongDef magic numbers as their
2//! constant names (`view.setVisibility(8)` → `android.view.View.GONE`).
3//!
4//! Two inputs, both from an Android SDK platform directory:
5//! * `android.jar` — classfile `ConstantValue` attributes give every
6//!   static final field's value (`android.view.View.GONE = 8`).
7//! * `data/annotations.zip` — per-method-parameter `@IntDef`/`@LongDef`
8//!   domains as `annotations.xml` items (the SDK's metalava metadata —
9//!   the jar itself does not retain them).
10//!
11//! A call site rewrites only on an EXACT domain match: flag domains
12//! (combinable values such as Intent flags) stay numeric for combined
13//! literals and render named for single values.
14
15use std::collections::HashMap;
16use std::sync::OnceLock;
17
18/// One constant domain: members (owner, field, value).
19#[derive(Debug, Clone)]
20struct ConstantDomain {
21    members: Vec<(String, String, i64)>,
22}
23
24impl ConstantDomain {
25    /// The member whose value matches exactly, when exactly one does —
26    /// duplicate values stay numeric (ambiguous beats wrong).
27    fn exact(&self, value: i64) -> Option<&(String, String, i64)> {
28        let mut hit = None;
29        for m in &self.members {
30            if m.2 == value {
31                if hit.is_some() {
32                    return None;
33                }
34                hit = Some(m);
35            }
36        }
37        hit
38    }
39}
40
41#[derive(Debug, Default)]
42pub struct PlatformSymbols {
43    /// (class, method, descriptor) → per-argument domains.
44    domains: HashMap<(String, String, String), Vec<Option<ConstantDomain>>>,
45}
46
47static SYMBOLS: OnceLock<PlatformSymbols> = OnceLock::new();
48
49/// Install the symbol table for this run (the driver installs the
50/// built-in default or an explicit `--symbols` build, before workers
51/// start — same pattern as the case-rename registry).
52pub fn set_symbols(s: PlatformSymbols) {
53    let _ = SYMBOLS.set(s);
54}
55
56/// The active table, if any.
57fn active() -> Option<&'static PlatformSymbols> {
58    SYMBOLS.get().filter(|s| !s.domains.is_empty())
59}
60
61/// Whether a symbol table is available (explicit or built-in).
62pub fn installed() -> bool {
63    active().is_some()
64}
65
66/// The named constant for a call argument, when the platform knows the
67/// parameter's domain and the literal matches exactly one member.
68/// Rendered dotted-and-qualified (the output has no imports).
69pub fn named_constant(cls: &str, name: &str, desc: &str, arg: usize, value: i64) -> Option<String> {
70    let syms = active()?;
71    let per_arg = syms.domains.get(&(cls.to_string(), name.to_string(), desc.to_string()))?;
72    let domain = per_arg.get(arg).or_else(|| per_arg.first()).cloned().flatten()?;
73    let (owner, field, _) = domain.exact(value)?;
74    Some(format!("{}.{}", owner.replace('/', "."), field))
75}
76
77impl PlatformSymbols {
78    /// Build from `annotations.xml` byte slices plus the resolved
79    /// constants ((class, field) → value) off android.jar.
80    pub fn from_metadata(xmls: &[&[u8]], constants: &HashMap<(String, String), i64>) -> Self {
81        let mut domains: HashMap<(String, String, String), Vec<Option<ConstantDomain>>> =
82            HashMap::new();
83        let mut on_domain = |class: &str, method: &str, desc: &str, param: usize,
84                             ann: &str, value: &str| {
85            if !ann.contains("IntDef") && !ann.contains("LongDef") {
86                return;
87            }
88            // `{android.view.View.VISIBLE, android.view.View.GONE}`
89            let members: Vec<(String, String, i64)> = value
90                .trim_matches(|c| c == '{' || c == '}')
91                .split(',')
92                .filter_map(|m| {
93                    let m = m.trim();
94                    let (owner, field) = m.rsplit_once('.')?;
95                    let owner = owner.replace('.', "/");
96                    let v = constants.get(&(owner.clone(), field.to_string()))?;
97                    Some((owner, field.to_string(), *v))
98                })
99                .collect();
100            if members.is_empty() {
101                return;
102            }
103            let entry = domains
104                .entry((class.to_string(), method.to_string(), desc.to_string()))
105                .or_default();
106            while entry.len() <= param {
107                entry.push(None);
108            }
109            entry[param] = Some(ConstantDomain { members });
110        };
111        for xml in xmls {
112            parse_annotations_xml(xml, &mut on_domain);
113        }
114        Self { domains }
115    }
116
117    /// Number of methods carrying at least one constant domain.
118    pub fn domain_count(&self) -> usize {
119        self.domains.values().filter(|v| v.iter().any(|d| d.is_some())).count()
120    }
121
122    /// Compact self-contained serialization (count-prefixed strings,
123    /// little-endian). The derived domain table is all the pass needs —
124    /// the android.jar constants map was only build input.
125    pub fn serialize(&self) -> Vec<u8> {
126        use std::collections::BTreeMap;
127        let ordered: BTreeMap<_, _> = self.domains.iter().collect(); // deterministic
128        let mut out = Vec::new();
129        out.extend_from_slice(&(ordered.len() as u32).to_le_bytes());
130        for ((cls, name, desc), per_arg) in ordered {
131            put_str(&mut out, cls);
132            put_str(&mut out, name);
133            put_str(&mut out, desc);
134            out.push(per_arg.len() as u8);
135            for d in per_arg {
136                match d {
137                    Some(d) => {
138                        out.push(1);
139                        out.extend_from_slice(&(d.members.len() as u16).to_le_bytes());
140                        for (owner, field, value) in &d.members {
141                            put_str(&mut out, owner);
142                            put_str(&mut out, field);
143                            out.extend_from_slice(&value.to_le_bytes());
144                        }
145                    }
146                    None => out.push(0),
147                }
148            }
149        }
150        out
151    }
152
153    /// Parse the plaintext format (one domain per line):
154    /// `cls method desc param member,member…` where member is
155    /// `owner.field=value` with dots. `-` marks an unmapped parameter.
156    /// This is the repo-maintained form (build.rs compresses it).
157    pub fn from_text(text: &[u8]) -> Option<Self> {
158        let text = String::from_utf8_lossy(text);
159        let mut domains = HashMap::new();
160        for line in text.lines() {
161            let line = line.trim();
162            if line.is_empty() || line.starts_with('#') {
163                continue;
164            }
165            let mut parts = line.split(' ');
166            let cls = parts.next()?.replace('.', "/");
167            let name = parts.next()?.to_string();
168            let desc = parts.next()?.to_string();
169            // Remaining parts: (param_idx, members) pairs.
170            let entry = domains
171                .entry((cls, name, desc))
172                .or_insert_with(Vec::new);
173            while let Some(idx) = parts.next() {
174                let param: usize = idx.parse().ok()?;
175                let members_s = parts.next()?;
176                while entry.len() <= param {
177                    entry.push(None);
178                }
179                if members_s == "-" {
180                    continue;
181                }
182                let members: Vec<(String, String, i64)> = members_s
183                    .split(',')
184                    .filter_map(|m| {
185                        // owner.field=value (owner uses dots)
186                        let (path, value) = m.rsplit_once('=')?;
187                        let value: i64 = value.parse().ok()?;
188                        let (owner_dot, field) = path.rsplit_once('.')?;
189                        Some((owner_dot.replace('.', "/"), field.to_string(), value))
190                    })
191                    .collect();
192                if !members.is_empty() {
193                    entry[param] = Some(ConstantDomain { members });
194                }
195            }
196        }
197        Some(Self { domains })
198    }
199
200    /// Emit the plaintext form (round-trips `from_text`).
201    pub fn to_text(&self) -> String {
202        use std::collections::BTreeMap;
203        let ordered: BTreeMap<_, _> = self.domains.iter().collect();
204        let mut out = String::new();
205        for ((cls, name, desc), per_arg) in ordered {
206            let mut line = format!("{cls} {name} {desc}");
207            for (i, d) in per_arg.iter().enumerate() {
208                match d {
209                    Some(d) => {
210                        let members: Vec<String> = d
211                            .members
212                            .iter()
213                            .map(|(owner, field, value)| {
214                                format!("{}.{}={}", owner.replace('/', "."), field, value)
215                            })
216                            .collect();
217                        line.push_str(&format!(" {i} {}", members.join(",")));
218                    }
219                    None => line.push_str(&format!(" {i} -")),
220                }
221            }
222            if line.split(' ').count() > 3 {
223                out.push_str(&line);
224                out.push('\n');
225            }
226        }
227        out
228    }
229
230    pub fn deserialize(bytes: &[u8]) -> Option<Self> {
231        let mut p = 0usize;
232        let rd_u32 = |p: &mut usize| -> Option<u32> {
233            let v = u32::from_le_bytes(bytes.get(*p..*p + 4)?.try_into().ok()?);
234            *p += 4;
235            Some(v)
236        };
237        let rd_u16 = |p: &mut usize| -> Option<u16> {
238            let v = u16::from_le_bytes(bytes.get(*p..*p + 2)?.try_into().ok()?);
239            *p += 2;
240            Some(v)
241        };
242        let rd_i64 = |p: &mut usize| -> Option<i64> {
243            let v = i64::from_le_bytes(bytes.get(*p..*p + 8)?.try_into().ok()?);
244            *p += 8;
245            Some(v)
246        };
247        let get_str = |p: &mut usize| -> Option<String> {
248            let len = rd_u16(p)? as usize;
249            let s = String::from_utf8(bytes.get(*p..*p + len)?.to_vec()).ok()?;
250            *p += len;
251            Some(s)
252        };
253        let count = rd_u32(&mut p)? as usize;
254        let mut domains = HashMap::with_capacity(count);
255        for _ in 0..count {
256            let cls = get_str(&mut p)?;
257            let name = get_str(&mut p)?;
258            let desc = get_str(&mut p)?;
259            let n = *bytes.get(p)?;
260            p += 1;
261            let mut per_arg = Vec::with_capacity(n as usize);
262            for _ in 0..n {
263                if *bytes.get(p)? == 1 {
264                    p += 1;
265                    let mc = rd_u16(&mut p)? as usize;
266                    let mut members = Vec::with_capacity(mc);
267                    for _ in 0..mc {
268                        let owner = get_str(&mut p)?;
269                        let field = get_str(&mut p)?;
270                        let value = rd_i64(&mut p)?;
271                        members.push((owner, field, value));
272                    }
273                    per_arg.push(Some(ConstantDomain { members }));
274                } else {
275                    p += 1;
276                    per_arg.push(None);
277                }
278            }
279            domains.insert((cls, name, desc), per_arg);
280        }
281        Some(Self { domains })
282    }
283}
284
285fn put_str(out: &mut Vec<u8>, s: &str) {
286    out.extend_from_slice(&(s.len() as u16).to_le_bytes());
287    out.extend_from_slice(s.as_bytes());
288}
289
290/// Java source type → dex descriptor fragment (`int` → `I`,
291/// `android.view.View` → `Landroid/view/View;`, `int[]` → `[I`).
292fn java_type_to_dex(t: &str) -> String {
293    let t = t.trim();
294    // Generic signatures (`Callback<? super List<InetAddress>>`, XML-
295    // escaped as &lt;…&gt;) never match our erased descriptors — map
296    // the whole parameter to Object rather than emit a key with spaces.
297    if t.contains('<') || t.contains("&lt;") || t.starts_with("? ") || t.starts_with("?\t") {
298        return "Ljava/lang/Object;".to_string();
299    }
300    if let Some(inner) = t.strip_suffix("[]") {
301        return format!("[{}", java_type_to_dex(inner));
302    }
303    let prim = match t {
304        "void" => "V",
305        "int" => "I",
306        "long" => "J",
307        "float" => "F",
308        "double" => "D",
309        "boolean" => "Z",
310        "byte" => "B",
311        "char" => "C",
312        "short" => "S",
313        _ => "",
314    };
315    if !prim.is_empty() {
316        return prim.to_string();
317    }
318    if t.is_empty() {
319        return String::new();
320    }
321    format!("L{};", t.replace('.', "/"))
322}
323
324/// Line-level walk of a metalava `annotations.xml`: items nest regularly
325/// (`<item name=…><annotation name=…><val name="value" val="{…}"/>`) —
326/// the same line-attr parsing style manifest.rs uses.
327/// (class, method, descriptor, param index, annotation, value).
328type DomainSink<'a> = dyn FnMut(&str, &str, &str, usize, &str, &str) + 'a;
329
330fn parse_annotations_xml(xml: &[u8], on_domain: &mut DomainSink<'_>) {
331    let text = String::from_utf8_lossy(xml);
332    let attr = |line: &str, key: &str| -> Option<String> {
333        let pat = format!("{key}=\"");
334        let start = line.find(&pat)? + pat.len();
335        let end = line[start..].find('"')? + start;
336        Some(line[start..end].to_string())
337    };
338    // `android.view.View void setVisibility(int) 0` → parts
339    let mut item: Option<(String, String, String, usize)> = None;
340    let mut cur_ann: Option<String> = None;
341    for line in text.lines() {
342        let t = line.trim();
343        if t.starts_with("<item ") {
344            // `android.view.View void setVisibility(int) 0` — class,
345            // RETURN type, method(args), parameter index.
346            let name = attr(t, "name").unwrap_or_default()
347                .replace("&lt;", "<")
348                .replace("&gt;", ">")
349                .replace("&quot;", "\"")
350                .replace("&amp;", "&");
351            let mut tail = name.rsplitn(2, ' ');
352            let param = tail.next().and_then(|p| p.parse::<usize>().ok());
353            let rest = tail.next().unwrap_or("").trim().to_string();
354            let (class, rest) = match rest.split_once(' ') {
355                Some((c, r)) => (c.replace('.', "/"), r.to_string()),
356                None => (rest.clone(), String::new()),
357            };
358            // `void setVisibility(int)` → method `setVisibility`, dex
359            // descriptor `(I)V`; constructors carry no return type
360            // (`Builder(long, java.lang.String, int)`) — anchor on the
361            // '(' and take the LAST space-separated token before it as
362            // the method name.
363            let (ret, method, args) = match rest.find('(') {
364                Some(p) => {
365                    let sig = &rest[..p];
366                    let (ret, method) = match sig.rsplit_once(' ') {
367                        Some((r, m)) => (r.to_string(), m.to_string()),
368                        None => ("void".to_string(), sig.to_string()),
369                    };
370                    (ret, method, rest[p + 1..].trim_end_matches(')'))
371                }
372                None => (String::new(), String::new(), ""),
373            };
374            let mut desc = String::from("(");
375            for a in args.split(',') {
376                desc.push_str(&java_type_to_dex(a.trim()));
377            }
378            desc.push(')');
379            desc.push_str(&java_type_to_dex(&ret));
380            item = param.map(|p| (class, method, desc, p));
381        } else if t.starts_with("<annotation ") {
382            cur_ann = attr(t, "name");
383        } else if t.starts_with("<val ") {
384            if let (Some((class, method, desc, param)), Some(ann)) = (&item, &cur_ann) {
385                if attr(t, "name").as_deref() == Some("value") {
386                    if let Some(v) = attr(t, "val") {
387                        on_domain(class, method, desc, *param, ann, &v);
388                    }
389                }
390            }
391        } else if t.starts_with("</item>") {
392            item = None;
393            cur_ann = None;
394        }
395    }
396}
397
398// ---------------------------------------------------------------------------
399// android.jar classfile constants
400// ---------------------------------------------------------------------------
401
402/// Read every static-final field's constant value out of one classfile.
403/// Only the pieces a constant lookup needs survive: the constant pool
404/// (Utf8/Class/Integer/Long/String entries), field_info with its
405/// ConstantValue attribute, and method descriptors for domain keys.
406/// (owner, field) → value; (owner, method, descriptor) list.
407pub type ClassConstants = (HashMap<(String, String), i64>, Vec<(String, String, String)>);
408
409pub fn classfile_constants(bytes: &[u8]) -> Option<ClassConstants> {
410    if bytes.len() < 10 || &bytes[..4] != b"\xca\xfe\xba\xbe" {
411        return None;
412    }
413    let rd_u1 = |p: usize| -> Option<u8> { bytes.get(p).copied() };
414    let rd_u2 = |p: usize| -> Option<u16> {
415        Some(u16::from_be_bytes([bytes.get(p).copied()?, bytes.get(p + 1).copied()?]))
416    };
417    let rd_u4 = |p: usize| -> Option<u32> {
418        Some(u32::from_be_bytes([
419            bytes.get(p).copied()?,
420            bytes.get(p + 1).copied()?,
421            bytes.get(p + 2).copied()?,
422            bytes.get(p + 3).copied()?,
423        ]))
424    };
425    let rd_i64 = |p: usize| -> Option<i64> {
426        Some(i64::from_be_bytes([
427            bytes.get(p).copied()?,
428            bytes.get(p + 1).copied()?,
429            bytes.get(p + 2).copied()?,
430            bytes.get(p + 3).copied()?,
431            bytes.get(p + 4).copied()?,
432            bytes.get(p + 5).copied()?,
433            bytes.get(p + 6).copied()?,
434            bytes.get(p + 7).copied()?,
435        ]))
436    };
437
438    // ---- constant pool ----
439    let count = rd_u2(8)? as usize;
440    // slot → (tag, data); longs/doubles occupy two slots.
441    let mut tags = vec![0u8; count];
442    let mut ints: HashMap<usize, i32> = HashMap::new();
443    let mut longs: HashMap<usize, i64> = HashMap::new();
444    let mut utf8s: HashMap<usize, (usize, usize)> = HashMap::new(); // (offset, len)
445    let mut classes: HashMap<usize, usize> = HashMap::new();        // slot → name idx
446    let mut p = 10usize;
447    let mut i = 1usize;
448    while i < count {
449        let tag = rd_u1(p)?;
450        p += 1;
451        tags[i] = tag;
452        match tag {
453            1 => {
454                let len = rd_u2(p)? as usize;
455                utf8s.insert(i, (p + 2, len));
456                p += 2 + len;
457            }
458            3 => {
459                ints.insert(i, i32::from_be_bytes(rd_u4(p)?.to_be_bytes()));
460                p += 4;
461            }
462            // Float(4): 4 bytes — grouping it with the u16 tags
463            // desynced the whole pool (34/12k classes survived).
464            4 => p += 4,
465            5 | 6 | 7 | 8 | 16 | 19 | 20 => {
466                if tag == 5 {
467                    longs.insert(i, rd_i64(p)?);
468                }
469                if tag == 7 {
470                    classes.insert(i, rd_u2(p)? as usize);
471                }
472                p += if tag == 5 || tag == 6 { 8 } else { 2 };
473            }
474            9 | 10 | 11 | 12 | 17 | 18 => p += 4,
475            15 => p += 3,
476            _ => return None, // unknown tag: bail on this class
477        }
478        // Long/Double occupy TWO pool slots — the phantom must be
479        // skipped or every following tag reads as 0 and the pool
480        // desyncs.
481        i += if tag == 5 || tag == 6 { 2 } else { 1 };
482        let _ = i;
483    }
484    let utf8 = |slot: usize| -> Option<String> {
485        let (off, len) = *utf8s.get(&slot)?;
486        // java classfiles use MODIFIED UTF-8; the android framework's
487        // names are ASCII — lossy decode is faithful for our purpose.
488        Some(String::from_utf8_lossy(bytes.get(off..off + len)?).into_owned())
489    };
490    // ---- access, this, super, interfaces ----
491    // (access_flags comes FIRST — reading this_class at p desynced
492    // everything downstream by 2 bytes.)
493    let this_class = *classes.get(&(rd_u2(p + 2)? as usize))?;
494    p += 6;
495
496    // ---- fields + methods ----
497    let ifaces = rd_u2(p)? as usize;
498    p += 2 + 2 * ifaces;
499    let class_name = utf8(this_class)?.trim_start_matches('L').trim_end_matches(';').to_string();
500
501    let mut constants = HashMap::new();
502    let mut methods = Vec::new();
503    for read_fields in [true, false] {
504        let n = rd_u2(p)? as usize;
505        p += 2;
506        for _ in 0..n {
507            let _access = rd_u2(p)?;
508            let name_idx = rd_u2(p + 2)? as usize;
509            let desc_idx = rd_u2(p + 4)? as usize;
510            let nattrs = rd_u2(p + 6)? as usize;
511            p += 8;
512            let name = utf8(name_idx)?;
513            let desc = utf8(desc_idx)?;
514            for _ in 0..nattrs {
515                let attr_name = utf8(rd_u2(p)? as usize)?;
516                let len = rd_u4(p + 2)? as usize;
517                let body = p + 6;
518                if read_fields && attr_name == "ConstantValue" {
519                    let idx = rd_u2(body)? as usize;
520                    let value = if let Some(v) = ints.get(&idx) {
521                        Some(*v as i64)
522                    } else {
523                        longs.get(&idx).copied()
524                    };
525                    if let Some(v) = value {
526                        constants.insert((class_name.clone(), name.clone()), v);
527                    }
528                }
529                if !read_fields {
530                    methods.push((class_name.clone(), name.clone(), desc.clone()));
531                }
532                p = body + len;
533            }
534        }
535    }
536    Some((constants, methods))
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    const XML: &[u8] = br#"
544<root>
545  <item name="android.view.View void setVisibility(int) 0">
546    <annotation name="androidx.annotation.IntDef">
547      <val name="value" val="{android.view.View.VISIBLE, android.view.View.INVISIBLE, android.view.View.GONE}" />
548    </annotation>
549  </item>
550  <item name="android.content.Intent android.content.Intent addFlags(int) 0">
551    <annotation name="androidx.annotation.IntDef">
552      <val name="value" val="{android.content.Intent.FLAG_ACTIVITY_NEW_TASK, android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP}" />
553    </annotation>
554  </item>
555  <item name="android.view.View void setSomething(android.view.View) 1">
556    <annotation name="androidx.annotation.StringDef">
557      <val name="value" val="{android.view.View.NAME}" />
558    </annotation>
559  </item>
560</root>
561"#;
562
563    #[test]
564    fn domains_parse_and_match() {
565        let mut constants: HashMap<(String, String), i64> = HashMap::new();
566        for (c, f, v) in [
567            ("android/view/View", "VISIBLE", 0i64),
568            ("android/view/View", "INVISIBLE", 4),
569            ("android/view/View", "GONE", 8),
570            ("android/content/Intent", "FLAG_ACTIVITY_NEW_TASK", 0x10000000),
571            ("android/content/Intent", "FLAG_ACTIVITY_CLEAR_TOP", 0x04000000),
572        ] {
573            constants.insert((c.to_string(), f.to_string()), v);
574        }
575        let syms = PlatformSymbols::from_metadata(&[XML], &constants);
576        assert_eq!(syms.domain_count(), 2, "IntDef domains only");
577        // named_constant reads the process-global registry.
578        set_symbols(PlatformSymbols::from_metadata(&[XML], &constants));
579
580        // Exact member values render; off-domain literals stay numeric.
581        assert_eq!(
582            named_constant("android/view/View", "setVisibility", "(I)V", 0, 8),
583            Some("android.view.View.GONE".to_string())
584        );
585        assert!(named_constant("android/view/View", "setVisibility", "(I)V", 0, 7).is_none());
586        // Unknown methods and wrong descriptors stay numeric.
587        assert!(named_constant("android/view/View", "setAlpha", "(F)V", 0, 0).is_none());
588        assert!(named_constant("android/view/View", "setVisibility", "(J)V", 0, 0).is_none());
589    }
590
591    #[test]
592    fn text_roundtrip() {
593        let mut constants: HashMap<(String, String), i64> = HashMap::new();
594        for (c, f, v) in [
595            ("android/view/View", "VISIBLE", 0i64),
596            ("android/view/View", "GONE", 8),
597        ] {
598            constants.insert((c.to_string(), f.to_string()), v);
599        }
600        let syms = PlatformSymbols::from_metadata(&[XML], &constants);
601        let text = syms.to_text();
602        let back = PlatformSymbols::from_text(text.as_bytes()).expect("text parse");
603        assert_eq!(back.domain_count(), syms.domain_count());
604        // The parsed structure must carry the setVisibility domain with
605        // GONE=8 (named_constant reads the process-global registry set
606        // by whichever test ran first — not reliable for isolation).
607        let per_arg = back
608            .domains
609            .get(&("android/view/View".to_string(), "setVisibility".to_string(), "(I)V".to_string()))
610            .expect("domain present");
611        let domain = per_arg.first().and_then(|d| d.as_ref()).expect("param 0 domain");
612        assert_eq!(domain.exact(8).map(|m| m.1.as_str()), Some("GONE"));
613        assert_eq!(domain.exact(0).map(|m| m.1.as_str()), Some("VISIBLE"));
614    }
615
616    #[test]
617    fn serialize_roundtrip() {
618        let mut constants: HashMap<(String, String), i64> = HashMap::new();
619        for (c, f, v) in [
620            ("android/view/View", "VISIBLE", 0i64),
621            ("android/view/View", "GONE", 8),
622        ] {
623            constants.insert((c.to_string(), f.to_string()), v);
624        }
625        let syms = PlatformSymbols::from_metadata(&[XML], &constants);
626        let blob = syms.serialize();
627        let back = PlatformSymbols::deserialize(&blob).expect("roundtrip");
628        assert_eq!(back.domain_count(), syms.domain_count());
629        // A truncated blob must not panic — parse fails cleanly.
630        assert!(PlatformSymbols::deserialize(&blob[..blob.len() / 2]).is_none());
631    }
632
633    #[test]
634    fn classfile_constants_reads_constant_value() {
635        // Minimal classfile: Utf8/Class/Utf8/Utf8/Utf8/Integer pool, one
636        // field with a ConstantValue attribute.
637        let mut entries: Vec<Vec<u8>> = Vec::new();
638        entries.push(vec![]); // slot 0 unused
639        let add_utf8 = |entries: &mut Vec<Vec<u8>>, s: &str| {
640            let mut e = vec![1u8];
641            e.extend_from_slice(&(s.len() as u16).to_be_bytes());
642            e.extend_from_slice(s.as_bytes());
643            entries.push(e);
644            entries.len() as u16 - 1
645        };
646        let class_entry = add_utf8(&mut entries, "View");
647        let mut e = vec![7u8];
648        e.extend_from_slice(&class_entry.to_be_bytes());
649        entries.push(e);
650        let class_idx = (entries.len() - 1) as u16;
651        let field_name = add_utf8(&mut entries, "GONE");
652        let field_desc = add_utf8(&mut entries, "I");
653        let attr_name = add_utf8(&mut entries, "ConstantValue");
654        let mut e = vec![3u8];
655        e.extend_from_slice(&8i32.to_be_bytes());
656        entries.push(e);
657        let int_idx = (entries.len() - 1) as u16;
658
659        let mut class = Vec::new();
660        class.extend_from_slice(b"\xca\xfe\xba\xbe");
661        class.extend_from_slice(&0u16.to_be_bytes()); // minor
662        class.extend_from_slice(&52u16.to_be_bytes()); // major
663        class.extend_from_slice(&(entries.len() as u16).to_be_bytes());
664        for e in &entries[1..] {
665            class.extend_from_slice(e);
666        }
667        class.extend_from_slice(&0x0008u16.to_be_bytes()); // access
668        class.extend_from_slice(&class_idx.to_be_bytes()); // this
669        class.extend_from_slice(&0u16.to_be_bytes()); // super = Object (invalid idx 0 ok)
670        class.extend_from_slice(&0u16.to_be_bytes()); // interfaces
671        class.extend_from_slice(&1u16.to_be_bytes()); // 1 field
672        class.extend_from_slice(&0x0019u16.to_be_bytes()); // field access
673        class.extend_from_slice(&field_name.to_be_bytes());
674        class.extend_from_slice(&field_desc.to_be_bytes());
675        class.extend_from_slice(&1u16.to_be_bytes()); // 1 attribute
676        class.extend_from_slice(&attr_name.to_be_bytes());
677        class.extend_from_slice(&2u32.to_be_bytes()); // attr len
678        class.extend_from_slice(&int_idx.to_be_bytes());
679        class.extend_from_slice(&0u16.to_be_bytes()); // methods count
680
681        let (constants, _methods) = classfile_constants(&class).expect("parse");
682        assert_eq!(
683            constants.get(&("View".to_string(), "GONE".to_string())),
684            Some(&8i64),
685            "constants: {constants:?}"
686        );
687    }
688}