Summary
SiteRestClient.siteResponseToSiteModel() parses blogging_prompts_settings from the WP.com sites response and dereferences two sub-fields without a null guard. If the server returns blogging_prompts_settings as a non-null object but with reminders_days or reminders_time null/absent, the parse throws an uncaught NullPointerException and the entire site fetch fails.
- Both fields are plain unannotated Java fields (Kotlin platform types), so the compiler inserts no caller-side null check — same mechanism as the null-
URL crash currently live in production (Sentry JETPACK-ANDROID-1JAS).
- Latent today — no production occurrences yet — but reachable:
blogging_prompts_settings ships under options, which is in the fields= allowlist, and there is zero test coverage of this path.
Root cause
In libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteRestClient.kt, inside siteResponseToSiteModel() (the from.options.blogging_prompts_settings?.let { ... } block, ~L1131–1150). The ?.let guards the parent object being null; it does nothing for the sub-fields:
1. reminders_days — null Map dereference (~L1137–1143) ❌
site.setIsBloggingReminderOnMonday(it.reminders_days["monday"] ?: false)
// ... through Sunday
it.reminders_days["monday"] desugars to it.reminders_days.get("monday"). The ?: false operates on the return value of get, not the receiver — so a null reminders_days map NPEs on the .get call. These seven lines are not inside any try/catch.
reminders_days is declared public Map<String, Boolean> reminders_days; (SiteWPComRestResponse.java:107) — no annotation, no @SerializedName, no custom deserializer. Stock Gson leaves it null when the key is absent or its value is JSON null.
2. reminders_time — null String dereference (~L1144–1149) ❌
try {
site.bloggingReminderHour = it.reminders_time.split(".")[0].toInt()
site.bloggingReminderMinute = it.reminders_time.split(".")[1].toInt()
} catch (ex: NumberFormatException) {
AppLog.e(API, "Received malformed blogging reminder time: " + ex.message)
}
it.reminders_time.split(".") throws NPE on a null reminders_time. The surrounding try catches only NumberFormatException — NullPointerException is not a subclass of it (NFE → IllegalArgumentException; NPE → RuntimeException), so the NPE escapes the catch. reminders_time is public String reminders_time; (SiteWPComRestResponse.java:108), same platform-type/Gson story.
The crash propagates uncaught out of siteResponseToSiteModel() to its callers (fetchSite, fetchSites, and the site-feature path).
Why it can happen
The "the server always sends a complete blogging_prompts_settings object" assumption is unverified and unenforced — no @NonNull, no test, no fixture. We have direct, recent production proof that this exact endpoint violates field-presence assumptions: site.url = from.URL reads an identically-unannotated field and just started returning null in production (Sentry JETPACK-ANDROID-1JAS, ~1,300 crashes / ~180 users in the first ~7.5h). The method itself already distrusts this object's contents (the NumberFormatException catch on reminders_time) — it just doesn't guard the fields' presence. /me/sites/ and /sites/$site/ are also documented in-method as returning different shapes for the same fields.
Evidence
Adversarially validated by three independent reviewers; two reproduced the exact exceptions with a standalone kotlinc repro of the platform-type + deref pattern:
reminders_days null → java.lang.NullPointerException: Cannot invoke "java.util.Map.get(Object)" because "it.reminders_days" is null
reminders_time null → java.lang.NullPointerException: reminders_time must not be null (Kotlin Intrinsics.checkNotNullExpressionValue emitted before split)
Bytecode confirmed the ?: false Elvis sits on the get result, after the null receiver has already been invoked.
Fix
Null-safe both dereferences (the surrounding code already treats this object as untrusted):
// reminders_days — all seven day lines:
site.setIsBloggingReminderOnMonday(it.reminders_days?.get("monday") ?: false)
// reminders_time — safe-call before split (or broaden the catch to cover NPE/IndexOutOfBounds):
it.reminders_time?.split(".")?.let { parts ->
try {
site.bloggingReminderHour = parts[0].toInt()
site.bloggingReminderMinute = parts[1].toInt()
} catch (ex: NumberFormatException) {
AppLog.e(API, "Received malformed blogging reminder time: " + ex.message)
}
}
A regression test that feeds siteResponseToSiteModel a blogging_prompts_settings with each sub-field null would lock this in — the path currently has no coverage.
Related
- Live null-
URL incident (same root pattern, different field): Sentry JETPACK-ANDROID-1JAS. The URL case is being addressed server-side (it's the only field that crashes the java.net.URI parser); these two are the remaining client-side null-deref siblings in the same parse method.
Found while triaging the null-URL crash.
Summary
SiteRestClient.siteResponseToSiteModel()parsesblogging_prompts_settingsfrom the WP.com sites response and dereferences two sub-fields without a null guard. If the server returnsblogging_prompts_settingsas a non-null object but withreminders_daysorreminders_timenull/absent, the parse throws an uncaughtNullPointerExceptionand the entire site fetch fails.URLcrash currently live in production (SentryJETPACK-ANDROID-1JAS).blogging_prompts_settingsships underoptions, which is in thefields=allowlist, and there is zero test coverage of this path.Root cause
In
libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteRestClient.kt, insidesiteResponseToSiteModel()(thefrom.options.blogging_prompts_settings?.let { ... }block, ~L1131–1150). The?.letguards the parent object being null; it does nothing for the sub-fields:1.
reminders_days— null Map dereference (~L1137–1143) ❌it.reminders_days["monday"]desugars toit.reminders_days.get("monday"). The?: falseoperates on the return value ofget, not the receiver — so a nullreminders_daysmap NPEs on the.getcall. These seven lines are not inside any try/catch.reminders_daysis declaredpublic Map<String, Boolean> reminders_days;(SiteWPComRestResponse.java:107) — no annotation, no@SerializedName, no custom deserializer. Stock Gson leaves it null when the key is absent or its value is JSONnull.2.
reminders_time— null String dereference (~L1144–1149) ❌it.reminders_time.split(".")throws NPE on a nullreminders_time. The surroundingtrycatches onlyNumberFormatException—NullPointerExceptionis not a subclass of it (NFE → IllegalArgumentException; NPE → RuntimeException), so the NPE escapes the catch.reminders_timeispublic String reminders_time;(SiteWPComRestResponse.java:108), same platform-type/Gson story.The crash propagates uncaught out of
siteResponseToSiteModel()to its callers (fetchSite,fetchSites, and the site-feature path).Why it can happen
The "the server always sends a complete
blogging_prompts_settingsobject" assumption is unverified and unenforced — no@NonNull, no test, no fixture. We have direct, recent production proof that this exact endpoint violates field-presence assumptions:site.url = from.URLreads an identically-unannotated field and just started returning null in production (SentryJETPACK-ANDROID-1JAS, ~1,300 crashes / ~180 users in the first ~7.5h). The method itself already distrusts this object's contents (theNumberFormatExceptioncatch onreminders_time) — it just doesn't guard the fields' presence./me/sites/and/sites/$site/are also documented in-method as returning different shapes for the same fields.Evidence
Adversarially validated by three independent reviewers; two reproduced the exact exceptions with a standalone
kotlincrepro of the platform-type + deref pattern:reminders_daysnull →java.lang.NullPointerException: Cannot invoke "java.util.Map.get(Object)" because "it.reminders_days" is nullreminders_timenull →java.lang.NullPointerException: reminders_time must not be null(KotlinIntrinsics.checkNotNullExpressionValueemitted beforesplit)Bytecode confirmed the
?: falseElvis sits on thegetresult, after the null receiver has already been invoked.Fix
Null-safe both dereferences (the surrounding code already treats this object as untrusted):
A regression test that feeds
siteResponseToSiteModelablogging_prompts_settingswith each sub-field null would lock this in — the path currently has no coverage.Related
URLincident (same root pattern, different field): SentryJETPACK-ANDROID-1JAS. TheURLcase is being addressed server-side (it's the only field that crashes thejava.net.URIparser); these two are the remaining client-side null-deref siblings in the same parse method.Found while triaging the null-
URLcrash.