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.
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 (sms-<_id>), 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. - 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. A text gone from the phone gets a line of its own in the phone's log (
deleted: when it was found gone); the archive keeps the text, and the page says "Deleted on your phone, kept here." Texts are never trimmed; requests and outcomes are (below). - Nothing passed: Android hides a text from Sioul while it waits in the outbox. Should a newer text be read at that moment, the reader's mark passes the hidden one; the same daily look finds the rows at or under the marks that the log lacks, and reads them then (
texts::tests::the_daily_look_finds_what_was_passed_and_what_went, 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 marks 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 reads the texts after the reader's marks (the provider's last rows read): 500 texts at most, the plain texts first, then the multimedia messages, oldest first, 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 every one after it, for the next step. The first import of a long history thus takes several steps; each new text comes at the next. A new text brings a step 20 seconds on (Texts.Heard, Android'sSMS_RECEIVED, while the background service runs). - When the import reads (
texts::import_now): a heavy import, the first (the whole history) or one the last batch left behind (500 rows, or a message whose media did not fit:behindin the reader's marks), goes on 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). Caught up, the import asks Android only when the phone's texts changed since the last look, 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, which does not watch, looks at each of its exchanges. Meanwhile, in the background service, the sharing's full rounds wait (share::set_frugal, up to 4 MB appended), as on a slow connection. Tested:texts::tests::a_heavy_import_waits_for_the_charger; Java's part,android/jvm-checks/StepsCheck.java. - 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.
- 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). - 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 cannot be written there.
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.
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.
- 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 the box, with how many texts it takes ("2 texts (180 characters)"; fewer characters per text with accents or signs outside GSM's alphabet), and Send.
- 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 (Text back opens the conversation, when texts are read on this computer) 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.
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.