English · Deutsch
The alarm clock that makes sure you actually wake up.
Brutus is a complete Android clock suite — Alarm · World Clock · Stopwatch · Timer — with one mission hiding behind the polished Material 3 Expressive UI (tonal surfaces, spatial springs, wavy progress, Space Grotesk display type, dark/light + optional Material You): its alarm module forces you to complete a configurable challenge (or a chain of challenges) before ringing stops. No cheating, no auto-dismissing, no snoozing your way back to sleep.
Everything is packed into a four-tab bottom navigation that keeps the brutal alarm engine one tap away while still giving you a proper clock app for everyday use.
- Screenshots
- Why Brutus?
- App structure
- Features
- Combinable wake-up challenges
- Difficulty + sensitivity presets
- Alarm sounds
- Hardcore Mode
- Ultra Hardcore Mode
- Sunrise pre-alarm
- Home-screen widget
- Reliability banners
- Global QR code
- Slide-to-snooze gesture
- Test mode
- World Clock
- Stopwatch
- Timer
- Theming and Material You
- Languages
- Scheduling
- Lock-screen overlay
- Reliability
- Install
- Permissions
- Build from source
- Release signing
- Tech stack
- Project structure
- Tests and CI
- How it works
- Design philosophy
- Troubleshooting
- Roadmap
- Developer
- Donate
- License
Coming with the next update. The gallery below is wired up and waiting for its twelve captures — the shot list (which screen, in which state, with which
adbcommand) lives indocs/screenshots/SHOTLIST.md. It is kept as a comment here rather than as twelve broken image links.
Stock Android alarms are polite. They ring, you tap Dismiss with your eyes closed, and you're back asleep in seconds. Brutus breaks that loop on purpose.
- No dismiss button up front — the big "Stop" button only appears after you've completed every challenge you selected for that alarm
- No silent mode escape — Brutus overrides
STREAM_ALARMto maximum and ignores Do Not Disturb viaUSAGE_ALARMaudio attributes - No snoozing without effort — the snooze button is a slide-to-unlock gesture, not a tap
- No workarounds after power loss — alarms are persisted in Room and re-registered on boot via a
BOOT_COMPLETEDreceiver - No cheating with a printed screenshot of your QR code in bed — you can put the QR far from your bed (like the bathroom mirror) and you must physically walk there to scan it
Starting with v1.2.0 Brutus ships as a full clock suite. A persistent bottom navigation bar offers four tabs:
| Tab | Icon | Purpose |
|---|---|---|
| Alarm | ⏰ | Expressive alarm list with countdown header, state-coded cards (enabled = muted red, disabled = gray; next alarm ringed by a primary outline), swipe-to-delete + undo, per-alarm edit sheet, all the brutal wake modes |
| World Clock | 🌐 | Live multi-time-zone board powered by java.time.ZoneId — add/remove cities, ticks every second |
| Stopwatch | ⏱ | Start / Stop / Lap stopwatch with centisecond precision via SystemClock.elapsedRealtime() |
| Timer | ⌛ | HMS-picker countdown timer with quick presets (1m, 3m, 5m, 10m, 15m, 30m) — rings system alarm tone on finish |
The Alarm tab stays the heart of the app: its cards show a large thin time reading, the repeat summary (Once / Every day / explicit day list), an optional label, and a toggle switch on the right. Below sits a full-width weekday strip — seven equal Mon Tue Wed Thu Fri Sat Sun pills that always render on a single line, active days filled bright red. A row of info chips underneath surfaces the mode (ULTRA HC / HARDCORE), ☀ Sunrise, the challenge (Math + Shake…), the snooze interval (Snooze 5m) and the ♪ sound at a glance (redesigned in v1.7.0). A countdown header (e.g. "Alarm in 13 hours, 29 minutes") sits above the list and refreshes every 30 s.
Since v1.9.0 every card also has a copy button (⧉): it opens the edit sheet prefilled with all of the source alarm's settings ("Copy alarm") — adjust the time, save, done; no confusing identical duplicate is created behind your back. And every delete — single or Delete all — shows an undo snackbar ("Undo") that restores the alarm(s) including their scheduling.
A premium monogram app icon (radial dark-red gradient + gradient-filled "B" with hairline highlight) replaces the previous alarm-bell icon.
Three independent challenge types. Enable one, two, or all three per alarm. Brutus runs them in sequence — you have to complete every one of them before the "Stop alarm" button appears.
| Mode | Description | Configurable |
|---|---|---|
| Math | Solve randomly generated problems (multiplication, addition, subtraction) with numeric answers | Count: 1–10 problems (default 3) |
| Shake | Shake the phone with a circular progress ring visualizing how many shakes are left | Count: 10–100 shakes, step 5 (default 30) |
| QR scan | Scan a specific QR code using ML Kit Barcode Scanning via CameraX | Uses the global QR code — see below |
Combination examples:
- Easy: Math (3 problems) only
- Medium: Math (5) + Shake (50)
- Brutus Mode: Shake (100) → Math (10) → QR scan across the house
Both Math and Shake now ship with a 3-step preset selector that appears inline in the edit dialog when the respective challenge is enabled.
| Math difficulty | Operator pool | Operand range |
|---|---|---|
| Easy | +, - |
1–20 (subtraction always ≥ 0) |
| Hard (default) | +, -, * |
up to 50 × 20 or three-digit add/sub |
| Brutal | +, -, * (biased) |
two-digit × two-digit, four-digit sums |
| Shake sensitivity | Accelerometer delta threshold (m/s²) |
|---|---|
| Sensitive | ≥ 9 — even light wrist flicks count |
| Normal (default) | ≥ 12 — the previous behavior |
| Strong | ≥ 16 — only deliberate, vigorous shakes register |
Settings are per-alarm and persist in the same Room row.
Every synthesized sound is generated on-device in real time using AudioTrack with USAGE_ALARM and CONTENT_TYPE_SONIFICATION attributes. No external audio assets, tiny APK impact, seamless looping.
Harsh sounds — built for wake-the-dead alarm duty:
| Sound | Character | Signal |
|---|---|---|
| Silent | No audio — useful for rehearsing wake modes quietly | — |
| System alarm | Android default alarm (fallback) | RingtoneManager.TYPE_ALARM |
| Klaxon | Pulsing two-tone alarm | 600/900 Hz square wave, 300 ms each |
| Siren | Sweeping siren | 400 → 1200 Hz sine sweep, 2 s cycle |
| Nuclear Alert | Rapid sharp beeping | 1 kHz square, 100 ms on / 100 ms off |
| Piercing | Piercing continuous beep | 3.5 kHz square wave with 8 Hz pulse — the most annoying one by design |
Extreme sounds (v1.7.0) — five more ways to be ripped out of bed:
| Sound | Character | Signal |
|---|---|---|
| Stadium horn | Brash stadium air-horn blat | Three detuned sawtooth voices (Bb3 / ~Eb4 / Bb4) stacked, 0.9 s |
| Jackhammer | Pounding construction-site rattle | ~73 Hz square gated 28 ms on / 22 ms off, with a clattering 5th-harmonic grit |
| Fire alarm | Standardized T-3 smoke-alarm cadence | 3.1 kHz square, three 0.5 s beeps + 1.5 s pause, looped |
| Dental drill | Screeching dental drill | 1.6 kHz FM carrier, 42 Hz modulator (index 9) with a slow ±220 Hz wail |
| Banshee | Dissonant rising wail | Four tightly-detuned voices (620–652 Hz) beating while the cluster sweeps +90 % up |
Gentle sounds (v1.5.0) — for the timer and casual wake-ups, capped at ~50–60 % amplitude:
| Sound | Character | Signal |
|---|---|---|
| Chime | Soft 3-note descending bell with overtones | E5 → C5 → G4 sine + 2nd/3rd harmonics, exp decay |
| Marimba | Woody pluck pattern | 440 Hz sine + 4th harmonic, three plucks/loop, fast envelope |
| Morning sun | Slow swelling A-major triad | A4 + C♯5 + E5, triangular envelope over 3 s |
Choosing Silent skips the audio path entirely; vibration still runs so the alarm is noticeable if you need it.
Sound preview works directly inside the edit dialog — tap a chip to hear it, tap Stop preview when you're done. The Timer screen has its own sound picker (gentle sounds only) — defaults to Chime, persists in SharedPreferences.
An opt-in per-alarm switch that makes the alarm immune to volume tampering. When a Hardcore-Mode alarm is ringing, Brutus:
- Clamps
STREAM_ALARMto its maximum value and keeps it there - Registers a
VOLUME_CHANGED_ACTIONreceiver that snaps any user-driven volume change back to max within a few milliseconds - Overrides
dispatchKeyEvent()in bothAlarmActivityandTestAlarmActivityto consume volume-up / volume-down / mute key events — the hardware buttons effectively become inert
The HARDCORE tag is shown on the alarm card in the list, and a red HARDCORE MODE badge flashes above the clock while the alarm screen is visible. Hardcore is never enabled by default — toggle it per alarm inside the edit sheet.
The guard is strictly scoped to the ringing window: as soon as the alarm is dismissed or snoozed, the receiver detaches and Android's normal volume behavior resumes. Outside of a firing alarm, the hardware volume keys behave normally, so the setting has zero footprint during daily use.
Android intentionally offers no API to globally "lock" a stream volume. Brutus achieves the locked-feel via immediate re-clamping + key event consumption — the cleanest approach available without requiring system-level permissions.
Hardcore Mode keeps you from silencing a ringing alarm. Ultra Hardcore Mode (v1.4.0) keeps you from going back to sleep after you dismiss it.
When enabled per alarm:
- Ultra Hardcore implies Hardcore. The volume lock + volume-key consumption apply automatically while either the main alarm or a follow-up is ringing.
- As soon as you dismiss the main alarm, Brutus schedules two follow-up alarms via
AlarmManager.setAlarmClock()— one at +10 minutes from dismiss, another at +15 minutes. Both run the same challenge chain, the same sound, and the same Hardcore guard. - A persistent reminder notification is posted from a dedicated
IMPORTANCE_HIGH/ bypass-DND channel. Title: "Ultra Hardcore active". It has aSolve taskaction — tapping it opens the anti-snooze task. - The anti-snooze task is a step-counter challenge: walk 30 steps (configurable per install) with the phone in your hand or pocket. Uses
Sensor.TYPE_STEP_COUNTERwhen available, falls back toTYPE_STEP_DETECTOR, and finally to an accelerometer impulse heuristic on older hardware that lacks a pedometer. - Completing the task cancels both pending follow-ups and clears the reminder notification. Cancelling without completing it leaves both follow-ups armed — Brutus will ring again.
- Reboot survives. Pending follow-ups are mirrored to
SharedPreferences(UltraHardcoreStore). AfterBOOT_COMPLETED, any follow-up whose trigger time is still in the future is re-registered withAlarmManager; expired ones are cleaned out.
The alarm screen shows a brighter ULTRA HARDCORE MODE badge instead of the regular Hardcore one, and the follow-up firings display "Re-alarm 1/2 — you did not get away" above the clock. Cards in the list carry an orange ULTRA HC tag.
Requires the ACTIVITY_RECOGNITION runtime permission (API 29+) for the step counter. If the user denies it, the step challenge degrades to the accelerometer fallback automatically — no Ultra Hardcore alarm ever locks the user out.
Ultra Hardcore can be disabled per alarm at any time. Toggling it off in the edit dialog also cancels any currently-armed follow-ups and dismisses the notification immediately.
A per-alarm opt-in (v1.6.0) that gives you a 10-minute gentle wake-up window before the main alarm starts. When enabled:
- A separate
setExactAndAllowWhileIdleregistration fires 10 min before the main trigger and launchesSunriseActivityon top of the lock screen. - The activity ramps the screen brightness linearly from ~5 % to 100 % and the background gradient shifts from black to dawn-orange.
- A soft Chime loops at the picker's amplitude — no max-volume override, no Hardcore guard. Just an ambient cue.
- The clock continues to tick centered on the screen with a live countdown to the main alarm.
- Two buttons: Stop alarm (disables the alarm entirely, same effect as toggling it off in the list) and Already awake — close sunrise (closes the pre-alarm; main alarm still fires at the configured time).
- Sunrise has no challenge requirements and no Hardcore behavior. The brutal alarm path takes over exactly at the configured time regardless of whether the Sunrise activity is still open.
Sunrise is intentionally lightweight (~70 lines of Compose, no schema work beyond a single sunriseEnabled column on AlarmEntity v6→v7) so it can be layered on top of any challenge / Hardcore / Ultra Hardcore combo.
A 2×1 cell widget (resizable horizontally / vertically) added in v1.6.0. Shows:
- Time of the next upcoming alarm (large, light-weight)
- Countdown — "in 7h 12m" / "in 23 min" / "in 2 days" (localized)
- Day strip — repeat-day shorthand for repeating alarms, or the weekday name for one-shot alarms
- A small BRUTUS marker in the brand red
Tapping the time opens the app. Updates every 30 minutes via AppWidgetProvider.updatePeriodMillis, plus an immediate ACTION_APPWIDGET_UPDATE broadcast on every alarm add / toggle / delete / fire so the widget never lags behind by more than a few seconds during user interaction. Boot recovery refreshes it too.
The widget reads from the same Room database the app uses, so widgets always agree with the in-app countdown.
The alarm list shows two red/orange banners when system state would silently break alarms:
- Exact alarms disabled (v1.3.0) —
AlarmManager.canScheduleExactAlarms()is false (Samsung's default on Android 12+). Deep-links toACTION_REQUEST_SCHEDULE_EXACT_ALARM. - Battery optimization active (v1.6.0) —
PowerManager.isIgnoringBatteryOptimizations()is false (default on every install). Aggressive battery managers on Xiaomi/Huawei/Samsung devices routinely kill background apps and silently swallow alarm broadcasts. Deep-links toACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONSso the user can whitelist Brutus with two taps. Falls back to the general battery-optimization list if the per-app dialog isn't supported. - Full-screen alarm blocked (v1.6.1) — Android 14+ no longer grants
USE_FULL_SCREEN_INTENTby default to apps outside the Calling / Default Alarm categories. Without it, Brutus's lock-screen overlay is silently downgraded to a heads-up notification and the app does not pop to the foreground when the alarm fires. The banner usesNotificationManager.canUseFullScreenIntent()and deep-links toACTION_MANAGE_APP_USE_FULL_SCREEN_INTENTso the user can flip the switch.
All three banners disappear automatically as soon as the corresponding system state is fixed — re-checks run on every ON_RESUME.
Brutus generates one unique QR code per installation, stored once in SharedPreferences, valid for every alarm forever. You never need to regenerate it. Workflow:
- Create any alarm with the QR challenge enabled
- The QR code is displayed inside the edit dialog
- Tap Save as PNG — writes a 1024×1024 PNG to
Pictures/Brutus/via MediaStore (Android 10+) or legacy storage (Android 8–9, with permission). The file is visible in any gallery app - Tap Share — sends the PNG through the Android share sheet (Gmail, WhatsApp, Bluetooth, etc.) via FileProvider
- Print it, tape it to your bathroom mirror / fridge / front door — the farther from bed, the better
The code format is brutus:{UUIDv4}, ~43 characters. Any ML Kit-compatible QR scanner can read it, but only Brutus's own scanner verifies the match.
Snoozing is available during every phase of the alarm (including while a challenge is running) — but only by swiping an orange thumb horizontally across a track past the 85% threshold. A tap does nothing. Features:
- Animated gradient fill behind the thumb grows with drag progress
- Pulsing "Swipe to snooze" hint with a drifting chevron icon, fades out as you drag
- Springs back on incomplete swipe (damping 0.55)
- Snaps to the end on successful trigger, then fires the snooze
- Snooze duration configurable per alarm: Off, 2, 5, 10, or 15 minutes (default 5). Set to Off to hide the snooze button entirely on the alarm screen — no escape except finishing the challenge
Every edit dialog has a Test wake modes now button. It opens the full alarm screen with your chosen sound and challenge chain — but without registering a real alarm, without max-volume override, and without lock-screen flags. Finish the test or just back out. Useful for:
- Checking how many math problems actually feels right
- Calibrating shake threshold to your phone's accelerometer
- Confirming you can scan your printed QR in realistic lighting
- Auditioning alarm sounds in context
A live time-zone board powered by java.time.ZoneId and ZonedDateTime. Each row shows the city (derived from the IANA zone ID), the region, the UTC offset, the local date, and the current time — all refreshed once per second. Add-sheet offers a searchable list of the ~600 available zone IDs on the device. Selection persists across launches via SharedPreferences (newline-separated zone IDs).
Default seeded set on first launch: Europe/Berlin, America/New_York, Asia/Tokyo. All removable, all replaceable.
Centisecond-precision stopwatch built on SystemClock.elapsedRealtime() (unaffected by wall-clock jumps). A single large monospace-width readout (using the Material 3 Light weight for elegance), a red Start / Stop circle button, and a surface-variant Reset / Lap circle button. Laps are persisted in-memory during the session and shown as a list with per-lap and cumulative columns. Lap button becomes available automatically while the timer is running.
Since v1.8.0 the stopwatch (and the timer) keep their entire state — including a running measurement, laps, and a timer's finish sound — in Activity-scoped ViewModels, so switching bottom-nav tabs no longer resets them.
HMS picker (hours 0–23, minutes 0–59, seconds 0–59) with up/down steppers on each column. Quick-preset row for common durations (1m, 3m, 5m, 10m, 15m, 30m). A gentle-sound picker (added in v1.5.0) below the presets lets you pick the finish tone — defaults to Chime, choice persists across launches via TimerSoundStore. Tapping a chip previews the sound; Stop halts the preview.
During the countdown the screen switches to a large 64 sp time readout and two circle buttons (Abort / Pause-Resume). When the timer expires the chosen synthesized sound (or the system ringtone if System alarm is picked) plays in a loop with USAGE_ALARM audio attributes until Stop is pressed — behavior mirrors a classic kitchen timer rather than a brutal wake mode.
The whole app runs on MaterialExpressiveTheme with MotionScheme.expressive() — the tonal
surfaces, spatial springs and shape scale come from one place (ui/theme/), not from
per-screen styling.
- Dark / light follows the system setting. The three alarm-facing activities (ring, Sunrise,
Ultra Hardcore task) deliberately pass
darkTheme = trueregardless — their layered black/red gradients assume light-on-dark content, and a white flash at 6 a.m. is its own kind of cruelty. - Material You is an opt-in, not the default. Alarm tab → ⋮ → Material You colors swaps the red brand scheme for the wallpaper-derived one. Shown only on API 31+, persisted in DataStore, applied instantly across every screen.
- Space Grotesk carries the display scale, with tabular numerals on the entire type scale since v2.1.0 — every clock, countdown and lap time ticks without the digits jittering sideways.
- Reduced motion is respected.
rememberReducedMotion()readsANIMATOR_DURATION_SCALEand gates the decorative loops (breathing backgrounds, pulsing snooze hint); state-driven transitions simply snap instead.
Brutus ships in English and German. English is the default resource set
(values/), German is a full translation (values-de/) — there is no partial
coverage and no silent fallback: every single string, plural and weekday label
exists in both.
- Follows the system language out of the box.
- Per-app language on Android 13+: the app declares
res/xml/locales_config.xml, so Settings → Apps → Brutus → Language lets you run Brutus in English on a German phone (or the other way round) without touching the system language. - Plurals are real plurals, not string concatenation:
1 day/2 days,in 1 Tag/in 2 Tagen,One more shake!/4 more shakes!. The widget countdown used to read "in 1 Tagen" — that bug class is now structurally impossible. - Date patterns are localized too, not just the words: the next-alarm line
renders as
Mon, Aug 17, 06:30in English andMo, 17. Aug, 06:30in German, because the pattern itself is a string resource.
Everything the user can read comes from resources — including notification channel names, the share sheet subject, accessibility labels and the widget.
Adding a third language is a translation job only, no code change: copy
values/strings.xml to values-<lang>/, translate, add the locale to
locales_config.xml. ResourceParityTest then enforces that
the new file stays complete and that its format specifiers match.
- Exact alarm time via
AlarmManager.setAlarmClock()— shown in system status bar, works in Doze, survives battery optimization - Per-weekday repeat — bitmask, Mon/Tue/Wed/Thu/Fri/Sat/Sun independently selectable
- One-shot mode — no days selected means fire once, then disable
- Automatic re-scheduling after the alarm fires (for repeating alarms)
- Boot receiver re-registers every enabled alarm after device restart or quick boot (
LOCKED_BOOT_COMPLETED)
The firing alarm presents a full-screen activity over the lock screen:
showWhenLocked = true/turnScreenOn = true/FLAG_KEEP_SCREEN_ONKeyguardManager.requestDismissKeyguard()to skip PIN entry- Full-screen notification with
CATEGORY_ALARMandsetFullScreenIntent() - Large centered digital clock ticking in real time
- "BRUTUS ALARM" banner with animated progress dots for multi-challenge sequences
- Back button is intentionally blocked during the challenge
- Excluded from recent apps (
excludeFromRecents)
| Concern | Mechanism |
|---|---|
| Keeping audio playing with screen off | Foreground service with mediaPlayback type + PARTIAL_WAKE_LOCK (10 min timeout) |
| Surviving silent / DND | STREAM_ALARM with maximum volume set at start, restored when dismissed |
| Surviving reboot | Room persistence + BOOT_COMPLETED / LOCKED_BOOT_COMPLETED receiver, held open via goAsync() so the reschedule can't be killed mid-flight (v1.8.0) |
| Surviving app kill | START_STICKY service, alarm is re-scheduled before firing |
| Preventing accidental snooze | Slide-to-snooze gesture with 85% drag threshold |
| Overlapping alarms | If a second alarm fires while one is still ringing, the old session is cleanly finished first — audio released, and a UHC main alarm's follow-ups get armed instead of silently dropped (v1.8.0) |
| Rescheduling with Sunrise | schedule() always cancels a previously armed sunrise pre-alarm before re-arming, so no stale sunrise can fire at the old time (v1.8.0) |
Grab the latest signed APK from the Releases page:
https://github.com/pepperonas/brutus/releases/latest
- Download
brutus-v*.apkon your Android device - Open the file — Android will prompt to allow install from this source if not already enabled
- Tap Install
The APK is signed with a permanent keystore, so future updates install cleanly over this one.
Sideloading means you are trusting a file from the internet — here is everything you need to check that it is really the one this repository published.
| Property | Value |
|---|---|
| Signing certificate | CN=Brutus, OU=Pepperonas, O=Pepperonas, L=Berlin, ST=Berlin, C=DE |
| Key | RSA 4096, valid 2026-04-12 → 2053-08-28 (10,000 days) |
| Signature scheme | APK Signature Scheme v2 |
| Certificate SHA-256 | 69d67a10a826cf4050da4b271af9b5ed500c962bfab07a8f8fe863e3d7600382 |
| v2.2.0 APK | 4,414,213 bytes · SHA-256 48cf9cfc85dffe864273ab4732468029d8a3dff2b4cb01e41b8007534cbab7a4 |
The certificate fingerprint is the durable one — it stays identical across every release, so a mismatch means the APK did not come from here. The APK hash changes with every version.
shasum -a 256 brutus-v2.2.0.apk
$ANDROID_HOME/build-tools/35.0.0/apksigner verify --print-certs -v brutus-v2.2.0.apkSamsung's One UI by default revokes SCHEDULE_EXACT_ALARM for third-party apps. v1.3.0 detects this automatically and shows a red "Exact alarms disabled" banner above the alarm list with an Enable button that deep-links straight into the right settings page (ACTION_REQUEST_SCHEDULE_EXACT_ALARM). Tap it once, toggle the switch, and you're back. The banner re-checks on every app resume and disappears as soon as the permission is granted.
If you'd rather do it manually:
Settings → Apps → Brutus → Alarms & reminders → Allow
| Permission | Purpose | When granted |
|---|---|---|
SCHEDULE_EXACT_ALARM / USE_EXACT_ALARM |
Precise alarm timing via setAlarmClock() |
Install time (API 33+: settings toggle) |
POST_NOTIFICATIONS |
Foreground service notification | Runtime, first app launch (API 33+) |
WAKE_LOCK |
Keep CPU awake during alarm playback | Install time |
RECEIVE_BOOT_COMPLETED |
Re-register alarms after reboot | Install time |
CAMERA |
QR code scanning challenge | Runtime, when the alarm fires and QR challenge is active |
ACTIVITY_RECOGNITION (since v1.4.0) |
Step counter for the Ultra Hardcore anti-snooze task | Runtime, when enabling Ultra Hardcore Mode or opening the task screen |
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS (since v1.6.0) |
Lets the battery-optimization banner deep-link the system whitelist dialog | Install time (the dialog itself is opt-in per device) |
VIBRATE |
Vibration pattern during alarm | Install time |
USE_FULL_SCREEN_INTENT |
Lock-screen alarm overlay | Install time |
FOREGROUND_SERVICE / FOREGROUND_SERVICE_MEDIA_PLAYBACK |
Alarm playback service | Install time |
WRITE_EXTERNAL_STORAGE (API ≤ 28 only) |
Save QR PNG on legacy Android | Runtime, when saving QR |
ACCESS_NETWORK_STATE (since v1.3.0) |
Lets Play Services check connectivity for the one-time ML Kit Barcode model download. Not declared by Brutus — it is merged into the final manifest from the ML Kit dependency | Install time |
Everything in that table except the last row is declared in app/src/main/AndroidManifest.xml; you can diff it against this list in ten seconds.
Brutus does not request INTERNET and never sends data anywhere. Starting with v1.3.0 the ML Kit Barcode model is shipped unbundled — the model itself is delivered via Google Play Services and pre-fetched at install time (com.google.mlkit.vision.DEPENDENCIES = barcode meta-data). This adds an ACCESS_NETWORK_STATE permission so Play Services can check connectivity for the one-time model download, but the app itself never opens a socket.
- JDK 17 (Temurin, Homebrew OpenJDK, or Android Studio's bundled JBR)
- Android SDK with Platform 35 and Build-Tools 35.0.0+
- Gradle 8.11+ (the included wrapper pulls it automatically)
git clone https://github.com/pepperonas/brutus.git
cd brutus
# Create local.properties with your SDK path (first build only)
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
# Build and install debug APK on a connected device
./gradlew installDebugRequires the signing keystore — see the next section.
./gradlew assembleRelease
# Output: app/build/outputs/apk/release/app-release.apkadb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell monkey -p com.pepperonas.brutus -c android.intent.category.LAUNCHER 1Release builds are signed using a keystore stored outside the repo. The Gradle config reads signing credentials from either local.properties or environment variables — whichever is present.
Add to local.properties (already in .gitignore):
brutus.storeFile=/absolute/path/to/brutus-release.jks
brutus.storePassword=your_store_password
brutus.keyAlias=brutus
brutus.keyPassword=your_key_passwordSet these repository secrets, then expose them as env vars in the workflow:
| Secret | Contents | Used by |
|---|---|---|
RELEASE_STORE_BASE64 |
the .jks keystore, base64-encoded (base64 -i brutus-release.jks | pbcopy) |
decoded to disk by release.yml, which then exports RELEASE_STORE_FILE |
RELEASE_STORE_PASSWORD |
store password | Gradle signingConfigs.release |
RELEASE_KEY_ALIAS |
key alias (brutus) |
Gradle signingConfigs.release |
RELEASE_KEY_PASSWORD |
key password | Gradle signingConfigs.release |
RELEASE_STORE_FILE is not a secret you set — the workflow writes it into $GITHUB_ENV
after decoding RELEASE_STORE_BASE64. Gradle prefers those environment variables over
local.properties whenever RELEASE_STORE_FILE is present, which is what makes the same
build.gradle.kts work locally and in CI.
The workflow can also be started by hand from the Actions tab
(workflow_dispatch) — it then runs the full signed build and verifies the
certificate, but skips the upload because there is no tag to attach to. That is
the way to check the signing setup without cutting a release.
Forks: without any keystore,
:app:assembleReleaseproduces a plain unsigned APK —signingConfigis only assigned when astoreFileis actually present. The release workflow additionally verifies that the built APK carries the expected certificate (69d67a10…) and fails otherwise, so a missing secret can never end up as a silently unsigned APK on the releases page.
keytool -genkeypair -v \
-keystore brutus-release.jks \
-keyalg RSA -keysize 4096 \
-validity 10000 \
-alias brutus \
-dname "CN=Brutus, OU=YourOrg, O=YourOrg, L=City, ST=State, C=DE"| Layer | Technology |
|---|---|
| Language | Kotlin 2.1.0 (JVM target 17) |
| UI toolkit | Jetpack Compose — BOM 2026.06.01 |
| Design system | Material 3 Expressive — material3 pinned to 1.5.0-alpha18 (see note below) + Material Icons Extended |
| Type | Space Grotesk (bundled, SIL OFL) with tabular numerals |
| Languages | English (default) + German, locales_config.xml for the Android 13+ per-app picker |
| Navigation | Navigation Compose 2.8.5 — one NavHost across the four tabs |
| Architecture | MVVM (AndroidViewModel + Repository), StateFlow + stateIn |
| Lifecycle | androidx.lifecycle 2.8.7 (runtime-ktx + viewmodel-compose) |
| Database | Room 2.6.1 with KSP code generation, schema v7 exported to app/schemas/ |
| Preferences | DataStore Preferences 1.1.1 (theme) + SharedPreferences (QR, world clock, timer sound, UHC follow-ups) |
| Scheduling | AlarmManager.setAlarmClock() |
| Background | Foreground Service (mediaPlayback type) + PARTIAL_WAKE_LOCK |
| Audio | AudioTrack (procedural PCM synthesis) + MediaPlayer (system ringtone) |
| Sensors | Accelerometer (shake), TYPE_STEP_COUNTER / TYPE_STEP_DETECTOR (Ultra Hardcore) |
| Camera | CameraX 1.4.1 |
| Barcode scanning | Google ML Kit Barcode Scanning (unbundled) 18.3.1 |
| QR generation | ZXing Core 3.5.3 |
| Tests | JUnit 4.13.2, kotlinx-coroutines-test 1.9.0, Robolectric 4.14.1, androidx.test:core 1.6.1 |
| Build | Gradle 8.11.1, AGP 8.7.3, KSP 2.1.0-1.0.29, R8 (minify + shrinkResources) |
| SDK levels | min 26 (Android 8.0) · target/compile 35 (Android 15) |
Why material3 is pinned past the BOM: BOM 2026.06.01 maps material3 1.4.0, where the
Expressive APIs (MaterialExpressiveTheme, MotionScheme, expressiveLightColorScheme) are
still internal. They graduate in the 1.5.0-alpha channel, and 1.5.0-alpha18 is the newest
alpha still built against Compose 1.11 — alpha19+ pulls Compose 1.12 and would force
compileSdk 37 + AGP 9.1. Components that had not graduated by alpha18 (ButtonGroup,
FloatingToolbar) are used behind an explicit @OptIn(ExperimentalMaterial3ExpressiveApi).
No Hilt, no Koin, no Dagger — manual DI via the Application class. No Retrofit, no coroutines channels, no Flow operators beyond stateIn. The codebase is small on purpose: 80 Kotlin files, ~12,000 lines — 53 of them production code.
app/src/main/java/com/pepperonas/brutus/
├── MainActivity.kt Entry activity, hosts the HomeScreen (4-tab NavHost)
├── AlarmActivity.kt Lock-screen overlay activity for the firing alarm
├── TestAlarmActivity.kt Preview activity for "test wake modes"
├── UltraHardcoreTaskActivity.kt Anti-snooze step-counter task (v1.4.0)
├── SunriseActivity.kt 10-minute gentle pre-alarm with brightness ramp (v1.6.0)
├── BrutusApplication.kt App init, notification channels
├── receiver/
│ ├── AlarmReceiver.kt BroadcastReceiver for the AlarmManager trigger
│ └── BootReceiver.kt Re-schedules alarms on BOOT_COMPLETED
├── service/
│ └── AlarmService.kt Foreground service — audio, wake lock, vibration, hardcore guard
├── scheduler/
│ └── AlarmScheduler.kt AlarmManager wrapper with next-occurrence math
├── data/
│ ├── AlarmEntity.kt Room entity — time, days bitmask, challenge flags, counts, hardcoreMode
│ ├── AlarmDao.kt DAO with Flow-based reactive queries
│ ├── AlarmDatabase.kt Room database — schema v7, migrations 4→5→6→7
│ └── AlarmRepository.kt Single data access abstraction
├── viewmodel/
│ ├── AlarmViewModel.kt State container with StateFlow of alarms
│ ├── TimerViewModel.kt Countdown state + ticker, survives tab switches (v1.8.0)
│ └── StopwatchViewModel.kt Stopwatch state + laps, survives tab switches (v1.8.0)
├── ui/
│ ├── theme/
│ │ ├── Color.kt Full M3 color-role sets (dark + light) from the BrutusRed seed
│ │ ├── Type.kt Expressive type scale — Space Grotesk display + tabular numerals
│ │ ├── Shape.kt Expressive shape scale (8/12/16/24/32 dp)
│ │ ├── Theme.kt MaterialExpressiveTheme: springs, dark/light, Material You opt-in
│ │ ├── ThemeSettings.kt DataStore-backed theme prefs (dynamic color)
│ │ ├── Motion.kt rememberReducedMotion() — gates decorative loops
│ │ └── ThemePreview.kt Dark/light/dynamic theme specimen previews
│ ├── screens/
│ │ ├── HomeScreen.kt Bottom-nav shell with NavHost across the four tabs
│ │ ├── AlarmListScreen.kt Expressive alarm list: tonal cards, swipe-to-delete, FAB, MDMDFSS strip
│ │ ├── AlarmEditDialog.kt Modal bottom sheet: time, days, sound, challenges, counts, QR, snooze, hardcore, test
│ │ ├── WorldClockScreen.kt Live multi-zone board, add/remove sheet
│ │ ├── StopwatchScreen.kt Start / Stop / Lap with centisecond precision
│ │ └── TimerScreen.kt HMS picker + countdown + quick presets + ringtone finish
│ └── alarm/
│ ├── AlarmScreen.kt Full-screen overlay with clock + challenge carousel + HARDCORE badge
│ ├── MathChallenge.kt Numeric input + random multiplication/addition/subtraction (3 difficulties)
│ ├── ShakeChallenge.kt Accelerometer listener + circular progress ring (3 sensitivities)
│ ├── StepChallenge.kt Step-counter Compose UI for the Ultra Hardcore anti-snooze task (v1.4.0)
│ ├── QrChallenge.kt CameraX preview + ML Kit barcode analyzer
│ └── SwipeToSnoozeButton.kt Custom gesture composable with spring-back animation
├── widget/
│ └── NextAlarmWidget.kt Home-screen widget (next alarm time + countdown + days) (v1.6.0)
└── util/
├── AlarmSound.kt Enum of available alarm sounds
├── AlarmSoundGenerator.kt Procedural PCM synthesis for all non-system sounds
├── BatteryOptimizationPermission.kt isIgnoring() check + Settings deep-link Intent (v1.6.0)
├── ChallengeDifficulty.kt Math/shake preset descriptions + shake delta threshold (v1.4.0)
├── ChallengeFlags.kt Bitmask helpers for challenge combinations
├── ExactAlarmPermission.kt canScheduleExactAlarms() check + deep-link Intent (v1.3.0+)
├── FullScreenIntentPermission.kt canUseFullScreenIntent() check + deep-link Intent (v1.6.1)
├── GlobalQrStore.kt SharedPreferences-backed global QR persistence
├── HardcoreAudioGuard.kt Volume clamp + VOLUME_CHANGED receiver for Hardcore Mode
├── Haptics.kt BrutusHaptics wrapper around HapticFeedbackConstants (v1.3.1+)
├── NextAlarmCalculator.kt Finds the soonest trigger across all alarms (for the list header)
├── QrGenerator.kt ZXing wrapper + save + share helpers
├── SoundPreviewPlayer.kt AudioTrack wrapper for in-dialog previews (handles all AlarmSound types)
├── TimerSoundStore.kt SharedPreferences-backed timer-finish sound (v1.5.0)
├── UltraHardcoreStore.kt SharedPreferences-backed follow-up alarm registry (v1.4.0)
└── WorldClockStore.kt SharedPreferences-backed time-zone selection
Resources that matter for the two languages:
app/src/main/res/
├── values/strings.xml English — the default set: ~180 strings, 6 plurals, weekday array
├── values-de/strings.xml German — full translation, identical key set
├── xml/locales_config.xml Declares en + de for the Android 13+ per-app language picker
└── layout/widget_next_alarm.xml Widget layout; its preview texts are resources too
80 Kotlin files, ~12,000 lines — 53 in main, 27 in test.
243 JVM unit tests guard the parts where a bug means someone oversleeps: what actually lands in
AlarmManager, the alarm-time arithmetic, persistence, the completeness of both translations, and
every string the user reads on a clock face. There are no instrumented tests — the whole suite runs
on the JVM in seconds.
| Suite | Tests | What it pins down |
|---|---|---|
scheduler/AlarmSchedulerTest |
23 | the registrations that actually reach AlarmManager: trigger on the configured wall-clock time, passed times roll to tomorrow, weekday matching, sunrise exactly 10 min ahead (and skipped when it would be in the past), setExactAndAllowWhileIdle against Doze, the v1.8.0 stale-sunrise fix, snooze intervals, and the request-code carve-out that keeps main / sunrise / two follow-ups from overwriting each other (Robolectric) |
util/NextAlarmCalculatorTest |
17 | one-shot today vs. tomorrow, repeating wrap-around, weekend selection, formatCountdown |
data/AlarmDaoTest |
15 | real SQL on an in-memory Room database: ordering, getEnabledAlarms for boot recovery, full-field round-trip, REPLACE on conflict, undo-restore via id = 0, repository pass-through (Robolectric) |
ui/screens/ClockFormattingTest |
14 | stopwatch and timer readouts: truncation instead of rounding up, the hour column appearing exactly at the hour, and a constant string width — the premise of the tabular numerals |
util/UltraHardcoreStoreTest |
12 | the follow-up bookkeeping that has to survive a reboot: sequences tracked independently, clearAllFor scoped to one alarm, step-target keys never leaking into the pending list (Robolectric) |
widget/NextAlarmWidgetFormatTest |
12 | the two strings on the home screen in both languages, including singular/plural and the no-rounding-up rule (Robolectric) |
util/NextAlarmCalendarEdgeTest |
11 | daylight saving: 23 real hours between triggers on the short night, 25 on the long one, wall-clock time preserved; the skipped and the duplicated hour; month, year and leap-day rollovers |
util/AlarmSoundTest |
11 | the persisted sound ids as a golden map — renumbering would silently change what existing alarms play — plus the display names in both languages |
ResourceParityTest |
11 | the two languages cannot drift: identical key sets, no blank values, matching format specifiers, complete plurals, seven weekdays each, no German left in the default file, and locales_config.xml in sync with the values-* folders |
BrutusApplicationTest |
8 | notification channels are write-once: importance, DND bypass, silence (Robolectric) |
ui/alarm/MathProblemTest |
8 | answer/display correctness, per-difficulty operand range and sign invariants across 500 samples, operator fallback |
viewmodel/TimerViewModelTest |
8 | countdown/pause math, cancel-undo state machine, an expired timer is deliberately not undoable (Robolectric) |
util/ChallengeFlagsTest |
8 | describe / activeList / has / sanitize bitmask edge cases, incl. the unknown-bit fallback |
util/ChallengeDifficultyTest |
8 | math operand ranges, shake threshold ordering (9 / 12 / 16 m/s²), distinct labels per preset in both languages |
util/AlarmSoundGeneratorTest |
7 | PCM buffer length, peak amplitudes, loop-boundary fade, gentle vs. harsh vs. extreme classification |
util/AlarmSoundGeneratorPropertiesTest |
7 | invariants every synthesized sound must hold — loop length budget, determinism, DC offset, gentle headroom; a new enum entry is covered automatically |
util/PermissionDeepLinkTest |
7 | the three reliability banners land on the right settings page (action + package: URI + NEW_TASK) (Robolectric) |
data/RoomSchemaExportTest |
7 | the running database's identity hash against the committed 7.json — a field added without a migration fails here instead of on a user's device |
LocalizedRuntimeTest |
7 | resolves every declared string through the resource system in both locales and renders a complete alarm card in each |
data/AlarmEntityDefaultsTest |
7 | the constructor defaults, which are a persisted contract |
data/AlarmEntityTest |
7 | timeString padding, repeatDaysString / soundName / challengeName in English and German, weekday bitmask, hardcoreEffective |
util/GlobalQrStoreTest |
6 | the installation's QR code never changes — every printed copy depends on it (Robolectric) |
util/WorldClockStoreTest |
6 | default seeding on first launch, round-trips, an empty list stays empty, blank filtering (Robolectric) |
util/TimerSoundStoreTest |
6 | timer tone persistence, id 0 vs. "unset", corrupt id degrades to the system tone (Robolectric) |
viewmodel/StopwatchViewModelTest |
6 | segment accumulation, laps, reset-undo snapshot semantics |
scheduler/AlarmSchedulerConstantsTest |
4 | Ultra Hardcore offsets, sunrise lead time, intent-extra uniqueness |
./gradlew :app:testDebugUnitTest # all 243
./gradlew :app:testDebugUnitTest --tests '*NextAlarmCalculatorTest'
# HTML report: app/build/reports/tests/testDebugUnitTest/index.htmlTestability by design: both ViewModels take an injectable clock (now: () -> Long,
defaulting to SystemClock::elapsedRealtime), so the timing state machines run
deterministically on the JVM — no Thread.sleep, no flakiness. Calendar-sensitive suites pin
TimeZone.setDefault(Europe/Berlin) rather than trusting the host zone, and the
Android-dependent suites run under Robolectric against real shadows — ShadowAlarmManager
records the actual registrations, Room runs in-memory against the generated implementation. No
mocking framework is used anywhere; the code under test is exercised, not simulated.
| Workflow | Trigger | Does |
|---|---|---|
tests.yml |
push to main, every PR |
JDK 17 + Gradle cache → :app:testDebugUnitTest, uploads the HTML report as an artifact when it fails |
release.yml |
tag v* |
runs the tests, decodes the keystore from RELEASE_STORE_BASE64, builds assembleRelease, renames the APK to brutus-<tag>.apk and attaches it to the GitHub release |
app/src/test/java/com/pepperonas/brutus/
├── BrutusApplicationTest.kt 8 — notification channels: importance, DND bypass, silence
├── LocaleContexts.kt test helper: a Context pinned to en / de
├── LocalizedRuntimeTest.kt 7 — every string resolves in both languages, full card render
├── ResourceParityTest.kt 11 — values/ vs. values-de/: keys, plurals, format specifiers
├── data/
│ ├── AlarmDaoTest.kt 15 — in-memory Room: ordering, enabled filter, round-trip, REPLACE, undo-restore
│ ├── AlarmEntityTest.kt 7 — timeString, repeatDaysString / soundName / challengeName in en + de
│ ├── AlarmEntityDefaultsTest.kt 7 — the persisted constructor defaults
│ └── RoomSchemaExportTest.kt 7 — runtime identity hash vs. committed schema, per-version columns
├── scheduler/
│ ├── AlarmSchedulerTest.kt 23 — what reaches AlarmManager: triggers, sunrise, snooze, follow-up request codes
│ └── AlarmSchedulerConstantsTest.kt 4 — UHC offsets, sunrise lead, intent extra uniqueness
├── ui/
│ ├── alarm/MathProblemTest.kt 8 — answer/display, per-difficulty range + sign invariants (500 samples)
│ └── screens/ClockFormattingTest.kt 14 — stopwatch/timer readouts, hour column, constant string width
├── util/
│ ├── AlarmSoundTest.kt 11 — golden map of the persisted sound ids, names per language
│ ├── AlarmSoundGeneratorTest.kt 7 — PCM length, peak amplitudes, loop-boundary fade, gentle vs harsh
│ ├── AlarmSoundGeneratorPropertiesTest.kt 7 — invariants across every synthesized sound
│ ├── ChallengeFlagsTest.kt 8 — describe / activeList / has / sanitize, unknown-bit fallback
│ ├── ChallengeDifficultyTest.kt 8 — operand ranges, threshold ordering, distinct labels
│ ├── GlobalQrStoreTest.kt 6 — the installation QR code is generated once and never changes
│ ├── NextAlarmCalculatorTest.kt 17 — one-shot today/tomorrow, repeating wrap, weekend selection
│ ├── NextAlarmCalendarEdgeTest.kt 11 — DST nights (23 h / 25 h), skipped + duplicated hour, rollovers
│ ├── PermissionDeepLinkTest.kt 7 — the three reliability banners land on the right settings page
│ ├── TimerSoundStoreTest.kt 6 — timer tone persistence and corrupt-id fallback
│ ├── UltraHardcoreStoreTest.kt 12 — reboot-surviving follow-up bookkeeping
│ └── WorldClockStoreTest.kt 6 — default seeding, round-trips, empty list sticks
├── viewmodel/
│ ├── StopwatchViewModelTest.kt 6 — segment accumulation, laps, reset-undo snapshot semantics
│ └── TimerViewModelTest.kt 8 — countdown/pause math, cancel-undo, finished-not-undoable
└── widget/
└── NextAlarmWidgetFormatTest.kt 12 — the home-screen countdown and repeat-day strip
T − ∞ User creates alarm → AlarmScheduler.schedule()
↓
AlarmManager.setAlarmClock(triggerTime)
↓
T 0s AlarmReceiver.onReceive()
↓
startForegroundService(AlarmService, ACTION_START)
↓
T + 50ms AlarmService.startAlarm()
• Acquire PARTIAL_WAKE_LOCK (10 min)
• Set STREAM_ALARM volume → max (save previous)
• Start vibration pattern
• Load alarm entity from Room (IO thread)
• Play sound via AudioTrack (synthesized) OR MediaPlayer (system)
• Launch AlarmActivity (FLAG_ACTIVITY_NEW_TASK)
• Re-schedule for next occurrence if repeating, else disable
↓
T + 100ms AlarmActivity renders
• setShowWhenLocked / setTurnScreenOn / dismissKeyguard
• AlarmScreen composable reads challengeFlags, math/shake counts, QR
• Iterates active challenges in sequence
↓
User completes all challenges
↓
AlarmActivity.stopAlarm()
• Intent(AlarmService, ACTION_STOP)
• finishAndRemoveTask()
↓
AlarmService.stopAlarm()
• Stop MediaPlayer/AudioTrack, cancel vibration
• Restore original STREAM_ALARM volume
• Release wake lock
• stopForeground + stopSelf
The same flow, but after the user slide-triggers the snooze button:
AlarmActivitysendsACTION_SNOOZEwith the alarm id to the service- Service calls
AlarmScheduler.scheduleSnooze()which registers a freshsetAlarmClock()fornow + snoozeDuration minutes - Current alarm is fully torn down
- The snooze fires exactly like a regular alarm — same challenges, same sound
T 0s Main alarm fires (same flow as a regular alarm)
↓
User completes challenges and dismisses
↓
T + 1s AlarmService.stopAlarm() sees ultraHardcoreMode=true
• AlarmScheduler.scheduleFollowup(seq=1, T+10min)
• AlarmScheduler.scheduleFollowup(seq=2, T+15min)
• UltraHardcoreStore.recordFollowup(...) × 2
• Persistent reminder notification posted (CHANNEL_ULTRA_HARDCORE)
↓
┌── User opens task → walks N steps ──→ both follow-ups cancelled, notification cleared
│
T + 10m AlarmReceiver fires with EXTRA_IS_FOLLOWUP=true, seq=1
• AlarmService skips reschedule logic (one-shot)
• Same challenge chain + Hardcore guard apply
• UltraHardcoreStore.clearFollowup(seq=1) on dismiss
↓
T + 15m AlarmReceiver fires with EXTRA_IS_FOLLOWUP=true, seq=2
• Same flow
• Notification auto-cleared once no follow-up entries remain for this alarmId
BootReceiver listens for both ACTION_BOOT_COMPLETED and ACTION_LOCKED_BOOT_COMPLETED (directBootAware = true). It queries all enabled alarms from Room and calls AlarmScheduler.schedule() on each. Additionally, since v1.4.0, it walks UltraHardcoreStore.listPending() and re-registers any pending follow-up whose triggerAt is still in the future — expired entries get cleaned out.
- Every decision favors waking the user up over UX politeness. If you need a polite alarm, use the system clock.
- Challenges are configurable because brains are different. Some people need math; others just need physical movement. Some need both.
- No account, no network, no tracking. Brutus never touches the internet.
- APK size matters more than we initially thought. v1.2.0 was 35 MB because of bundled ML Kit; v1.3.0 switched to the unbundled variant and turned on R8 minification + resource shrinking, cutting the download by roughly 88 % without losing any functionality. v2.2.0 ships at 4,414,213 bytes (≈ 4.2 MiB) — and that now includes the full Material 3 Expressive theme layer and a second language.
- Procedural audio beats licensed samples. Synthesized sounds mean no copyright issues, no asset loading, no file cache — and the sounds can be tuned to be as nasty as needed.
- Destructive DB migration is acceptable during pre-1.0 development. Once Brutus hits a real release cadence, proper Room migrations will replace the current fallback.
Check that Alarms & reminders is allowed for Brutus in system settings. On Samsung, Xiaomi, and Huawei devices this is frequently denied by default. Also disable battery optimization for Brutus (Settings → Apps → Brutus → Battery → Unrestricted).
Confirm STREAM_ALARM is not muted at the system level (some phones have a dedicated hardware mute for alarms). Try switching the alarm sound to System — if that works, one of the synthesized sounds hit a device-specific AudioTrack bug; please open an issue.
Make sure the printed QR has good lighting and enough contrast. Test mode with the phone camera in realistic conditions first. The scanner only accepts an exact string match — if you regenerate the QR globally or re-install the app, the old printed code becomes invalid.
Force-stop Brutus once via system settings. This is typically an edge case when the service didn't finish cleanly after the alarm was killed by aggressive battery management.
Starting with v1.3.0 Brutus uses proper Room migrations and exports its schemas to app/schemas/. Migration paths from v4 onward preserve user alarms across updates. Pre-v1.0.0 dev versions (1, 2, 3) still fall back to a destructive recreate — anyone on those was a developer-tester anyway.
Planned, no specific timeline:
- Samsung-style multi-tab clock suite (v1.2.0)
- World Clock, Stopwatch, Timer (v1.2.0)
- Hardcore Mode — volume lock + volume-key consumption (v1.2.0)
- Premium monogram app icon (v1.2.0)
- Proper Room migrations + schema export (v1.3.0)
- R8 / ProGuard rules for size-optimized release builds (v1.3.0)
- Unbundled ML Kit Barcode for slim APK (v1.3.0)
- Exact-alarm permission banner with deep link to system settings (v1.3.0)
- Tasteful haptic feedback on key interactions (v1.3.1)
- JUnit test coverage for alarm-time math (v1.3.1)
- GitHub Actions workflow for tests + tagged releases (v1.3.1)
- Ultra Hardcore Mode — two follow-up alarms + step-counter anti-snooze task (v1.4.0)
- Configurable shake sensitivity (v1.4.0)
- Math difficulty presets (easy / hard / brutal) (v1.4.0)
- Gentle alarm sounds + configurable timer finish tone (v1.5.0)
- Sunrise pre-alarm with screen brightness ramp + chime fade-in (v1.6.0)
- Home-screen widget showing next upcoming alarm (v1.6.0)
- Battery-optimization detection banner with deep-link to system whitelist (v1.6.0)
- Pop alarm to the foreground via full-screen-intent banner + hardened activity launch (v1.6.1)
- Redesigned alarm cards — full-width weekday strip + info chips (mode, sunrise, challenge, snooze, sound) (v1.7.0)
- Five extreme alarm sounds — stadium horn, jackhammer, fire alarm, dental drill, banshee (v1.7.0)
- Bug-fix pass:
goAsync()in boot/widget receivers, overlapping-alarm session takeover, stale-sunrise cancel, camera release after QR scan, siren loop click (v1.8.0) - Timer & stopwatch survive tab switches via Activity-scoped ViewModels (v1.8.0)
- GUI polish: delete-all confirmation, one-line weekday picker in the edit sheet, pinned save CTA, 48 dp delete target, TalkBack snooze action, search placeholder (v1.8.0)
- Undo snackbar for deletions (single + delete-all, restores scheduling) (v1.9.0)
- Copy alarms — prefilled "Alarm kopieren" edit sheet per card (v1.9.0)
- Material 3 Expressive redesign — full color-role system (dark + light + Material You opt-in),
MaterialExpressiveThemewith spatial springs, Space Grotesk display type with tabular numerals, tonal card hierarchy, swipe-to-delete, morphing FAB/CTAs, on-screen math keypad, wavy progress rings, day/night world-clock roles, shared-axis tab transitions, reduced-motion support (v2.0.0) - Tabular numerals on the entire type scale — every number in the app ticks like the stopwatch, zero jitter (v2.1.0)
- Undo everywhere — world-clock zone removal (restored in place), stopwatch reset (elapsed + laps), timer abort (resumes with remaining time), on top of the existing alarm-delete undo (v2.1.0)
- Alarm-card color encodes the enabled state — all active alarms share one muted red, disabled ones sink to gray, the next alarm wears a thin primary outline instead of a confusing different fill (v2.1.1)
- ViewModel unit tests — injectable clock, 20 new tests for the stopwatch/timer state machines (incl. undo snapshots) + AlarmEntity helpers, Robolectric for the timer (v2.1.1)
- Per-alarm sound override at runtime
- Multi-QR support (different codes for different alarms)
- Wear OS companion
- English + German localization — every string in
values/+values-de/, per-app language picker on Android 13+ - Localization beyond English and German
- Sleep statistics tab (how often, dismiss latency, snooze rate)
- Backup / restore alarm list as JSON
Contributions welcome on any of these — open an issue first to coordinate.
Martin Pfeffer · celox.io
GitHub: @pepperonas · Email: martin.pfeffer@celox.io
Brutus is part of a family of small, focused Android apps published under pepperonas — all built solo, all offline-first, all opinionated.
If Brutus actually gets you out of bed in the morning, consider buying me a coffee (or a louder alarm) via PayPal:
Donations are never expected — the app is and will stay free, ad-free, and offline. Every contribution funds more brutal alarm experiments.
MIT License
Copyright (c) 2026 pepperonas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
| Component | License |
|---|---|
Space Grotesk — bundled in res/font/ |
SIL Open Font License 1.1 · full text |
| AndroidX · Jetpack Compose · Material 3 · Room · CameraX · DataStore | Apache License 2.0 |
| ZXing Core | Apache License 2.0 |
| Google ML Kit Barcode Scanning (via Play Services) | Android SDK Terms + ML Kit terms |
The bundled font is the only third-party asset shipped inside the APK — every alarm sound is synthesized at runtime, so there is no sample licensing to track.
