Skip to content

Commit cb4f3cd

Browse files
pengdevgithub-actions[bot]
authored andcommitted
Fix PointAnnotationClusterActivity NPE crash on invalid HTTP response (#10413)
## Summary Fixes: https://mapbox.atlassian.net/browse/MAPSAND-2547 - Fix NPE crash in `PointAnnotationClusterActivity` when the network request for GeoJSON data returns an error response - Add HTTP error checking in `AnnotationUtils.loadStringFromNet()` to return `null` on non-2xx responses instead of passing a null/empty body downstream - Add GeoJSON parse error handling with user-facing `Toast` messages in both the `app` and `compose-app` examples ## Problem Reported via mobot tests on Samsung Galaxy S22, Android 13, v11.19.0-beta.1. The examples app crashes with `NullPointerException: Null type` when tapping "Add Cluster Symbol Annotations". #### Root cause analysis from logcat 1. The ArcGIS URL (`opendata.arcgis.com`) returns a **301 redirect** to `hub.arcgis.com`, which intermittently serves non-GeoJSON content (HTML error pages instead of the expected `.geojson` response). 2. `loadStringFromNet()` **never checks `response.isSuccessful`** — it reads the response body unconditionally, even on 4xx/5xx error codes. The HTML error page is returned as a valid `String`. 3. The HTML string is passed directly to `FeatureCollection.fromJson()`, which throws an NPE because there is no `"type"` field in the response (AutoValue's generated code performs a null check on the required `type` property). 4. Two identical crashes occurred **39 seconds apart** with different PIDs, confirming the endpoint was consistently returning bad content during that time window. 5. The 551 "Unable to make space for entry" cache warning messages visible in the same logcat are **unrelated** — they originate from maps-core's tile cache on a different thread, triggered by an earlier offline download, and are not connected to this crash. ## Solution Check `response.isSuccessful` in `AnnotationUtils` before reading the body, returning `null` early on HTTP errors. In both `PointAnnotationClusterActivity` variants, null-check the result before parsing, wrap JSON parsing in a `try/catch`, and show a `Toast` on failure. The Compose variant also removes the now-unnecessary `suspendCancellableCoroutine`/`ExperimentalCoroutinesApi` wrapper. ## Key Changes - **`AnnotationUtils.loadStringFromNet()`** (`app` + `compose-app`): Check `response.isSuccessful`; log and return `null` on HTTP errors. Replace manual `BufferedReader`/`StringBuilder` with `response.body?.string()`. - **`app/.../markersandcallouts/PointAnnotationClusterActivity`**: Null-check `loadStringFromNet()` result, wrap `FeatureCollection.fromJson()` in `try/catch`, show `Toast` on failure, add `TAG` constant. - **`compose-app/.../annotation/PointAnnotationClusterActivity`**: Same null/parse error handling; refactored `loadData()` from `suspendCancellableCoroutine` to a plain `fun` returning `emptyList()` on failure; removed `@OptIn(ExperimentalCoroutinesApi::class)`. - **Imports**: Replaced wildcard `java.io.*` with explicit imports in both `AnnotationUtils` files. ## Test plan - [x] Manually test `PointAnnotationClusterActivity` in `app` with network available - [x] Manually test `PointAnnotationClusterActivity` in `compose-app` with network available - [x] Manually test with network disabled to confirm `Toast` error message appears cc @mapbox/sdk-platform cc @mapbox/maps-android GitOrigin-RevId: fc93f22b63f964e2375915d626614b00b28efb2e
1 parent 9b23e92 commit cb4f3cd

File tree

5 files changed

+57
-33
lines changed

5 files changed

+57
-33
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ Mapbox welcomes participation and contributions from everyone.
99
## Features ✨ and improvements 🏁
1010
* [compose] Add declarative animation API to experimental `Marker` composable with two animation triggers: `appearAnimation` and `disappearAnimation`. Each trigger accepts a list of `MarkerAnimationEffect` including `wiggle` (pendulum rotation), `scale`, `fadeIn`, and `fadeOut`. Effects can be customized with parameters (e.g., `scale(from = 0.5f, to = 1.5f)`, `fade(from = 0.5f, to = 1.0f)`) and combined for rich animations. See `MarkersActivity` example for usage.
1111

12+
## Bug fixes 🐞
13+
* Fix NPE crash in `PointAnnotationClusterActivity` example when the remote GeoJSON endpoint returns a non-successful HTTP response.
14+
1215
# 11.19.0-rc.1 February 12, 2026
1316

1417
## Features ✨ and improvements 🏁

app/src/main/java/com/mapbox/maps/testapp/examples/annotation/AnnotationUtils.kt

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import kotlinx.coroutines.withContext
1010
import okhttp3.Cache
1111
import okhttp3.OkHttpClient
1212
import okhttp3.Request
13-
import java.io.*
14-
import java.nio.charset.Charset
13+
import java.io.File
14+
import java.io.IOException
1515
import java.util.*
1616

1717
/**
@@ -117,13 +117,12 @@ object AnnotationUtils {
117117

118118
return try {
119119
val response = client.newCall(request).execute()
120-
val inputStream = BufferedInputStream(response.body?.byteStream())
121-
val rd = BufferedReader(InputStreamReader(inputStream, Charset.forName("UTF-8")))
122-
val sb = StringBuilder()
123-
rd.forEachLine {
124-
sb.append(it)
120+
if (!response.isSuccessful) {
121+
logE(TAG, "HTTP error ${response.code} for $url")
122+
response.close()
123+
return null
125124
}
126-
sb.toString()
125+
response.body?.string()
127126
} catch (e: IOException) {
128127
logE(TAG, "Unable to download $url")
129128
null

app/src/main/java/com/mapbox/maps/testapp/examples/markersandcallouts/PointAnnotationClusterActivity.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import com.mapbox.maps.MapboxMap
1212
import com.mapbox.maps.Style
1313
import com.mapbox.maps.extension.style.expressions.dsl.generated.literal
1414
import com.mapbox.maps.extension.style.expressions.generated.Expression.Companion.color
15+
import com.mapbox.maps.logE
1516
import com.mapbox.maps.plugin.annotation.AnnotationConfig
1617
import com.mapbox.maps.plugin.annotation.AnnotationSourceOptions
1718
import com.mapbox.maps.plugin.annotation.ClusterOptions
@@ -141,14 +142,27 @@ class PointAnnotationClusterActivity : AppCompatActivity(), CoroutineScope {
141142
}
142143

143144
private fun loadData() {
144-
AnnotationUtils.loadStringFromNet(this@PointAnnotationClusterActivity, POINTS_URL)?.let {
145-
FeatureCollection.fromJson(it).features()?.let { features ->
145+
val json = AnnotationUtils.loadStringFromNet(this@PointAnnotationClusterActivity, POINTS_URL)
146+
if (json == null) {
147+
runOnUiThread {
148+
Toast.makeText(this@PointAnnotationClusterActivity, "Failed to download data from network", Toast.LENGTH_LONG).show()
149+
binding.progress.visibility = View.GONE
150+
}
151+
return
152+
}
153+
try {
154+
FeatureCollection.fromJson(json).features()?.let { features ->
146155
features.shuffle()
147156
options = features.take(AMOUNT).map { feature ->
148157
PointAnnotationOptions()
149158
.withGeometry((feature.geometry() as Point))
150159
}
151160
}
161+
} catch (e: Exception) {
162+
logE(TAG, "Failed to parse GeoJSON: ${e.message}")
163+
runOnUiThread {
164+
Toast.makeText(this@PointAnnotationClusterActivity, "Failed to parse GeoJSON: ${e.message}", Toast.LENGTH_LONG).show()
165+
}
152166
}
153167
runOnUiThread {
154168
options?.let {
@@ -159,6 +173,7 @@ class PointAnnotationClusterActivity : AppCompatActivity(), CoroutineScope {
159173
}
160174

161175
companion object {
176+
private const val TAG = "PointAnnotationCluster"
162177
private const val AMOUNT = 10000
163178
private const val ICON_FIRE_STATION = "fire-station"
164179
private const val LONGITUDE = -77.00897

compose-app/src/main/java/com/mapbox/maps/compose/testapp/examples/annotation/PointAnnotationClusterActivity.kt

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,12 @@ import com.mapbox.maps.extension.compose.style.standard.ThemeValue
3030
import com.mapbox.maps.extension.compose.style.standard.rememberStandardStyleState
3131
import com.mapbox.maps.extension.style.expressions.dsl.generated.literal
3232
import com.mapbox.maps.extension.style.expressions.generated.Expression
33+
import com.mapbox.maps.logE
3334
import com.mapbox.maps.plugin.annotation.AnnotationConfig
3435
import com.mapbox.maps.plugin.annotation.AnnotationSourceOptions
3536
import com.mapbox.maps.plugin.annotation.ClusterOptions
3637
import com.mapbox.maps.plugin.annotation.generated.PointAnnotationOptions
3738
import kotlinx.coroutines.Dispatchers
38-
import kotlinx.coroutines.ExperimentalCoroutinesApi
39-
import kotlinx.coroutines.suspendCancellableCoroutine
4039
import kotlinx.coroutines.withContext
4140

4241
/**
@@ -152,25 +151,31 @@ public class PointAnnotationClusterActivity : ComponentActivity() {
152151
}
153152
}
154153

155-
@OptIn(ExperimentalCoroutinesApi::class)
156-
private suspend fun loadData(): List<Point> {
157-
return suspendCancellableCoroutine { continuation ->
158-
AnnotationUtils.loadStringFromNet(this@PointAnnotationClusterActivity, POINTS_URL)
159-
?.let {
160-
FeatureCollection.fromJson(it).features()?.let { features ->
161-
features.shuffle()
162-
continuation.resume(
163-
features.take(AMOUNT).map { feature ->
164-
feature.geometry() as Point
165-
},
166-
onCancellation = null
167-
)
168-
}
154+
private fun loadData(): List<Point> {
155+
val json = AnnotationUtils.loadStringFromNet(this@PointAnnotationClusterActivity, POINTS_URL)
156+
if (json == null) {
157+
runOnUiThread {
158+
Toast.makeText(this@PointAnnotationClusterActivity, "Failed to download data from network", Toast.LENGTH_LONG).show()
159+
}
160+
return emptyList()
161+
}
162+
return try {
163+
FeatureCollection.fromJson(json).features()?.let { features ->
164+
features.shuffled().take(AMOUNT).map { feature ->
165+
feature.geometry() as Point
169166
}
167+
} ?: emptyList()
168+
} catch (e: Exception) {
169+
logE(TAG, "Failed to parse GeoJSON: ${e.message}")
170+
runOnUiThread {
171+
Toast.makeText(this@PointAnnotationClusterActivity, "Failed to parse GeoJSON: ${e.message}", Toast.LENGTH_LONG).show()
172+
}
173+
emptyList()
170174
}
171175
}
172176

173177
private companion object {
178+
const val TAG = "PointAnnotationCluster"
174179
const val ZOOM: Double = 10.0
175180
const val AMOUNT = 10000
176181
const val ICON_FIRE_STATION = "fire-station"

compose-app/src/main/java/com/mapbox/maps/compose/testapp/examples/utils/AnnotationUtils.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import com.mapbox.maps.logE
77
import okhttp3.Cache
88
import okhttp3.OkHttpClient
99
import okhttp3.Request
10-
import java.io.*
10+
import java.io.BufferedReader
11+
import java.io.File
12+
import java.io.IOException
13+
import java.io.InputStreamReader
1114
import java.nio.charset.Charset
1215
import java.util.*
1316

@@ -112,13 +115,12 @@ internal object AnnotationUtils {
112115

113116
return try {
114117
val response = client.newCall(request).execute()
115-
val inputStream = BufferedInputStream(response.body?.byteStream())
116-
val rd = BufferedReader(InputStreamReader(inputStream, Charset.forName("UTF-8")))
117-
val sb = StringBuilder()
118-
rd.forEachLine {
119-
sb.append(it)
118+
if (!response.isSuccessful) {
119+
logE(TAG, "HTTP error ${response.code} for $url")
120+
response.close()
121+
return null
120122
}
121-
sb.toString()
123+
response.body?.string()
122124
} catch (e: IOException) {
123125
logE(TAG, "Unable to download $url")
124126
null

0 commit comments

Comments
 (0)