Texts on your computers, read and sent through your phone¶
Your phone's texts can be read on your computers, and texts written there can be sent by your phone, without Sioul becoming the phone's SMS app. The phone's messages app stays the record of your texts: a fault of Sioul's can delay a text on the computer, never lose one on the phone. Your computers keep the whole history, multimedia messages and their media included, sealed, as an archive that only grows. This is phase (b) of research/sms.md; phase (a), the phone's notifications on the Porch, is android.md. Phase (c), Sioul as the SMS app, is not built.
Code: crates/sioul-core/src/texts.rs (the lines, the ledger, the never-twice decision, the counting of parts, the page's model), crates/sioul-sync/src/textseal.rs (lines and media sealed at rest), crates/sioul-app/src/texts.rs (the phone's import and step, the computer's page, requests and media), android/package/src/com/aurelienpierre/sioul/Texts.java, TextSend.java, TextsAsk.java, crates/sioul-app/qml/TextsPage.qml, TextsSetup.qml.
What the phone may do¶
Sioul asks Android, in Settings ▸ This phone ▸ Texts, for READ_SMS (read your texts, so that your computers show them), RECEIVE_SMS (hear a new text, so that it reaches your computers within a minute or two), SEND_SMS (send the texts you write on a computer) and READ_PHONE_STATE (your SIMs' names). Each comes with its sentence on the page. It never asks for the SMS role.
- What Android shows Sioul: the inbox and the sent box only. Drafts and texts that failed or wait to leave are the SMS app's alone; the page says so once. A multimedia message Android has not downloaded shows, when Android shows it at all, with its parts said "not downloaded".
- Codes: from Android 16's December 2025 release, a text holding an app's verification code is hidden from Sioul for three hours. Nothing is done about it.
- On an APK installed by hand, from Android 15, these permissions wait behind "Allow restricted settings", the switch you pass for notification access: the first time Sioul asks, Android answers with its own "Restricted setting", and only then does App info's ⋮ offer Allow restricted settings, after which the question can be asked again. No app can read whether Android keeps them so: once they were asked and are still not given, from Android 13 Sioul says these steps, in their order, with Sioul's app info, on this tab, in the join's result and on the Porch (android.md).
What is kept¶
The archive holds each text as the phone's provider holds it, so that nothing a later restore needs is lost:
- A text (
texts::Text): every column of its row incontent://sms(fields), and, read from them, its row's name (sms-<_id>@<date>, below), when, its conversation on that phone, the other people's numbers as Sioul keys them (a group's from Android's list of the conversation's recipients), who wrote it in a group, the words, the SIM. - A multimedia message: every column of its row in
content://mms, of its addresses (content://mms/<id>/addr) and of its parts (content://mms/part). A part's words and the SMIL that lays them out stay in the line; every other part's content (pictures, sounds, videos, contact cards, other files) travels as a file of its own, never inside a line. - A row's name (
texts::Row): the provider's id with the row's own date,sms-412@1791446340000,mms-31@1791446340(the multimedia provider counts in seconds). The plain texts' table has no AUTOINCREMENT (AOSP's MmsSmsDatabaseHelper, Android 10 to 16): once the newest text is deleted, the next one takes its id, and a provider emptied starts again at 1; the date tells those texts apart in the log (each line once, by its name), in the reader, in the day's look for deletions, in the outcomes and on the page. The multimedia table has AUTOINCREMENT: its ids are never taken again. Lines written before 16 October 2026 keep their names (sms-412), read with their own time as their date: every device, of any version, reads both. A computer folds a phone's lines by row (Row::of: the name, or an older name with its time), never by name: a text logged under both names, as when a phone went back to 0.0.6 and its reader wrote again under the names of then each text this version read, shows once (texts::tests::each_row_is_one_text_on_a_computer, in the core). Until then a text that took a deleted text's id was never read (the review of 16 October 2026, HIGH 2); a phone of this version reads it at its reader's first step, while the phone still holds it. - Left out: a part's
_data, the place of its file on that phone, which means nothing elsewhere; columns that are empty or hold binary data, which no provider column a restore needs does. - Media: on the phone, Java copies a part's content into Sioul's private folder; Rust hashes it (SHA-256), puts it in the sharing's
blobs/(sealed with the sharing key, content-addressed: the same picture twice travels once) and deletes the copy. Each computer brings the blob and keeps it sealed intexts/media/<hash>in its data folder (XChaCha20-Poly1305, additional data "sioul-texts media"). A part over the largest size chosen on the phone (Settings ▸ This phone ▸ Texts: 1, 5, 10, 25 or 50 MB; 10 by default) stays there: the page says "Too large to bring here: it is on your phone." - Every medium goes, not only those you open: carriers cap a multimedia message between a few hundred kilobytes and a few megabytes, so a phone's media weigh tens to hundreds of megabytes; fetching one when you open it would need the phone awake and its sync app running at that moment, which the sharing cannot promise.
- Deletions: once a day the phone lists the provider's rows there are, each with its date. A text gone from the phone (its row gone, or its id another text's now) gets a line of its own in the phone's log (
deleted: when it was found gone), once; the archive keeps the text, and the page says "Deleted on your phone, kept here." Texts are never trimmed; requests and outcomes are (below). A table Android could not list whole is left out of the listing (Texts.ids): nothing of it is read or said deleted, never "none there" taken from "not known"; the day's look then waits an hour, so that steps meanwhile never list (nor measure the media) twice each (texts::tests::a_look_that_cannot_list_waits_an_hour), and the media are measured only on a whole listing. A row whose date its provider rewrote (an MMS downloaded or sent writes its date after its row; a text resent) is read again under its new date, and its old line is marked moved (moved: the name of its line now, at the step that reads it and at the day's look), never "deleted", when the two are one message: a text of the same words, sent from the phone or dated within ten minutes of the other (texts::MOVED_WITHIN_MS); a multimedia message whose media are the same by their hashes, its words and media as complete or more (one read before its parts were attached comes whole). Computers show that message once, under its new name, and the old name goes into itsalso, so that a text sent from a computer still finds its request there. A text that took a deleted text's id otherwise leaves that one said deleted: other words, another picture, or the same words from the same person hours later (her "Oui." deleted, her next "Oui." under its id) (texts::tests::a_row_its_provider_dated_again_is_one_text, in the app;each_row_is_one_text_on_a_computer, in the core). - Nothing passed: Android hides a text from Sioul while it waits in the outbox. Each step compares every row the phone lists with the rows its log holds, by name, never with a mark: a row shown late is read at the step after Android shows it (
texts::tests::a_text_that_comes_during_a_long_import_goes_first,pictures_that_come_are_read_whatever_their_dates, in the app). - A restore: the archive keeps every column and every media file a later restore onto a new phone would need. The restore itself is not built.
What travels¶
The sharing's part Texts (texts), off until you turn it on, on each device: on the phone to read and send, on each computer that should read or write texts. Three folders of lines in the data folder, one writer per file (database.md), and the media's blobs:
data/texts/log/<phone>.jsonl: each phone's texts, the whole history, a line each, in and out, and a line per deletion. Never trimmed.data/texts/send/<computer>.jsonl: the texts written on a computer (texts::Request: a random key of 32 hex digits, the phone that must send it, the number, the words, the SIM, when it was written, how far the writer's clock may run ahead of the phone's). Its lines travel in the records as every store's, and, so that a request reaches the phone within its quarter of an hour whatever the records' size, the file itself too: copied into the sharing folder astexts/send/<computer>.jsonl, sent to the server first on a lane of its own, right after a text is written, fetched by the phone's pull; the phone's decision reads it beside the records' copy, each key once (database.md).data/texts/outcome/<phone>.jsonl: what became of each (texts::Outcome): handed to Android, sent, delivered, not sent and why, expired, refused, clocks apart, in doubt. A device takes its own requests and outcomes out after 31 days, another's after 45.blobs/: the media, as above.- Private, never shared (the state folder's
texts/): on the phone, the ledger and its mark, the reader's state and measures (reader.json), Java's results (results.jsonl), the media copied from Android for a moment (incoming/); on a computer, the clock's measure (clock.json).
Sealed at rest. Every line of these folders is sealed by the device that writes it (XChaCha20-Poly1305 with a key made from the sharing key, HKDF "sioul-texts 1"; textseal), and padded inside to a multiple of 256 bytes first; each media file kept on a computer is sealed with the same key. The sharing seals each line and each blob again in the folder. A computer's copy of your texts is unreadable without the sharing key, which each device keeps in its keyring (the Secret Service, the Credential Manager, the Keychain, Android's KeyStore). This differs from the calls' log, which a computer keeps plain. To show a picture or open a file, the page writes a plain copy in Sioul's cache (~/.cache/sioul/texts/ on Linux, yours alone) and deletes the folder when the page goes out of sight.
Reading, on the phone: paced¶
The phone's step has two parts, so that the history never holds back what must arrive first.
- At every exchange, quick: Android's word on each text sent, the requests decided and sent, a lost claim looked for (below). Doses, do-not-disturb and calls travel in the same exchange, never behind the texts.
- After the exchange, never inside it: the import's next step, after the background service's exchange (
steps.rs), or in a thread of the window's. Each step lists every row the phone holds, each id with its date (texts-ids: one query per table), and reads those its log lacks (texts::Reader,reader.json, version 4: the rows its log holds, the rows that came and the history still to read, each as[id, date]). Rows that appeared since the last step are read at once, oldest first, on the battery too: 500 rows at most, 100 multimedia messages at most, with their media up to about 24 MB (the first medium of a step always, up to the largest size). A multimedia message whose media do not fit waits, with the multimedia messages after it, for the next step, which reads on whatever the battery (left). A step with nothing new asks Android that one listing, a history waiting or not (texts::tests::a_step_with_a_history_waiting_costs_what_a_caught_up_step_costs). A new text brings a step 20 seconds on (Texts.Heard, Android'sSMS_RECEIVED, while the background service runs); so does a message received that no broadcast tells Sioul, a multimedia one once downloaded, seen as an inbox's newest id grown (Texts.arrived, from the background service's watch of the provider). A text sent from the phone waits for the next step. - A history, newest first: the first import, what the log of a reader of an older version lacks, and a block of 500 rows or more appearing at once (SMS Backup & Restore restoring an old phone's history on 16 October 2026 put some ten thousand texts on the new phone, with ids above its own texts, and every text received during the restore took an id inside the block) are read newest first by date, never by id, so that no history holds back a text that comes meanwhile. On the charger, each step reads the 500 newest rows not read yet (100 multimedia messages at most beside the texts), their media within 24 MB. On the battery, it reads the rows that came after the history was found, whatever their dates (a picture's date is its download's, in seconds; one composed before a reply keeps the time it was composed), and of the history only what is dated within a day before it was found (the first import's start, or the block's finding; a row that came in the very step that found it is listed with the history, and read when dated within that day): a text that comes during a long import reaches your computers within a minute or two, as it does once the import is done (
texts::recent_now,texts::tests::a_row_that_comes_with_a_block_is_read_on_the_battery). Each row once in the log, by its name; rows whose line could not be written (a full disk) are read again at the next step. Tested:texts::tests::a_text_that_comes_during_a_long_import_goes_first(a stand-in provider: two years of history and the last day's texts, a text during the import, then, caught up, 900 old texts put at once, one in the outbox, and a text after them; each row once, none passed),the_newest_by_date_come_first_wherever_their_ids(a phone's own texts under a restored block, a text during a restore),a_text_that_takes_a_deleted_text_s_id_is_read,a_provider_emptied_and_filled_again_is_read,rows_a_log_could_not_take_are_read_again,a_block_found_late_reads_only_its_last_day_on_the_battery. - From a reader of version 2 or 3 (8 to 16 October 2026, rows known by their ids alone): at its first step, at once and on the battery too, the reader reads its log and takes the rows it holds from it; what the log lacks (a text that took a deleted text's id, never read then) is read as a history. Its counts are the log's own, whatever a stop between the log and
reader.jsonleft (texts::tests::a_reader_of_an_older_version_reads_what_its_log_lacks). Going back to 0.0.6 or earlier after this version costs one reading of the whole history: that reader starts again from the first row, on the charger, and writes again, under the names of then, each text this version read. Computers of this version show each such text once (they fold by row, above); a computer still on 0.0.6 shows it twice, and shows a moved line as a deletion beside its text: update every computer with the phone. Updated again, the phone reads nothing again (Row::oftakes both names for one row). - When the import reads (
texts::import_now): a history (the first import's, or such a block:behindin the reader's marks) goes on in full only while the phone is on its charger, batch after batch: it may write and send hundreds of megabytes, on mobile data too (8 October 2026: a first import of some ten thousand texts sent six full rounds of the records, each sent twice, beside its media). Meanwhile its newest texts go at the first step and at each change of the phone's texts, on the battery too. Caught up, the import asks Android only when the phone's texts changed since the last look, when the last step left texts that came, at a reader of an older version's first step, and with the day's look for deletions all the same (a change Android did not say): the background service watches Android's provider of texts (Texts.watch,texts-changes), and a new text heard marks them changed; the window's process watches too, without steps of its own (it exchanges each minute by itself): its exchanges ask Android for every row only after a change. While a heavy import goes on, or waits for the charger, no full round of the records starts (share::set_importing, until 64 MB were appended): the import's records go once, in the rounds that continue the last full one. Tested:texts::tests::a_heavy_import_waits_for_the_charger; Java's part,android/jvm-checks/StepsCheck.java(Texts.changedSince,Texts.grew). - On a computer, the media are brought from the blobs in a thread of the window, 24 MB at a time, when the phones' logs changed or media were left for later.
- The window told: what the window's own thread read (a phone's import) or brought (a computer's media) is said to the window as an exchange's news are (
shared_in, withdata/texts/log/ordata/texts/media/), so that the pages showing texts read them again at once; on a phone, Settings ▸ This phone ▸ Texts, which also reads its counts again every ten seconds while an import goes on. - Java reads; Rust keys the numbers, seals the lines and decides what travels. Java never writes a shared file.
- Tested in
crates/sioul-sync/src/share.rs:a_first_import_goes_in_batches_and_never_holds_back_the_doses(3,000 texts in six steps of 500 and a blob, the doses' and do-not-disturb's news there at each exchange),doses_are_never_known_wrongly_whatever_carries_them(a texts import beside it, 20 texts a minute and a blob every ten minutes),the_texts_archive_only_grows_and_a_deletion_is_marked_everywhere.
The sizes said¶
- On the phone, Settings ▸ This phone ▸ Texts says, before the import, how many texts the phone holds and how many megabytes of media ("Your phone holds 4,830 texts and 210 MB of media."); during it, how much has gone ("1,500 of 4,830 texts and 40 MB of 210 MB of media have gone to your computers"; off the charger during a heavy import, "the rest follows while your phone charges"), read again every ten seconds; then that all is there. The measure is taken when the tab first opens with the permissions given, then once a day with the look for deletions.
- On a computer, the part's line in the sharing panel says what this computer keeps: "Keeps 4,830 texts and 210 MB of media here."
Sending: never twice¶
A text sent twice can harm, as a dose taken twice can; a text sent hours late can harm too. Every rule below is tested (cargo test --release -p sioul-core texts, -p sioul-sync text).
- A random key per text. Two computers writing to the same person make two texts, as asked.
- One named phone. A request names the phone that must send it; another phone never does (
Verdict::NotMine). Only your own devices ask. Each request names its writer, the computer that wrote it, inside its seal (texts::write_request, as a reply's request does): a line read from another device's file than its writer's is passed over (read_requests), so that nothing, the server or a stray file, moves a request from one computer's name to another's; and the phone sends only for a device the sharing counts now (share::asking); one gone by a sealed fact (it left, you forgot it, another device replaces it) is refused, "Not sent: your phone does not count the computer that wrote it among your devices." (Verdict::Refused("not-counted")); one the sharing does not know yet (its records have not come: a new computer's first text may reach the phone before them; a file alone; a device silent for weeks that wakes) waits, nothing decided or said, judged again at each step while its quarter of an hour lasts, then expired (Verdict::Wait,texts::Standing; the replies' requests likewise; the review's re-check, I-a). Where a device stands is read only for a request not decided yet. A request of a Sioul before 19 October 2026 names no writer: judged by its file's device when it was written before the phone's ledger began to check writers (the mark'swriters, set at the first step of a Sioul that checks), refused after, "Not sent: the computer that wrote it has an older Sioul than your phone." (old-computer). Any device that holds the passphrase can still write in any name: the boundary is the sharing key, as for replies. - The ledger. Before Android is asked, the phone writes the key in its private ledger (
texts/ledger.jsonl, never shared), flushed to the disk, under a lock both of its processes take. A key in the ledger is never decided again, whatever the sharing reads again: a part switched off and on,files.jsonlost, a rebuild (Verdict::Decided). - The mark. The ledger has a mark, the moment it began. A request written before it is refused: a ledger lost with the phone's data cannot make old requests new (
Verdict::BeforeMark). - The expiry. A request older than 15 minutes on the phone's clock is not sent (
Verdict::Expired). The time written is corrected by how far the writer's clock may run ahead: each computer measures it at every exchange, as the least gap of the last day between the phone's entry's time and its own clock when that entry came. Transit is included, so the bound is high, and a request looks older, never younger. A request dated more than two minutes ahead of the phone's clock, once corrected, is refused: the clocks disagree (Verdict::Clocks). - No retry by Sioul. Android retries three times by itself, each marked "reject duplicates". A text not sent is said, never sent again by itself.
- After a crash between the claim and Android's word, two minutes on, the phone looks among the texts Android filed in the sent box with Sioul as their creator, by the number and the words' hash. Found: sent. Not found: "Your phone may not have sent it: look in its messages before you send it again." (
texts::doubt_after). Never sent again by itself. - Send again makes a new request, with a new key.
Refused from a computer, said at once and again by the phone: short numbers, which can cost money (Android would ask on the phone); several recipients, which make a multimedia message; texts with no words; texts over 1,600 characters. Pictures, videos, sounds and cards go as a multimedia message (below).
From the Porch: Reply on a text from a whole number, on a computer that reads the texts of the phone it came to, writes one of these requests for that phone (never the phone chosen to send others; Send again keeps the request's phone), by the same rules (porch.md, android.md: decision 12); its states show under the Porch's line. Once Android has it, the phone lets that number's next texts through at once for 20 minutes, but in a pause, as after a reply (replies::texted, a hash of the number). The same holds for a text written on this page.
On the phone, Java hands the text to Android (SmsManager.createForSubscriptionId, divideMessage, sendMultipartTextMessage), from the SIM asked, else the conversation's last, else the phone's default. Android files it in the sent box with Sioul as its creator, so the SMS app shows it in the conversation. Each part's sent and delivered results come to TextSend.Result, which writes them in Java's own file (texts/results.jsonl), flushed. Rust copies them into the phone's outcomes: sent when every part was; not sent, and why, when one was not; delivered only when the carrier reported every part received. The page never says or implies that a text was not delivered when no report came.
Multimedia messages sent from a computer¶
Decided on 17 October 2026: pictures attached on a computer, made fit for the carrier's limit there, sent by the phone without being its SMS app. The message itself, in sioul-core (mms):
- The message as Android sends it (
mms::encode): an M-Send.req of MMS 1.2 (OMA's MMS encapsulation over WSP's encoding), byte for byte as AOSP's own composer writes one (PduComposer, android16-release): its key as the transaction id (Android files it in the provider'str_id, where the phone finds it again), the MMS version, the date, From as the insert-address token (the carrier writes the sender's number), each number with/TYPE=PLMN, class Personal, no subject, no delivery or read report; the bodymultipart/related, its SMIL first (AOSP Messaging's layouts: pictures and videos above, the words below; a video or a sound plays its own length), then each medium, then the words in UTF-8. - Plain names: each part is named by its kind and rank (
image1.jpg,video1.mp4,contact1.vcf,text1.txt), never by its file's own name, which can say where or who: a medium has no name in the encoder at all. - The measure (
mms::size): the same code, counting instead of writing, so that the computer that reduces a picture and the phone that sends it hold the same number against the carrier's limit; and the room a limit leaves for the media's own bytes (mms::room: the message measured with its media empty, two bytes a medium kept for their lengths). - The carrier's answer (
mms::conf): the M-Send.conf Android hands back, its status (Ok, else why not) and its Message-ID; any header not needed passed over by its form. - Checked:
cargo test --release -p sioul-core mms(the headers byte for byte; a picture with words, words to two numbers, three media, lengths at their edges, a picture of 600 KB; the measure equal to the length; numbers written plainly; what cannot be written refused; the answer read), and Android's own parser on a computer's JVM (android/jvm-checks/MmsCheck.java): each message Rust writes (android/jvm-checks/mms-samples.jsonl, which the tests keep equal to what the encoder writes now;SIOUL_MMS_SAMPLES_WRITE=1writes it anew) reads as a SendReq with its key, numbers, version, date, class, reports, and each part's type, name, Content-ID, place, charset and very bytes; each answer as a SendConf. A message Android cannot read would be sent but never filed in your messages. - The request (
texts::Request): its media (media: each a content's hash, its type and its size, never a file's name), its words inwords, andbodyleft empty, so that a phone whose Sioul predates multimedia messages refuses it ("empty") rather than sending its words alone; the computer then says "Not sent: your phone's Sioul cannot send pictures yet. Update it, then send it again." (also, a group's other numbers, is refused, "several", until groups come). Refused from a computer, said at once and again by the phone (texts::refusal_mms): no medium, more than five (MEDIA_MAX), a kind the phone does not send (pictures, videos, sounds, contact cards and calendar entries only), words over 1,600 characters, a short number. - Its media travel ahead of it, as sealed blobs of the sharing (content-addressed: one sent twice travels once), each also sealed on the computer that wrote it so that the message shows it at once; they go up first on the urgent lane, before the request that names them (
remote::send_urgent, each asked of the server by its own name: database.md); the other computers bring them as they bring a received picture. Send again sends the same media again, already there. - On the phone (
texts.rs,Step::prepare, at the quick step): a multimedia message that may go is made ready before anything is claimed: its media looked for first by their files alone (sealed_here, a look at each file's place): one not there yet is asked for by its name and nothing else is done, neither the log read nor Android asked nor any medium opened, so that each step while it waits for its media, a dose's alarm's included, is as quick as one with nothing to send (the review's re-check, L-a: with 20,000 texts in the log and a message waiting for one of its two media, a step took 205 ms here in the order of f79ef03, and takes 0.12 ms now, against 0.065 ms with nothing waiting); then each medium opened from the sharing (the folder, or the copy fetched from its server: the phone asks for one not there yet by its name,blobs::Wanted::media, which its own fetch brings too) and checked against its hash; its message built (mms::encode) and measured against what its SIM's carrier takes now (Android's live value,getCarrierConfigValues, never one raised): larger, it is refused, "Not sent: it is larger than your carrier takes from this phone.", never left to Android's error 5; then written and flushed in the private texts folder (outgoing/<key>.pdu). Only then is its key claimed in the ledger, flushed, and Java hands it to Android by its address in Sioul's file provider (TextSend.sendMms,sendMultimediaMessage, SEND_SMS alone). The phone's log is read for a conversation's last SIM only when a request about to go names none (Step::sim_for, once a step at most): never in a step whose requests are decided, as every step is but the one that sends, a dose's alarm's included (the review of 18 October 2026, M-1: with 20,000 texts in the log, such a step took 172 ms on this computer at d93874a, which read the log at each step, and takes 65 µs now;texts::tests::a_quick_step_reads_the_log_only_for_a_text_about_to_go, anda_quick_step_s_time_with_twenty_thousand_texts, ignored, run with--ignored --nocapture). A medium not there yet: nothing claimed, nothing said, looked at again at the next step; at the quarter of an hour, "Not sent: its pictures or files did not reach your phone within 15 minutes." - What became of it: Android's word comes to
TextSend.Resultby an explicit, mutable intent, with the carrier's answer (M-Send.conf) and the HTTP status Android adds to a failed post: sent only when Android said Ok and the carrier did (texts::took; Android's Ok alone is not the carrier's), its row named as the log names it (mms-31@<date>); Android's Ok with no answer of the carrier's Sioul can read, the provider decides (Texts.findMms: each row of Sioul's with the key, its box and the carrier's status Android read in it): in the sent box, sent; among the failed with a refusal Android read, not sent and why; else (Android filed it among the failed with an answer it could not read either, after the carrier's HTTP 2xx, which its Ok always follows, or in no box) "Your phone may have sent it: your carrier took it with an answer neither your phone nor Sioul could read. Ask whether it came before you attach it again.", with no Send again (the re-check, I-g); a sent box Sioul cannot read at that moment (the permission withdrawn, the query failed:Texts.findMmssays so) is looked at again at the next step, and by the look below until Android's last word, never read as "not there". Android's code 4 with no HTTP status (or one under 400) says that each of its three tries ended on the network, a read timeout after the message was posted included (MmsHttpClient,MmsRequest, android16-release): the carrier may have taken it, once or more, so the page says "Your phone may have sent it: the mobile network failed at each of Android's tries, perhaps after your carrier took it. Ask whether it came before you attach it again.", with no Send again (the review of 18 October 2026, M-2); with a status of 400 or more, the carrier refused it. Android's code 0 (it stopped the request) and 1 (an unexpected failure, which may come after the message went), and any code Sioul does not know, are said in doubt too, "Your phone may not have sent it: Android met an unexpected failure. Look in its messages before you send it again.", with Send again. Every failure in words (texts::mms_failure): mobile data off ("Not sent: mobile data is off on your phone, and multimedia messages need it. Your phone will not try again."), no mobile network within Android's 30 minutes, no multimedia settings for the carrier, not readable, an inactive SIM, the carrier's messaging app asking for it to be tried again later (code 6), too large for the satellite link the phone was on (code 13); the carrier's refusal, its content refused (too large, or a kind it does not carry), a plan without multimedia messages, no credit left, or not taken at that moment. Never "Delivered": no report is asked. Its file goes once its outcome is written. - Never twice, for a multimedia message: the rules below hold as for a text: one random key (also the message's transaction id, which Android files in the provider's
tr_id), one named phone, the ledger flushed before Android is asked (here after the message is built, measured and written: a crash before the claim leaves nothing done), the mark, the expiry, no retry by Sioul. After a crash, or a phone restarted while Android waited for a network (its queue lives in memory), a claim with no word, handed or not, is looked for by its key at each step from 40 minutes on (texts::LOOK_AFTER_MMS_MS: Android may wait 30 minutes for a network before its first try) until Android's last word, 100 minutes on (texts::MMS_LAST_WORD_MS: each of its three tries asks for the network again and may wait 30 minutes and 5 seconds for it, then two minutes on it, with 2 and 4 seconds between: some 97 minutes;MmsRequest,MmsNetworkManager, android16-release). Found in the sent box, sent. Meanwhile, past 40 minutes, the page says "Your phone is still trying to send it: Android may keep trying until about 16:20."; past that time with no word from the phone, which will say at its next step, "Waiting for your phone (it last shared at 15:02)." (the re-check, I-b). Not found by then: "Your phone may not have sent it: look in its messages before you send it again." Android may hold a message longer: it sends four at once, and holds one for another SIM until the current SIM's are done, so a fifth message, or one behind a slow one, may still go after the doubt is said (the re-check, I-c). Android itself posts a message again when the carrier's answer to it was lost (three tries): the carrier may, rarely, take it twice; Sioul never adds a try of its own. - What its carriers let the phone send, said in its outcomes (a line of no request's,
"state": "limits"): each SIM's largest message, the picture size it advises, how many people one message may go to, whether groups and multimedia messages are allowed, numbers only, never a carrier's name; when it changes, else once a day (Step::publish_limits,texts::limits_of). A computer of an older Sioul passes it over: no request has its key. The page offers Attach only for a phone that said it, and Settings ▸ This phone ▸ Texts shows the same values, as Android says them, read only. - Checked:
cargo test --release -p sioul-core texts(the refusals, an older phone's "empty" and its words, the look until Android's last word and the words meanwhile and after, each result's words, codes 0 to 13 and one unknown, the limits line, the request shown with its words and media, the writers and where a device stands),-p sioul-app texts(a stand-in Android: the message waiting for its picture then claimed once, the log never read and nothing opened while it waits, its bytes as Android gets them, sent on the carrier's Ok with its row; too large, a carrier with none, Android refusing; expired with its media missing; Android's Ok alone, the sent box, a refusal Android read, an answer nobody could read; a look not made; Java's results kept a month and trimmed without loss; a device counted, gone, not known yet; the limits said),-p sioul-sync lane(its media up before it, never twice, the phone's fetch by name) andasking(counted, gone, not known yet), and the JVM checks (TextsCheck: Java's line of the result, the file's address in the provider, a SIM's values). Not tried on a phone yet (android.md). - What you attach, made fit on the computer (
crates/sioul-app/src/attach.rs, Qt incpp/pictures.cpp, in a thread of its own, never the window's), before anything travels, so that the sharing, the server and the phone only ever carry what will be sent:- How much: the whole message at most the smaller of what the phone said its SIM's carrier takes (the SIM the conversation last used; not known, the smallest of those that take multimedia messages) and what Settings ▸ Texts chose on this computer (
texts.toml,mms_kb), never above Android's own ceiling for one (8 MB,texts::MMS_MAX_EVER), whatever a line says; a phone's line dated more than a day ahead of this computer's clock is passed over (texts::limits_of: a phone whose clock was once set ahead would be read by it until that date). The phone measures again against its SIM's live value at each send. The room for the media is the message's own measure (mms::room) with its media empty, its words the most they may weigh (1,600 characters of four bytes each) and the longest number a message goes to (mms::LONGEST_NUMBER, 15 digits after its +), so that neither typing nor the number the phone writes pushes it over; each medium's length is kept as many bytes as the limit's own (four from 2 MB on). - A picture is told by its first bytes, never by its name (
attach::sniff): JPEG, PNG, WebP, TIFF, BMP, GIF, HEIC, AVIF, JPEG XL. Only that format's reader of Qt decodes it, no other tried, so that none of the documents Qt's plugins can also render (PDF, SVG, PostScript, a camera's raw file) is ever opened here. Where this computer's Qt reads no such picture (HEIC on most), it is refused: "this computer cannot read HEIC pictures. Send it from your phone." KDE's image formats read HEIC and AVIF on Linux; Apple's systems read HEIC. - Always written again as a baseline JPEG, even one that fits: read with its orientation applied (its EXIF tag), decoded at about the size wanted (a JPEG in eighths of its own), turned into sRGB from its own profile, laid on white where it is transparent, then written from a fresh copy of its pixels, which holds no metadata at all: no EXIF (no GPS position, no camera, no time), no XMP, no IPTC, no comment, no PNG text, no colour profile. Qt's readers keep a JPEG's comments and a PNG's texts as the image's own text, which its writer would write back, and its colour space as a profile: the fresh copy carries neither, and the file holds JFIF's header, the tables, one frame and one scan.
- Made small in steps (
attach::fit): the long side at most 1,600 pixels (OMA's guaranteed size), and the carrier's advice (a phone with no carrier file says 640 × 480: such pictures are smaller, never refused for it); quality 85; over its share, the quality searched down to 50, its interval halved four times at most; still over, a quarter smaller and searched again; never under 480 pixels: "this picture cannot be made small enough for a multimedia message." - As they are, when they fit: videos (MP4, 3GP, MOV, WebM), sounds (AMR, M4A, AAC, MP3, Ogg, WAV), contact cards (vCard) and calendar entries (vCalendar, iCalendar), told by their first bytes as pictures are, never by their names (
attach::sniff_medium: ISO's boxes and their brands, WebM's header and its DocType, AMR's, Ogg's, WAV's, an ID3 tag, or a whole audio frame, AAC's or MPEG's, with the next frame's header where its own says, so that a text in UTF-16, which begins with FF FE, is no sound; BEGIN:VCARD and BEGIN:VCALENDAR; the name only tells an MP4 of sound from one of video; the first 16 KB read), copied no larger than they were weighed (a file grown since is refused, never cut), with whatever their files hold: a video may hold where it was filmed, a sound its tags. Too large, refused: "a multimedia message takes 600 kB at most. Send it from your phone." No re-encoding of videos or sounds. An animated GIF (more than one frame) keeps its frames as they are, written again without its comments, its plain-text blocks, other programs' blocks (XMP, a colour profile, an editor's notes) and what follows its end; each frame's timing written as its four bytes alone and the loop block as its count alone, whatever a crafted GIF puts in them besides; its end written when it lacks one, as Qt reads such a GIF (attach::gif_pictures_alone, no frame decoded: each block says its length). - Other files are refused, whatever their names say, "Send it by mail.": a phone may never open them in a multimedia message (a PDF named
.mp4among them). A folder, a picture that does not decode, a file that does not read: "it could not be read." - Five at most a message (
texts::MEDIA_MAX; a sixth: "one message holds five pictures or files at most."); what goes as it is first, in its order; the pictures share equally what is left (attach::share), 16 KB each at least, or the last ones are refused, "too many pictures for one message beside the rest."; then what a picture going at its best (its first long side, quality 85) leaves, with the shares of those refused, goes once to those made smaller. - Where they wait: in Sioul's cache, yours alone (
texts-attached/, made 0700, its files named by when and rank, never by the file chosen), deleted once the message is shared, when you remove them all, open another conversation or go back to the list, when the page goes out of sight, and at the window's next start after Sioul quit or crashed with files attached (texts::at_start); made again from the files chosen when one is removed (the others then share anew) or when the limit changed since. Sent, each is sealed in the sharing and here as every medium (above), named in the message by its kind and rank (image1.jpg).
- How much: the whole message at most the smaller of what the phone said its SIM's carrier takes (the SIM the conversation last used; not known, the smallest of those that take multimedia messages) and what Settings ▸ Texts chose on this computer (
- Settings ▸ Texts, on a computer (
TextsSending.qml,texts.rssending): how much a multimedia message sent from here may weigh, "Up to your carrier's limit (600 kB)" (the carrier's own, 614,400 bytes until the phone said) or "300 kB, for older phones and some networks abroad", saved at once; never above what this computer prepares (8 MB), whatever a line says; under it, what your phone said its carrier takes, a line for each SIM, read only. A phone has no such choice: Settings ▸ This phone ▸ Texts shows its carriers' values, read only. - On the page (
TextsPage.qml): Attach under Send, offered only once the phone said what its carrier takes (before, a file dropped says why); files dropped anywhere on the page are attached to the conversation open. While they are made fit, "Making them fit…" and Send waits. Each is shown as it will go: a picture as it was written again, with its size ("Picture, 412 kB"), the others by their kind and size, each with ×; why any did not go, a sentence each; under Send, how much the message takes, "One multimedia message (412 kB of 600 kB)", counted as you type from their sizes (mms::size_with_lengths, the same measure). Send then sends one multimedia message: its media sealed and on their way first, the words with them; a picture alone needs no words. - Checked:
cargo test --release -p sioul-app --test attach(on each system's CI too, with its own Qt:.github/workflows/build.yml), with Qt, offscreen, on pictures the test makes byte by byte with made-up metadata in every field (a GPS position in the Atlantic): a camera's JPEG (EXIF with orientation 6, GPS, maker, model and time; XMP; IPTC; a comment; a profile whose red and blue are sRGB's blue and red) comes out turned, its red blue, holding only JFIF, the tables, one baseline frame and one scan, none of the made-up words; a PNG with texts, XMP and EXIF, its transparent half white; a TIFF with its description and GPS; a WebP with EXIF, XMP and the profile (made by Pillow); a BMP and a still GIF; an animated GIF given a comment, XMP and words after its end comes out its frames alone, byte for byte as before them, Qt reading its three frames, and is refused over 20 kB; a HEIC refused where Qt reads none; a large picture of noise decoded at 1,600 pixels, made small for a third of its size, at the carrier's 640 pixels, never into 4 KB; a whole message of five (a picture, a PNG, a video, a card, an animated GIF; a sixth too many) within 300 kB with the most words and the longest number, its files named by when and rank; the refusals (a PDF named.mp4among them: another file). Without the fresh copy, the test fails on the comment and the texts written back and on the profile; without the orientation or the sRGB, on the size and the colour (tried on 18 October 2026). WithSIOUL_ATTACH_SAMPLESnaming a folder, each picture in it is checked the same way: libheif's public HEIC example, given made-up EXIF, GPS and XMP, came out a clean JPEG.-p sioul-app attach(the steps, the shares, the first bytes of pictures and of media, whole audio frames, UTF-16 notes and a UTF-16 contact card refused, WebM by its DocType, the kinds, an animated GIF's frames alone, a crafted one's timing and loop values alone, one with no end, the room with any number and any words) andtexts::tests::what_a_message_from_here_may_weigh;tst_texts.qml(Attach offered once the phone said, the round last asked alone taken, the counter, Send as one multimedia message, one removed, another conversation, a changed limit).
The page, on a computer¶
Texts is a page of the window, under Mail among the places on the left (TextsPage.qml, the window's page 14), laid out as Mail is: the list on the left, what you open on the right.
- The conversations, on the left: newest first, people by your address books' names, their last words on one line, when, and "A draft by an AI agent waits here." where one does. Over them, the search looks through every text's words, a medium's name, and the people's names and numbers (
texts::search, case and accents aside); each conversation found shows the newest text holding the words. With nothing in the list, the page says why: no texts yet, or none holding the words. - A conversation, on the right, as a thread: the whole history in time order, each day said once over its first text ("Today", "Yesterday", "Monday 5 October", the year added when it is another), yours on the right and theirs on the left on Sioul's quiet surfaces, each with its time. For a text written here, its state in words, never in red: "Waiting for your phone (it last shared at 14:02).", "Your phone is sending it.", "Sent at 14:03.", "Delivered at 14:03.", "Not sent: the phone had no signal. Your phone will not try again.", "Not sent: it waited more than 15 minutes for your phone.", "Your phone may not have sent it…", with Send again where it can be. The text Android filed shows once, with its state. A group is read only: answer it from your phone. A thread read to its end stays at its end as a text comes.
- Read again at once: while the page is open, each exchange that brings texts, an outcome or media (the sharing's word,
sharedIn, namingdata/texts/; the media brought here say so too) reads the list and the conversation open again: the conversation stays open, the draft being typed stays, the row chosen stays chosen, the list at its top stays at its top (the conversation that just got a text shows there) and one scrolled down keeps its top row; a thread read to its end shows the new text, and one read back keeps the messages at its top where they were: the top one, found a few pixels down past the rows' spacing, and the next two, each by its words, its time within its second (its copy may be another phone's now, timed to another millisecond) and its rank among messages so alike; the first still there keeps its place (a request's bubble replaced by its text: the next one), else the top one's place does, never the view's old coordinates nor the conversation's first message. Only what the phones' logs added is read (texts::Logs: a log only grows; another file under its name, or one cut short, is read again whole; a file is taken for unchanged only by its size, time, inode and change time, so that one whose time was set back is read again; on Windows, Rust's standard library gives neither, though NTFS keeps a file ID and a change time, and NTFS's tunnelling gives a file made again within seconds under the same name the old one's creation time, so there such a file is read again once its size or time changes), and each text's fingerprint is made once, as its line is read, the line then kept without its raw columns (20,000 lines read whole and hashed at each refresh took 0.3 s on the window's thread; cloning their raw columns at each refresh, 0.14 s). Never on a clock of its own: the page is read again when something came, when it is shown again, and with the Porch's news. Tested:crates/sioul-app/tests/qml/tst_texts.qml,texts::tests::the_logs_are_read_again_only_where_they_grew. - Media: a picture shows in its message; a sound, a video, a contact card or another file says its kind and size, with Open (in the program your computer uses for it) and Save…. A medium not here says why: too large (on your phone), not downloaded by your phone, or on its way.
- Deleted on your phone: the text stays, said so quietly.
- Writing, under the thread: an AI agent's drafts first (Use it puts their words in the box, and sending stays yours; docs/mcp.md), then what you attach, then the box, with how many texts it takes ("2 texts (180 characters)"; fewer characters per text with accents or signs outside GSM's alphabet), or with media how much of its limit the multimedia message takes, Send, and Attach (above).
- The keyboard: Up and Down move in the list, Enter opens; in the thread, Up, Down, Page Up, Page Down, Home and End read back and forth; Escape closes the conversation. Tab goes from the search to the list, the thread, the drafts and the box.
- A narrow window (under 720 pixels), as Mail: the list, then the conversation, and the window's Back.
- Where: the places on the left; the Porch's "From your phone" lines (Reply on a text from a whole number sends one of these texts, porch.md; Open on the Texts page, in its menu, opens the conversation; Text back where no Reply is offered) and a person's sheet (Texts with them…), through
window.openTexts. A phone has no Texts page: its own SMS app is where a conversation opens there. - One phone sends: the phone chosen in
texts.toml(configuration, this computer's own), else the only phone, else the one that shared last.
Each text once, across phones¶
A text can be in two phones' logs. After SMS Backup & Restore puts an old phone's texts on a new one, the new phone's import reads them again and writes them in its own log, under its own rows and conversations. Everything that lists texts on a computer shows each such text once: the Texts page's conversations and threads, its search, the part's line in the sharing panel ("Keeps 4,830 texts…"), and the agents' tools texts and search_texts (mcp.md). The phones' logs keep every copy: a text is folded when it is read, never left out when it is written, so nothing is lost if either phone's log goes (texts::one_each). The Porch's lines from your phone are the phones' notifications (android.md), which a restore never makes.
-
The matching rule (
texts::Print): two copies are one text when they agree on- the kind (a text or a multimedia message) and the way (received or sent);
- the moment, to the second (the multimedia provider counts in seconds);
- the other people, keyed as Sioul keys numbers, in any order: a text's address; a multimedia message's sender when it was received, those it went to (to, copy, hidden copy) when it was sent, this phone's own address left out however Android or a backup wrote it;
- the words (each text part's, for a multimedia message), with the control characters XML cannot carry left out, since a backup never holds them; an empty part, or one a backup wrote "null", counts as none;
- the media's content types, whatever their case.
Never the provider's row or conversation, which each phone numbers its own way, nor whether a medium came: a picture over one phone's size is the same message.
-
A text truly there twice stays twice: for each fingerprint, the page shows as many as the phone holding most of them. The same words sent twice within one second, two rows on each phone, are two texts.
- The copy shown has the most media here, else is the one not deleted, else is the first phone's by name. It is said deleted on the phone only when every copy is. A text sent from a computer is said on its own phone's row (an outcome names its phone as well as its row, and its row by its name: the id Android gave it with that row's date, read from Android when its result came), never on another phone's row of the same number, nor on an older text whose id it took; an outcome written before 16 October 2026 names its row by its id alone, and is said on that id's text only from when its request could be sent to two minutes after the outcome itself (Android files the row before its result comes): never on a text received days later under that id (
texts::tests::a_row_is_its_id_with_its_date,each_row_is_one_text_on_a_computer). - Tested, either way round:
texts::tests::a_text_in_two_phones_logs_shows_once_and_one_sent_twice_stays_twice,texts::tests::the_matching_rule_is_what_a_restore_keeps, and the agents' tools insioul-cli(mcp::tests::texts_only_when_allowed_and_never_sent, a text in two phones' logs).
On the demo profile¶
SIOUL_DEMO_TEXTS=1 tools/demo/run.sh OUT en texts shows the page on stand-in texts written by tools/demo/make-demo.py --texts: four conversations, among them a week's with Priya from the choir, a text deleted on the phone, a multimedia message with a drawn picture and a video left on the phone, texts sent from the computer in each state, and an AI agent's draft. The steps take the page at 1100 pixels, at the window's narrowest (680) and at 1600, and say in the log what passes the window's edge (OVERFLOW, WIDE). They are plain lines marked "demo:", and a plain media file, which only a demo profile reads (SIOUL_DEMO), with a stand-in phone.
Not checked yet¶
- On a phone: the permissions' screens on /e/OS, the parts' results, delivery reports from the owner's carrier, the times through eDrive and through Sioul's own sending; multimedia messages sent by the phone and group texts, on real data (only counts, sizes and states may be looked at).
- Sending: a real text, to a number the owner chooses, with him present.
- The look after a crash on a real phone; two SIMs.
- Multimedia messages, with the owner present (counts, sizes, times and states only): the carrier's values as the phone says them (expected for a French carrier: 614,400 bytes, 2592 × 1944) on Settings ▸ This phone ▸ Texts and on a computer's Settings ▸ Texts; the mobile-data switch and /e/OS's words; a first message of some 100 kB, then one just under the limit; airplane mode and a restart during a send; how long the media take to reach the phone; a HEIC picture from the phone attached on a computer that reads them; the picture as received on another phone, its orientation and colours.