An Android alarm clock that refuses to be swiped away.
When it goes off it does not play a ringtone. It fetches the day's forecast and the trending headlines, draws them into a picture, encodes that picture into a Slow-Scan Television signal — the analogue mode radio amateurs have used to send images over HF since the 1950s — and transmits it out of the speaker on a loop.
To turn it off you have to decode the transmission and type the information you read off it.
- Daily alarm with exact scheduling, surviving reboot and Doze
- Optional heads-up notification ahead of the alarm, with a one-tap "dismiss today" action, and a configurable lead time
- Rings again on an interval if it is ignored, until the code is entered
- Emergency code as an escape hatch, in case you genuinely cannot wait for a decode
- Weather location from a hand-picked city or from the device
- Headlines from any RSS or Atom feed you point it at (Google RSS by default)
- Celsius or Fahrenheit, defaulting to your region
- 24 SSTV formats to transmit in, from an 8-second Robot frame to a PD one taking nearly 5 minutes
- Alarm volume on the home screen, so you can set it without leaving the app
- Test transmissions, with the option to mute one or accept the emergency code for that run only
- Material 3 throughout, including Material You dynamic colour on the alarm screen
The picture carries the day's high and low. You type them into the numpad, and each value is padded to a fixed width so the answer is always plain digits:
| Reading | You type | Why |
|---|---|---|
| 30 °C and 24 °C | 3024 |
two digits each |
| 7 °C and 13 °C | 0713 |
single digits get a leading zero |
| −1 °C and −6 °C | 0106 |
there is no minus key, so the sign is dropped |
| 117 °F and 97 °F | 117097 |
Fahrenheit past 100 needs three digits each |
Which reading comes first is randomised per alarm, and only the transmitted picture says which — so the image genuinely has to be decoded rather than guessed at. The numpad digits are shuffled each time too, which kills the muscle memory that makes normal alarms so easy to dismiss asleep.
If the forecast cannot be fetched there is nothing to read off the weather, so the picture falls back to carrying a random code instead.
Single device. Hold a button and the picture paints itself on screen, line by line, in step with the audio going out of the speaker. Release it and the lines you missed stay as static until the transmission loops back round.
(This never opens the microphone. It reads the audio player's position, maps it onto the SSTV timeline, and paints the corresponding scan lines with the sync jitter and speckle a real decode has. What you see matches what a receiver pointed at the phone would produce, without the latency and failure modes of an actual audio loopback.)
Multi device. No on-screen decoding at all. Point a real SSTV decoder — Robot36, MMSSTV, QSSTV, or a radio — at the speaker and read the passcode off the received picture. Every format the app offers has a published timing specification, so anything that can decode SSTV can decode this.
https://github.com/SEKY443/sstv-encoder
Dependency-free Kotlin, runs on both the JVM and Android, installable in one line via JitPack. If you want SSTV encoding for your own project, get it there rather than copying files out of this app — the API reference, the full mode tables and the implementation notes all live in that repo.
There is no maintained SSTV encoder library for Android. The well-known projects are decoders, and the C++ ones are GPL — which is why it was written from scratch. It used to live inside this app; it is now maintained separately and pulled in as the app's only non-AndroidX runtime dependency.
Output is 16-bit mono PCM at 22 050 Hz, played through AudioTrack with USAGE_ALARM so it
follows the system alarm volume rather than media volume.
The two details that make or break a decode — a phase-continuous oscillator, and sample counts derived from cumulative elapsed time rather than per-segment rounding — are explained in the library repo.
What stays app-specific is the picture drawn for the medium: heavy strokes, high contrast, no fine detail, and a colour calibration bar across the top so a receiving operator can check the channels arrived in the right order.
The library can generate 35 modes. The app offers 24 of them.
The missing eleven are the ones whose timings the library had to reconstruct because no published
specification could be found — the Wraase SC1 and AVT families, and the legacy monochrome modes.
They are fine to experiment with, but this alarm can only be dismissed by decoding what it sends,
so offering a format that stock decoder software cannot read would let someone lock themselves out
of their own alarm. SstvModeCatalog filters them out and a test fails if any ever slips through.
What is left runs from an 8-second Robot 8 B/W frame to PD 290 at 289 seconds — the mode names are nominal, the picker shows what each one actually takes — grouped by family with its size alongside.
The offered formats are not one shape. They run from 320 × 120 to 800 × 616, and the layout is drawn once in a fixed 320 × 256 design space and scaled onto whichever the mode uses. Scaling the canvas rather than every coordinate keeps one layout to reason about.
Stretching to fit works for the 4:3 and 5:4 frames, which land within a few percent of the design. It does not work for the short ones: Martin 3/4 at 320 × 128 and the Robot black-and-white modes at 320 × 120 lose about half their height, and the small labels squash until they are unreadable. The picker says so on those formats, and Scale up to fill the frame offers the alternative — scale both axes by the larger factor so the text keeps its drawn size, and let the layout run off the frame.
That crop is the interesting part. Centring it loses the temperature readings; anchoring at the top
loses the band that names which reading to type first, which is randomised per alarm and therefore
unguessable. Either loss makes the alarm impossible to answer honestly. The anchor sits between the
two, keeping the weather block and the prompt band on screen and giving up the calibration bar, the
title and the headlines — decoration rather than answers. SstvFrameCropTest checks that invariant
for every format on offer.
The audio is the entire product in multi-device mode: if the tones are wrong, no external decoder can read the passcode and the alarm becomes impossible to dismiss. So the encoder is tested by measuring the waveform it produces — counting zero crossings to recover the leader tone, the VIS header, the black and white frequencies and the green-blue-red sweep order — rather than by trusting the constants. The library repo covers that side in full.
51 unit tests here, covering the passcode rules, the settings and the scheduling arithmetic, plus the two invariants that keep the alarm answerable: that no unreadable format reaches the picker, and that scaling never crops away the readings or the typing order. The encoder's own tests — waveform measurement and the mode timing table — live with the library.
./gradlew testgit clone <your-fork>
cd SSTVAlarm
./gradlew assembleDebugRequires JDK 17+. Everything else — AGP 9.2.1, Gradle 9.4.1, Kotlin 2.2.10 — comes from the
wrapper and the version catalog. minSdk 24, targetSdk 36.
No API keys to configure. Both data sources are keyless:
| Source | Used for |
|---|---|
| Open-Meteo | daily forecast and city search |
| Google News RSS | trending headlines, replaceable with any RSS or Atom feed |
The app has no proprietary dependencies — no Google Play Services, no Firebase, no analytics, no ads — so it qualifies for F-Droid as-is, with one exception: the SSTV encoder is normally resolved from JitPack, and F-Droid's build environment has no network access beyond the repositories it explicitly allows, which does not include JitPack.
To keep this reproducible for F-Droid without giving up JitPack for everyday contributors, the
encoder is also committed as a git submodule at libs/sstv-encoder, pinned to
the release named by sstvEncoder in
gradle/libs.versions.toml. settings.gradle.kts builds from it
automatically whenever it is populated, and falls back to JitPack otherwise — so a plain
git clone without --recurse-submodules still builds, it just resolves the encoder from JitPack
instead of building it from source:
git clone --recurse-submodules <your-fork>
cd SSTVAlarm
./gradlew assembleDebugF-Droid's own build metadata sets submodules: yes, which runs
git submodule update --init --recursive before building, so its build always uses the in-repo
source rather than JitPack.
| Language | Locale | File |
|---|---|---|
| English | en |
values/strings.xml |
| 正體中文(台灣) | zh-TW |
values-zh-rTW/strings.xml |
| 繁體中文(香港) | zh-HK |
values-zh-rHK/strings.xml |
| 简体中文 | zh-CN |
values-zh-rCN/strings.xml |
| 日本語 | ja |
values-ja/strings.xml |
If you speak a language that is not on that list, a pull request would be very welcome. You do not need to know Kotlin or even build the app — a translation is one XML file.
- Copy
values/strings.xmltovalues-<language>/strings.xml. Android usesvalues-de,values-ko,values-pt-rBR,values-es, and so on. - Translate the text between the tags. Never change the
name="..."attributes — those are how the code finds each string. - Add a matching
<locale>entry tores/xml/locales_config.xml, otherwise your language only reaches users whose whole device is set to it. - Run the checker and make sure it passes:
python3 tools/check_translations.pyThree things worth knowing before you start:
- Placeholders must survive.
%1$sand%2$dare filled in at runtime. Where several appear in one string they are numbered, so you may reorder them freely to suit your grammar. Getting one wrong crashes the app for that language only, which is exactly whattools/check_translations.pyis there to catch. - The strings marked
SSTV FRAMEare drawn into the transmitted picture, which is between 320 × 120 and 800 × 616 pixels and then turned into audio. Keep them short and prefer upper case. The Chinese and Japanese translations deliberately leave those in English, because CJK glyphs at that size do not survive the round trip legibly — do the same if your script has the same problem. - English is the source of truth.
values/strings.xmlis where a string changes first, and a translation follows its wording. A translation that says more than the English is drift, not detail — it goes stale the moment the English is reworded. - Partial translations are fine. Anything you leave out falls back to English, so a half-finished language is still an improvement. Send it and fill in the rest later.
Temperature units follow the device region automatically. (Fahrenheit in the US 🦅 and a handful of other regions, Celsius everywhere else)
core/ settings, persistence, temperature and passcode rules
data/ forecast, headlines, geocoding, location resolution
sstv/ frame renderer, bitmap bridge, audio playback
(the encoder itself is the SEKY443/sstv-encoder dependency)
alarm/ scheduling, receivers, foreground service, alarm activity
ui/ Compose screens, numpad, simulated decoder
- Location while the alarm is ringing. Android grants location "while in use", and an alarm fires with the app in the background — the platform silently returns null for every provider. The app works around this by capturing the position while it is in the foreground and reusing it, rather than asking for background location access. Setting a city by hand avoids the question entirely.
- The alarm screen is dark by default regardless of system theme, on the grounds that a full-brightness white screen at seven in the morning is hostile. There is a setting to make it follow light and dark mode; the Material You palette applies either way.
- The alarm volume will not go to zero. Android holds an app without the notification-policy
permission above the stream's real minimum — a Pixel reports
Min: 1 w/o perm:3and clamps to 3 of 7 — because an app should not be able to silence an alarm. There is no public API for that second number, so the slider discovers it: a write that comes back higher than it asked for is the floor, and the track shrinks to match. - A muted test still paints the picture. Muting sets the AudioTrack volume to zero rather than skipping playback, so the playback head still advances and the single-device decode stays in step with a transmission nobody can hear.



