An SMS and MMS app for the Mudita Kompakt, restyled for the phone's e-ink screen, with two things bolted on that the phone cannot otherwise have: Desktop Sync, reading and replying from a browser on a computer, and Signal, in the same inbox as your texts.
Kotozute is 言伝 — word left with someone to carry the rest of the way. The 伝 is the one in 人づて (hitozute, by way of a person) and 伝える (to convey). It names what is handed over rather than the machinery that carries it, which is the point: the phone, the cell tower and the browser are all just the someone in the middle.
It continues QKSMS by way of QUIK, and branched off InkMessage, which is where the e-ink work started.
![]() |
![]() |
![]() |
![]() |
Desktop Sync, which is the page the phone serves to a browser on your own computer:
Measured against the stock app rather than guessed at:
- No avatars — the stock app identifies a conversation by its title, not by per-message circles
- Bubble borders: thin solid incoming, thick solid outgoing (upstream had these reversed, with a dashed outgoing border)
- Sender names always bold; message previews bold only when unread
- Lato is bundled statically. Upstream loaded it through Google Play Services' downloadable-fonts provider, which silently does nothing on a degoogled phone, so the font never rendered on a Kompakt
- Black and white only. E-ink dithers colour to muddy grey, so the accent-colour system always resolves to black
- Consistent icon weights, and chevrons on navigable settings rows
An HTTP + WebSocket server runs inside the app on the phone and serves its own web dashboard. Your browser talks directly to the phone: no cloud service, no relay server in between.
- Conversation list with instant search
- Full thread history, paging back through long conversations
- Send replies, and start new conversations with contact autocomplete (by name or number)
- Pictures and video display inline and play in place, on both rails
- Right-click a conversation for archive, pin, mute, mark unread, block or delete; right-click a message to copy, forward, delete it, or put an emoji on it
- An archive shelf and a blocked list, so nothing filed away is out of reach
- Cross between someone's SMS and Signal threads from a badge in the thread header
- Safety numbers, the Signal account and the devices on it
- Settings: theme, unread-at-the-top, the Signal switches, VPN only, and a sync you can trigger from the browser
- Scheduled messages on both rails: write one now, pick a time, and the phone sends it whether or not the browser is still open — and see what is waiting, and cancel it
- Reading a thread in the browser marks it read on the phone and clears its notification
- Live updates over a WebSocket, with polling as a fallback
- The access link can be reset if it leaks
It is a page for a computer. There are no responsive breakpoints and none are planned: the phone that serves this already has the app on it. Opened on a phone anyway it scales the whole layout down to fit rather than breaking, but it will be small.
- In the app: Settings → Desktop Sync, and switch it on.
- Tap Show link. It gives you the address to open on your computer and a six-digit code to type in, which saves reading a long token off an e-ink screen. The code lasts three minutes and works once. The full link is there too if you would rather paste that; either way the browser remembers, so the bookmark is just the address.
- Switch it off again with the same row, or Stop on the notification. It is off by default and never starts on its own.
- If the link is ever shared or seen by someone else, Reset Desktop Sync link mints a new token. Existing bookmarks and open tabs stop working immediately.
Two independent gates.
Where you can connect from. VPN only is on by default: any request that does not come through a VPN is refused before the token is even looked at, so another device on your café or home Wi-Fi cannot reach the dashboard even if it somehow had your link. This also means your messages are never in the clear on a network — the relay speaks plain HTTP, but the VPN encrypts the connection end to end.
Any VPN that gives the phone its own address works: Tailscale, ZeroTier, a WireGuard or Nebula network of your own. Tailscale is recognised by its fixed address ranges; for any other VPN the relay accepts peers on the network of the phone's VPN address, and Show link lists that address under VPN. A VPN whose network overlaps the Wi-Fi's is not trusted, since that would let the house in too. A VPN that only carries the phone's traffic out to the internet, like a commercial privacy VPN, has no computer of yours on the far side and so lets nothing in.
Turn the switch off and it also accepts LAN connections, which is worth knowing about for one case: a VPN does not start by itself after a reboot on MuditaOS, which has no always-on VPN toggle. Until you open the VPN app, a restricted relay refuses everyone — Settings says so plainly when that happens.
The restriction is enforced per request rather than by binding only the VPN address, deliberately: binding it would mean the server cannot start at all while the VPN is down, so a reboot could leave the relay dead. This way the socket always binds and simply turns everyone else away.
Who you are. Every request also needs a random 120-bit token generated on first run, so being on the VPN isn't enough by itself — useful if your VPN network has devices you don't fully control.
Don't port-forward the port, and don't expose it with Tailscale Funnel or anything like it.
Desktop Sync needs the INTERNET permission, which InkMessage deliberately left disabled and this app re-enables for exactly this feature. Leave it off and no server is ever started.
Desktop Sync runs as a foreground service, which means Android keeps a notification in the shade for as long as the relay is up — that's how the platform works, and it's also your only visible sign the relay is running. Some minimalist launchers count every notification a package has posted when deciding whether to badge its icon, without checking whether the notification asked to be badged. On those launchers the relay's notification will light up an unread marker on Messaging that never clears.
The notification lives on its own channel (desktop_sync_v2) and declares setShowBadge(false) plus CATEGORY_SERVICE, so a launcher that honours either one will behave. If yours doesn't, open the notification's channel settings — the cog next to it in the shade — and switch that one channel off. Real messages badge from a different channel and are unaffected. The cost is that you lose the shade indicator telling you the relay is running.
Signal threads sit in the same conversation list as your texts, newest first, with a badge on the rows that came over Signal. Nothing is marked SMS: unmarked means SMS, which keeps the badge off nearly every row.
- Send and receive, one-to-one and in groups, with pictures
- React to a message: hold it and pick an emoji, or hold it again to take yours back
- Write one now and send it later, from the same scheduled list your texts use
- The same person's SMS and Signal threads are separate conversations, and the badge in either one crosses to the other
- Search reaches message bodies on both rails at once, from the phone or the browser
- Disappearing messages disappear, and a view-once photo is never stored
- Safety numbers, with a loud warning when someone's key has changed. If a message to them will not send because their safety number changed (a new phone or a reinstall), it stays in the conversation as "Not sent" and a notice offers View safety number or Accept and send (tap twice). Accepting trusts the new key and sends the waiting messages once, oldest first; nothing is sent before you accept. Compare the number with them first if the message is private
- Pin, mute, mark unread, archive, block, and a details screen with the pictures a conversation has carried
- Bring old messages across from a Signal Desktop export
Nothing but the phone. Signal runs in the app: it holds a Signal identity of its own and talks to Signal's servers directly, with nothing in between.
There are two ways in, both under Settings → Signal.
Link this phone, which is the ordinary one. On a phone that already has your Signal, open Settings → Linked devices → Link new device, and scan the code this one shows — the same exchange that links Signal Desktop.
Register this number, for a phone that is the only Signal you have. This makes it become the account for that number: if that number is on Signal anywhere else, that stops working and its linked devices are dropped, and the messages already on the other phone stay there. It needs a number that can receive a text from a short code — Google Voice and most other internet numbers cannot, and neither can they take the call — and there is a "call me with the code instead" option for when the text does not come.
There is no push, so the app collects its own messages. Signal does not send notifications to anything but its own clients, so how quickly a message arrives is decided by Keep Signal connected. On, a foreground service holds the connection open and messages arrive as they are sent, at the cost of a permanent notification. Off, which is the default, Signal catches up about every 15 minutes (less often while the phone sleeps) and when you open the app. Measured on the phone in forced deep idle, with the app backgrounded: with the switch on the message arrives, with it off nothing arrives until the next catch-up.
On a Kompakt, switch Messaging on in DuraSpeed as well (how). It closes background apps a few minutes after the screen goes off, and that stops both the connection and the catch-ups.
Threads start empty. Signal's servers do not hold history and a newly linked device is not sent any, so conversations fill from the day you pair. Importing a Signal Desktop export is how you get the rest.
Before the app could be a Signal device in its own right, Signal ran on a computer of your own and the phone talked to it over a bridge. Both halves are gone: the app has no bridge in it, and the bridge's own source is no longer in this repository.
If you are still running one, it is worth one last thing. A phone that used to be on a bridge
has messages whose pictures were never copied to it — it kept the attachment's id and asked
the bridge for the file when you opened one, and nobody is left to ask.
kotozute-bridge --export <folder> writes those files out, and Settings → Signal → Bring
history in reads that folder and keeps them for good. The binary and its source are at the
v1.16.0 tag, which is the
last release that carried either.
Nothing else is lost by it going. Reactions, blocking and read receipts were the bridge's to send and the phone sends them itself now. The one thing that did not survive is the list of other devices on the account: the bridge could ask signal-cli, and the phone's Signal library has no way to ask at all, so the account screen says which device this phone is and stops there.
Worth saying plainly, so nobody goes looking:
- No video calls. Voice calls work (see below); video never will. The Kompakt has no front camera and e-ink cannot show it, so a video call rings here as a voice call.
- No PIN, registration lock, number change or account transfer. Linked to a phone that already has Signal, those all belong to that phone. Registered here, they are simply not implemented — registration is done without a registration lock, and setting one afterwards needs a client that supports it.
- A linked device dies with its primary. If the phone holding the Signal account is ever unregistered, this one stops with it. That does not apply if this phone registered the number itself, because then it is the primary.
- History does not arrive by itself. Signal's servers do not hold it and a newly linked device is not sent any, so threads start empty and fill from the day you pair. Importing an export is how you get the rest.
Signal voice calls, both ways, with Signal's own calling library (RingRTC) at the version Signal Android uses. A call comes up over the lock screen the way a phone call does, rings with the phone's ringtone as the ringer setting allows, and keeps going with the screen off. One-to-one conversations have a phone at the top to call out; groups do not, because group calls are not supported.
Every call goes through Signal's relay servers rather than straight between the two phones, so nobody on a call learns this phone's IP address. Headsets and Bluetooth are not handled yet, and another of your devices is not yet told when a call was answered here. RingRTC adds about 13 MB to the download.
With Whereabouts installed, the attach menu has Send where I am, in texts and on Signal alike. Whereabouts names Messaging and asks first, takes one fix and hands it over, and Messaging sends a map link and the coordinates. Messaging itself has no location permission. Without Whereabouts the option is not there.
With Glance installed, how many messages are unread shows on the Kompakt's lock screen, texts and Signal each on their own, counted as messages: "3 texts · 2 Signal". Only the numbers are handed over, and only to Glance; never a sender or a word of a message. Settings → General → Unread on the lock screen turns it off.
Requires JDK 17 — the project's Kotlin/kapt toolchain fails on JDK 21 with an IllegalAccessError about com.sun.tools.javac.
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
./gradlew :presentation:assembleReleaseThe APK lands in presentation/build/outputs/apk/release/.
Released builds are signed with a real key supplied through signing/, which is gitignored and written by the release workflow from repository secrets. There is no fallback: a clone without it builds an unsigned release APK, which will not install anywhere. That is deliberate — a missing signing key should stop you, not quietly hand you something installable. assembleDebug still works anywhere, signed with the usual Android debug key.
Running inkMessage+? Uninstall it first — there is no upgrade path. This app has a different application ID, so Android treats it as unrelated software rather than a newer version, and installing over the old one stops with
INSTALL_FAILED_UPDATE_INCOMPATIBLE.Your messages are safe. They live in Android's own message store, not in this app, and it reads them back the first time it runs — on a long history that takes several minutes, showing "Syncing messages…" over an empty list until it finishes. What uninstalling clears is the app's own settings, so expect to make it your default SMS app again and to turn Desktop Sync back on.
Sideload the APK and let Android make it your default SMS app; it then imports your existing messages from the system SMS database. It installs alongside the stock Mudita SMS app rather than replacing it, so you can switch back whenever you like.
Also inherited from upstream: scheduled messages, backup/restore, blocking, archiving, voice messages, attachments of any file type, and delayed sending.
Switch Messaging on in DuraSpeed, or texts that arrive while the phone sleeps are lost. DuraSpeed is a MediaTek service on the Kompakt that closes installed apps a few minutes after the screen goes dark. Once it has, it drops every text meant for Messaging until the app is opened again: Android hands the text over, DuraSpeed skips the hand-over, Android discards its own copy, and the text is gone. Not late: it does not appear when you next open the app. Picture messages go the same way, and so does Signal's background connection. The stock Mudita app is on DuraSpeed's own allow list, which is why it never misses one.
Kompakt's Settings has no way in to DuraSpeed: no menu entry, and no search box to look for it in. Its own screen will not open for another app either, but its App info page will, and Messaging's setup guide has an Open DuraSpeed button that goes there. Then:
- Tap Open on DuraSpeed's App info page.
- Switch Messaging on in the list. On means allowed to run in the background, which is easy to read the wrong way round. Switching DuraSpeed off at the top works too, for every app.
If DuraSpeed closes Messaging before that, the app says so the next time you open it, with the time it happened, and the same button. Texts that were already lost cannot be got back.
From a computer with adb, DuraSpeed can be switched off for the whole phone instead:
adb shell settings put global setting.duraspeed.enabled 0
adb shell settings put system setting.duraspeed.enabled 0
Standing on a lot of other people's work:
- QKSMS — Moez Bhatti, where almost all of this code comes from
- QUIK — Marcos Jones (octoshrimpy), who kept it alive — Liberapay · Ko-Fi · Patreon
- InkMessage — lamdanAmiti, who took it to e-ink first, and where this branched off
- android-smsmms — Jake and Luke Klinker
- Typography and visual language follow Mudita's MMD design system
Download https://github.com/wanderwildwood/kotozute/releases/latest/download/kotozute.apk and
sideload it. That address always points at the newest release, and every release publishes a
.sha256 beside the APK if you would rather check than trust.
The app never updates itself: it does not check for updates, download them or install them, and it holds no permission to install apps. Updates come from GitHub or the InkApps store.
To be told about each new release, add this repository to Obtainium:
https://github.com/wanderwildwood/kotozute
Obtainium will offer each new release as it appears. The application id is settled — updates install over what you have, keeping your settings and anything the app has stored.
This is not a Signal product, and has no connection to the Signal Foundation or Signal Messenger LLC. It is an unofficial, third-party client. It speaks to Signal's servers directly, as a device linked to or registered on your account, using Signal's own published libraries — libsignal, and a fork of Signal's service layer. Before the bridge was removed it could instead talk to signal-cli, which is also a separate and unofficial project.
"Signal" is used here to name the service being reached, and for nothing else. Whether a third-party client suits you is your judgement to make, and worth making deliberately — an account reached this way is still your account, with everything that implies, and none of this code has been reviewed by anyone at Signal.
Privacy: what the app stores and what it sends is set out in PRIVACY.md.
AGPL-3.0-or-later — see LICENSE for the full text.
This was GPL-3.0-only, inherited from QKSMS by way of QUIK and InkMessage, and the SMS half of the app still is that code. The Signal half now carries work taken from Signal Android itself, which is AGPL-3.0, and a combined work containing AGPL code is distributed under the AGPL. So the whole is AGPL-3.0-or-later. GPLv3 §13 is what permits the combination; it does not let the result be offered as GPL alone.
The practical difference from GPLv3 is §13's network clause: anyone who modifies this and lets people use it over a network must offer those users the corresponding source, not only people they hand a build to. That reaches further than it might sound — this app serves a page over the LAN for Desktop Sync — so a modified build used that way carries the obligation.
Copyright in the work added by this fork is held by wander wildwood. Upstream copyrights are untouched and still apply to the code they cover: Moez Bhatti's on QKSMS, the QUIK and InkMessage contributors', and Signal Messenger, LLC's on the code taken from Signal Android.
The Signal rail is deliberately not a reimplementation. Protocol-sensitive logic is taken from Signal's own source rather than written again from a reading of the wire format, for the same reason the SMS rail is QKSMS's code rather than a fresh SMS client: parity and security live in the details, and the details are where a hand-rolled version silently differs.
-
libsignal (
org.signal:libsignal-android) — AGPL-3.0, unmodified, linked. The protocol primitives, published by Signal as a binary. Nothing here reimplements any of it. -
RingRTC (
org.signal:ringrtc-android2.72.0) — AGPL-3.0, unmodified, linked. Signal's calling library, WebRTC underneath it, published by Signal as a binary; its checksum is pinned to the one Signal Android pins. The call handling around it (SignalCallEngine) is a trimmed port of Signal Android's call manager, and the ringer and ringback are Signal's, sounds included. -
Signal's service layer — AGPL-3.0, copied into this repository and compiled here, under
signal-service/. Six of Signal Android's own modules (lib/libsignal-service,lib/network,core/network,core/util-jvm,core/models-jvm,core/serialization) taken at commitd555580a46, 2026-09-16. 643 of the 644 files are byte-identical to upstream; the one exception, and the reason for it, is recorded insignal-service/README.mdand commented at the line it changes. Every file keeps its upstream copyright header andSPDX-License-Identifier.This replaced
com.github.turasa:signal-network, a third-party fork resolved from JitPack, which earlier versions of this app linked instead. -
Signal Android (
signalapp/Signal-Android) — AGPL-3.0. Logic adapted into this app, rather than copied wholesale, is marked at the file or function that carries it, naming what it came from.
Where Signal's code is adapted rather than taken, the adaptation says so where it sits. Anyone redistributing a build should carry this notice — and note that the service layer being in this repository rather than linked is why this project is AGPL-3.0-or-later rather than GPL: Signal's code is AGPL-3.0-only, and a work containing it can be distributed no other way.
Signal Android is watched the way this fork's other upstreams are. A blobless clone lives at
/opt/projects/signal-android-upstream, and the weekly upstream scan tracks it with a
watermark, scoped to the paths the Signal rail actually takes from — message decryption,
contact discovery, the recipient model, and key handling. Signal lands dozens of commits a day
across a whole product; without that scope the entry would be noise, and an alert nobody reads
is the same as no alert.




