diff --git a/README.md b/README.md index 6a70dc3..f170b18 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The implementation follows [Google's officical specifications][google-api-specs] ## Features -* [x] Ensure that Google's requirements for a valid url are met (except URL length) +* [x] Ensure that Google's requirements for a valid url are met * [x] Support all possbile [parameters][google-api-params] * [x] Automatic check that map size is within [bounds][google-api-imagesize] (supports [premium plan][google-maps-premium]) * [x] Typesafe paremters: center, markers, path, viewport, zoom level, scale, map type, image format diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 5518c1f..7adda6c 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -9,11 +9,11 @@ plugins { } android { - compileSdkVersion(28) + compileSdkVersion(29) defaultConfig { minSdkVersion(21) - targetSdkVersion(28) + targetSdkVersion(29) versionCode = 1 versionName = "1.0" } @@ -50,7 +50,5 @@ publishing { dependencies { implementation(Libs.kotlin_stdlib_jdk8) - implementation("androidx.appcompat:appcompat:1.1.0") - implementation("androidx.core:core-ktx:1.1.0") testImplementation(Libs.junit) } diff --git a/android/src/androidTest/java/com/ivoberger/statikgmaps/android/ExampleInstrumentedTest.kt b/android/src/androidTest/java/com/ivoberger/statikgmaps/android/ExampleInstrumentedTest.kt deleted file mode 100644 index e09d5b4..0000000 --- a/android/src/androidTest/java/com/ivoberger/statikgmaps/android/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.ivoberger.statikgmaps.android - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.ivoberger.statikgmaps.android.test", appContext.packageName) - } -} diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml deleted file mode 100644 index 64fad88..0000000 --- a/android/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - android - diff --git a/android/src/test/java/com/ivoberger/statikgmaps/android/ExampleUnitTest.kt b/android/src/test/java/com/ivoberger/statikgmaps/android/ExampleUnitTest.kt deleted file mode 100644 index dfaca53..0000000 --- a/android/src/test/java/com/ivoberger/statikgmaps/android/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.ivoberger.statikgmaps.android - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/buildSrc/src/main/kotlin/Libs.kt b/buildSrc/src/main/kotlin/Libs.kt index ded64a7..14ac296 100644 --- a/buildSrc/src/main/kotlin/Libs.kt +++ b/buildSrc/src/main/kotlin/Libs.kt @@ -7,16 +7,6 @@ import kotlin.String * `$ ./gradlew buildSrcVersions` */ object Libs { - /** - * https://developer.android.com/jetpack/androidx - */ - const val appcompat: String = "androidx.appcompat:appcompat:" + Versions.appcompat - - /** - * http://developer.android.com/tools/extras/support-library.html - */ - const val core_ktx: String = "androidx.core:core-ktx:" + Versions.core_ktx - /** * https://developer.android.com/studio */ @@ -37,6 +27,12 @@ object Libs { "de.fayard.buildSrcVersions:de.fayard.buildSrcVersions.gradle.plugin:" + Versions.de_fayard_buildsrcversions_gradle_plugin + /** + * https://github.com/wupdigital/android-maven-publish + */ + const val android_maven_publish: String = "digital.wup:android-maven-publish:" + + Versions.android_maven_publish + /** * http://junit.org */ diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 4244243..c8312f2 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -12,10 +12,6 @@ import org.gradle.plugin.use.PluginDependencySpec * YOU are responsible for updating manually the dependency version. */ object Versions { - const val appcompat: String = "1.1.0" - - const val core_ktx: String = "1.1.0" - const val aapt2: String = "3.5.0-5435860" const val com_android_tools_build_gradle: String = "3.5.0" @@ -24,6 +20,8 @@ object Versions { const val de_fayard_buildsrcversions_gradle_plugin: String = "0.4.2" + const val android_maven_publish: String = "3.6.2" + const val junit: String = "4.12" const val org_jetbrains_dokka: String = "0.9.18" diff --git a/core/src/main/java/com/ivoberger/statikgmapsapi/core/Location.kt b/core/src/main/java/com/ivoberger/statikgmapsapi/core/Location.kt new file mode 100644 index 0000000..7b80131 --- /dev/null +++ b/core/src/main/java/com/ivoberger/statikgmapsapi/core/Location.kt @@ -0,0 +1,40 @@ +package com.ivoberger.statikgmapsapi.core + +/** + * Container to hold a location either by latitude and longitude or an address + * Latitude and longitude will be checked for validity, addresses won't. + */ +data class Location( + val latitude: Double? = null, + val longitude: Double? = null, + val address: String? = null +) { + init { + // require coordinates to be set and in their valid ranges or an address to be set + require( + (latitude != null && longitude != null) || address != null + ) { "A location must be specified by latitude and longitude or a valid address" } + require( + (latitude != null && longitude != null && latitude in -90.0..90.0 && longitude in -180.0..180.0) + || address != null + ) { "Latitude must be between -90 and 90, longitude between -180 and 180" } + } + + override fun toString() = address ?: "$latitude,$longitude" +} + + +/** + * Creates a [Location] from a [Pair] of [Double] + */ +fun Pair.toLocation() = Location(first, second) + +/** + * Creates a [List] of [Location] from a [List] of [Pair] of [Double] + */ +fun List>.toLocations() = map { it.toLocation() } + + +internal fun List.toUrlParam(): String = fold("") { param, pair -> + "${if (param.isNotBlank()) "$param|" else ""}${pair.latitude},${pair.longitude}" +} diff --git a/core/src/main/java/com/ivoberger/statikgmapsapi/core/PolylineUtil.kt b/core/src/main/java/com/ivoberger/statikgmapsapi/core/PolylineUtil.kt index 71195dc..5d0c6f4 100644 --- a/core/src/main/java/com/ivoberger/statikgmapsapi/core/PolylineUtil.kt +++ b/core/src/main/java/com/ivoberger/statikgmapsapi/core/PolylineUtil.kt @@ -1,5 +1,9 @@ package com.ivoberger.statikgmapsapi.core +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.sqrt + /** * Encodes a polyline using Google's polyline algorithm @@ -8,15 +12,15 @@ package com.ivoberger.statikgmapsapi.core * @receiver path as pair of doubles * @return encoded polyline */ -fun List>.encode(): String { +fun List.encode(): String { val result: MutableList = mutableListOf() var prevLat = 0 var prevLong = 0 for ((lat, lng) in this) { - val iLat = (lat * 1e5).toInt() - val iLong = (lng * 1e5).toInt() + val iLat = (lat!! * 1e5).toInt() + val iLong = (lng!! * 1e5).toInt() val deltaLat = encodeValue(iLat - prevLat) val deltaLong = encodeValue(iLong - prevLong) @@ -54,4 +58,46 @@ private fun splitIntoChunks(toEncode: Int): List { return chunks } +/** + * https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm + */ +internal fun List.simplify(epsilon: Double): List { + // Find the point with the maximum distance + var dmax = 0.0 + var index = 0 + val end = this.size + + for (i in 1..(end - 2)) { + val d = perpendicularDistance(this[i], this[0], this[end - 1]) + if (d > dmax) { + index = i + dmax = d + } + } + // If max distance is greater than epsilon, recursively simplify + return if (dmax > epsilon) { + // Recursive call + val recResults1: List = subList(0, index + 1).simplify(epsilon) + val recResults2: List = subList(index, end).simplify(epsilon) + + // Build the result list + listOf(recResults1.subList(0, recResults1.lastIndex), recResults2).flatMap { it.toList() } + } else { + listOf(this[0], this[end - 1]) + } +} + +private fun perpendicularDistance( + pt: Location, lineFrom: Location, lineTo: Location +): Double = + abs( + (lineTo.longitude!! - lineFrom.longitude!!) + * (lineFrom.latitude!! - pt.latitude!!) + - (lineFrom.longitude - pt.longitude!!) + * (lineTo.latitude!! - lineFrom.latitude) + ) / sqrt( + (lineTo.longitude - lineFrom.longitude).pow(2.0) + + (lineTo.latitude - lineFrom.latitude).pow(2.0) + ) + diff --git a/core/src/main/java/com/ivoberger/statikgmapsapi/core/StatikGMapsUrl.kt b/core/src/main/java/com/ivoberger/statikgmapsapi/core/StatikGMapsUrl.kt index 6750f1d..d0552fe 100644 --- a/core/src/main/java/com/ivoberger/statikgmapsapi/core/StatikGMapsUrl.kt +++ b/core/src/main/java/com/ivoberger/statikgmapsapi/core/StatikGMapsUrl.kt @@ -79,7 +79,7 @@ class StatikGMapsUrl( * This parameter takes a location as either latitude to longitude pair * For more information, see TODO Create location class to accommodate both coordinates and addresses. */ - var center: Pair? = null + var center: Location? = null /** * (required if markers, path or visible not present) * defines the zoom level of the map, which determines the magnification level of the map. @@ -88,13 +88,15 @@ class StatikGMapsUrl( */ var zoom: Int? = null - var markers: List> = listOf() - var path: List> = listOf() - var visible: List> = listOf() + var markers: List = listOf() + var path: List = listOf() + var visible: List = listOf() var encodePath: Boolean = false + var simplifyPath = false private val maxSizeStandard = 640 private val maxSizePremium = 2048 + private val maxUrlLength = 8192 override fun toString(): String { setUp() @@ -113,7 +115,7 @@ class StatikGMapsUrl( ) { "Values for center and zoom or markers, path or visible are required" } require(zoom == null || zoom!! in 0..20) { "zoom values are required to be >= 0 and <= 20" } - val params: MutableList> = mutableListOf() + val params: MutableMap = mutableMapOf() params += "key" to apiKey @@ -138,9 +140,22 @@ class StatikGMapsUrl( } if (markers.isNotEmpty()) params += "markers" to markers.toUrlParam() - if (path.isNotEmpty()) if (encodePath) params += "path" to "enc:${path.encode()}" else params += "path" to path.toUrlParam() + if (path.isNotEmpty()) params += "path" to if (encodePath) "enc:${path.encode()}" else path.toUrlParam() if (visible.isNotEmpty()) params += "visible" to visible.toUrlParam() + val url = makeUrl(params.toList()) + + require(url.length <= maxUrlLength /* || simplifyPath */) { + val msg = + "The resulting url violated Google's length restriction and automatic simplification is off." + if (!encodePath) "$msg Encoding the path can help reduce url length" + else msg + } + + return url + } + + private fun makeUrl(params: List>): String { var url = "${if (https) "https" else "http"}://$baseUrl" url = "$url${params.filter { it.second != null }.joinToString( "&", @@ -156,14 +171,25 @@ class StatikGMapsUrl( return url } + private fun simplifyPath(url: String, params: MutableMap): String { + var epsilon = .1 + var simplifiedUrl = url + var simplifiedPath = path + + while (simplifiedUrl.length > maxUrlLength) { + simplifiedPath = simplifiedPath.simplify(epsilon) + params["path"] = + if (encodePath) "enc:${simplifiedPath.encode()}" else simplifiedPath.toUrlParam() + simplifiedUrl = makeUrl(params.toList()) + epsilon += epsilon / 2 + } + return simplifiedUrl + } + private fun downscale() { val maxAllowedSize: Int = if (premiumPlan) maxSizePremium else maxSizeStandard val maxActualSize = size!!.toList().max()!! val scaleFactor: Float = maxAllowedSize / maxActualSize.toFloat() size = (size!!.first * scaleFactor).toInt() to (size!!.second * scaleFactor).toInt() } - - private fun List>.toUrlParam(): String = fold("") { param, pair -> - "${if (param.isNotBlank()) "$param|" else ""}${pair.first},${pair.second}" - } } diff --git a/core/src/test/kotlin/com/ivoberger/statikgmapsapi/core/DownscaleTest.kt b/core/src/test/kotlin/com/ivoberger/statikgmapsapi/core/DownscaleTest.kt index a57a574..4fbc3f8 100644 --- a/core/src/test/kotlin/com/ivoberger/statikgmapsapi/core/DownscaleTest.kt +++ b/core/src/test/kotlin/com/ivoberger/statikgmapsapi/core/DownscaleTest.kt @@ -9,7 +9,7 @@ class DownscaleTest { var origSize = 300 to 170 val url = StatikGMapsUrl("placeholder") { size = origSize - center = .0 to .0 + center = (.0 to .0).toLocation() zoom = 14 } url.toString() @@ -28,7 +28,7 @@ class DownscaleTest { val origRatio = origSize.first / origSize.second.toFloat() val url = StatikGMapsUrl("placeholder") { size = origSize - center = .0 to .0 + center = (.0 to .0).toLocation() zoom = 14 } url.toString() @@ -43,7 +43,7 @@ class DownscaleTest { val origRatio = origSize.first / origSize.second.toFloat() val url = StatikGMapsUrl("placeholder") { size = origSize - center = .0 to .0 + center = (.0 to .0).toLocation() zoom = 14 scale = 4 premiumPlan = true diff --git a/core/src/test/kotlin/com/ivoberger/statikgmapsapi/core/PathSimplificationTest.kt b/core/src/test/kotlin/com/ivoberger/statikgmapsapi/core/PathSimplificationTest.kt new file mode 100644 index 0000000..bf2ea73 --- /dev/null +++ b/core/src/test/kotlin/com/ivoberger/statikgmapsapi/core/PathSimplificationTest.kt @@ -0,0 +1,35 @@ +package com.ivoberger.statikgmapsapi.core + +import org.junit.Assert.assertTrue +import org.junit.Test + +class PathSimplificationTest { + + @Test + fun notNecessary() { + val url = StatikGMapsUrl("placeholder") { + size = 300 to 170 + path = listOf( + Location(.0, .0), + Location(.0, 10.0), + Location(5.0, .0), + Location(7.0, .6) + ) + } + assertTrue(url.toString().endsWith(url.path.toUrlParam())) + } + + @Test + fun compressionOnly() { + val url = StatikGMapsUrl("placeholder") { + size = 300 to 170 + path = listOf( + Location(.0, .0), + Location(.0, 10.0), + Location(5.0, .0), + Location(7.0, .6) + ) + } + assertTrue(url.toString().endsWith(url.path.toUrlParam())) + } +}