I build BetterMuslim, a free Islamic habit app for iOS and Android. Fajr, the dawn prayer, is the one people miss most, and most of the time it isn't a lack of will, it's the snooze button. So I built an alarm that rings at Fajr and keeps coming back until you show you're up: you read a short du'a aloud, or you take a photo of your prayer mat.
This is how it works under the hood, and what broke on real phones.
A real alarm on iOS: AlarmKit
Before iOS 26 an app could only send notifications. They don't ring with the silent switch on, and one notification is one short sound. iOS 26 added AlarmKit, which lets an app book real system alarms that ring on silent, like the Clock app.
Booking one looks like this (trimmed):
let alert = AlarmPresentation.Alert( title: "Fajr", stopButton: stop, secondaryButton: wake, // "I'm up" secondaryButtonBehavior: .custom ) let configuration = AlarmManager.AlarmConfiguration<FajrAlarmMetadata>( countdownDuration: nil, schedule: .fixed(date), attributes: AlarmAttributes(presentation: AlarmPresentation(alert: alert), tintColor: gold), stopIntent: FajrStopIntent(alarmID: id.uuidString), secondaryIntent: FajrWakeIntent(alarmID: id.uuidString), sound: sound ) try await AlarmManager.shared.schedule(id: id, configuration: configuration) The sound is one ayah from the reciter the person picked in the app. AlarmKit plays sounds by name from Library/Sounds, it doesn't take MP3, and past about 30 seconds it falls back to its own tone. So the app downloads the recitation, decodes it to 16-bit PCM, writes a CAF file and cuts it at 29 seconds.
The Stop button problem
AlarmKit always shows a Stop button and you can't hide it. You also can't count on the Stop intent running: in my testing it was skipped when the alarm was dismissed with the phone unlocked, and on the simulator App Intents couldn't find my intent types at all.
So I stopped relying on Stop. Each Fajr alarm is booked as a first ring plus a run of backup rings. Stop silences one ring, and the next one is already booked. Only the proof (the du'a or the photo) cancels the rest.
private static func bookWindow(at date: Date, into booked: inout [String: Double]) async throws { for k in 0...FajrWakeState.backups { let fire = date.addingTimeInterval(Double(k) * FajrWakeState.backupGap) let id = UUID() try await book(id: id, at: fire) booked[id.uuidString] = fire.timeIntervalSince1970 FajrWakeState.booked = booked // saved after each booking, not at the end } } What I learned here:
- Save every alarm id natively (UserDefaults) as soon as AlarmKit has it, not at the end and not only in JS. If a booking throws halfway, the next re-booking has to know which alarms are live.
- On every re-booking, cancel any alarm AlarmKit holds that isn't in your list. An id lost from an older build or a crash mid-booking would otherwise ring forever.
- Book a few days ahead and re-book on every app open and in background refresh. Prayer times move every day, and one Fajr is 31 system alarms.
- Spread the backups over the whole time the app still asks for the proof. My first version rang every 30 seconds for 20 rings. That stopped after 10 minutes while the app still wanted the proof, so someone who tapped "I'm up" on a locked phone and fell back asleep heard nothing more.
"I'm up" has to stop the ring itself
The second button is a .custom button that opens the app. A custom button doesn't stop the alarm by itself. When I left it alerting, the ring went quiet behind the app, held back the backups, and when the phone was locked again they all came at once. The fix is to stop that one ring in the intent before the app opens:
struct FajrWakeIntent: LiveActivityIntent { static var title: LocalizedStringResource = "Wake up for Fajr" static var openAppWhenRun = true @Parameter(title: "Alarm ID") var alarmID: String func perform() async throws -> some IntentResult { if let id = UUID(uuidString: alarmID) { try? AlarmManager.shared.stop(id: id) } NotificationCenter.default.post(name: .fajrWakeOpened, object: nil) return .result() } } While the proof screen is open, the app also holds off the next backups for a moment, so a ring doesn't take the microphone in the middle of the du'a. If you put the phone down, the later ones still come.
Proof 1: reading the du'a aloud
The default proof is a du'a the Prophet ﷺ said when he woke up:
الْحَمْدُ لِلَّهِ الَّذِي أَحْيَانَا بَعْدَ مَا أَمَاتَنَا وَإِلَيْهِ النُّشُورُ
"All praise is for Allah, who gave us life after He caused us to die, and to Him is the return." (Sahih al-Bukhari 6312)
On iOS the app listens with SFSpeechRecognizer in Arabic, on-device when the phone supports it. On Android it's SpeechRecognizer with ar-SA, restarted after each pause with the text kept.
Recognisers drop the harakat, merge short words and swap letter forms, so comparing the full text never works. Each text has a few key words stored as normalised stems, and you need most of them (4 of 5 for this du'a). Everything the recogniser writes goes through this first:
export function normalizeArabic(text: string): string { return text .replace(/[ً-ٰٟۖ-ۭـ]/g, '') // harakat, Quranic marks, tatweel .replace(/[أإآٱ]/g, 'ا') .replace(/ى/g, 'ي') .replace(/ة/g, 'ه') .replace(/ؤ/g, 'و') .replace(/ئ/g, 'ي'); } As you read, the words light up on screen, so you can see it's following you.
In the first ten days about 1 in 4 du'a tries never passed. Most of what's below came from that:
- My first Android version only measured seconds of voice, so saying anything passed. Android checks the words now too.
- Some voices the recogniser just doesn't get: an accent, a whisper at dawn, no on-device model. One person read for 70 seconds and never got through. Now, if you've read aloud for twice the time the text takes and at least two of its words were heard, it counts.
- With no recogniser at all (no network at dawn and no on-device model), the app falls back to the voice itself and counts the seconds you're speaking. The voice threshold follows the room's own level, because people read softly at dawn so they don't wake the house. A fixed threshold missed them.
- Android's speech service sometimes disconnects mid-read (error 11). That needs a quiet retry, not a failure.
Proof 2: a photo of your prayer mat
The other proof is a photo of your prayer mat. There's no setup photo to compare against: a model on the phone judges any photo, and the photo never leaves the phone.
iOS: the image encoder of Apple's MobileCLIP S0, converted to Core ML with 8-bit palettized weights. It's not in the app bundle. It downloads (11 MB) the first time someone picks the photo proof and gets compiled on the phone. Offline, I embedded short text prompts with the matching text encoder: "a photo of a prayer rug", "a photo of feet standing on a prayer rug" and a few more on one side, "a photo of a bed", "a photo of a wall", "a photo of a kitchen" and so on on the other. The photo's embedding is compared with all of them using CLIP's logit scale, and the score is the share that goes to the prayer mat prompts:
let matScores = mat.map(similarity), otherScores = other.map(similarity) let top = max(matScores.max() ?? 0, otherScores.max() ?? 0) // CLIP's logit scale (100), shifted by the top score so exp() can't overflow let weight = { (score: Float) in Double(exp(100 * (score - top))) } let matWeight = matScores.map(weight).reduce(0, +) let total = matWeight + otherScores.map(weight).reduce(0, +) return ["mat": matWeight / total, "light": brightness(image)] On 30 prayer rug photos and 30 others, the rugs scored 0.75 to 1.00, and beds, kitchens, bathrooms and walls 0.1 or less. The pass mark is 0.45. A patterned carpet can pass, and that's fine: you're up and standing at a rug.
Android: ML Kit's own image labels have no rug or mat, only things like "Textile" and "Pattern". But ImageNet has class 741, "prayer rug, prayer mat". So Android runs EfficientNet-Lite0 (int8, about 5 MB, in the app's assets) through ML Kit's custom image labeler and reads that one class. On 125 photos, prayer mats scored 0.6 to 0.96, and bedrooms, towels and blankets under 0.25. The pass mark there is 0.3.
Both also return the photo's mean brightness, so a failed photo in a dark room gets "turn on a light" instead of "that's not a prayer mat".
Phones without AlarmKit
Android and iPhones before iOS 26 get a run of loud notifications instead: close together at first, then spread out. On Android they play on the alarm stream, so they ring on silent. On older iPhones it's weaker: they're time-sensitive notifications that don't ring with the silent switch on, and iOS only keeps 64 pending notifications per app, shared with the prayer reminders, so there are fewer of them.
What I'd tell anyone building this
- Plan for Stop being pressed. Don't try to fight it.
- Keep the alarm state in native storage. When an alarm opens your app at 5 am, your JS might not be running yet.
- Test on a real phone. The simulator crashed on a custom alarm sound and couldn't find the App Intents.
- Look at what happens on real mornings. The read-twice rule and the room-level threshold both came from failed tries in the first week, not from my own testing.
The alarm is part of BetterMuslim, a free app for prayer, Quran, du'as and daily habits, with no ads and no subscription. The alarm is the last step though. What actually gets you up is the night before, and I wrote that part down here: How to pray 5 times a day consistently.
If you're building with AlarmKit and ran into something else, tell me in the comments.
