diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 561aea8..7468ef0 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -263,13 +263,15 @@ HttpClient(Android) { ### User-Agent Header -All requests include a user-agent header as required by OSM tile usage policy: +All requests include a user-agent header that identifies both OpenMapView and the app, as required by the OSM tile usage policy: ```kotlin -header("User-Agent", USER_AGENT) // "OpenMapView/ (https://github.com/afarber/OpenMapView)" +header("User-Agent", UserAgent.value) // "OpenMapView/ (; https://github.com/afarber/OpenMapView)" ``` -`USER_AGENT` is generated at build time by the `generateLibraryVersion` Gradle task, using the same Git-tag-derived version as the published artifact. +The package name comes from the `Context` of the map, so if one app overuses the tile servers, a block only affects that app. The version is `LIBRARY_VERSION`, generated at build time by the `generateLibraryVersion` Gradle task from the same Git tag as the published artifact. + +Tile overlays send the same header by default. A `UrlTileProvider` subclass can override `getUserAgent()` to send a different one. ### Coroutine-Based Downloads diff --git a/docs/REPLACING_GOOGLE_MAPS.md b/docs/REPLACING_GOOGLE_MAPS.md index bfa21bf..4b04ea0 100644 --- a/docs/REPLACING_GOOGLE_MAPS.md +++ b/docs/REPLACING_GOOGLE_MAPS.md @@ -536,7 +536,7 @@ Start using maps immediately without: - No monthly billing - No credit card required -Note: If using public OpenStreetMap tile servers, follow their [Tile Usage Policy](https://operations.osmfoundation.org/policies/tiles/). For high-volume applications, consider self-hosting tiles or using commercial OSM providers. +Note: If using public OpenStreetMap tile servers, follow their [Tile Usage Policy](https://operations.osmfoundation.org/policies/tiles/). OpenMapView sends a User-Agent containing your app package name, so the tile servers can tell apps apart. For high-volume applications, consider self-hosting tiles or using commercial OSM providers. ### Complete Data Control diff --git a/openmapview/build.gradle.kts b/openmapview/build.gradle.kts index 2369a85..82a22ab 100644 --- a/openmapview/build.gradle.kts +++ b/openmapview/build.gradle.kts @@ -83,8 +83,6 @@ abstract class GenerateLibraryVersionTask : DefaultTask() { | |internal const val LIBRARY_VERSION = "${libraryVersion.get()}" | - |internal const val USER_AGENT = "OpenMapView/${'$'}LIBRARY_VERSION (https://github.com/afarber/OpenMapView)" - | """.trimMargin(), ) } diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt index eafdb07..856c5a5 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt @@ -217,6 +217,10 @@ class MapController( private val scope = CoroutineScope(Dispatchers.Main + Job()) private val tileDownloader = TileDownloader() + init { + UserAgent.init(context) + } + // Replaced on every tile source change, so downloads still running for the old // source can't put their tiles into the cache of the new one @Volatile diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/TileDownloader.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/TileDownloader.kt index d776e26..44408f9 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/TileDownloader.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/TileDownloader.kt @@ -41,7 +41,7 @@ class TileDownloader { try { val response = client.get(url) { - header("User-Agent", USER_AGENT) + header("User-Agent", UserAgent.value) } if (!response.status.isSuccess()) { Log.w(TAG, "Failed to download tile from $url: HTTP ${response.status.value}") diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/UrlTileProvider.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/UrlTileProvider.kt index 9bbb070..71bb1a4 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/UrlTileProvider.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/UrlTileProvider.kt @@ -67,11 +67,14 @@ abstract class UrlTileProvider( /** * Returns the User-Agent header to use for HTTP requests. + * + * The default identifies both OpenMapView and the app, e.g. + * `OpenMapView/0.17.1 (com.example.app; https://github.com/afarber/OpenMapView)`. * Subclasses can override to provide a custom User-Agent. * * @return User-Agent string */ - protected open fun getUserAgent(): String = USER_AGENT + protected open fun getUserAgent(): String = UserAgent.value /** * Builds the URL for the specified tile coordinates by replacing placeholders diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/UserAgent.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/UserAgent.kt new file mode 100644 index 0000000..fc2e7cc --- /dev/null +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/UserAgent.kt @@ -0,0 +1,36 @@ +/* + * 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.content.Context + +/** + * User-Agent sent with tile requests, e.g. + * `OpenMapView/0.17.1 (com.example.app; https://github.com/afarber/OpenMapView)`. + * + * The OSM tile usage policy asks for a User-Agent that identifies the application, so the + * app's package name is included. Otherwise a block caused by one app would hit every app + * using OpenMapView. The package name is process-wide, because [UrlTileProvider] has no + * [Context]; it is set by every [MapController]. + */ +internal object UserAgent { + @Volatile + private var packageName: String? = null + + fun init(context: Context) { + packageName = context.packageName + } + + val value: String + get() = build(packageName) + + fun build(packageName: String?): String { + val app = packageName?.let { "$it; " } ?: "" + return "OpenMapView/$LIBRARY_VERSION (${app}https://github.com/afarber/OpenMapView)" + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/TileDownloaderTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/TileDownloaderTest.kt index eedf935..2c2774e 100644 --- a/openmapview/src/test/kotlin/de/afarber/openmapview/TileDownloaderTest.kt +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/TileDownloaderTest.kt @@ -7,7 +7,9 @@ package de.afarber.openmapview +import android.content.Context import android.graphics.Bitmap +import androidx.test.core.app.ApplicationProvider import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond @@ -58,8 +60,10 @@ class TileDownloaderTest { } @Test - fun testDownloadTile_SendsVersionedUserAgent() = + fun testDownloadTile_SendsAppUserAgent() = runTest { + val context: Context = ApplicationProvider.getApplicationContext() + UserAgent.init(context) var userAgent: String? = null val mockEngine = MockEngine { request -> @@ -79,7 +83,10 @@ class TileDownloaderTest { downloader.downloadTile("https://example.com/tile.png") assertTrue("Library version should not be blank", LIBRARY_VERSION.isNotBlank()) - assertEquals("OpenMapView/$LIBRARY_VERSION (https://github.com/afarber/OpenMapView)", userAgent) + assertEquals( + "OpenMapView/$LIBRARY_VERSION (${context.packageName}; https://github.com/afarber/OpenMapView)", + userAgent, + ) downloader.close() } diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/UrlTileProviderTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/UrlTileProviderTest.kt index 30282bb..365420f 100644 --- a/openmapview/src/test/kotlin/de/afarber/openmapview/UrlTileProviderTest.kt +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/UrlTileProviderTest.kt @@ -7,9 +7,12 @@ package de.afarber.openmapview +import android.content.Context +import androidx.test.core.app.ApplicationProvider import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.utils.io.ByteReadChannel import kotlinx.coroutines.isActive @@ -44,6 +47,25 @@ class UrlTileProviderTest { assertEquals("https://example.com/3/1/2.png", requestedUrl) } + @Test + fun testGetTile_SendsAppUserAgent() = + runTest { + val context: Context = ApplicationProvider.getApplicationContext() + UserAgent.init(context) + var userAgent: String? = null + val provider = + createProvider( + MockEngine { request -> + userAgent = request.headers[HttpHeaders.UserAgent] + respond(ByteReadChannel(byteArrayOf(1, 2, 3)), HttpStatusCode.OK) + }, + ) + + provider.getTile(1, 2, 3) + + assertEquals(UserAgent.build(context.packageName), userAgent) + } + @Test fun testGetTile_NotFoundReturnsNull() = runTest { diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/UserAgentTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/UserAgentTest.kt new file mode 100644 index 0000000..7bf69c9 --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/UserAgentTest.kt @@ -0,0 +1,40 @@ +/* + * 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.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UserAgentTest { + @Test + fun testBuild_WithPackageName_IdentifiesApp() { + assertEquals( + "OpenMapView/$LIBRARY_VERSION (com.example.app; https://github.com/afarber/OpenMapView)", + UserAgent.build("com.example.app"), + ) + } + + @Test + fun testBuild_WithoutPackageName_IdentifiesLibraryOnly() { + assertEquals("OpenMapView/$LIBRARY_VERSION (https://github.com/afarber/OpenMapView)", UserAgent.build(null)) + } + + @Test + fun testMapController_SetsPackageName() { + val context: Context = ApplicationProvider.getApplicationContext() + + MapController(context) + + assertEquals(UserAgent.build(context.packageName), UserAgent.value) + } +}