Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/PUBLIC_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ OpenMapView provides comprehensive event listener support using Kotlin `fun inte
| `setOnCameraMoveListener(OnCameraMoveListener)` | `void` | |
| `setOnCameraIdleListener(OnCameraIdleListener)` | `void` | |
| `setOnCameraMoveCanceledListener(OnCameraMoveCanceledListener)` | `void` | |
| `setOnMapLoadedCallback(OnMapLoadedCallback)` | `void` | Not implemented - tiles load asynchronously, callback could be added |
| `setOnMapLoadedCallback(OnMapLoadedCallback)` | `void` | Fires once when all visible base tiles are drawn (tile overlays are not awaited) |
| `setInfoWindowAdapter(InfoWindowAdapter)` | `void` | Not implemented - custom adapters not yet implemented |
| `setOnInfoWindowClickListener(OnInfoWindowClickListener)` | `void` | |
| `setOnInfoWindowCloseListener(OnInfoWindowCloseListener)` | `void` | Called when info window is closed (manual or auto-dismiss) |
Expand Down
3 changes: 2 additions & 1 deletion docs/TESTING_UNIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The `--continue` flag ensures all tests run even if some fail, useful for gettin

## Test Structure

### Current Test Coverage (482 tests across 36 test classes)
### Current Test Coverage (489 tests across 37 test classes)

| Test Class | Tests | Description |
|------------|-------|-------------|
Expand All @@ -72,6 +72,7 @@ The `--continue` flag ensures all tests run even if some fail, useful for gettin
| **MapControllerTest** | 103 | Zoom, pan, marker/polyline/polygon management, touch detection, padding, camera bounds, edge effects |
| **MapControllerTileLoadingTest** | 7 | Base tile loading order (disk, network, stale fallback) and retry backoff |
| **MapFeaturesTest** | 5 | Feature query methods (traffic, indoor, buildings, myLocation) |
| **MapLoadedCallbackTest** | 7 | setOnMapLoadedCallback: fires once when all visible tiles are drawn, waits for missing tiles and camera moves |
| **MapProjectionTest** | 8 | Projection screen/LatLng conversion, pan offset, padding, visible region |
| **MapTypeTest** | 18 | Map type switching and validation |
| **MarkerIconFactoryTest** | 3 | Default marker icon generation and caching |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ class MapController(
private var tileRetries = TileRetryTracker()
private var onTileLoadedCallback: (() -> Unit)? = null

// One-shot callback for setOnMapLoadedCallback, checked at the end of every draw
private var onMapLoadedCallback: OnMapLoadedCallback? = null

// API key error overlay (null if no error)
private var apiKeyErrorOverlay: ApiKeyErrorOverlay? = null
private var requestedMapType: Int = MapType.STANDARD
Expand Down Expand Up @@ -1137,6 +1140,22 @@ class MapController(
paddingBottom = bottom
}

/**
* Sets a callback that is invoked once, after a draw in which all visible base map
* tiles were shown and the camera was not moving. If the map is already fully drawn,
* the callback is invoked after the next frame. Tile overlays are not waited for,
* because sparse overlays have no tiles for many areas.
*
* The callback runs on the main thread, outside of the draw pass.
*
* @param callback The callback, or null to remove a pending one
*/
fun setOnMapLoadedCallback(callback: OnMapLoadedCallback?) {
onMapLoadedCallback = callback
// Redraw, so an already loaded map reports it without waiting for a camera change
if (callback != null) onTileLoadedCallback?.invoke()
}

/**
* Sets a callback to be invoked when tiles finish loading.
*
Expand Down Expand Up @@ -1252,7 +1271,9 @@ class MapController(
drawTileOverlay(canvas, visibleTiles, centerPixelX, centerPixelY, overlay)
}

// Draw base map tiles (z-index 0)
// Draw base map tiles (z-index 0). Without a tile source (MapType.NONE) there is
// nothing to wait for, a stale offline fallback tile counts as drawn.
var allTilesDrawn = true
for (tile in visibleTiles) {
val (tilePixelX, tilePixelY) = ProjectionUtils.tileToPixel(tile)

Expand All @@ -1265,6 +1286,7 @@ class MapController(
} else {
canvas.drawRect(screenX, screenY, screenX + TILE_SIZE, screenY + TILE_SIZE, tilePlaceholderPaint)
canvas.drawRect(screenX, screenY, screenX + TILE_SIZE, screenY + TILE_SIZE, tileBorderPaint)
if (tileSource != null) allTilesDrawn = false
}

if (needsDownload(tile, SystemClock.elapsedRealtime())) {
Expand Down Expand Up @@ -1321,6 +1343,14 @@ class MapController(

// Draw API key error overlay if present (on top of everything, but touch events pass through)
apiKeyErrorOverlay?.draw(canvas, viewWidth, viewHeight)

if (allTilesDrawn && !isCameraMoving) {
onMapLoadedCallback?.let { callback ->
onMapLoadedCallback = null
// Run outside of the draw pass, so the callback may e.g. take a snapshot
scope.launch { callback.onMapLoaded() }
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Alexander Farber
* SPDX-License-Identifier: MIT
*
* This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView)
*/

package de.afarber.openmapview

/**
* Callback for when the map has finished rendering.
*
* Set it using [OpenMapView.setOnMapLoadedCallback]. It is called once, after all visible
* base map tiles have been drawn and the camera is not moving. It is not called if the
* tiles can't be loaded (e.g. offline) or while the camera keeps moving.
*/
fun interface OnMapLoadedCallback {
/**
* Called when the map has finished rendering.
*/
fun onMapLoaded()
}
21 changes: 21 additions & 0 deletions openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,27 @@ class OpenMapView
controller.onCameraIdleListener = listener
}

/**
* Sets a callback that is invoked once when the map has finished rendering, i.e.
* all visible base map tiles are drawn and the camera is not moving.
*
* If the map is already fully drawn, the callback is invoked after the next frame.
* It is not invoked while tiles can't be loaded (e.g. offline) or while the camera
* keeps moving. Tile overlays are not waited for.
*
* Example usage:
* ```kotlin
* mapView.setOnMapLoadedCallback {
* mapView.snapshot { bitmap -> bitmap?.let { saveToFile(it) } }
* }
* ```
*
* @param callback The callback to invoke, or null to remove a pending one
*/
fun setOnMapLoadedCallback(callback: OnMapLoadedCallback?) {
controller.setOnMapLoadedCallback(callback)
}

/**
* Sets a listener to handle camera movement cancellation events.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Copyright (c) 2025 Alexander Farber
* SPDX-License-Identifier: MIT
*
* This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView)
*/

package de.afarber.openmapview

import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Looper
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf

@RunWith(RobolectricTestRunner::class)
class MapLoadedCallbackTest {
private lateinit var controller: MapController
private var loadedCount = 0
private val callback = OnMapLoadedCallback { loadedCount++ }

@Before
fun setUp() {
controller = MapController(RuntimeEnvironment.getApplication())
controller.setViewSize(SIZE, SIZE)
// Memory-only cache, so no tile comes from disk and no download can finish
setField("tileCache", TileCache())
}

@After
fun tearDown() {
controller.onDestroy()
}

@Test
fun testFiresOnceWhenAllTilesAreDrawn() {
controller.setOnMapLoadedCallback(callback)
putVisibleTiles()

draw()
draw()

assertEquals(1, loadedCount)
}

@Test
fun testWaitsForMissingTile() {
controller.setOnMapLoadedCallback(callback)
val tiles = visibleTiles()
tiles.drop(1).forEach { tileCache().put(it, tileBitmap()) }

draw()
assertEquals(0, loadedCount)

tileCache().put(tiles.first(), tileBitmap())
draw()
assertEquals(1, loadedCount)
}

@Test
fun testFiresForAlreadyLoadedMap() {
putVisibleTiles()
draw()
var redrawRequested = false
controller.setOnTileLoadedCallback { redrawRequested = true }

controller.setOnMapLoadedCallback(callback)

// OpenMapView redraws on this request, the draw then reports the loaded map
assertEquals(true, redrawRequested)
draw()
assertEquals(1, loadedCount)
}

@Test
fun testFiresWithoutBaseMap() {
controller.setMapType(MapType.NONE)
controller.setOnMapLoadedCallback(callback)

draw()

assertEquals(1, loadedCount)
}

@Test
fun testWaitsWhileCameraIsMoving() {
controller.setOnMapLoadedCallback(callback)
putVisibleTiles()
controller.isCameraMoving = true

draw()
assertEquals(0, loadedCount)

controller.isCameraMoving = false
draw()
assertEquals(1, loadedCount)
}

@Test
fun testNullRemovesPendingCallback() {
controller.setOnMapLoadedCallback(callback)
controller.setOnMapLoadedCallback(null)
putVisibleTiles()

draw()

assertEquals(0, loadedCount)
}

@Test
fun testOpenMapViewForwardsCallback() {
val mapView = OpenMapView(RuntimeEnvironment.getApplication())
mapView.layout(0, 0, SIZE, SIZE)
mapView.setMapType(MapType.NONE)
mapView.setOnMapLoadedCallback(callback)

mapView.draw(Canvas(Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888)))
shadowOf(Looper.getMainLooper()).idle()

assertEquals(1, loadedCount)
}

// Draws once and runs the callbacks posted to the main thread. A mocked canvas, because
// Robolectric's legacy graphics can't draw bitmaps that are partly outside the canvas.
private fun draw() {
controller.draw(mockk<Canvas>(relaxed = true))
shadowOf(Looper.getMainLooper()).idle()
}

private fun visibleTiles(): List<TileCoordinate> =
ViewportCalculator.getVisibleTiles(controller.getCenter(), controller.getZoom().toInt(), SIZE, SIZE)

private fun putVisibleTiles() = visibleTiles().forEach { tileCache().put(it, tileBitmap()) }

private fun tileBitmap(): Bitmap = Bitmap.createBitmap(256, 256, Bitmap.Config.RGB_565)

private fun tileCache(): TileCache {
val field = MapController::class.java.getDeclaredField("tileCache")
field.isAccessible = true
return field.get(controller) as TileCache
}

private fun setField(
name: String,
value: Any,
) {
val field = MapController::class.java.getDeclaredField(name)
field.isAccessible = true
field.set(controller, value)
}

private companion object {
const val SIZE = 256
}
}
Loading