I'm the developer of Zakat, a small offline Android app (Kotlin + Jetpack Compose + Room) that helps Muslims track the zakat they owe each year. Zakat falls due once per lunar year, so the whole app depends on one question: what Gregorian date is "1 Dhul Qi'dah 1448"?
This post covers three things:
- why I answer that question with a bundled lookup table instead of a calendar algorithm;
- two crashes, found in a QA pass and fixed in v1.3, both caused by Hijri month edges;
- the tests that now cover the whole table, not a few sample dates.
Hijri dates in 60 seconds
- The Hijri calendar is lunar. A month has 29 or 30 days, and a year is about 354 days, so a Hijri date moves about 11 days earlier in the Gregorian calendar each year.
- No fixed rule says which months have 29 days and which have 30. That depends on which calendar you follow:
- Arithmetic ("tabular") calendars use a fixed 30-year cycle of leap years. They are easy to compute, but can be a day or two off from what people actually observe.
- Umm al-Qura is Saudi Arabia's official calendar. It is calculated from astronomical data and published as tables.
- Local moon sighting means a mosque or a national authority announces each month when the crescent is seen. Nobody can know it in advance.
- People remember their zakat date as a Hijri anniversary, for example "every 30 Ramadan" or "every 1 Dhul Qi'dah". The app has to turn that into a real day each year, months or years ahead, to send a reminder.
Why not just compute it?
Android does have options: java.time.chrono.HijrahDate, and ICU's IslamicCalendar with civil, tabular and Umm al-Qura variants. I didn't use them, for four reasons:
- Determinism. Which variant you get, and what data backs it, depends on the API, the Android version and the device's ICU data. My
minSdkis 26. I wanted every device to give exactly the same answer. - Testability. The business logic lives in a pure-Kotlin
coremodule with no Android dependency, tested on the JVM. A data file is easy to test there. "Whatever the device's calendar does" is not. - No network, by design. The app has no
INTERNETpermission, so fetching dates from a server was never an option. - Reviewability. A table is data. You can diff it, check any row against an official calendar, and regenerate it.
The rule in the codebase is short: the app never computes a Hijri date at runtime. Every date comes from a table lookup.
The table
A small Python script, run on my machine and never shipped, uses the hijridate package (Umm al-Qura) to write the Gregorian date of the first day of every Hijri month from 1440 to 1500 AH:
{ "schemaVersion": 1, "startYear": 1440, "endYear": 1500, "monthStarts": [ { "y": 1440, "m": 1, "date": "2018-09-11" }, { "y": 1440, "m": 2, "date": "2018-10-10" }, ... { "y": 1501, "m": 1, "date": "2077-11-17" } ] } That is 61 years × 12 months plus one sentinel row: 733 entries, about 52 KB of JSON, covering 11 Sep 2018 to 16 Nov 2077.
Two design choices matter:
1. The table does not store month lengths. A month's length is the gap between its first day and the next month's first day. The table can't disagree with itself, because it only says each fact once. The sentinel row (1 Muharram 1501) exists only so the last real month has a "next month" to measure against.
fun monthLength(year: Int, month: Int): Int { val index = keyIndex[MonthKey(year, month)] ?: throw HijriDateOutOfRangeException("No table entry for hijri $year-$month") if (index + 1 >= sortedKeys.size) { throw HijriDateOutOfRangeException("Hijri $year-$month is the trailing sentinel") } val start = monthStarts.getValue(sortedKeys[index]) val nextStart = monthStarts.getValue(sortedKeys[index + 1]) return ChronoUnit.DAYS.between(start, nextStart).toInt() } fun resolve(date: HijriDate): LocalDate { val length = monthLength(date.year, date.month) if (date.day > length) { throw HijriDateOutOfRangeException( "Hijri ${date.year}-${date.month} has only $length days, got day ${date.day}" ) } return monthStart(date.year, date.month).plusDays((date.day - 1).toLong()) } Across the table there are 389 thirty-day months and 343 twenty-nine-day months. Remember that ratio.
2. The JVM module and the APK read the same file. The JSON lives in core/src/main/resources, and the app module adds that folder as an asset source, so there is one file and no copy to drift:
// app/build.gradle.kts sourceSets { getByName("main") { assets.srcDirs("../core/src/main/resources") } } The reverse lookup (Gregorian → Hijri) is a binary search over the month starts. It is only used once, to suggest the user's first year; after that, each year follows from the previous one.
The honest limit
Umm al-Qura is a calculated calendar. If your mosque announces Ramadan from a local sighting, its first day can differ from the table by a day. So a per-year due-date override always wins over the table:
fun effectiveDueDate(tableComputedDate: LocalDate, override: LocalDate?): LocalDate = override ?: tableComputedDate The app does not try to know better than the user's own community.
Crash 1: one bad date, a crash on every launch
The due-date override was a normal date picker, so nothing stopped a user from picking, say, 27 March 2098, which is outside the table.
-
hijriDateContaining()guarded its range with Kotlin'srequire(...), which throws a plainIllegalArgumentException. - Every caller caught
HijriDateOutOfRangeException, the typed exception, so this one slipped through. - The Dashboard computes the Hijri date of each year when it loads.
The result: the app crashed on every launch, and the only way out was clearing the app's data. That is the worst outcome for an app whose whole job is keeping your records.
The fix had three layers:
- One exception type for one meaning. Every out-of-range path in the table now throws
HijriDateOutOfRangeException. There is no barerequireon any path a caller is expected to handle. - Prevent it at the input. The date picker is limited to the table's real coverage (
minDate/maxDate= 11 Sep 2018 to 16 Nov 2077). - Degrade when the data is already bad. A date stored outside the table by an older version shows "Hijri date unavailable", and the year still opens so the user can fix it.
The fix was tested on a real upgrade path: install the old build, plant an out-of-range date in its database, watch it crash-loop, install the fixed build over it, and check that it opens.
Crash 2: the 30th of a 29-day month
Settings store the anniversary as a (month, day) pair, for example 30 Ramadan. When the user starts a new zakat year, the app resolves that pair in the new Hijri year.
In the Umm al-Qura table, Ramadan 1448 (starting 8 Feb 2027) has 29 days, and so does Dhul Qi'dah 1448. resolve(HijriDate(1448, 9, 30)) correctly threw, because that day does not exist. But it threw inside a viewModelScope.launch with no handler, so the process died.
The bug was not in the table. It was a mix-up between two meanings:
- An exact date ("the 30th of this month"): if the day doesn't exist, the input is invalid. Throwing is correct.
- An anniversary ("my zakat is due on 30 Ramadan every year"): in a 29-day Ramadan, the user means the last day of Ramadan. Clamping is correct.
So now there are two functions, and the name tells you which one you're calling:
/** An annual anniversary: day 30 in a 29-day month resolves to day 29. */ fun resolveAnniversary(year: Int, month: Int, day: Int): LocalDate { val clampedDay = day.coerceAtMost(monthLength(year, month)) return resolve(HijriDate(year, month, clampedDay)) } The ViewModel also stopped trusting that nothing can go wrong:
viewModelScope.launch { val result = try { repository.startYear(hijriYear)?.let { StartYearResult.Created(it) } ?: StartYearResult.AlreadyExists } catch (e: CancellationException) { throw e // never swallow coroutine cancellation } catch (e: HijriDateOutOfRangeException) { StartYearResult.OutOfRange } catch (e: Exception) { StartYearResult.Failed } onResult(result) } Note the CancellationException rethrow. A blanket catch (e: Exception) in a coroutine also catches cancellation, which quietly breaks structured concurrency.
The tests that would have caught both
Before these fixes, my tests checked a set of anchor dates: 1 Dhul Qi'dah of every year, plus a few others, against the spec. They were all correct, and they still missed both bugs. Anchors prove the data is right. They say nothing about the edges.
The table is finite: 732 real months. When your domain is that small, test all of it instead of samples. These run in milliseconds on the JVM:
@Test fun `coverage bounds are the first and last real days, excluding the sentinel`() { assertEquals(1440..1500, table.supportedYears) assertEquals(LocalDate.of(2018, 9, 11), table.firstCoveredDate) assertEquals(LocalDate.of(2077, 11, 16), table.lastCoveredDate) } @Test fun `day 30 in a 29-day month clamps to day 29`() { assertEquals(29, table.monthLength(1448, 9)) assertEquals(table.resolve(HijriDate(1448, 9, 29)), table.resolveAnniversary(1448, 9, 30)) } @Test fun `an anniversary outside the supported years still throws the typed exception`() { assertThrows(HijriDateOutOfRangeException::class.java) { table.resolveAnniversary(1501, 11, 1) } assertThrows(HijriDateOutOfRangeException::class.java) { table.resolveAnniversary(1439, 11, 1) } } And the one that walks the entire table. For every month of every year, a day-30 anniversary must land inside coverage, and on that same month's last day:
@Test fun `every month of every supported year resolves day 30 inside coverage and in the same month`() { for (y in table.supportedYears) for (m in 1..12) { val d = table.resolveAnniversary(y, m, 30) assertTrue("$y-$m-30 -> $d", table.covers(d)) val back = table.hijriDateContaining(d) assertEquals("$y-$m", HijriDate(y, m, table.monthLength(y, m)), back) } } A last test checks that covers() and hijriDateContaining() agree at both edges of the table. Neither bug can come back without a red build.
The broader lesson: every "this can't happen" in date code is a month with 29 days that you haven't met yet. Both crashes were in code with tests. They just weren't the right tests.
Disclosure: I built this app. It's free, with no ads, no account and no analytics, and it's in English, French and Arabic (full RTL): Zakat on Google Play. It's a calculation aid, not a fatwa.
A question for you: if you've shipped anything with Hijri dates, how did you handle the gap between Umm al-Qura and local moon sighting? I'd love to hear how others approach it.
