Multi-module Gradle project: :app (phone), :wear (watch OS), :shared
(domain models reused on both). Phone has play and fdroid flavors;
:wear is play-only. Localization strings come from the localization/
submodule and are generated via tools/localization/sync_localizations.py
(also wired into the syncLocalizations Gradle task).
For human-facing contributor guidance see CONTRIBUTING.md. This file
exists to surface project-specific invariants AI agents have to know
that aren't obvious from reading the code.
This codebase has shipped two upgrade-crash incidents caused by the
same root pattern. The third one is the one you almost write. Read the
upgrade-safe-persistence skill (.claude/skills/upgrade-safe-persistence/)
before modifying any of:
shared/src/main/java/org/ntust/app/tigerduck/shared/Course.ktapp/src/main/java/org/ntust/app/tigerduck/data/cache/DataCache.ktapp/src/main/java/org/ntust/app/tigerduck/data/DataMigration.ktapp/src/play/java/org/ntust/app/tigerduck/wear/WearScheduleBridge.ktwear/src/main/java/org/ntust/app/tigerduck/wear/data/SchedulePersistence.ktapp/proguard-rules.pro(R8 keep list)- Anything under
network/model/**,data/model/**, or the announcements DTOs listed inproguard-rules.pro
The one-line rule: any new field added to a Gson-deserialized data
class persisted across upgrades must be nullable (String?), a
primitive (Int/Boolean), or accompanied by a DataMigration step
that rewrites old cache files. Non-null Kotlin types with default
values do NOT survive Gson's Unsafe.allocateInstance path — the
default is silently dropped and the field is null at runtime.
Past incidents:
- v1.3.x → v1.4.0:
Coursemoved to:shared, R8 renamed its fields (no keep rule), Gson read cache JSON with un-matching keys → all fields null →CourseDto.<init>NPE inwearBridge.publish()fromTigerDuckApp.onCreate. Hotfix v1.4.1 added the R8 keep rule, the"courseNo":token sentinel inDataCache.load, andDataMigrationstep1 → 2to sweep obfuscated caches. - v1.4.1 → v1.4.2 (caught pre-ship): added
Course.classroomMapJson: String = "{}"(non-null). v1.4.1 caches lack the key → Gson Unsafe path → field null → same NPE shape viaWearScheduleBridge.toDto. Fixed by making the field nullable and coalescing at thetoDtocall site.
- Flavor split is load-bearing.
playuses Firebase + Google Play Services;fdroidcannot. Anything FCM / GMS-flavored lives underapp/src/play/. Don't sprinkle Play-Services imports intomain/. - Localization strings are generated from the
localization/submodule. Edit the JSON in the submodule, notapp/src/main/res/values*/strings.xml(those are regenerated and would be clobbered). Run:app:syncLocalizationsto refresh. name-abbr/submodule must be present —verifyNameAbbrSubmodulefails the build if it's missing. CI checks out submodules explicitly; don't dropsubmodules: truefrom new workflows.- NTUST cert pins have a hard-coded expiry epoch in
app/build.gradle.kts(PIN_EXPIRY_EPOCH).TigerDuckApp.warnIfPinsNearExpirylogs a warning in the 30-day window. Rotate before lapse; post-expiry the platform falls back to system CA trust silently. - No
Co-Authored-By: Claudetrailer on commits — per global user preference. Applies to every commit in this repo, every workflow.
.greptile/rules.md carries project-specific review rules — both
"do not flag" allowlist entries and "please flag" patterns. Read it
before assuming Greptile feedback is universally applicable.