mapbox / mapbox/mapbox-maps-android

Blurry tiles in offline mode despite downloading multiple pixelRatios (1.0, 2.0, 3.0)

Open
#2,712 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug :beetle:
Dominant language
Kotlin
Stars
578
Forks
161
PR merge metrics
No merged PRs in 30d

Description

## Environment

- Android OS version: 13 TKQ1.221114.001
- Devices affected: RedMi Note 11 Pro 5G
- Maps SDK Version: com.mapbox.maps:android-ndk27:11.16.2

## Observed behavior and steps to reproduce

1. Implement an offline map download using offlineManager.createTilesetDescriptor for multiple pixel ratios: listOf(1.0f, 2.0f, 3.0f).
2. Ensure that StylePack is fully downloaded using offlineManager.loadStylePack and waiting for completion via a suspendCancellableCoroutine.
3. Call tileStore.loadTileRegion with the created descriptors (minZoom 6, maxZoom 23) for a specific geometry.
4. Verify that the download is successful (logs show completedResourceCount matches requiredResourceCount and size is several MBs).
5. Disconnect the device from the internet (Airplane mode).
6. Open the map at the downloaded location and zoom in to level 6-23.
Actual result: likely several zoom levels lower than the current view, upscaled.
7. Reconnect the internet.
Observation: The map immediately refreshes and displays sharp, high-resolution tiles. This suggests the renderer is bypassing the TileStore while offline or failing to find the correct pixelRatio match in the local database.

## Expected behavior

The MapView should use the high-resolution tiles already stored in the TileStore when the device is offline, providing the same visual quality as the online mode for the downloaded zoom levels.

## Notes / preliminary analysis

I have implemented a robust download logic that covers all standard pixelRatio values (1.0, 2.0, 3.0) to avoid common lookup mismatches. Even though the TileStore confirms the presence of the data, the offline renderer does not seem to "see" it.

