Skip to content

Commit 1d0d40b

Browse files
committed
Add OSM attribution
1 parent 526dcc3 commit 1d0d40b

File tree

7 files changed

+225
-11
lines changed

7 files changed

+225
-11
lines changed

docs/TESTING_UNIT.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,18 @@ The `--continue` flag ensures all tests run even if some fail, useful for gettin
5050

5151
## Test Structure
5252

53-
### Current Test Coverage (72 tests)
53+
### Current Test Coverage (76 tests)
5454

5555
| Test Class | Tests | Description |
5656
|------------|-------|-------------|
57+
| **AttributionOverlayTest** | 4 | Attribution rendering and touch detection |
5758
| **BitmapDescriptorFactoryTest** | 7 | Marker icon generation with colors |
59+
| **DiskTileCacheTest** | - | Persistent disk cache with DiskLruCache |
60+
| **MapControllerTest** | 28 | Zoom, pan, marker management, touch detection |
5861
| **MarkerTest** | 8 | Marker creation, equality, and properties |
5962
| **ProjectionTest** | 12 | Web Mercator projection calculations |
60-
| **TileCacheTest** | 6 | LRU bitmap caching |
63+
| **TileCacheTest** | 6 | Memory LRU cache behavior |
6164
| **TileDownloaderTest** | 2 | HTTP tile downloading with mocked Ktor client |
62-
| **MapControllerTest** | 28 | Zoom, pan, marker management, touch detection |
6365
| **ViewportCalculatorTest** | 10 | Visible tile calculation |
6466

6567
### Example Test
@@ -373,20 +375,22 @@ Current coverage includes:
373375
- Core projection math (Web Mercator)
374376
- Tile coordinate calculations
375377
- Marker API and bitmap generation
376-
- Tile caching logic
378+
- Memory cache (LRU) behavior
379+
- Disk cache (persistent storage)
377380
- Viewport calculation
378381
- MapController rendering logic
379-
- Touch gesture handling (marker hit detection)
382+
- Touch gesture handling (marker and attribution hit detection)
380383
- Zoom level validation and bounds
381384
- Network tile downloading (with mocking)
382385
- Pan offset calculations
386+
- Attribution overlay rendering and interaction
383387

384388
Future coverage should include:
385-
- Disk cache implementation tests
386389
- Tile pre-fetching tests
387390
- Performance benchmarks
388391
- Memory usage tests
389392
- Error recovery scenarios
393+
- Cache promotion tests (disk to memory)
390394

391395
## References
392396

examples/Example01Pan/src/main/kotlin/de/afarber/openmapview/example01pan/MainActivity.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
package de.afarber.openmapview.example01pan
99

