Skip to main content

sioul_core/
mms.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Multimedia messages sent from a computer through the phone (docs/texts.md,
5//! "Multimedia messages sent from a computer"): the message Android's `sendMultimediaMessage`
6//! sends, an M-Send.req of the MMS 1.2 encapsulation (OMA-TS-MMS_ENC-V1_3,
7//! over WSP's encoding, WAP-230-WSP), written on the phone from a request's
8//! words and media (`encode`); the computer measures a message with the same
9//! code (`size`), so both count the same bytes against the carrier's limit;
10//! and the carrier's answer to it (M-Send.conf, `conf`), which Android hands
11//! back with its result.
12//!
13//! The bytes are those AOSP's own composer writes for such a message
14//! (`PduComposer`, android16-release), which Android's own parser reads back
15//! (`android/jvm-checks/MmsCheck.java`, on the samples of
16//! `android/jvm-checks/mms-samples.jsonl`, which this module's tests write
17//! and check): its headers in its order; From as the insert-address token
18//! (the carrier writes the sender's number: the phone need not know its
19//! own); each number with `/TYPE=PLMN`; no subject; class Personal; no
20//! delivery or read report (they would need another restricted permission,
21//! and Android's carrier default offers none); the body `multipart/related`,
22//! its SMIL first (AOSP Messaging's layouts), then each medium, then the
23//! words in UTF-8. Each part is named plainly (`image1.jpg`, `text1.txt`),
24//! never by its file's own name, which can say where or who: a medium here
25//! has no name at all.
26
27use std::fmt::Write as _;
28
29/// A medium of a message: its content type, as the message names it
30/// (`image/jpeg`, `video/mp4`, `text/x-vCard`), and its bytes.
31#[derive(Debug, Clone, Copy)]
32pub struct Medium<'a> {
33    pub ct: &'a str,
34    pub bytes: &'a [u8],
35}
36
37/// A multimedia message to send (M-Send.req).
38#[derive(Debug, Clone, Copy)]
39pub struct Message<'a> {
40    /// The request's key: the message's transaction id, which Android files
41    /// in the provider's `tr_id`, so that the phone finds it there again.
42    pub key: &'a str,
43    /// Its numbers, as written: one, or several for a group.
44    pub to: &'a [&'a str],
45    /// When it was written (seconds since 1970: the Date header).
46    pub date: i64,
47    /// Its words, if any: one text part after the media.
48    pub words: &'a str,
49    /// Its media, in their order.
50    pub media: &'a [Medium<'a>],
51}
52
53/// Why a message cannot be written.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Refused {
56    /// No number, or one that is no phone number (letters, too short).
57    Number,
58    /// Neither words nor media.
59    Empty,
60    /// A key that cannot be a transaction id (empty, too long, not ASCII).
61    Key,
62    /// A medium's content type that cannot be written (empty, too long, not ASCII).
63    Type,
64}
65
66/// The message's bytes, as Android sends them (the PDU).
67pub fn encode(message: &Message) -> Result<Vec<u8>, Refused> {
68    let mut out = Vec::with_capacity(message.media.iter().map(|m| m.bytes.len()).sum::<usize>() + message.words.len() + 1024);
69    write(message, &mut out)?;
70    Ok(out)
71}
72
73/// How many bytes the message is (`encode`'s, counted without being
74/// written): what the carrier's limit is held against, on the computer that
75/// reduces its pictures and on the phone that sends it.
76pub fn size(message: &Message) -> Result<usize, Refused> {
77    let mut count = Count(0);
78    write(message, &mut count)?;
79    Ok(count.0)
80}
81
82/// How many bytes the message is once its media hold `lengths` bytes each,
83/// given with their content types alone (`media`' bytes left empty): what a
84/// computer's page counts as words are typed, never reading the media again.
85/// The same number `size` gives the message whole: a part's headers do not
86/// depend on its bytes, only its data's length does (a uintvar).
87pub fn size_with_lengths(message: &Message, lengths: &[usize]) -> Result<usize, Refused> {
88    let empty = size(message)?;
89    let grown = lengths.iter().map(|&length| {
90        let mut count = Count(0);
91        uintvar(&mut count, length);
92        length + count.0 - 1
93    });
94    Ok(empty + grown.sum::<usize>())
95}
96
97/// The bytes left under `limit` (the carrier's, else the one chosen) for the
98/// media's own bytes, all of them together, for a message whose media are
99/// given empty (their content types alone): its size so, taken from the
100/// limit, less what their lengths may add once they hold their bytes (a
101/// medium's length is a uintvar, one byte when empty, as many as the
102/// limit's own at most: two more each under 2 MB, three from 2 MB on). None
103/// when even that is over. A message whose media hold no more between them
104/// then fits (`size`).
105pub fn room(message: &Message, limit: usize) -> Option<usize> {
106    let empty = size(message).ok()?;
107    let mut longest = Count(0);
108    uintvar(&mut longest, limit);
109    limit.checked_sub(empty)?.checked_sub(longest.0.saturating_sub(1) * message.media.len())
110}
111
112/// A number as a message's To header writes it: its digits, with the `+`
113/// that may begin it ("05 36 49 12 34" is `0536491234`, "+33 1 99 00 12 34"
114/// is `+33199001234`); none for a name in letters or fewer than three
115/// digits (a sender named in letters cannot be answered so).
116pub fn address(number: &str) -> Option<String> {
117    let number = number.trim();
118    let mut out = String::new();
119    for (i, c) in number.chars().enumerate() {
120        match c {
121            '0'..='9' => out.push(c),
122            '+' if i == 0 => out.push(c),
123            ' ' | '\u{a0}' | '\u{202f}' | '.' | '-' | '(' | ')' | '/' => {}
124            _ => return None,
125        }
126    }
127    (out.chars().filter(char::is_ascii_digit).count() >= 3).then_some(out)
128}
129
130/// The longest number a message goes to (E.164's 15 digits after its `+`),
131/// for a measure that holds whatever number the phone then writes: a
132/// computer measures a message with it, never shorter than the phone's own.
133pub const LONGEST_NUMBER: &str = "+000000000000000";
134
135/// The carrier's answer to a message sent (M-Send.conf, ENC 6.1.2), as
136/// Android hands it back with its result (`SmsManager.EXTRA_MMS_DATA`).
137#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct Conf {
139    /// X-Mms-Response-Status: `OK` (0x80), else why not (ENC 7.3.48:
140    /// 0xC0 to 0xDF for a moment, 0xE0 to 0xFF for good, 0x81 to 0x88 as
141    /// MMS 1.0 said them).
142    pub status: u8,
143    /// Its Message-ID, given by the carrier when it took it.
144    pub id: String,
145    /// Its X-Mms-Transaction-ID: the request's key, as sent.
146    pub key: String,
147    /// Its X-Mms-Response-Text, when the carrier said why in words.
148    pub said: String,
149}
150
151/// The carrier took the message (X-Mms-Response-Status Ok).
152pub const OK: u8 = 0x80;
153
154impl Conf {
155    /// Whether the carrier took it.
156    pub fn ok(&self) -> bool {
157        self.status == OK
158    }
159}
160
161/// The carrier's answer, read; none for bytes that are no M-Send.conf. A
162/// header Sioul does not need is passed over by its WSP form.
163pub fn conf(pdu: &[u8]) -> Option<Conf> {
164    let mut at = Reader { bytes: pdu, at: 0 };
165    if at.byte()? != MESSAGE_TYPE || at.byte()? != SEND_CONF {
166        return None;
167    }
168    let mut conf = Conf::default();
169    let mut status = None;
170    while let Some(field) = at.byte() {
171        match field {
172            TRANSACTION_ID => conf.key = at.text()?,
173            MESSAGE_ID => conf.id = at.text()?,
174            RESPONSE_STATUS => status = Some(at.byte()?),
175            RESPONSE_TEXT => conf.said = at.encoded()?,
176            field if field >= 0x80 => at.skip()?,
177            _ => return None,
178        }
179    }
180    conf.status = status?;
181    Some(conf)
182}
183
184// ------------------------------------------------------------------ writing
185
186/// Header fields and values (ENC 7.3; AOSP's `PduHeaders`, `PduPart`).
187const CONTENT_TYPE: u8 = 0x84;
188const DATE: u8 = 0x85;
189const DELIVERY_REPORT: u8 = 0x86;
190const FROM: u8 = 0x89;
191const MESSAGE_CLASS: u8 = 0x8A;
192const MESSAGE_ID: u8 = 0x8B;
193const MESSAGE_TYPE: u8 = 0x8C;
194const MMS_VERSION: u8 = 0x8D;
195const READ_REPORT: u8 = 0x90;
196const RESPONSE_STATUS: u8 = 0x92;
197const RESPONSE_TEXT: u8 = 0x93;
198const TO: u8 = 0x97;
199const TRANSACTION_ID: u8 = 0x98;
200const SEND_REQ: u8 = 0x80;
201const SEND_CONF: u8 = 0x81;
202/// MMS 1.2 (`PduHeaders.CURRENT_MMS_VERSION`), as a short integer.
203const VERSION_1_2: u8 = 0x80 | (1 << 4) | 2;
204const INSERT_ADDRESS: u8 = 0x81;
205const PERSONAL: u8 = 0x80;
206const NO: u8 = 0x81;
207/// A part's Content-Type parameters and headers (WSP 8.4.2).
208const P_CHARSET: u8 = 0x81;
209const P_NAME: u8 = 0x85;
210const P_TYPE: u8 = 0x89;
211const P_START: u8 = 0x8A;
212const P_CONTENT_LOCATION: u8 = 0x8E;
213const P_CONTENT_ID: u8 = 0xC0;
214/// UTF-8's MIBenum.
215const UTF_8: u8 = 106;
216/// How long a picture, a card or the words show, for a phone that follows the SMIL (AOSP Messaging's).
217const SHOWN_MS: u32 = 5000;
218
219/// The content types WSP numbers (AOSP's `PduContentTypes`, by number): a
220/// part of one of these types names it by its number, any other by its
221/// words, as Android's composer does (an exact match: `text/x-vCard`).
222const NUMBERED: [&str; 0x53] = [
223    "*/*",
224    "text/*",
225    "text/html",
226    "text/plain",
227    "text/x-hdml",
228    "text/x-ttml",
229    "text/x-vCalendar",
230    "text/x-vCard",
231    "text/vnd.wap.wml",
232    "text/vnd.wap.wmlscript",
233    "text/vnd.wap.wta-event",
234    "multipart/*",
235    "multipart/mixed",
236    "multipart/form-data",
237    "multipart/byterantes",
238    "multipart/alternative",
239    "application/*",
240    "application/java-vm",
241    "application/x-www-form-urlencoded",
242    "application/x-hdmlc",
243    "application/vnd.wap.wmlc",
244    "application/vnd.wap.wmlscriptc",
245    "application/vnd.wap.wta-eventc",
246    "application/vnd.wap.uaprof",
247    "application/vnd.wap.wtls-ca-certificate",
248    "application/vnd.wap.wtls-user-certificate",
249    "application/x-x509-ca-cert",
250    "application/x-x509-user-cert",
251    "image/*",
252    "image/gif",
253    "image/jpeg",
254    "image/tiff",
255    "image/png",
256    "image/vnd.wap.wbmp",
257    "application/vnd.wap.multipart.*",
258    "application/vnd.wap.multipart.mixed",
259    "application/vnd.wap.multipart.form-data",
260    "application/vnd.wap.multipart.byteranges",
261    "application/vnd.wap.multipart.alternative",
262    "application/xml",
263    "text/xml",
264    "application/vnd.wap.wbxml",
265    "application/x-x968-cross-cert",
266    "application/x-x968-ca-cert",
267    "application/x-x968-user-cert",
268    "text/vnd.wap.si",
269    "application/vnd.wap.sic",
270    "text/vnd.wap.sl",
271    "application/vnd.wap.slc",
272    "text/vnd.wap.co",
273    "application/vnd.wap.coc",
274    "application/vnd.wap.multipart.related",
275    "application/vnd.wap.sia",
276    "text/vnd.wap.connectivity-xml",
277    "application/vnd.wap.connectivity-wbxml",
278    "application/pkcs7-mime",
279    "application/vnd.wap.hashed-certificate",
280    "application/vnd.wap.signed-certificate",
281    "application/vnd.wap.cert-response",
282    "application/xhtml+xml",
283    "application/wml+xml",
284    "text/css",
285    "application/vnd.wap.mms-message",
286    "application/vnd.wap.rollover-certificate",
287    "application/vnd.wap.locc+wbxml",
288    "application/vnd.wap.loc+xml",
289    "application/vnd.syncml.dm+wbxml",
290    "application/vnd.syncml.dm+xml",
291    "application/vnd.syncml.notification",
292    "application/vnd.wap.xhtml+xml",
293    "application/vnd.wv.csp.cir",
294    "application/vnd.oma.dd+xml",
295    "application/vnd.oma.drm.message",
296    "application/vnd.oma.drm.content",
297    "application/vnd.oma.drm.rights+xml",
298    "application/vnd.oma.drm.rights+wbxml",
299    "application/vnd.wv.csp+xml",
300    "application/vnd.wv.csp+wbxml",
301    "application/vnd.syncml.ds.notification",
302    "audio/*",
303    "video/*",
304    "application/vnd.oma.dd2+xml",
305    "application/mikey",
306];
307
308/// Where a message's bytes go: written (`Vec`), or counted (`Count`).
309trait Out {
310    fn put(&mut self, bytes: &[u8]);
311    fn byte(&mut self, byte: u8) {
312        self.put(&[byte]);
313    }
314}
315
316impl Out for Vec<u8> {
317    fn put(&mut self, bytes: &[u8]) {
318        self.extend_from_slice(bytes);
319    }
320}
321
322/// Bytes counted, not kept.
323struct Count(usize);
324
325impl Out for Count {
326    fn put(&mut self, bytes: &[u8]) {
327        self.0 += bytes.len();
328    }
329}
330
331/// An unsigned integer of 7 bits a byte, the most significant first (WSP 8.1.2).
332fn uintvar(out: &mut impl Out, value: usize) {
333    let mut bytes = [0u8; 10];
334    let mut at = bytes.len();
335    let mut rest = value;
336    loop {
337        at -= 1;
338        bytes[at] = (rest & 0x7F) as u8 | if at == bytes.len() - 1 { 0 } else { 0x80 };
339        rest >>= 7;
340        if rest == 0 {
341            break;
342        }
343    }
344    out.put(&bytes[at..]);
345}
346
347/// A length: one byte up to 30, else 31 and a uintvar (WSP 8.4.2.2).
348fn value_length(out: &mut impl Out, length: usize) {
349    if length <= 30 {
350        out.byte(length as u8);
351    } else {
352        out.byte(31);
353        uintvar(out, length);
354    }
355}
356
357/// A Text-string: the bytes and a 0, behind a quote when the first is 128 or more (WSP 8.4.2.1).
358fn text(out: &mut impl Out, bytes: &[u8]) {
359    if bytes.first().is_some_and(|b| *b >= 0x80) {
360        out.byte(0x7F);
361    }
362    out.put(bytes);
363    out.byte(0);
364}
365
366/// A Quoted-string: a `"`, the bytes and a 0.
367fn quoted(out: &mut impl Out, bytes: &[u8]) {
368    out.byte(b'"');
369    out.put(bytes);
370    out.byte(0);
371}
372
373/// A Long-integer: its byte count, then its bytes, the most significant first, as few as hold it.
374fn long_integer(out: &mut impl Out, value: u64) {
375    let bytes = value.to_be_bytes();
376    let first = bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len());
377    out.byte((bytes.len() - first) as u8);
378    out.put(&bytes[first..]);
379}
380
381/// A content type, by its WSP number when it has one, else by its words.
382fn content_type(out: &mut impl Out, ct: &str) {
383    match NUMBERED.iter().position(|known| *known == ct) {
384        Some(number) => out.byte(0x80 | number as u8),
385        None => text(out, ct.as_bytes()),
386    }
387}
388
389/// What a medium is, for its name and its place in the SMIL.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391enum Kind {
392    Image,
393    Video,
394    Audio,
395    Card,
396    Other,
397}
398
399fn kind_of(ct: &str) -> Kind {
400    let ct = ct.to_ascii_lowercase();
401    if ct.starts_with("image/") {
402        Kind::Image
403    } else if ct.starts_with("video/") {
404        Kind::Video
405    } else if ct.starts_with("audio/") {
406        Kind::Audio
407    } else if matches!(ct.as_str(), "text/x-vcard" | "text/vcard" | "text/x-vcalendar" | "text/calendar") {
408        Kind::Card
409    } else {
410        Kind::Other
411    }
412}
413
414/// A medium's plain name in the message, by its kind and its rank among
415/// those of its kind (`image1.jpg`, `video1.mp4`, `contact1.vcf`): never
416/// its file's own.
417fn names(media: &[Medium]) -> Vec<(Kind, String)> {
418    let mut seen = [0usize; 5];
419    media
420        .iter()
421        .map(|medium| {
422            let kind = kind_of(medium.ct);
423            let rank = &mut seen[kind as usize];
424            *rank += 1;
425            let ct = medium.ct.to_ascii_lowercase();
426            let (stem, extension) = match kind {
427                Kind::Image => ("image", match ct.as_str() {
428                    "image/png" => "png",
429                    "image/gif" => "gif",
430                    "image/webp" => "webp",
431                    "image/bmp" => "bmp",
432                    _ => "jpg",
433                }),
434                Kind::Video => ("video", if ct.contains("3gpp") { "3gp" } else if ct.contains("webm") { "webm" } else { "mp4" }),
435                Kind::Audio => ("audio", match ct.as_str() {
436                    "audio/amr" => "amr",
437                    "audio/mpeg" | "audio/mp3" => "mp3",
438                    "audio/ogg" | "audio/opus" => "ogg",
439                    "audio/aac" => "aac",
440                    "audio/wav" | "audio/x-wav" => "wav",
441                    _ => "m4a",
442                }),
443                Kind::Card if ct.contains("cal") => ("calendar", "vcs"),
444                Kind::Card => ("contact", "vcf"),
445                Kind::Other => ("other", "dat"),
446            };
447            (kind, format!("{stem}{rank}.{extension}"))
448        })
449        .collect()
450}
451
452/// The words' part's name.
453const WORDS_NAME: &str = "text1.txt";
454
455/// The message's SMIL, as AOSP Messaging lays one out: the pictures and
456/// videos above (four fifths of the height when there are words), the words
457/// below, one `<par>` each, the words last. A video or a sound plays its
458/// own length (no duration said: Sioul does not read it, and a wrong one
459/// would cut it short on a phone that follows the SMIL).
460fn smil(parts: &[(Kind, String)], words: bool) -> String {
461    let mut body = String::new();
462    for (kind, name) in parts {
463        let _ = match kind {
464            Kind::Image => write!(body, "<par dur=\"{SHOWN_MS}ms\"><img src=\"{name}\" region=\"Image\" /></par>"),
465            Kind::Video => write!(body, "<par><video src=\"{name}\" region=\"Image\" /></par>"),
466            Kind::Audio => write!(body, "<par><audio src=\"{name}\" /></par>"),
467            Kind::Card | Kind::Other => write!(body, "<par dur=\"{SHOWN_MS}ms\"><ref src=\"{name}\" /></par>"),
468        };
469    }
470    if words {
471        let _ = write!(body, "<par dur=\"{SHOWN_MS}ms\"><text src=\"{WORDS_NAME}\" region=\"Text\" /></par>");
472    }
473    let visual = parts.iter().any(|(kind, _)| matches!(kind, Kind::Image | Kind::Video));
474    let other = parts.iter().any(|(kind, _)| matches!(kind, Kind::Audio | Kind::Card));
475    let layout = match (visual, other, words) {
476        (true, _, true) => r#"<region id="Image" fit="meet" top="0" left="0" height="80%" width="100%"/><region id="Text" top="80%" left="0" height="20%" width="100%"/>"#,
477        (true, _, false) => r#"<region id="Image" fit="meet" top="0" left="0" height="100%" width="100%"/>"#,
478        (false, true, false) => "",
479        _ => r#"<region id="Text" top="0" left="0" height="100%" width="100%"/>"#,
480    };
481    format!("<smil><head><layout><root-layout/>{layout}</layout></head><body>{body}</body></smil>")
482}
483
484/// One part of the body: its Content-Type (with its name, and its charset
485/// for words), its Content-ID and Content-Location, then its bytes (WSP 8.5,
486/// as `PduComposer.makeMessageBody` writes them).
487fn part(out: &mut impl Out, ct: &str, name: &str, charset: Option<u8>, data: &[u8]) {
488    let mut typed = Vec::new();
489    content_type(&mut typed, ct);
490    typed.push(P_NAME);
491    text(&mut typed, name.as_bytes());
492    if let Some(charset) = charset {
493        typed.push(P_CHARSET);
494        typed.push(0x80 | charset);
495    }
496    let mut headers = Vec::new();
497    value_length(&mut headers, typed.len());
498    headers.extend_from_slice(&typed);
499    let id = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
500    headers.push(P_CONTENT_ID);
501    quoted(&mut headers, format!("<{id}>").as_bytes());
502    headers.push(P_CONTENT_LOCATION);
503    text(&mut headers, name.as_bytes());
504    uintvar(out, headers.len());
505    uintvar(out, data.len());
506    out.put(&headers);
507    out.put(data);
508}
509
510/// What `encode` and `size` write, in AOSP's order (`PduComposer.makeSendRetrievePdu`).
511fn write(message: &Message, out: &mut impl Out) -> Result<(), Refused> {
512    let key = message.key.as_bytes();
513    if key.is_empty() || key.len() > 64 || !key.iter().all(|b| b.is_ascii_graphic()) {
514        return Err(Refused::Key);
515    }
516    let numbers: Vec<String> = message.to.iter().map(|n| address(n)).collect::<Option<_>>().ok_or(Refused::Number)?;
517    if numbers.is_empty() {
518        return Err(Refused::Number);
519    }
520    if message.words.is_empty() && message.media.is_empty() {
521        return Err(Refused::Empty);
522    }
523    if message.media.iter().any(|m| m.ct.is_empty() || m.ct.len() > 100 || !m.ct.bytes().all(|b| b.is_ascii_graphic())) {
524        return Err(Refused::Type);
525    }
526    out.put(&[MESSAGE_TYPE, SEND_REQ, TRANSACTION_ID]);
527    text(out, key);
528    out.put(&[MMS_VERSION, VERSION_1_2, DATE]);
529    long_integer(out, u64::try_from(message.date).unwrap_or(0).max(1));
530    out.put(&[FROM, 1, INSERT_ADDRESS]);
531    for number in &numbers {
532        // Encoded-string-value with its charset, as Android's composer writes every address.
533        let mut value = vec![0x80 | UTF_8];
534        text(&mut value, format!("{number}/TYPE=PLMN").as_bytes());
535        out.byte(TO);
536        value_length(out, value.len());
537        out.put(&value);
538    }
539    out.put(&[MESSAGE_CLASS, PERSONAL, DELIVERY_REPORT, NO, READ_REPORT, NO, CONTENT_TYPE]);
540    // multipart/related, its SMIL first: start and type.
541    let mut related = Vec::new();
542    content_type(&mut related, "application/vnd.wap.multipart.related");
543    related.push(P_START);
544    text(&mut related, b"<smil>");
545    related.push(P_TYPE);
546    text(&mut related, b"application/smil");
547    value_length(out, related.len());
548    out.put(&related);
549    let named = names(message.media);
550    let words = !message.words.is_empty();
551    uintvar(out, 1 + message.media.len() + usize::from(words));
552    // The SMIL part, as AOSP Messaging names it: "smil" and smil.xml.
553    let layout = smil(&named, words);
554    part(out, "application/smil", "smil.xml", None, layout.as_bytes());
555    for (medium, (_, name)) in message.media.iter().zip(&named) {
556        part(out, medium.ct, name, None, medium.bytes);
557    }
558    if words {
559        part(out, "text/plain", WORDS_NAME, Some(UTF_8), message.words.as_bytes());
560    }
561    Ok(())
562}
563
564// ------------------------------------------------------------------ reading
565
566/// The bytes of an answer, read in turn.
567struct Reader<'a> {
568    bytes: &'a [u8],
569    at: usize,
570}
571
572impl Reader<'_> {
573    fn byte(&mut self) -> Option<u8> {
574        let byte = *self.bytes.get(self.at)?;
575        self.at += 1;
576        Some(byte)
577    }
578
579    fn take(&mut self, count: usize) -> Option<&[u8]> {
580        let end = self.at.checked_add(count)?;
581        let taken = self.bytes.get(self.at..end)?;
582        self.at = end;
583        Some(taken)
584    }
585
586    fn uintvar(&mut self) -> Option<usize> {
587        let mut value: usize = 0;
588        for _ in 0..5 {
589            let byte = self.byte()?;
590            value = (value << 7) | usize::from(byte & 0x7F);
591            if byte & 0x80 == 0 {
592                return Some(value);
593            }
594        }
595        None
596    }
597
598    /// A Text-string, its quote passed over.
599    fn text(&mut self) -> Option<String> {
600        let rest = self.bytes.get(self.at..)?;
601        let end = rest.iter().position(|b| *b == 0)?;
602        let mut bytes = &rest[..end];
603        self.at += end + 1;
604        if bytes.first() == Some(&0x7F) || bytes.first() == Some(&b'"') {
605            bytes = &bytes[1..];
606        }
607        Some(String::from_utf8_lossy(bytes).into_owned())
608    }
609
610    /// An Encoded-string-value: a Text-string, or a length, a charset and a Text-string.
611    fn encoded(&mut self) -> Option<String> {
612        let first = *self.bytes.get(self.at)?;
613        if first > 31 {
614            return self.text();
615        }
616        let length = self.length()?;
617        let value = self.take(length)?;
618        let mut inner = Reader { bytes: value, at: 0 };
619        // The charset: a short integer, or a long one.
620        match inner.byte()? {
621            byte if byte >= 0x80 => {}
622            byte if byte <= 30 => {
623                inner.take(usize::from(byte))?;
624            }
625            _ => return None,
626        }
627        inner.text()
628    }
629
630    /// A Value-length.
631    fn length(&mut self) -> Option<usize> {
632        match self.byte()? {
633            31 => self.uintvar(),
634            byte if byte <= 30 => Some(usize::from(byte)),
635            _ => None,
636        }
637    }
638
639    /// A header's value of a field this reader does not need, passed over by its form (WSP 8.4.1.2).
640    fn skip(&mut self) -> Option<()> {
641        match *self.bytes.get(self.at)? {
642            0..=31 => {
643                let length = self.length()?;
644                self.take(length)?;
645            }
646            32..=127 => {
647                self.text()?;
648            }
649            _ => {
650                self.at += 1;
651            }
652        }
653        Some(())
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use std::path::Path;
661
662    const KEY: &str = "0f3c9a5e7b2d4f6a8c1e3b5d7f9a2c4e";
663    /// 18 October 2026, 14:03 in Paris, in seconds.
664    const WRITTEN: i64 = 1_792_324_980;
665
666    fn hex(bytes: &[u8]) -> String {
667        bytes.iter().map(|b| format!("{b:02x}")).collect()
668    }
669
670    fn sha256(bytes: &[u8]) -> String {
671        use sha2::{Digest, Sha256};
672        hex(&Sha256::digest(bytes))
673    }
674
675    /// Bytes standing for a picture or a sound: `count` of them, from `seed`.
676    fn stand_in(count: usize, seed: u8) -> Vec<u8> {
677        (0..count).map(|i| (i as u8).wrapping_mul(31).wrapping_add(seed)).collect()
678    }
679
680    /// A message read back as Android reads one: its headers and each part.
681    #[derive(Debug, Default)]
682    struct Read {
683        headers: Vec<(u8, Vec<u8>)>,
684        start: String,
685        parts: Vec<ReadPart>,
686    }
687
688    #[derive(Debug, Default, PartialEq)]
689    struct ReadPart {
690        ct: String,
691        name: String,
692        charset: u8,
693        id: String,
694        location: String,
695        data: Vec<u8>,
696    }
697
698    /// The test's own decoder of an M-Send.req, as WSP reads one: every
699    /// header (its value's bytes), then the body's parts.
700    fn read(pdu: &[u8]) -> Read {
701        let mut r = Reader { bytes: pdu, at: 0 };
702        let mut read = Read::default();
703        loop {
704            let field = r.byte().expect("a header");
705            if field == CONTENT_TYPE {
706                break;
707            }
708            let from = r.at;
709            match field {
710                MESSAGE_TYPE | MMS_VERSION | MESSAGE_CLASS | DELIVERY_REPORT | READ_REPORT => {
711                    r.byte().unwrap();
712                }
713                TRANSACTION_ID => {
714                    r.text().unwrap();
715                }
716                DATE => {
717                    let count = usize::from(r.byte().unwrap());
718                    r.take(count).unwrap();
719                }
720                FROM | TO => {
721                    let length = r.length().unwrap();
722                    r.take(length).unwrap();
723                }
724                _ => panic!("a header Sioul does not write: {field:#x}"),
725            }
726            read.headers.push((field, pdu[from..r.at].to_vec()));
727        }
728        let length = r.length().unwrap();
729        let related = r.take(length).unwrap().to_vec();
730        assert_eq!(related[0], 0x80 | 0x33, "multipart/related");
731        let mut inner = Reader { bytes: &related, at: 1 };
732        assert_eq!(inner.byte(), Some(P_START));
733        read.start = inner.text().unwrap();
734        assert_eq!(inner.byte(), Some(P_TYPE));
735        assert_eq!(inner.text().unwrap(), "application/smil");
736        assert_eq!(inner.at, related.len());
737        let count = r.uintvar().unwrap();
738        for _ in 0..count {
739            let headers_length = r.uintvar().unwrap();
740            let data_length = r.uintvar().unwrap();
741            let headers = r.take(headers_length).unwrap().to_vec();
742            let data = r.take(data_length).unwrap().to_vec();
743            let mut h = Reader { bytes: &headers, at: 0 };
744            let typed_length = h.length().unwrap();
745            let typed = h.take(typed_length).unwrap().to_vec();
746            let mut t = Reader { bytes: &typed, at: 0 };
747            let ct = if typed[0] >= 0x80 {
748                NUMBERED[usize::from(t.byte().unwrap() & 0x7F)].to_string()
749            } else {
750                t.text().unwrap()
751            };
752            let mut part = ReadPart { ct, data, ..ReadPart::default() };
753            while let Some(parameter) = t.byte() {
754                match parameter {
755                    P_NAME => part.name = t.text().unwrap(),
756                    P_CHARSET => part.charset = t.byte().unwrap() & 0x7F,
757                    _ => panic!("a parameter Sioul does not write: {parameter:#x}"),
758                }
759            }
760            while let Some(header) = h.byte() {
761                match header {
762                    P_CONTENT_ID => part.id = h.text().unwrap(),
763                    P_CONTENT_LOCATION => part.location = h.text().unwrap(),
764                    _ => panic!("a part header Sioul does not write: {header:#x}"),
765                }
766            }
767            read.parts.push(part);
768        }
769        assert_eq!(r.at, pdu.len(), "nothing after the last part");
770        read
771    }
772
773    #[test]
774    fn integers_and_lengths_at_their_edges() {
775        let written = |f: &dyn Fn(&mut Vec<u8>)| {
776            let mut out = Vec::new();
777            f(&mut out);
778            out
779        };
780        assert_eq!(written(&|o| uintvar(o, 0)), [0x00]);
781        assert_eq!(written(&|o| uintvar(o, 127)), [0x7F]);
782        assert_eq!(written(&|o| uintvar(o, 128)), [0x81, 0x00]);
783        assert_eq!(written(&|o| uintvar(o, 16_383)), [0xFF, 0x7F]);
784        assert_eq!(written(&|o| uintvar(o, 16_384)), [0x81, 0x80, 0x00]);
785        assert_eq!(written(&|o| uintvar(o, 614_400)), [0xA5, 0xC0, 0x00]);
786        assert_eq!(written(&|o| value_length(o, 30)), [30]);
787        assert_eq!(written(&|o| value_length(o, 31)), [31, 31]);
788        assert_eq!(written(&|o| value_length(o, 128)), [31, 0x81, 0x00]);
789        assert_eq!(written(&|o| long_integer(o, WRITTEN as u64)), [4, 0x6A, 0xD4, 0xB5, 0x74]);
790        assert_eq!(written(&|o| text(o, "é".as_bytes())), [0x7F, 0xC3, 0xA9, 0x00], "a text beginning past 127 is quoted");
791        for value in [0, 1, 127, 128, 300, 16_383, 16_384, 2_097_151, 2_097_152, 614_400] {
792            let mut out = Vec::new();
793            uintvar(&mut out, value);
794            assert_eq!(Reader { bytes: &out, at: 0 }.uintvar(), Some(value));
795        }
796        for length in [0, 30, 31, 127, 128, 20_000] {
797            let mut out = Vec::new();
798            value_length(&mut out, length);
799            assert_eq!(Reader { bytes: &out, at: 0 }.length(), Some(length));
800        }
801    }
802
803    /// The headers as Android's composer writes them, byte for byte, then a
804    /// picture with words: the SMIL, the picture, the words, each named
805    /// plainly.
806    #[test]
807    fn a_picture_with_words_is_written_as_android_writes_it() {
808        let picture = stand_in(200, 7);
809        let media = [Medium { ct: "image/jpeg", bytes: &picture }];
810        let message = Message { key: KEY, to: &["+33 1 99 00 12 34"], date: WRITTEN, words: "Voilà l’entrée.", media: &media };
811        let pdu = encode(&message).unwrap();
812        let mut head = vec![0x8C, 0x80, 0x98];
813        head.extend_from_slice(KEY.as_bytes());
814        head.extend_from_slice(&[0x00, 0x8D, 0x92, 0x85, 0x04, 0x6A, 0xD4, 0xB5, 0x74, 0x89, 0x01, 0x81, 0x97, 24, 0xEA]);
815        head.extend_from_slice(b"+33199001234/TYPE=PLMN\0");
816        head.extend_from_slice(&[0x8A, 0x80, 0x86, 0x81, 0x90, 0x81, 0x84, 27, 0xB3, 0x8A]);
817        head.extend_from_slice(b"<smil>\0\x89application/smil\0\x03");
818        assert_eq!(hex(&pdu[..head.len()]), hex(&head));
819        let read = read(&pdu);
820        assert_eq!(read.headers.len(), 9, "type, key, version, date, from, to, class, two reports");
821        assert_eq!(read.start, "<smil>");
822        let names: Vec<(&str, &str, &str, &str, u8)> = read.parts.iter().map(|p| (p.ct.as_str(), p.name.as_str(), p.id.as_str(), p.location.as_str(), p.charset)).collect();
823        assert_eq!(names, [("application/smil", "smil.xml", "<smil>", "smil.xml", 0), ("image/jpeg", "image1.jpg", "<image1>", "image1.jpg", 0), ("text/plain", "text1.txt", "<text1>", "text1.txt", 106)]);
824        assert_eq!(read.parts[1].data, picture);
825        assert_eq!(read.parts[2].data, "Voilà l’entrée.".as_bytes());
826        let layout = String::from_utf8(read.parts[0].data.clone()).unwrap();
827        assert!(layout.contains(r#"<region id="Image" fit="meet" top="0" left="0" height="80%" width="100%"/><region id="Text""#), "{layout}");
828        assert!(layout.contains(r#"<body><par dur="5000ms"><img src="image1.jpg" region="Image" /></par><par dur="5000ms"><text src="text1.txt" region="Text" /></par></body>"#), "{layout}");
829        assert_eq!(size(&message), Ok(pdu.len()));
830    }
831
832    /// Words alone to two numbers: two To headers, one message to both (a
833    /// group's, for later); a number as written, its spaces and dots left out.
834    #[test]
835    fn words_to_two_are_one_message_to_both() {
836        let message = Message { key: KEY, to: &["+33 2 61 91 00 42", "03.53.01.42.42"], date: WRITTEN, words: "Rendez-vous à 18 h.", media: &[] };
837        let read = read(&encode(&message).unwrap());
838        let to: Vec<String> = read.headers.iter().filter(|(field, _)| *field == TO).map(|(_, value)| String::from_utf8_lossy(&value[2..value.len() - 1]).into_owned()).collect();
839        assert_eq!(to, ["+33261910042/TYPE=PLMN", "0353014242/TYPE=PLMN"]);
840        assert_eq!(read.parts.len(), 2);
841        let layout = String::from_utf8(read.parts[0].data.clone()).unwrap();
842        assert_eq!(layout, r#"<smil><head><layout><root-layout/><region id="Text" top="0" left="0" height="100%" width="100%"/></layout></head><body><par dur="5000ms"><text src="text1.txt" region="Text" /></par></body></smil>"#);
843    }
844
845    /// Three media and words: each named by its kind and rank, never its
846    /// file's; a video plays its own length; a card is a reference.
847    #[test]
848    fn three_media_are_named_by_their_kind() {
849        let (gif, video, card) = (stand_in(64, 1), stand_in(300, 2), b"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice\r\nEND:VCARD\r\n".to_vec());
850        let media = [Medium { ct: "image/gif", bytes: &gif }, Medium { ct: "video/3gpp", bytes: &video }, Medium { ct: "text/x-vCard", bytes: &card }];
851        let message = Message { key: KEY, to: &["0465714242"], date: WRITTEN, words: "Trois choses.", media: &media };
852        let pdu = encode(&message).unwrap();
853        let read = read(&pdu);
854        let names: Vec<(&str, &str)> = read.parts.iter().map(|p| (p.ct.as_str(), p.name.as_str())).collect();
855        assert_eq!(names, [("application/smil", "smil.xml"), ("image/gif", "image1.gif"), ("video/3gpp", "video1.3gp"), ("text/x-vCard", "contact1.vcf"), ("text/plain", "text1.txt")]);
856        let layout = String::from_utf8(read.parts[0].data.clone()).unwrap();
857        assert!(layout.contains(r#"<par><video src="video1.3gp" region="Image" /></par>"#), "{layout}");
858        assert!(layout.contains(r#"<par dur="5000ms"><ref src="contact1.vcf" /></par>"#), "{layout}");
859        // A content type WSP numbers goes by its number, another by its words.
860        assert!(pdu.windows(b"video/3gpp\0".len()).any(|w| w == b"video/3gpp\0"));
861        assert!(!pdu.windows(b"image/gif".len()).any(|w| w == b"image/gif"));
862        assert_eq!(size(&message), Ok(pdu.len()));
863    }
864
865    /// The measure is the message's own length, whatever its size: a large
866    /// picture (a length in three bytes), several, words of 2,000 bytes.
867    #[test]
868    fn the_measure_is_the_message_s_length() {
869        let (large, small) = (stand_in(600_000, 3), stand_in(16_384, 4));
870        let words = "é".repeat(1_000);
871        for media in [vec![Medium { ct: "image/jpeg", bytes: &large }], vec![Medium { ct: "image/png", bytes: &small }; 5], vec![]] {
872            let message = Message { key: KEY, to: &["0536494242"], date: WRITTEN, words: &words, media: &media };
873            assert_eq!(size(&message), Ok(encode(&message).unwrap().len()));
874        }
875    }
876
877    /// The size counted from the media's lengths alone is the message's own.
878    #[test]
879    fn the_size_from_lengths_is_the_message_s_own() {
880        for lengths in [vec![0usize], vec![127, 128], vec![16_383, 16_384, 1], vec![600_000], vec![3, 30_000, 2_097_152]] {
881            let blobs: Vec<Vec<u8>> = lengths.iter().map(|&n| stand_in(n, n as u8)).collect();
882            let full: Vec<Medium> = blobs.iter().map(|bytes| Medium { ct: "image/jpeg", bytes }).collect();
883            let empty: Vec<Medium> = lengths.iter().map(|_| Medium { ct: "image/jpeg", bytes: &[] }).collect();
884            let message = Message { key: KEY, to: &["0199001234"], date: WRITTEN, words: "Le dîner.", media: &full };
885            assert_eq!(size_with_lengths(&Message { media: &empty, ..message }, &lengths), size(&message), "{lengths:?}");
886        }
887    }
888
889    /// The room for the media under a limit, shared among one, two or five
890    /// pictures and filled: the message fits, within two bytes a picture of
891    /// the limit; words over the limit leave none.
892    #[test]
893    fn the_media_fill_their_room_and_no_more() {
894        let limit = 614_400;
895        for count in [1usize, 2, 5] {
896            let empty = vec![Medium { ct: "image/jpeg", bytes: &[] }; count];
897            let message = Message { key: KEY, to: &["0199001234"], date: WRITTEN, words: "Les photos de samedi.", media: &empty };
898            let room = room(&message, limit).unwrap();
899            let shares: Vec<Vec<u8>> = (0..count).map(|i| stand_in(room / count + usize::from(i < room % count), i as u8)).collect();
900            let media: Vec<Medium> = shares.iter().map(|bytes| Medium { ct: "image/jpeg", bytes }).collect();
901            let full = size(&Message { media: &media, ..message }).unwrap();
902            assert!(full <= limit && limit - full <= 2 * count, "{count}: {full} of {limit}");
903        }
904        // Over 2 MB a medium, its length takes four bytes (the review of 18 October 2026, I-2).
905        for limit in [2_097_151usize, 2_097_152, 2_100_000, 8 << 20] {
906            let empty = [Medium { ct: "video/mp4", bytes: &[] }];
907            let message = Message { key: KEY, to: &["0199001234"], date: WRITTEN, words: "", media: &empty };
908            let room = room(&message, limit).unwrap();
909            let bytes = stand_in(room, 7);
910            let full = size(&Message { media: &[Medium { ct: "video/mp4", bytes: &bytes }], ..message }).unwrap();
911            assert!(full <= limit && limit - full <= 3, "{limit}: {full}");
912        }
913        let empty = [Medium { ct: "image/jpeg", bytes: &[] }];
914        assert_eq!(room(&Message { key: KEY, to: &["0199001234"], date: WRITTEN, words: &"é".repeat(400_000), media: &empty }, limit), None, "words over the limit leave no room");
915    }
916
917    #[test]
918    fn numbers_are_written_plainly() {
919        assert_eq!(address("05 36 49 12 34").as_deref(), Some("0536491234"));
920        assert_eq!(address(" +33 1-99.00/12 (34) ").as_deref(), Some("+33199001234"));
921        assert_eq!(address("+33\u{202f}1\u{a0}99 00 12 34").as_deref(), Some("+33199001234"));
922        assert_eq!(address("SIOUL"), None, "a sender named in letters");
923        assert_eq!(address("36179").as_deref(), Some("36179"), "a short code is a number; the phone's rules refuse it");
924        assert_eq!(address("12"), None);
925        assert_eq!(address("05+36"), None, "a + inside");
926    }
927
928    #[test]
929    fn what_cannot_be_written_is_refused() {
930        let picture = stand_in(10, 5);
931        let media = [Medium { ct: "image/jpeg", bytes: &picture }];
932        let good = Message { key: KEY, to: &["0199001234"], date: WRITTEN, words: "", media: &media };
933        assert!(encode(&good).is_ok(), "a picture alone");
934        assert_eq!(encode(&Message { to: &[], ..good }), Err(Refused::Number));
935        assert_eq!(encode(&Message { to: &["0199001234", "Alice"], ..good }), Err(Refused::Number));
936        assert_eq!(encode(&Message { media: &[], ..good }), Err(Refused::Empty));
937        assert_eq!(encode(&Message { key: "", ..good }), Err(Refused::Key));
938        assert_eq!(encode(&Message { key: "clé", ..good }), Err(Refused::Key));
939        assert_eq!(size(&Message { media: &[Medium { ct: "image/jpé", bytes: &picture }], ..good }), Err(Refused::Type));
940        assert_eq!(size(&Message { media: &[Medium { ct: "", bytes: &picture }], ..good }), Err(Refused::Type));
941    }
942
943    /// The carrier's answer: taken, with its Message-ID; refused, with its
944    /// words; a header not needed passed over; anything else, none.
945    #[test]
946    fn the_carrier_s_answer_is_read() {
947        let taken = conf_taken();
948        assert_eq!(conf(&taken), Some(Conf { status: OK, id: "msg-20261018-1403-42".into(), key: KEY.into(), said: String::new() }));
949        assert!(conf(&taken).unwrap().ok());
950        let refused = conf_refused();
951        let read = conf(&refused).unwrap();
952        assert_eq!((read.status, read.said.as_str(), read.ok()), (0xE5, "Contenu refusé", false));
953        // A header Sioul does not need, in each of its forms, passed over.
954        let mut more = taken.clone();
955        more.extend_from_slice(&[0x83]);
956        more.extend_from_slice(b"http://mms.example.org/x\0");
957        more.extend_from_slice(&[0xA7, 0x80, 0x88, 0x05, 0x81, 0x03, 0x01, 0x51, 0x80]);
958        assert_eq!(conf(&more).map(|c| c.id), Some("msg-20261018-1403-42".into()));
959        assert_eq!(conf(&[0x8C, 0x80, 0x98, b'a', 0]), None, "a send request is no answer");
960        assert_eq!(conf(&taken[..taken.len() - 4]), None, "cut short");
961        assert_eq!(conf(&[0x8C, 0x81, 0x98, b'a', 0, 0x8D, 0x92]), None, "no status");
962    }
963
964    fn conf_taken() -> Vec<u8> {
965        let mut out = vec![0x8C, 0x81, 0x98];
966        text(&mut out, KEY.as_bytes());
967        out.extend_from_slice(&[0x8D, 0x92, 0x92, 0x80, 0x8B]);
968        text(&mut out, b"msg-20261018-1403-42");
969        out
970    }
971
972    fn conf_refused() -> Vec<u8> {
973        let mut out = vec![0x8C, 0x81, 0x98];
974        text(&mut out, KEY.as_bytes());
975        out.extend_from_slice(&[0x8D, 0x92, 0x92, 0xE5, 0x93]);
976        let mut said = vec![0x80 | UTF_8];
977        text(&mut said, "Contenu refusé".as_bytes());
978        value_length(&mut out, said.len());
979        out.extend_from_slice(&said);
980        out
981    }
982
983    /// The samples Android's own parser reads (`android/jvm-checks/MmsCheck.java`):
984    /// each message as `encode` writes it, with what it must read there.
985    fn samples() -> Vec<serde_json::Value> {
986        let picture = stand_in(200, 7);
987        let (gif, video, card) = (stand_in(64, 1), stand_in(300, 2), b"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice\r\nEND:VCARD\r\n".to_vec());
988        let (edge_127, edge_128, large) = (stand_in(127, 8), stand_in(128, 9), stand_in(16_384, 10));
989        let messages: Vec<(&str, Vec<&str>, String, Vec<(&str, &[u8])>)> = vec![
990            ("a picture with words", vec!["+33 1 99 00 12 34"], "Voilà l’entrée.".into(), vec![("image/jpeg", &picture)]),
991            ("words to two", vec!["+33 2 61 91 00 42", "03.53.01.42.42"], "Rendez-vous à 18 h.".into(), vec![]),
992            ("three media", vec!["0465714242"], "Trois choses.".into(), vec![("image/gif", &gif), ("video/3gpp", &video), ("text/x-vCard", &card)]),
993            // A part's bytes at 127 and 128 (one byte of length, then two); a content type's at 30 and 31.
994            ("lengths at their edges", vec!["0536494242"], String::new(), vec![("image/png", &edge_127), ("image/png", &edge_128), ("application/x-abc", &edge_127), ("application/x-abcd", &edge_128)]),
995            ("a large picture and long words", vec!["0199001234"], "é".repeat(1_000), vec![("image/jpeg", &large)]),
996        ];
997        let mut out = Vec::new();
998        for (name, to, words, media) in &messages {
999            let media: Vec<Medium> = media.iter().map(|&(ct, bytes)| Medium { ct, bytes }).collect();
1000            let message = Message { key: KEY, to, date: WRITTEN, words, media: &media };
1001            let pdu = encode(&message).unwrap();
1002            let read = read(&pdu);
1003            let parts: Vec<serde_json::Value> = read.parts.iter().map(|p| serde_json::json!({ "ct": p.ct, "name": p.name, "id": p.id, "location": p.location, "charset": p.charset, "size": p.data.len(), "sha256": sha256(&p.data) })).collect();
1004            let numbers: Vec<String> = to.iter().map(|n| address(n).unwrap()).collect();
1005            out.push(serde_json::json!({ "name": name, "kind": "send-req", "key": KEY, "to": numbers, "date": WRITTEN, "parts": parts, "pdu": hex(&pdu) }));
1006        }
1007        out.push(serde_json::json!({ "name": "the carrier took it", "kind": "send-conf", "key": KEY, "status": OK, "id": "msg-20261018-1403-42", "pdu": hex(&conf_taken()) }));
1008        out.push(serde_json::json!({ "name": "the carrier refused its content", "kind": "send-conf", "key": KEY, "status": 0xE5, "id": "", "pdu": hex(&conf_refused()) }));
1009        out
1010    }
1011
1012    /// The samples in `android/jvm-checks/mms-samples.jsonl` are what
1013    /// `encode` writes now, so that Android's own parser checks this very
1014    /// encoder (`android/jvm-checks/run.sh`). After a change of the
1015    /// encoder: `SIOUL_MMS_SAMPLES_WRITE=1 cargo test --release -p sioul-core
1016    /// mms`, then that check.
1017    #[test]
1018    fn the_samples_android_reads_are_these() {
1019        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../android/jvm-checks/mms-samples.jsonl");
1020        let now: String = samples().iter().map(|line| format!("{line}\n")).collect();
1021        if std::env::var_os("SIOUL_MMS_SAMPLES_WRITE").is_some() {
1022            std::fs::write(&path, &now).unwrap();
1023        }
1024        let kept = std::fs::read_to_string(&path).unwrap_or_default();
1025        assert!(kept == now, "{} is not what the encoder writes now: SIOUL_MMS_SAMPLES_WRITE=1 cargo test --release -p sioul-core mms, then android/jvm-checks/run.sh", path.display());
1026    }
1027}