Code snippet of my descriptor logic:
```kotlin
suspend fun ensureStylePackDownloaded(styleUri: String) {
try {
val stylePackOptions = StylePackLoadOptions.Builder()
.glyphsRasterizationMode(GlyphsRasterizationMode.IDEOGRAPHS_RASTERIZED_LOCALLY)
.metadata(Value("stylepack-for-$styleUri"))
.acceptExpired(true)
.build()

AppLogger.d(TAG, "StylePack: starting download for $styleUri (will WAIT for completion)")

suspendCancellableCoroutine { cont ->
offlineManager.loadStylePack(
styleUri,
stylePackOptions,
{ progress ->
AppLogger.d(TAG, "StylePack progress for $styleUri: $progress")
},
{ expected ->
if (expected.isValue) {
AppLogger.d(TAG, "StylePack DONE for $styleUri: ${expected.value}")
} else {
AppLogger.e(TAG, "StylePack ERROR for $styleUri: ${expected.error}")
}
if (cont.isActive) cont.resume(Unit)
}
)
}

AppLogger.d(TAG, "StylePack: finished, proceeding to tile download")
} catch (e: Throwable) {
AppLogger.w(TAG, "StylePack download failed (non-fatal): ${e.message}")
}
}

fun downloadRegion(
context: Context,
region: OfflineRegion,
styleUri: String,
zoomLevels: List,
mapPixelRatio: Float,
downloadingRegions: Set,
onDownloadingRegionsUpdate: (Set) -> Unit,
onProgress: (String, Float) -> Unit,
onCompletion: (String, Result) -> Unit
) {
if (downloadingRegions.contains(region.id) || activeDownloadJobs.containsKey(region.id)) {
AppLogger.w(TAG, "Region ${region.id} is already downloading; skipping")
return
}
if (zoomLevels.isEmpty()) {
onCompletion(region.id, Result.failure(Exception("No zoom levels selected")))
return
}
if (styleUri.isBlank()) {
onCompletion(region.id, Result.failure(Exception("Style URI cannot be empty")))
return
}

val sortedZooms = zoomLevels.sorted()
val minZoom = sortedZooms.first()
val maxZoom = sortedZooms.last()

val pixelRatios = listOf(1.0f, 2.0f, 3.0f)
val descriptors = pixelRatios.map { ratio ->
offlineManager.createTilesetDescriptor(
TilesetDescriptorOptions.Builder()
.styleURI(styleUri)
.minZoom(minZoom)
.maxZoom(maxZoom)
.pixelRatio(ratio)
.build()
)
}

AppLogger.d(TAG, "Descriptor: minZoom=$minZoom, maxZoom=$maxZoom, pixelRatios=$pixelRatios (mapPixelRatio=$mapPixelRatio)")
val sw = region.bounds.southwest
val ne = region.bounds.northeast
AppLogger.d(TAG, "BBox: SW=[${sw.longitude()},${sw.latitude()}] NE=[${ne.longitude()},${ne.latitude()}]")

val ts = getOrCreateTileStore()
AppLogger.d(TAG, "downloadRegion TileStore=${ts.hashCode()} MapboxOptions.tileStore=${MapboxOptions.mapsOptions.tileStore?.hashCode()}")

val updated = downloadingRegions.toMutableSet().also { it.add(region.id) }
onDownloadingRegionsUpdate(updated)
onProgress(region.id, 0.0f)

val job = CoroutineScope(Dispatchers.Main).launch {
AppLogger.d(TAG, "Starting cleanup of old regions for: ${region.id}")
val oldRegions = suspendCancellableCoroutine { cont ->
ts.getAllTileRegions { result ->
val ids = result.value
?.filter { it.id == region.id || it.id.startsWith("${region.id}_z") }
?.map { it.id }
?: emptyList()
AppLogger.d(TAG, "Found ${ids.size} old regions to remove: $ids")
cont.resume(ids)
}
}
oldRegions.forEach { id ->
AppLogger.d(TAG, "Removing old region: $id")
ts.removeTileRegion(id)
}
if (oldRegions.isNotEmpty()) {
AppLogger.d(TAG, "Waiting 300ms for cleanup to complete...")
delay(300)
} else {
AppLogger.d(TAG, "No old regions found to remove")
}

AppLogger.d(TAG, "Ensuring style pack is downloaded for: $styleUri")
ensureStylePackDownloaded(styleUri)

val loadOptions = TileRegionLoadOptions.Builder()
.geometry(region.polygon)
.descriptors(descriptors)
.metadata(Value.valueOf(region.name))
.acceptExpired(false)
.networkRestriction(com.mapbox.common.NetworkRestriction.NONE)
.build()

AppLogger.d(TAG, "Starting loadTileRegion: id=${region.id}")

val result = suspendCancellableCoroutine { cont ->
val cancelable = ts.loadTileRegion(
region.id,
loadOptions,
{ progress ->
val pct = if (progress.requiredResourceCount > 0)
progress.completedResourceCount.toFloat() / progress.requiredResourceCount
else 0f
AppLogger.d(TAG, "Progress: ${progress.completedResourceCount}/${progress.requiredResourceCount} err=${progress.erroredResourceCount}")
onProgress(region.id, pct)
}
) { expected ->
cont.resume(expected)
}
cont.invokeOnCancellation { cancelable.cancel() }
}

activeDownloads.remove(region.id)
activeDownloadJobs.remove(region.id)
onDownloadingRegionsUpdate(updated.toMutableSet().also { it.remove(region.id) })
onProgress(region.id, 1.0f)

if (result.isValue) {
val tileRegion = result.value!!
AppLogger.d(TAG, "Download complete: ${tileRegion.requiredResourceCount} resources for ${region.id}")
AppLogger.d(TAG, "TileRegion details: requiredCount=${tileRegion.requiredResourceCount}, completedSize=${tileRegion.completedResourceSize}, expires=${tileRegion.expires}")
AppLogger.d(TAG, "Region ${region.id} successfully downloaded (${tileRegion.completedResourceSize / (1024 * 1024)} MB)")

// Diagnostics
ts.getAllTileRegions { r ->
r.value?.forEach { reg ->
if (reg.id == region.id) {
AppLogger.d(TAG, "TileStore confirmed: id=${reg.id} required=${reg.requiredResourceCount} size=${reg.completedResourceSize / (1024 * 1024)} MB")
}
}
}

// Log detailed info for the downloaded region
ts.getTileRegion(region.id) { regResult ->
regResult.value?.let { reg ->
AppLogger.d(TAG, " region details: id=${reg.id} required=${reg.requiredResourceCount} completed=${reg.completedResourceCount} size=${reg.completedResourceSize / (1024 * 1024)}MB expires=${reg.expires}")
}
}

// Verify actual tile from the region — attempt to read metadata
ts.getTileRegionMetadata(region.id) { metaResult ->
if (metaResult.isValue) {
AppLogger.d(TAG, " metadata OK: ${metaResult.value}")
} else {
AppLogger.w(TAG, " metadata ERROR: ${metaResult.error}")
}
}
onCompletion(region.id, Result.success(tileRegion))
} else {
val errType = result.error?.type
val errMsg = result.error?.message
if (errType == com.mapbox.common.TileRegionErrorType.CANCELED) {
AppLogger.d(TAG, "Download canceled for ${region.id}")
onCompletion(region.id, Result.failure(Exception(context.getString(R.string.offline_error_canceled))))
} else {
AppLogger.e(TAG, "Download failed for ${region.id}: $errMsg")
onCompletion(region.id, Result.failure(Exception(errMsg ?: "Download failed")))
}
}
}

activeDownloadJobs[region.id] = job
}
```

## Additional links and references
My device has a screen density of 2.75, which is why I am downloading for both 2.0f and 3.0f.
Blurry Map
![Image](https://github.com/user-attachments/assets/4fa5cb9e-d545-4af5-8991-2953bdbdc7e4)
Sharp Map
![Image](https://github.com/user-attachments/assets/626b5ed9-4aa8-41c6-bb0d-5b0d59be3f37)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the report with offlineManager.createTilesetDescriptor, TileStore.loadTileRegion, pixel ratios 1.0, 2.0, and 3.0, then compare map rendering in Airplane mode with the same location online. Start by checking whether the downloaded TileStore region is selected by the offline renderer at zoom levels 6–23. Done means downloaded high-resolution tiles render sharply while offline, matching online quality.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, kotlin
Domain
mobile
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.