Skip to content

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

@nauc

Description

@nauc

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:

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
Image
Sharp Map
Image

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug 🪲Something isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions