Android Client
Source: kypost-android/README.md.
What it is
KyPost for Android is an Android email client backed by a self-hosted KyPost relay. It shows keyword-based inbox tabs and syncs contacts in both directions. Native-push pairing authenticates relay access and contact sync. It gets notifications through native pairing and FCM or direct pull. The current Android application id is org.kysecurity.mail; the package rename is a breaking change, so existing com.urlxl.mail installs must reinstall and pair again.
Features
- Mail: The paired relay proxies mail. The app stores no mail credentials on the device.
- Keyword tabs: Tabs come from server tab and label fields. Tune them in the Keywords screen.
- Compose: To, Cc, and Bcc complete from local contacts. An address-book picker adds recipients.
- Contacts: Two-way sync against the relay. Open it from the Inbox overflow menu.
- PGP: One screen shows your own PGP public-key QR code and scans another person’s, saving that key onto an existing contact. The app encrypts outgoing mail to a recipient’s key on the device — the plaintext body never reaches the relay for that path — and decrypts client-custody mail once it holds a sealed key envelope (see Device enrollment). Without that envelope, encrypted mail hands off to webmail in the user’s own browser or the installed webmail PWA, never a Custom Tab.
- Signature badges: decided on the device from keys in the local contact store. The relay’s
verifiedflag is not read at all. - MFA push approval: Push notifications approve or deny logins. An in-app screen handles approval when OEM background limits block the notification.
- Themes: Shares presets with the web app. Default is Patina Ky. Choose in the Themes screen.
- Push notifications: System notifications plus in-app history for new mail. Delivery is FCM, a UnifiedPush distributor you choose, or App Pull, which polls your own server and involves neither. Each user selects the delivery mode (
pushorpull) on the web Notifications page. - Large screens: a navigation rail and wide inbox, contacts and compose layouts at 600dp, and inbox/contacts master-detail through activity embedding at 800dp — measured on a Fold, where 720dp wrongly split the 751dp cover screen. State survives fold and unfold: the folder, tab, scroll position, an in-progress contact edit, and the app lock.
- Security: an app-lock PIN or biometric with escalating delays and wipe-on-repeat, Hostile Location Protection, and a SQLCipher-encrypted mail database. See Security Model.
Push pairing
Deep link or QR code:
kypost://native-pair?sub=<subscriberId>&srv=<serverUrl>®=<registrationUrl>&pt=<pairingToken>&pin=<tlsPin>
Required params: sub, srv, pt. Optional: reg, pin.
srv and any reg must be https and the same origin. The app refuses the link otherwise, because reg is where the device secret is minted while the confirmation dialog shows the user srv. hash is legacy: a link that still carries it comes from an outdated server, the app ignores it, and the server no longer accepts it.
Authentication is per device, not per account. Registration exchanges the one-time pairingToken for a deviceId and a deviceSecret minted by the server. Every later request sends them as X-Kypost-Device-Id and X-Kypost-Device-Secret headers. Revoke a single device from the server’s Security page.
Storage: a Keystore-backed EncryptedSharedPreferences file holds the subscriber id, server URL, registration URL, pairing token, device id and device secret. Plaintext DataStore holds notification history and sync status.
Registration: The app calls reg from the QR code. If reg is absent, it uses {srv}/api/notifications/native/register. It sends the FCM token. It marks the device as paired only after success (ok:true or synced:true). A scan alone does not pair it. It repeats the call on token refresh, and each successful registration mints a new device secret that invalidates the previous one.
FCM data keys: messageId, senderName, emailSubject, Keywords.
Pull mode (FCM bypass)
The registration response also returns deliveryMode (push or pull) and pullEndpoint. If pullEndpoint is absent, the app uses {srv}/api/notifications/native/pull.
In pull mode the app polls:
GET {pullEndpoint}?after=<cursor>
The cursor is the only query parameter. Authentication is the X-Kypost-Device-Id and X-Kypost-Device-Secret headers, never a query parameter — credentials in a URL end up in access logs and browser history. No session and no Bearer token. FCM stays registered but is not the source of truth.
The same dispatcher that handles an FCM data message renders each polled notification. Tap behavior is identical.
Dedup uses strictly increasing seq. The app keeps a durable per-subscriber lastCursor at max(lastCursor, response.cursor) only after it delivers notifications, so a crash causes a re-fetch, not a loss.
deliveryMode in both register and pull responses is authoritative. A change to push on the web stops polling. A change to pull starts it. The app reads it again on each foreground.
Cadence: WorkManager periodic work at the platform minimum of 15 minutes, plus immediate pull on foreground and after pairing. Near real-time needs a foreground service with a short loop and a persistent notification. That is not the default. The app backs off after 400, 401, 503, and network errors.
Firebase setup
- Create or update the Firebase Android app for
org.kysecurity.mail. - Download
google-services.json. - Put it at
app/google-services.json. - Enable FCM.
Notification permission (Android 13+)
The app requests POST_NOTIFICATIONS at launch. If the user denies it, the app still parses payloads and saves them to in-app history. It does not show system notifications.
Pairing steps from QR
- Web app shows a QR code with the deep link (
sub,srv,pt, optionalregandpin). - In Push Notifications, tap Scan QR Code or open
kypost://native-pairdirectly. The scanner is Google’splay-services-code-scanner; pair by entering the URL instead if you would rather keep Play Services out of that path. - The app validates
sub,srv,pt, checkssrvand anyregarehttpsand same-origin, and resolves the registration endpoint. - It shows a confirmation dialog naming the server host, then calls the native registration endpoint with the FCM token. Success marks the device as paired and stores the returned
deviceIdanddeviceSecret. - On each token refresh, it repeats the registration with stored pairing.
Troubleshooting
- Scheme and host must be exactly
kypost://native-pair. - Required params
sub,srv,ptmust exist, andsrvandregmust behttpsand the same origin. - Device must reach the resolved registration endpoint.
- Firebase config must match
org.kysecurity.mail. 400means malformed or missing field.401means bad or expiredpt; scan a new QR code.503means backend has noPAIRING_SECRETand retry will not help.- On Android 13+, grant notification permission or no system notification appears.
Installing
Signed builds are attached to each tagged
release: an .apk
to sideload, an .aab for Play distribution, and a .sha256 beside each one.
Verify before you install — the digest is the whole point of publishing it:
sha256sum -c kypost-<tag>.apk.sha256
There is no F-Droid or Play listing. The signing key is checked in CI before
publication: the workflow refuses to release unless apksigner reports the
expected signer, so an APK that verifies against the published digest carries
the key the project intends.
Build and test
./gradlew testDebugUnitTest
./gradlew assembleDebug
./gradlew connectedDebugAndroidTest # needs device or emulator
Instrumented tests need a connected device or emulator with a secure lock screen set. The Keystore-backed enrollment vault refuses to create a key without one by design, so on a bare emulator the vault suites fail as though the code were broken. CI sets a PIN first.
Release builds
:app:assembleRelease fails without signing material, deliberately: AGP does not
fall back to the debug keystore, so without the guard a green run would emit an
unsigned APK. Add a gitignored keystore.properties at the repository root with
storeFile, storePassword, keyAlias and keyPassword.
CI’s release-build job generates a throwaway key per run, so every PR verifies
the R8 configuration and lintVitalRelease without the real key being reachable
from a pull_request trigger. Published builds come from release.yml on a v*
tag: it reads the real key from the protected release environment, checks the
tag against versionName, and refuses to publish unless apksigner reports the
expected signer. Never build a release for distribution from a workstation.
Test coverage:
- Deep-link parser (
NativePairingDeepLinkParserTest) - Pairing validation (
PairingValidatorTest) - Registration endpoint resolution (
NativeRegistrationEndpointResolverTest) - Payload parsing (
messageId,senderName,emailSubject,Keywords) - Registration request mapping (
NativeRegistrationRequestMapperTest) - Secure pairing store round-trip and encryption (
SecurePairingStoreTest, instrumented)