-
Notifications
You must be signed in to change notification settings - Fork 155
Open
Labels
bug 🪲Something isn't workingSomething isn't working
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
- Implement an offline map download using offlineManager.createTilesetDescriptor for multiple pixel ratios: listOf(1.0f, 2.0f, 3.0f).
- Ensure that StylePack is fully downloaded using offlineManager.loadStylePack and waiting for completion via a suspendCancellableCoroutine.
- Call tileStore.loadTileRegion with the created descriptors (minZoom 6, maxZoom 23) for a specific geometry.
- Verify that the download is successful (logs show completedResourceCount matches requiredResourceCount and size is several MBs).
- Disconnect the device from the internet (Airplane mode).
- 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. - 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:
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}")
}
// Resume regardless of the result — we proceed to tile download anyway
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}")
// Do not throw an exception — attempt to continue with tile download
}
}
fun downloadRegion(
context: Context,
region: OfflineRegion,
styleUri: String,
zoomLevels: List<Byte>,
mapPixelRatio: Float,
downloadingRegions: Set<String>,
onDownloadingRegionsUpdate: (Set<String>) -> Unit,
onProgress: (String, Float) -> Unit,
onCompletion: (String, Result<TileRegion>) -> 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()
// Build a list of pixelRatios for download.
// MapView SDK requests tiles with URL suffixes like @2x or @3x
// (floor(pixelRatio) → 2.75 → @2x, 3.5 → @3x).
// TileStore saves tiles using the exact pixel_ratio provided in the descriptor.
// However, TileStore lookup logic uses floor(mapPixelRatio).
// Therefore, we download pixel_ratio=2.0 (for @2x devices with 2.0-2.99 density)
// and pixel_ratio=3.0 (for @3x devices with 3.0+ density).
// pixel_ratio=1.0 is used as a low-dpi fallback.
// We do NOT add the exact mapPixelRatio (e.g., 2.75) because TileStore
// will still search for 2.0, not 2.75.
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 {
// Cleanup existing regions with the same ID before starting a new download
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) // Do not accept expired tiles — always download fresh data
.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

Sharp Map

Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
bug 🪲Something isn't workingSomething isn't working