Imported from katsugtgz/shollu-android (
app/src/main/java/com/ebsoft/shollu/data/AGENTS.md). Install upstream withnpx skills add katsugtgz/shollu-android --skill data. Copyright stays with the author.
data/ — persistence, calculation caching, city seed, clock seam
OVERVIEW
Room DB + DataStore prefs + repositories (prayer calc cache, city seed) + pure domain models; no DI — classes are concrete, seams are constructor params (AppClock, SholluPreferences).
WHERE TO LOOK
- Seeding / preset defaults →
db/SholluDatabase.kt(defaultPresets(),seedPlan(),ensureDefaultPresets()) - Reminder schedule encoding (days/hour-minute ranges, type enum) →
db/entity/ReminderEntity.kt - Enum↔String persistence fallbacks →
db/Converters.kt - City listing/search SQL ordering →
db/dao/CityDao.kt; reminder list order →db/dao/ReminderDao.kt - Every pref key + safe default →
preferences/SholluPreferences.kt(companion) - Prayer-time caching / midnight rollover →
repository/PrayerRepository.kt,repository/AppClock.kt - City table bootstrap (raw JSON + fallback list) →
repository/CityRepository.kt - Polar validity / next-prayer math →
model/PrayerTimes.kt; method angles →model/CalculationMethod.kt - APK self-update files →
update/AppUpdater.kt,update/UpdatePolicy.kt,update/GitHubReleaseClient.kt,update/PreferenceUpdateStore.kt,update/ApkUpdateInstaller.kt,update/ApkVerifier.kt,update/UpdateModule.kt
CONVENTIONS
SholluDatabase.seedPlan(seededMarker, existing, catalogGeneration)is the pure idempotency core;ensureDefaultPresets()is the impure wrapper (mutex + marker + generation). Change seeding logic inseedPlan, not the wrapper.- Seeded-once marker is
SholluPreferences.DEFAULT_PRESETS_SEEDED; catalog gen isPRESET_CATALOG_GENERATION. Missing gen on a seeded install = generation 1 (four-row catalog) so generation 2 (Ayyamul Bidh + malam-sebelumnya) can still insert. After generation 2, user-deleted rows stay gone. Empty table + seeded marker never inserts. Failed table read → abort, marker/gen unchanged. ReminderEntityvalidates ininit {}viarequire():timeHour 0..23,timeMinute 0..59,preWarningMinutes >= 0. Constructing with bad values throws — copy-with-edit pattern is the only safe mutation.ReminderType(5 presets + CUSTOM) andDaysOfWeeklive inReminderEntity.kt, not separate files.DaysOfWeekis a value object overrawValue: String—"*"(everyday),"ONCE", or CSV"1,4"(1=Mon…7=Sun; out-of-range dropped indaysSet).model/Reminder.ktistypealias Reminder = ReminderEntity— one shape everywhere, no mapper.- Type converters never throw: null/blank/unknown →
CUSTOM/"*"(viafromStringcompanions). CityDao.getAllCities()orderscountry = 'Indonesia' DESC, name ASC— Indonesia-first is SQL, not UI. City search is client-side:filterCitiesinui/screens/settings/LocationPickerDialog.ktANDs whitespace-split terms againstnameandprovince(Locale.ROOT, case-insensitive); there is no SQL LIKE path.ReminderDao.getAllReminders()orders bytimeHour, timeMinute— UI timeline depends on this order.SholluPreferences: every read issafeDataStore.mapDistinct { prefs[KEY] ?: default }(map+distinctUntilChanged). DataStore emits the full snapshot on any key write; without distinct, unrelated toggles retrigger every mapped Flow. Defaults: Jakarta (-6.2088, 106.8456, elev 8.0, tz 7.0),KEMENAG_RI, asrSTANDARD, ihtiyat 2, hijri 0, pre-prayer 10, iqomah 10, themeEMERALD, langINDONESIAN, per-prayer offsets 0. Enum reads wrapvalueOfin try/catch → default enum.IOExceptionon DataStore read →emit(emptyPreferences())(defaults surface); other exceptions rethrow. Corruption →ReplaceFileCorruptionHandlerresets to empty.SELECTED_CITY_IS_GPS: true only when city came from GPS; stored tz is a DST snapshot re-derived onACTION_TIMEZONE_CHANGED.updateCity(city, isGps=false)— picking from the fixed list clears it.PrayerRepositorycache is access-orderLinkedHashMap<PrayerCalculationKey, PrayerTimes>guarded bycacheLock(not concurrent; mutate only under the lock); key = date + full city (lat/lon/elev/tz) + method + juristic + ihtiyat + offsets map. Any new calc input MUST join the key or stale results leak. Cap isMAX_CALCULATION_CACHE(400):removeEldestEntrydrops the least-recently-accessed key on insert over cap — never wipe the whole map. Hits go throughgetso they refresh LRU order.clearCache()takes the same lock (tests).calculateForDateSync()=runBlocking(Dispatchers.IO); any exception → hardcoded Jakarta/KEMENAG_RI/ihtiyat-2 fallback. Never call from a coroutine (blocks a thread).CityRepository.initializeCitiesIfNeeded()seeds fromR.raw.cities(Gson →List<CityEntity>) when count==0; parse failure/empty → 14 hardcoded cities (12 Indonesia + Makkah + Madinah). ReturnsResult<Unit>. This class has NO interface — unlike Prayer/Reminder repos.AppClockseam + internaldatePulseFlow(clock, pollIntervalMillis): wakes at midnight+50ms, capped at one poll interval (30s default) so wall-clock/timezone jumps re-emit. Value always fromclock, delay only sets cadence.
APK self-update
Done when check, cache, and install all hold:
- Check:
AppUpdater.check()from MainActivityLaunchedEffectonly. Play-installed copies stay Quiet. Tests injectReleaseFetcher+UpdateStorefakes. - Throttle: stamp last-check on failed fetch without rewriting the APK cache. A throttled check still prompts from cache.
- Snooze:
Nantistores tag + expiry (now + 24h). Same tag re-prompts after expiry; a newer tag prompts immediately. - Digest: persist tag+url+size even with blank digest. Verify digest at install (
ApkVerifier). - Gson DTOs in
GitHubReleaseClientneed@SerializedNameon every JSON field plus the ProGuard keep rules — R8 rename otherwise dropsassetsand release APKs never prompt. InstallResult.Starteddismisses the Compose dialog; system PackageInstaller UI owns confirmation after that.ApkUpdateInstallersingle-flights downloads and abandons uncommitted sessions.
ANTI-PATTERNS
- Don't reseed by checking table emptiness alone — user deleting all presets would get them back. Marker decides.
- Don't bypass
seedMutexby calling DAO inserts directly during startup; onCreate callback and app-start path race. - Don't read DataStore via
context.dataStore.datadirectly — skips the IOException recovery; usesafeDataStoreflows. - Don't add a prayer-calculation input without extending
PrayerCalculationKey. - Don't schedule on a
PrayerTimeswhoseisSubuhValid/isIsyaValidis false — times are clamped placeholders at high latitude. - Don't reorder
CityDao/ReminderDaoORDER BY casually; UI order and "next reminder" logic assume them.
NOTES
- DB is version 1,
fallbackToDestructiveMigration()(no migrations written; a schema bump wipes user reminders — prefs survive in DataStore). defaultPresets()seeds 6 rows: Al-Kahfi, Dhuha, two SENIN_KAMIS (sahur 03:30 Mon/Thu + malam sebelumnya 20:00 Sun/Wed), AYYAMUL_BIDH (Hijri 13–15 vianextPresetInstant+ userhijriAdjustment), TAHAJJUD enabled (Subuh−45). Catalog generation 2 upgrades seeded v1 installs; unseeded non-empty tables are never treated as a gen-2 upgrade.- All six ship
isEnabled = trueand must be seeded beforeReminderAlarmSchedulerarms alarms. PrayerTimes.getNextPrayerTarget(now, tomorrow)is the single next-prayer selector (polar-aware). Pass the real next-day instance for the correct post-Isya rollover time — omitting it reuses today's schedule for tomorrow's dawn.CalculationMethod: 10 methods; UMM_AL_QURA + QATAR useishaIntervalMin=90withishaAngle=0;defaultIhtiyatMinis per-method (KEMENAG_RI 2, MUIS 1, rest 0).