10+
import android.content.Intent
11+
import android.net.Uri
1012
import android.os.Bundle
1113
import androidx.activity.ComponentActivity
1214
import androidx.activity.compose.setContent
@@ -15,6 +17,7 @@ import androidx.compose.material3.MaterialTheme
1517
import androidx.compose.material3.Surface
1618
import androidx.compose.runtime.Composable
1719
import androidx.compose.ui.Modifier
20+
import androidx.compose.ui.platform.LocalContext
1821
import androidx.compose.ui.viewinterop.AndroidView
1922
import de.afarber.openmapview.LatLng
2023
import de.afarber.openmapview.OpenMapView
@@ -37,16 +40,23 @@ class MainActivity : ComponentActivity() {
3740

3841
@Composable
3942
fun MapViewScreen() {
43+
val context = LocalContext.current
4044
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
4145

4246
AndroidView(
43-
factory = { context ->
44-
OpenMapView(context).apply {
47+
factory = { ctx ->
48+
OpenMapView(ctx).apply {
4549
// Register lifecycle observer for proper cleanup
4650
lifecycleOwner.lifecycle.addObserver(this)
4751

4852
setCenter(LatLng(51.4661, 7.2491)) // Bochum, Germany
4953
setZoom(14.0)
54+
55+
// Set attribution click listener to open OSM copyright page
56+
setOnAttributionClickListener {
57+
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.openstreetmap.org/copyright"))
58+
context.startActivity(intent)
59+
}
5060
}
5161
},
5262
modifier = Modifier.fillMaxSize(),

examples/Example02Zoom/src/main/kotlin/de/afarber/openmapview/example02zoom/MainActivity.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
package de.afarber.openmapview.example02zoom
99

10+
import android.content.Intent
11+
import android.net.Uri
1012
import android.os.Bundle
1113
import androidx.activity.ComponentActivity
1214
import androidx.activity.compose.setContent
@@ -27,6 +29,7 @@ import androidx.compose.runtime.remember
2729
import androidx.compose.runtime.setValue
2830
import androidx.compose.ui.Alignment
2931
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.platform.LocalContext
3033
import androidx.compose.ui.unit.dp
3134
import androidx.compose.ui.viewinterop.AndroidView
3235
import de.afarber.openmapview.LatLng
@@ -51,20 +54,27 @@ class MainActivity : ComponentActivity() {
5154

5255
@Composable
5356
fun MapViewScreen() {
57+
val context = LocalContext.current
5458
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
5559
var zoomLevel by remember { mutableStateOf(14.0) }
5660
var mapView: OpenMapView? by remember { mutableStateOf(null) }
5761

5862
Box(modifier = Modifier.fillMaxSize()) {
5963
AndroidView(
60-
factory = { context ->
61-
OpenMapView(context).apply {
64+
factory = { ctx ->
65+
OpenMapView(ctx).apply {
6266
// Register lifecycle observer for proper cleanup
6367
lifecycleOwner.lifecycle.addObserver(this)
6468

6569
setCenter(LatLng(51.4661, 7.2491)) // Bochum, Germany
6670
setZoom(14.0)
6771
mapView = this
72+
73+
// Set attribution click listener to open OSM copyright page
74+
setOnAttributionClickListener {
75+
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.openstreetmap.org/copyright"))
76+
context.startActivity(intent)
77+
}
6878
}
6979
},
7080
modifier = Modifier.fillMaxSize(),

examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
package de.afarber.openmapview.example03markers
99

10+
import android.content.Intent
11+
import android.net.Uri
1012
import android.os.Bundle
1113
import android.widget.Toast
1214
import androidx.activity.ComponentActivity
@@ -112,6 +114,12 @@ fun MapViewScreen() {
112114
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
113115
true // Consume the click event
114116
}
117+
118+
// Set attribution click listener to open OSM copyright page
119+
setOnAttributionClickListener {
120+
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.openstreetmap.org/copyright"))
121+
context.startActivity(intent)
122+
}
115123
}
116124
},
117125
modifier = Modifier.fillMaxSize(),
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright (c) 2025 Alexander Farber
3+
* SPDX-License-Identifier: MIT
4+
*
5+
* This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView)
6+
*/
7+
8+
package de.afarber.openmapview
9+
10+
import android.content.Context
11+
import android.graphics.Canvas
12+
import android.graphics.Color
13+
import android.graphics.Paint
14+
import android.graphics.Rect
15+
import android.text.TextPaint
16+
17+
/**
18+
* Attribution overlay displaying OpenStreetMap copyright notice.
19+
*
20+
* Required by OSM tile usage policy to show attribution on all maps using OSM data.
21+
* Renders a semi-transparent background with clickable attribution text in the bottom-right corner.
22+
*/
23+
class AttributionOverlay(
24+
context: Context,
25+
) {
26+
private val attributionText = "© OpenStreetMap contributors"
27+
28+
private val textPaint =
29+
TextPaint().apply {
30+
color = Color.BLACK
31+
textSize = 12f * context.resources.displayMetrics.density
32+
isAntiAlias = true
33+
}
34+
35+
private val backgroundPaint =
36+
Paint().apply {
37+
color = Color.argb(180, 255, 255, 255)
38+
style = Paint.Style.FILL
39+
}
40+
41+
private val textBounds = Rect()
42+
private val padding = (4 * context.resources.displayMetrics.density).toInt()
43+
44+
var onAttributionClickListener: (() -> Unit)? = null
45+
46+
init {
47+
textPaint.getTextBounds(attributionText, 0, attributionText.length, textBounds)
48+
}
49+
50+
fun draw(
51+
canvas: Canvas,
52+
viewWidth: Int,
53+
viewHeight: Int,
54+
) {
55+
val textWidth = textBounds.width()
56+
val textHeight = textBounds.height()
57+
58+
val bgLeft = viewWidth - textWidth - padding * 2
59+
val bgTop = viewHeight - textHeight - padding * 2
60+
val bgRight = viewWidth
61+
val bgBottom = viewHeight
62+
63+
canvas.drawRect(bgLeft.toFloat(), bgTop.toFloat(), bgRight.toFloat(), bgBottom.toFloat(), backgroundPaint)
64+
65+
val textX = viewWidth - textWidth - padding
66+
val textY = viewHeight - padding
67+
68+
canvas.drawText(attributionText, textX.toFloat(), textY.toFloat(), textPaint)
69+
}
70+
71+
fun handleTouch(
72+
x: Float,
73+
y: Float,
74+
viewWidth: Int,
75+
viewHeight: Int,
76+
): Boolean {
77+
val textWidth = textBounds.width()
78+
val textHeight = textBounds.height()
79+
80+
val bgLeft = viewWidth - textWidth - padding * 2
81+
val bgTop = viewHeight - textHeight - padding * 2
82+
val bgRight = viewWidth
83+
val bgBottom = viewHeight
84+
85+
if (x >= bgLeft && x <= bgRight && y >= bgTop && y <= bgBottom) {
86+
onAttributionClickListener?.invoke()
87+
return true
88+
}
89+
return false
90+
}
91+
}

openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class OpenMapView
2525
) : FrameLayout(context, attrs, defStyleAttr),
2626
DefaultLifecycleObserver {
2727
private val controller = MapController(context)
28+
private val attributionOverlay = AttributionOverlay(context)
2829
private var lastTouchX = 0f
2930
private var lastTouchY = 0f
3031

@@ -53,6 +54,7 @@ class OpenMapView
5354
override fun dispatchDraw(canvas: Canvas) {
5455
super.dispatchDraw(canvas)
5556
controller.draw(canvas)
57+
attributionOverlay.draw(canvas, width, height)
5658
}
5759

5860
override fun onSizeChanged(
@@ -94,7 +96,14 @@ class OpenMapView
9496
val movementDistance = kotlin.math.sqrt((dx * dx + dy * dy).toDouble())
9597

9698
if (movementDistance < 10) {
97-
// Minimal movement, check for marker touch
99+
// Check attribution overlay first
100+
if (attributionOverlay.handleTouch(event.x, event.y, width, height)) {
101+
controller.commitPan()
102+
invalidate()
103+
return true
104+
}
105+
106+
// Check for marker touch
98107
val touchedMarker = controller.handleMarkerTouch(event.x, event.y)
99108
if (touchedMarker != null) {
100109
val consumed = controller.onMarkerClickListener?.invoke(touchedMarker) ?: false
@@ -150,6 +159,10 @@ class OpenMapView
150159
controller.onMarkerClickListener = listener
151160
}
152161

162+
fun setOnAttributionClickListener(listener: () -> Unit) {
163+
attributionOverlay.onAttributionClickListener = listener
164+
}
165+
153166
override fun onResume(owner: LifecycleOwner) {
154167
controller.onResume()
155168
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright (c) 2025 Alexander Farber
3+
* SPDX-License-Identifier: MIT
4+
*
5+
* This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView)
6+
*/
7+
8+
package de.afarber.openmapview
9+
10+
import android.content.Context
11+
import android.graphics.Canvas
12+
import androidx.test.core.app.ApplicationProvider
13+
import org.junit.Before
14+
import org.junit.Test
15+
import org.junit.runner.RunWith
16+
import org.robolectric.RobolectricTestRunner
17+
import kotlin.test.assertFalse
18+
import kotlin.test.assertTrue
19+
20+
@RunWith(RobolectricTestRunner::class)
21+
class AttributionOverlayTest {
22+
private lateinit var context: Context
23+
private lateinit var overlay: AttributionOverlay
24+
25+
@Before
26+
fun setup() {
27+
context = ApplicationProvider.getApplicationContext()
28+
overlay = AttributionOverlay(context)
29+
}
30+
31+
@Test
32+
fun `draw renders without crashing`() {
33+
val canvas = Canvas()
34+
overlay.draw(canvas, 800, 600)
35+
}
36+
37+
@Test
38+
fun `handleTouch detects click in bottom right corner`() {
39+
val viewWidth = 800
40+
val viewHeight = 600
41+
42+
var clicked = false
43+
overlay.onAttributionClickListener = {
44+
clicked = true
45+
}
46+
47+
val result = overlay.handleTouch(750f, 580f, viewWidth, viewHeight)
48+
49+
assertTrue(result, "Touch in attribution area should return true")
50+
assertTrue(clicked, "Attribution click listener should be invoked")
51+
}
52+
53+
@Test
54+
fun `handleTouch ignores click outside attribution area`() {
55+
val viewWidth = 800
56+
val viewHeight = 600
57+
58+
var clicked = false
59+
overlay.onAttributionClickListener = {
60+
clicked = true
61+
}
62+
63+
val result = overlay.handleTouch(100f, 100f, viewWidth, viewHeight)
64+
65+
assertFalse(result, "Touch outside attribution area should return false")
66+
assertFalse(clicked, "Attribution click listener should not be invoked")
67+
}
68+
69+
@Test
70+
fun `handleTouch works without listener set`() {
71+
val viewWidth = 800
72+
val viewHeight = 600
73+
74+
val result = overlay.handleTouch(750f, 580f, viewWidth, viewHeight)
75+
76+
assertTrue(result, "Touch in attribution area should return true even without listener")
77+
}
78+
}

0 commit comments

Comments
 (0)