From edfce541f6dff42804d2a8de451758a4162f9748 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sat, 10 Sep 2022 19:44:51 +0200 Subject: [PATCH] add support for offline caching --- app/build.gradle | 13 +- .../johan/evmap/ExampleInstrumentedTest.kt | 24 -- .../johan/evmap/storage/SavedRegionDaoTest.kt | 95 ++++++ .../java/net/vonforst/evmap/auto/MapScreen.kt | 5 +- .../net/vonforst/evmap/api/ChargepointApi.kt | 35 +- .../api/goingelectric/GoingElectricApi.kt | 133 ++++++-- .../api/openchargemap/OpenChargeMapApi.kt | 119 +++++-- .../vonforst/evmap/fragment/MapFragment.kt | 14 +- .../net/vonforst/evmap/model/ChargersModel.kt | 5 +- .../net/vonforst/evmap/model/FiltersModel.kt | 16 + .../vonforst/evmap/storage/CacheLiveData.kt | 131 ++++++++ .../evmap/storage/ChargeLocationsDao.kt | 314 +++++++++++++++++- .../net/vonforst/evmap/storage/Database.kt | 100 ++++-- .../vonforst/evmap/storage/FavoritesDao.kt | 3 +- .../vonforst/evmap/storage/SavedRegionDao.kt | 111 +++++++ .../vonforst/evmap/storage/TypeConverters.kt | 13 + .../java/net/vonforst/evmap/ui/Clustering.kt | 15 +- .../net/vonforst/evmap/utils/LocationUtils.kt | 18 +- .../vonforst/evmap/viewmodel/MapViewModel.kt | 47 ++- .../net/vonforst/evmap/viewmodel/Utils.kt | 16 + 20 files changed, 1061 insertions(+), 166 deletions(-) delete mode 100644 app/src/androidTest/java/com/johan/evmap/ExampleInstrumentedTest.kt create mode 100644 app/src/androidTest/java/com/johan/evmap/storage/SavedRegionDaoTest.kt create mode 100644 app/src/main/java/net/vonforst/evmap/storage/CacheLiveData.kt create mode 100644 app/src/main/java/net/vonforst/evmap/storage/SavedRegionDao.kt diff --git a/app/build.gradle b/app/build.gradle index 0d2b54a1..72bcd1c9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -156,6 +156,12 @@ android { } } + packagingOptions { + pickFirst 'lib/x86/libc++_shared.so' + pickFirst 'lib/arm64-v8a/libc++_shared.so' + pickFirst 'lib/x86_64/libc++_shared.so' + pickFirst 'lib/armeabi-v7a/libc++_shared.so' + } } configurations { @@ -203,7 +209,7 @@ dependencies { googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion" // AnyMaps - def anyMapsVersion = '7fdcf50fc4' + def anyMapsVersion = '8f1226e1c5' implementation "com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion" googleImplementation "com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion" googleImplementation 'com.google.android.gms:play-services-maps:18.1.0' @@ -239,6 +245,7 @@ dependencies { implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" + implementation 'com.github.anboralabs:spatia-room:0.2.6' // billing library def billing_version = "6.0.0" @@ -261,6 +268,9 @@ dependencies { testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0" //noinspection GradleDependency testImplementation 'org.json:json:20080701' + testImplementation 'org.robolectric:robolectric:4.9' + testImplementation 'androidx.test:core:1.5.0' + testImplementation 'androidx.arch.core:core-testing:2.2.0' // testing for car app testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion" @@ -269,6 +279,7 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.arch.core:core-testing:2.2.0' kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0" diff --git a/app/src/androidTest/java/com/johan/evmap/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/johan/evmap/ExampleInstrumentedTest.kt deleted file mode 100644 index 90bae25b..00000000 --- a/app/src/androidTest/java/com/johan/evmap/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.johan.evmap - -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.johan.evmap", appContext.packageName) - } -} diff --git a/app/src/androidTest/java/com/johan/evmap/storage/SavedRegionDaoTest.kt b/app/src/androidTest/java/com/johan/evmap/storage/SavedRegionDaoTest.kt new file mode 100644 index 00000000..9facff52 --- /dev/null +++ b/app/src/androidTest/java/com/johan/evmap/storage/SavedRegionDaoTest.kt @@ -0,0 +1,95 @@ +package com.johan.evmap.storage + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import co.anbora.labs.spatia.geometry.Mbr +import co.anbora.labs.spatia.geometry.MultiPolygon +import kotlinx.coroutines.runBlocking +import net.vonforst.evmap.storage.AppDatabase +import net.vonforst.evmap.storage.SavedRegion +import net.vonforst.evmap.storage.SavedRegionDao +import net.vonforst.evmap.utils.distanceBetween +import net.vonforst.evmap.viewmodel.await +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.ZoneOffset +import java.time.ZonedDateTime + +@RunWith(AndroidJUnit4::class) +class SavedRegionDaoTest { + private lateinit var database: AppDatabase + private lateinit var dao: SavedRegionDao + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = AppDatabase.createInMemory(context) + dao = database.savedRegionDao() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun testGetSavedRegion() { + val ds = "test" + + val ts1 = ZonedDateTime.of(2023, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC).toInstant() + val region1 = Mbr(9.0, 53.0, 10.0, 54.0, 4326).asPolygon() + runBlocking { + dao.insert( + SavedRegion( + region1, + ds, ts1, null, false + ) + ) + } + assertEquals(region1, dao.getSavedRegion(ds, 0)) + runBlocking { + assertTrue(dao.savedRegionCovers(53.1, 53.2, 9.1, 9.2, ds, 0).await()) + assertTrue(dao.savedRegionCoversRadius(53.05, 9.15, 10.0, ds, 0).await()) + assertFalse(dao.savedRegionCovers(52.1, 52.2, 9.1, 9.2, ds, 0).await()) + } + + val ts2 = ZonedDateTime.of(2023, 1, 1, 1, 0, 0, 0, ZoneOffset.UTC).toInstant() + val region2 = Mbr(9.0, 55.0, 10.0, 56.0, 4326).asPolygon() + runBlocking { + dao.insert( + SavedRegion( + region2, + ds, ts2, null, false + ) + ) + } + assertEquals(MultiPolygon(listOf(region1, region2)), dao.getSavedRegion(ds, 0)) + assertEquals(region2, dao.getSavedRegion(ds, ts1.toEpochMilli())) + + runBlocking { + assertTrue(dao.savedRegionCovers(53.1, 53.2, 9.1, 9.2, ds, 0).await()) + assertTrue(dao.savedRegionCoversRadius(53.05, 9.15, 10.0, ds, 0).await()) + assertFalse(dao.savedRegionCovers(53.1, 55.2, 9.1, 9.2, ds, 0).await()) + } + } + + @Test + fun testMakeCircle() { + val lat = 53.0 + val lng = 10.0 + val radius = 10000.0 + val circle = runBlocking { dao.makeCircle(lat, lng, radius) } + for (point in circle.points) { + assertEquals(radius, distanceBetween(lat, lng, point.y, point.x), 10.0) + } + } +} \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt index dd61c5fd..a59c9074 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt @@ -428,14 +428,15 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : } else { // try multiple search radii until we have enough chargers var chargers: List? = null - for (radius in listOf(searchRadius, searchRadius * 10, searchRadius * 50)) { + val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50) + for (radius in radiusValues) { val response = repo.getChargepointsRadius( searchLocation, radius, zoom = 16f, filtersWithValue ).awaitFinished() - if (response.status == Status.ERROR) { + if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) { loadingError = true this@MapScreen.chargers = null invalidate() diff --git a/app/src/main/java/net/vonforst/evmap/api/ChargepointApi.kt b/app/src/main/java/net/vonforst/evmap/api/ChargepointApi.kt index c12818a9..aa3878fc 100644 --- a/app/src/main/java/net/vonforst/evmap/api/ChargepointApi.kt +++ b/app/src/main/java/net/vonforst/evmap/api/ChargepointApi.kt @@ -8,23 +8,35 @@ import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper import net.vonforst.evmap.model.* import net.vonforst.evmap.viewmodel.Resource +import java.time.Duration interface ChargepointApi { + /** + * Query for chargepoints within certain geographic bounds + */ suspend fun getChargepoints( referenceData: ReferenceData, bounds: LatLngBounds, zoom: Float, + useClustering: Boolean, filters: FilterValues? - ): Resource> + ): Resource + /** + * Query for chargepoints within a given radius in kilometers + */ suspend fun getChargepointsRadius( referenceData: ReferenceData, location: LatLng, radius: Int, zoom: Float, + useClustering: Boolean, filters: FilterValues? - ): Resource> + ): Resource + /** + * Fetches detailed data for a specific charging site + */ suspend fun getChargepointDetail( referenceData: ReferenceData, id: Long @@ -34,8 +46,15 @@ interface ChargepointApi { fun getFilters(referenceData: ReferenceData, sp: StringProvider): List> + fun convertFiltersToSQL(filters: FilterValues, referenceData: ReferenceData): FiltersSQLQuery + val name: String val id: String + + /** + * Duration we are limited to if there is a required API local cache time limit. + */ + val cacheLimit: Duration } interface StringProvider { @@ -66,4 +85,16 @@ fun createApi(type: String, ctx: Context): ChargepointApi { } else -> throw IllegalArgumentException() } +} + +data class FiltersSQLQuery( + val query: String, + val requiresChargepointQuery: Boolean, + val requiresChargeCardQuery: Boolean +) + +data class ChargepointList(val items: List, val isComplete: Boolean) { + companion object { + fun empty() = ChargepointList(emptyList(), true) + } } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricApi.kt b/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricApi.kt index f8b3aa4c..4aa81829 100644 --- a/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricApi.kt +++ b/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricApi.kt @@ -13,7 +13,6 @@ import net.vonforst.evmap.R import net.vonforst.evmap.addDebugInterceptors import net.vonforst.evmap.api.* import net.vonforst.evmap.model.* -import net.vonforst.evmap.ui.cluster import net.vonforst.evmap.viewmodel.Resource import net.vonforst.evmap.viewmodel.getClusterDistance import okhttp3.Cache @@ -23,6 +22,7 @@ import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.http.* import java.io.IOException +import java.time.Duration interface GoingElectricApi { @FormUrlEncoded @@ -126,18 +126,19 @@ class GoingElectricApiWrapper( baseurl: String = "https://api.goingelectric.de", context: Context? = null ) : ChargepointApi { - private val clusterThreshold = 11f val api = GoingElectricApi.create(apikey, baseurl, context) override val name = "GoingElectric.de" - override val id = "going_electric" + override val id = "goingelectric" + override val cacheLimit = Duration.ofDays(1) override suspend fun getChargepoints( referenceData: ReferenceData, bounds: LatLngBounds, zoom: Float, + useClustering: Boolean, filters: FilterValues? - ): Resource> { + ): Resource { val freecharging = filters?.getBooleanValue("freecharging") val freeparking = filters?.getBooleanValue("freeparking") val open247 = filters?.getBooleanValue("open_247") @@ -149,33 +150,32 @@ class GoingElectricApiWrapper( val connectorsVal = filters?.getMultipleChoiceValue("connectors") if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) { // no connectors chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val connectors = formatMultipleChoice(connectorsVal) val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards") if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) { // no chargeCards chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val chargeCards = formatMultipleChoice(chargeCardsVal) val networksVal = filters?.getMultipleChoiceValue("networks") if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) { // no networks chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val networks = formatMultipleChoice(networksVal) val categoriesVal = filters?.getMultipleChoiceValue("categories") if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) { // no categories chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val categories = formatMultipleChoice(categoriesVal) // do not use clustering if filters need to be applied locally. - val useClustering = zoom < clusterThreshold val geClusteringAvailable = minConnectors == null || minConnectors <= 1 val useGeClustering = useClustering && geClusteringAvailable val clusterDistance = if (useClustering) getClusterDistance(zoom) else null @@ -217,9 +217,9 @@ class GoingElectricApiWrapper( } } while (startkey != null && startkey < 10000) - val result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom) + val result = postprocessResult(data, minPower, connectorsVal, minConnectors) - return Resource.success(result) + return Resource.success(ChargepointList(result, startkey == null)) } private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) = @@ -230,8 +230,9 @@ class GoingElectricApiWrapper( location: LatLng, radius: Int, zoom: Float, + useClustering: Boolean, filters: FilterValues? - ): Resource> { + ): Resource { val freecharging = filters?.getBooleanValue("freecharging") val freeparking = filters?.getBooleanValue("freeparking") val open247 = filters?.getBooleanValue("open_247") @@ -243,33 +244,32 @@ class GoingElectricApiWrapper( val connectorsVal = filters?.getMultipleChoiceValue("connectors") if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) { // no connectors chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val connectors = formatMultipleChoice(connectorsVal) val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards") if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) { // no chargeCards chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val chargeCards = formatMultipleChoice(chargeCardsVal) val networksVal = filters?.getMultipleChoiceValue("networks") if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) { // no networks chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val networks = formatMultipleChoice(networksVal) val categoriesVal = filters?.getMultipleChoiceValue("categories") if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) { // no categories chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val categories = formatMultipleChoice(categoriesVal) // do not use clustering if filters need to be applied locally. - val useClustering = zoom < clusterThreshold val geClusteringAvailable = minConnectors == null || minConnectors <= 1 val useGeClustering = useClustering && geClusteringAvailable val clusterDistance = if (useClustering) getClusterDistance(zoom) else null @@ -308,19 +308,18 @@ class GoingElectricApiWrapper( } } while (startkey != null && startkey < 10000) - val result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom) - return Resource.success(result) + val result = postprocessResult(data, minPower, connectorsVal, minConnectors) + return Resource.success(ChargepointList(result, startkey == null)) } private fun postprocessResult( chargers: List, minPower: Int?, connectorsVal: MultipleChoiceFilterValue?, - minConnectors: Int?, - zoom: Float + minConnectors: Int? ): List { // apply filters which GoingElectric does not support natively - var result = chargers.filter { it -> + return chargers.filter { it -> if (it is GEChargeLocation) { it.chargepoints .filter { it.power >= (minPower ?: 0) } @@ -330,18 +329,6 @@ class GoingElectricApiWrapper( true } }.map { it.convert(apikey, false) } - - // apply clustering - val useClustering = zoom < clusterThreshold - val geClusteringAvailable = minConnectors == null || minConnectors <= 1 - val clusterDistance = if (useClustering) getClusterDistance(zoom) else null - if (!geClusteringAvailable && useClustering) { - // apply local clustering if server side clustering is not available - Dispatchers.IO.run { - result = cluster(result, zoom, clusterDistance!!) - } - } - return result } override suspend fun getChargepointDetail( @@ -481,5 +468,83 @@ class GoingElectricApiWrapper( ) ) } + + override fun convertFiltersToSQL( + filters: FilterValues, + referenceData: ReferenceData + ): FiltersSQLQuery { + if (filters.isEmpty()) return FiltersSQLQuery("", false, false) + var requiresChargepointQuery = false + var requiresChargeCardQuery = false + + val result = StringBuilder() + if (filters.getBooleanValue("freecharging") == true) { + result.append(" AND freecharging IS 1") + } + if (filters.getBooleanValue("freeparking") == true) { + result.append(" AND freeparking IS 1") + } + if (filters.getBooleanValue("open_247") == true) { + result.append(" AND twentyfourSeven IS 1") + } + if (filters.getBooleanValue("barrierfree") == true) { + result.append(" AND barrierFree IS 1") + } + if (filters.getBooleanValue("exclude_faults") == true) { + result.append(" AND fault_report_description IS NULL AND fault_report_created IS NULL") + } + + val minPower = filters.getSliderValue("min_power") + if (minPower != null && minPower > 0) { + result.append(" AND json_extract(cp.value, '$.power') >= ${minPower}") + requiresChargepointQuery = true + } + + val connectors = filters.getMultipleChoiceValue("connectors") + if (connectors != null && !connectors.all) { + val connectorsList = if (connectors.values.size == 0) { + "" + } else { + "'" + connectors.values.joinToString("', '") { GEChargepoint.convertTypeFromGE(it) } + "'" + } + result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})") + requiresChargepointQuery = true + } + + val networks = filters.getMultipleChoiceValue("networks") + if (networks != null && !networks.all) { + val networksList = if (networks.values.size == 0) { + "" + } else { + "'" + networks.values.joinToString("', '") + "'" + } + result.append(" AND network IN (${networksList})") + } + + val chargecards = filters.getMultipleChoiceValue("chargecards") + if (chargecards != null && !chargecards.all) { + val chargecardsList = if (chargecards.values.size == 0) { + "" + } else { + chargecards.values.joinToString(",") + } + result.append(" AND json_extract(cc.value, '$.id') IN (${chargecardsList})") + requiresChargeCardQuery = true + } + + val categories = filters.getMultipleChoiceValue("categories") + if (categories != null && !categories.all) { + throw NotImplementedError() // category cannot be determined in SQL + } + + + val minConnectors = filters.getSliderValue("min_connectors") + if (minConnectors != null && minConnectors > 1) { + result.append(" GROUP BY ChargeLocation.id HAVING COUNT(1) >= ${minConnectors}") + requiresChargepointQuery = true + } + + return FiltersSQLQuery(result.toString(), requiresChargepointQuery, requiresChargeCardQuery) + } } diff --git a/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapApi.kt b/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapApi.kt index cd9dc0e1..1da1c0f4 100644 --- a/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapApi.kt +++ b/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapApi.kt @@ -4,15 +4,12 @@ import android.content.Context import com.car2go.maps.model.LatLng import com.car2go.maps.model.LatLngBounds import com.squareup.moshi.Moshi -import kotlinx.coroutines.Dispatchers import net.vonforst.evmap.BuildConfig import net.vonforst.evmap.R import net.vonforst.evmap.addDebugInterceptors import net.vonforst.evmap.api.* import net.vonforst.evmap.model.* -import net.vonforst.evmap.ui.cluster import net.vonforst.evmap.viewmodel.Resource -import net.vonforst.evmap.viewmodel.getClusterDistance import okhttp3.Cache import okhttp3.OkHttpClient import retrofit2.Response @@ -21,6 +18,9 @@ import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.http.GET import retrofit2.http.Query import java.io.IOException +import java.time.Duration + +private const val maxResults = 3000 interface OpenChargeMapApi { @GET("poi/") @@ -30,7 +30,7 @@ interface OpenChargeMapApi { @Query("minpowerkw") minPower: Double? = null, @Query("operatorid") operators: String? = null, @Query("statustypeid") statusType: String? = null, - @Query("maxresults") maxresults: Int = 500, + @Query("maxresults") maxresults: Int = maxResults, @Query("compact") compact: Boolean = true, @Query("verbose") verbose: Boolean = false ): Response> @@ -45,7 +45,7 @@ interface OpenChargeMapApi { @Query("minpowerkw") minPower: Double? = null, @Query("operatorid") operators: String? = null, @Query("statustypeid") statusType: String? = null, - @Query("maxresults") maxresults: Int = 500, + @Query("maxresults") maxresults: Int = maxResults, @Query("compact") compact: Boolean = true, @Query("verbose") verbose: Boolean = false ): Response> @@ -105,11 +105,11 @@ class OpenChargeMapApiWrapper( baseurl: String = "https://api.openchargemap.io/v3/", context: Context? = null ) : ChargepointApi { - private val clusterThreshold = 11 + override val cacheLimit = Duration.ofDays(300L) val api = OpenChargeMapApi.create(apikey, baseurl, context) override val name = "OpenChargeMap.org" - override val id = "open_charge_map" + override val id = "openchargemap" private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) = if (value == null || value.all) null else value.values.joinToString(",") @@ -118,8 +118,9 @@ class OpenChargeMapApiWrapper( referenceData: ReferenceData, bounds: LatLngBounds, zoom: Float, + useClustering: Boolean, filters: FilterValues?, - ): Resource> { + ): Resource { val refData = referenceData as OCMReferenceData val minPower = filters?.getSliderValue("min_power")?.toDouble() @@ -129,14 +130,14 @@ class OpenChargeMapApiWrapper( val connectorsVal = filters?.getMultipleChoiceValue("connectors") if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) { // no connectors chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val connectors = formatMultipleChoice(connectorsVal) val operatorsVal = filters?.getMultipleChoiceValue("operators") if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) { // no operators chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val operators = formatMultipleChoice(operatorsVal) @@ -154,16 +155,16 @@ class OpenChargeMapApiWrapper( return Resource.error(response.message(), null) } + val data = response.body()!! val result = postprocessResult( - response.body()!!, + data, minPower, connectorsVal, minConnectors, excludeFaults, - refData, - zoom + refData ) - return Resource.success(result) + return Resource.success(ChargepointList(result, data.size < maxResults)) } catch (e: IOException) { return Resource.error(e.message, null) } @@ -174,8 +175,9 @@ class OpenChargeMapApiWrapper( location: LatLng, radius: Int, zoom: Float, + useClustering: Boolean, filters: FilterValues? - ): Resource> { + ): Resource { val refData = referenceData as OCMReferenceData val minPower = filters?.getSliderValue("min_power")?.toDouble() @@ -185,14 +187,14 @@ class OpenChargeMapApiWrapper( val connectorsVal = filters?.getMultipleChoiceValue("connectors") if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) { // no connectors chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val connectors = formatMultipleChoice(connectorsVal) val operatorsVal = filters?.getMultipleChoiceValue("operators") if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) { // no operators chosen - return Resource.success(emptyList()) + return Resource.success(ChargepointList.empty()) } val operators = formatMultipleChoice(operatorsVal) @@ -208,16 +210,16 @@ class OpenChargeMapApiWrapper( return Resource.error(response.message(), null) } + val data = response.body()!! val result = postprocessResult( - response.body()!!, + data, minPower, connectorsVal, minConnectors, excludeFaults, - refData, - zoom + refData ) - return Resource.success(result) + return Resource.success(ChargepointList(result, data.size < 499)) } catch (e: IOException) { return Resource.error(e.message, null) } @@ -229,28 +231,17 @@ class OpenChargeMapApiWrapper( connectorsVal: MultipleChoiceFilterValue?, minConnectors: Int?, excludeFaults: Boolean?, - referenceData: OCMReferenceData, - zoom: Float + referenceData: OCMReferenceData ): List { // apply filters which OCM does not support natively - var result = chargers.filter { it -> + return chargers.filter { it -> it.connections .filter { it.power == null || it.power >= (minPower ?: 0.0) } .filter { if (connectorsVal != null && !connectorsVal.all) it.connectionTypeId in connectorsVal.values.map { it.toLong() } else true } .sumOf { it.quantity ?: 1 } >= (minConnectors ?: 0) }.filter { it.statusTypeId == null || (it.statusTypeId !in removedStatuses && if (excludeFaults == true) it.statusTypeId !in faultStatuses else true) - }.map { it.convert(referenceData, false) }.distinct() as List - - // apply clustering - val useClustering = zoom < clusterThreshold - if (useClustering) { - val clusterDistance = getClusterDistance(zoom) - Dispatchers.IO.run { - result = cluster(result, zoom, clusterDistance!!) - } - } - return result + }.map { it.convert(referenceData, false) }.distinct() } override suspend fun getChargepointDetail( @@ -330,4 +321,62 @@ class OpenChargeMapApiWrapper( ) } + override fun convertFiltersToSQL( + filters: FilterValues, + referenceData: ReferenceData + ): FiltersSQLQuery { + if (filters.isEmpty()) return FiltersSQLQuery("", false, false) + + val refData = referenceData as OCMReferenceData + var requiresChargepointQuery = false + + val result = StringBuilder() + + if (filters.getBooleanValue("exclude_faults") == true) { + result.append(" AND fault_report_description IS NULL AND fault_report_created IS NULL") + } + + val minPower = filters.getSliderValue("min_power") + if (minPower != null && minPower > 0) { + result.append(" AND json_extract(cp.value, '$.power') >= ${minPower}") + requiresChargepointQuery = true + } + + val connectors = filters.getMultipleChoiceValue("connectors") + if (connectors != null && !connectors.all) { + val connectorsList = if (connectors.values.size == 0) { + "" + } else { + "'" + connectors.values.joinToString("', '") { + OCMConnection.convertConnectionTypeFromOCM( + it.toLong(), + refData + ) + } + "'" + } + result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})") + requiresChargepointQuery = true + } + + val operators = filters.getMultipleChoiceValue("operators") + if (operators != null && !operators.all) { + val networksList = if (operators.values.size == 0) { + "" + } else { + "'" + operators.values.joinToString("', '") { opId -> + refData.operators.find { it.id == opId.toLong() }?.title.orEmpty() + } + "'" + } + result.append(" AND network IN (${networksList})") + } + + val minConnectors = filters.getSliderValue("min_connectors") + if (minConnectors != null && minConnectors > 1) { + result.append(" GROUP BY ChargeLocation.id HAVING COUNT(1) >= ${minConnectors}") + requiresChargepointQuery = true + } + + return FiltersSQLQuery(result.toString(), requiresChargepointQuery, false) + } + } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt index e27c6ca0..b8c14fcd 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt @@ -422,7 +422,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac val charger = vm.charger.value?.data if (charger?.editUrl != null) { (activity as? MapsActivity)?.openUrl(charger.editUrl) - if (vm.apiId.value == "going_electric") { + if (vm.apiId.value == "goingelectric") { // instructions specific to GoingElectric Toast.makeText( requireContext(), @@ -606,6 +606,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac } } vm.chargepoints.observe(viewLifecycleOwner, Observer { res -> + val chargepoints = res.data + if (chargepoints != null) { + updateMap(chargepoints) + } when (res.status) { Status.ERROR -> { val view = view ?: return@Observer @@ -625,11 +629,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac Status.LOADING -> { } } - - val chargepoints = res.data - if (chargepoints != null) { - updateMap(chargepoints) - } }) vm.useMiniMarkers.observe(viewLifecycleOwner) { vm.chargepoints.value?.data?.let { updateMap(it) } @@ -861,6 +860,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac override fun onMapReady(map: AnyMap) { this.map = map + vm.mapProjection = map.projection val context = this.context ?: return chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory) @@ -885,12 +885,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac map.uiSettings.setIndoorLevelPickerEnabled(false) map.setOnCameraIdleListener { + vm.mapProjection = map.projection vm.mapPosition.value = MapPosition( map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom ) vm.reloadChargepoints() } map.setOnCameraMoveListener { + vm.mapProjection = map.projection vm.mapPosition.value = MapPosition( map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom ) diff --git a/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt b/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt index 5dd50868..fa696c5d 100644 --- a/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt +++ b/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt @@ -58,7 +58,7 @@ data class ChargeLocation( val id: Long, val dataSource: String, val name: String, - @Embedded val coordinates: Coordinate, + val coordinates: Coordinate, @Embedded val address: Address?, val chargepoints: List, val network: String?, @@ -351,7 +351,8 @@ abstract class ChargerPhoto(open val id: String) : Parcelable { data class ChargeLocationCluster( val clusterCount: Int, - val coordinates: Coordinate + val coordinates: Coordinate, + val items: List? = null ) : ChargepointListItem() @Parcelize diff --git a/app/src/main/java/net/vonforst/evmap/model/FiltersModel.kt b/app/src/main/java/net/vonforst/evmap/model/FiltersModel.kt index 7371e6b7..7e16231a 100644 --- a/app/src/main/java/net/vonforst/evmap/model/FiltersModel.kt +++ b/app/src/main/java/net/vonforst/evmap/model/FiltersModel.kt @@ -6,6 +6,7 @@ import androidx.room.ForeignKey import androidx.room.Index import net.vonforst.evmap.adapter.Equatable import net.vonforst.evmap.storage.FilterProfile +import java.net.URLEncoder import kotlin.reflect.KClass sealed class Filter : Equatable { @@ -51,6 +52,8 @@ sealed class FilterValue : BaseObservable(), Equatable { var profile: Long = FILTERS_CUSTOM abstract fun hasSameValueAs(other: FilterValue): Boolean + + abstract fun serializeValue(): String } @Entity( @@ -72,6 +75,8 @@ data class BooleanFilterValue( override fun hasSameValueAs(other: FilterValue): Boolean { return other is BooleanFilterValue && other.value == this.value } + + override fun serializeValue(): String = value.toString() } @Entity( @@ -99,6 +104,12 @@ data class MultipleChoiceFilterValue( !this.all && other.values == this.values } } + + override fun serializeValue(): String = if (all) { + "ALL" + } else { + "[" + values.sorted().joinToString(",") { URLEncoder.encode(it, "UTF-8") } + "]" + } } @Entity( @@ -120,6 +131,8 @@ data class SliderFilterValue( override fun hasSameValueAs(other: FilterValue): Boolean { return other is SliderFilterValue && other.value == this.value } + + override fun serializeValue() = value.toString() } data class FilterWithValue(val filter: Filter, val value: T) : Equatable @@ -138,6 +151,9 @@ fun FilterValues.getMultipleChoiceFilter(key: String) = fun FilterValues.getMultipleChoiceValue(key: String) = this.find { it.value.key == key }?.value as MultipleChoiceFilterValue? +fun FilterValues.serialize() = this.sortedBy { it.value.key } + .joinToString(",") { it.value.key + "=" + it.value.serializeValue() } + const val FILTERS_DISABLED = -2L const val FILTERS_CUSTOM = -1L const val FILTERS_FAVORITES = -3L \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/CacheLiveData.kt b/app/src/main/java/net/vonforst/evmap/storage/CacheLiveData.kt new file mode 100644 index 00000000..c352c935 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/storage/CacheLiveData.kt @@ -0,0 +1,131 @@ +package net.vonforst.evmap.storage + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import net.vonforst.evmap.model.ChargeLocation +import net.vonforst.evmap.viewmodel.Resource +import net.vonforst.evmap.viewmodel.Status +import java.time.Duration +import java.time.Instant + +/** + * LiveData implementation that allows loading data both from a cache and an API. + * + * It gives the cache result while loading, and then switches to the API result if the API call was + * successful. + */ +class CacheLiveData( + cache: LiveData, + api: LiveData>, + skipApi: LiveData? = null +) : + MediatorLiveData>() { + private var cacheResult: T? = null + private var apiResult: Resource? = null + private var skipApiResult: Boolean = false + + init { + updateValue() + addSource(cache) { + cacheResult = it + removeSource(cache) + updateValue() + } + if (skipApi == null) { + addSource(api) { + apiResult = it + updateValue() + } + } else { + addSource(skipApi) { skip -> + removeSource(skipApi) + skipApiResult = skip + updateValue() + if (!skip) { + addSource(api) { + apiResult = it + updateValue() + } + } + } + } + } + + private fun updateValue() { + val api = apiResult + val cache = cacheResult + + if (api == null && cache == null) { + Log.d("CacheLiveData", "both API and cache are still loading") + // both API and cache are still loading + value = Resource.loading(null) + } else if (cache != null && api == null) { + Log.d("CacheLiveData", "cache has finished loading before API") + // cache has finished loading before API + if (skipApiResult) { + value = Resource.success(cache) + } else { + value = Resource.loading(cache) + } + } else if (cache == null && api != null) { + Log.d("CacheLiveData", "API has finished loading before cache") + // API has finished loading before cache + value = when (api.status) { + Status.SUCCESS -> api + Status.ERROR -> Resource.loading(api.data) + Status.LOADING -> api // should not occur + } + } else if (cache != null && api != null) { + Log.d("CacheLiveData", "Both cache and API have finished loading") + // Both cache and API have finished loading + value = when (api.status) { + Status.SUCCESS -> api + Status.ERROR -> Resource.error(api.message, cache) + Status.LOADING -> api // should not occur + } + } + } +} + +/** + * LiveData implementation that allows loading data both from a cache and an API. + * + * It first tries loading from cache, and if the result is newer than `cacheSoftLimit` it does not + * reload from the API. + */ +class PreferCacheLiveData( + cache: LiveData, + val api: LiveData>, + cacheSoftLimit: Duration +) : + MediatorLiveData>() { + init { + value = Resource.loading(null) + addSource(cache) { cacheRes -> + removeSource(cache) + if (cacheRes != null) { + if (cacheRes.isDetailed && cacheRes.timeRetrieved > Instant.now() - cacheSoftLimit) { + value = Resource.success(cacheRes) + } else { + value = Resource.loading(cacheRes) + loadFromApi(cacheRes) + } + } else { + loadFromApi(null) + } + } + } + + private fun loadFromApi( + cache: ChargeLocation? + ) { + addSource(api) { apiRes -> + value = when (apiRes.status) { + Status.SUCCESS -> apiRes + Status.ERROR -> Resource.error(apiRes.message, cache) + Status.LOADING -> Resource.loading(cache) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt b/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt index 6299d4d9..4df12ff0 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt @@ -1,34 +1,90 @@ package net.vonforst.evmap.storage import androidx.lifecycle.* -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy +import androidx.room.* +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteQuery +import co.anbora.labs.spatia.geometry.Mbr +import co.anbora.labs.spatia.geometry.Polygon import com.car2go.maps.model.LatLng import com.car2go.maps.model.LatLngBounds +import com.car2go.maps.util.SphericalUtil import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import net.vonforst.evmap.api.ChargepointApi +import net.vonforst.evmap.api.ChargepointList import net.vonforst.evmap.api.StringProvider import net.vonforst.evmap.api.goingelectric.GEReferenceData import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper import net.vonforst.evmap.model.* +import net.vonforst.evmap.ui.cluster import net.vonforst.evmap.viewmodel.Resource +import net.vonforst.evmap.viewmodel.Status import net.vonforst.evmap.viewmodel.await +import net.vonforst.evmap.viewmodel.getClusterDistance +import net.vonforst.evmap.viewmodel.singleSwitchMap +import java.time.Duration +import java.time.Instant +import kotlin.math.sqrt @Dao abstract class ChargeLocationsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) abstract suspend fun insert(vararg locations: ChargeLocation) + @Query("SELECT EXISTS(SELECT 1 FROM chargelocation WHERE dataSource == :dataSource AND id == :id AND isDetailed == 1 AND timeRetrieved > :after )") + abstract suspend fun checkExistsDetailed(id: Long, dataSource: String, after: Long): Boolean + + suspend fun insertOrReplaceIfNoDetailedExists( + afterDate: Long, + vararg locations: ChargeLocation + ) { + locations.forEach { + if (it.isDetailed || !checkExistsDetailed(it.id, it.dataSource, afterDate)) { + insert(it) + } + } + } + @Delete abstract suspend fun delete(vararg locations: ChargeLocation) + + @Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND id == :id AND isDetailed == 1 AND timeRetrieved > :after") + abstract fun getChargeLocationById( + id: Long, + dataSource: String, + after: Long + ): LiveData + + @SkipQueryVerification + @Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND Within(coordinates, BuildMbr(:lng1, :lat1, :lng2, :lat2)) AND timeRetrieved > :after") + abstract fun getChargeLocationsInBounds( + lat1: Double, + lat2: Double, + lng1: Double, + lng2: Double, + dataSource: String, + after: Long + ): LiveData> + + @SkipQueryVerification + @Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND PtDistWithin(coordinates, MakePoint(:lng, :lat, 4326), :radius) AND timeRetrieved > :after ORDER BY Distance(coordinates, MakePoint(:lng, :lat, 4326))") + abstract fun getChargeLocationsRadius( + lat: Double, + lng: Double, + radius: Double, + dataSource: String, + after: Long + ): LiveData> + + @RawQuery(observedEntities = [ChargeLocation::class]) + abstract fun getChargeLocationsCustom(query: SupportSQLiteQuery): LiveData> } /** * The ChargeLocationsRepository wraps the ChargepointApi and the DB to provide caching - * functionality. + * and clustering functionality. */ class ChargeLocationsRepository( api: ChargepointApi, private val scope: CoroutineScope, @@ -36,6 +92,13 @@ class ChargeLocationsRepository( ) { val api = MutableLiveData>().apply { value = api } + // if zoom level is below this value, server-side clustering will be used (if the API provides it) + private val serverSideClusteringThreshold = 9f + private fun shouldUseServerSideClustering(zoom: Float) = zoom < serverSideClusteringThreshold + + // if cached data is available and more recent than this duration, API will not be queried + private val cacheSoftLimit = Duration.ofDays(1) + val referenceData = this.api.switchMap { api -> when (api) { is GoingElectricApiWrapper -> { @@ -61,18 +124,68 @@ class ChargeLocationsRepository( } private val chargeLocationsDao = db.chargeLocationsDao() + private val savedRegionDao = db.savedRegionDao() fun getChargepoints( bounds: LatLngBounds, zoom: Float, filters: FilterValues? ): LiveData>> { - return liveData { - val refData = referenceData.await() - val result = api.value!!.getChargepoints(refData, bounds, zoom, filters) + val api = api.value!! - emit(result) + val dbResult = if (filters == null) { + chargeLocationsDao.getChargeLocationsInBounds( + bounds.southwest.latitude, + bounds.northeast.latitude, + bounds.southwest.longitude, + bounds.northeast.longitude, + api.id, + cacheLimitDate(api) + ) + } else { + queryWithFilters(api, filters, bounds) + }.map { applyLocalClustering(it, zoom) } + val filtersSerialized = + filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() } + ?.serialize() + val savedRegionResult = savedRegionDao.savedRegionCovers( + bounds.southwest.latitude, + bounds.northeast.latitude, + bounds.southwest.longitude, + bounds.northeast.longitude, + api.id, + cacheSoftLimitDate(api), + filtersSerialized + ) + val useClustering = shouldUseServerSideClustering(zoom) + val apiResult = liveData { + val refData = referenceData.await() + val time = Instant.now() + val result = api.getChargepoints(refData, bounds, zoom, useClustering, filters) + emit(applyLocalClustering(result, zoom)) + if (result.status == Status.SUCCESS) { + val chargers = result.data!!.items.filterIsInstance() + chargeLocationsDao.insertOrReplaceIfNoDetailedExists( + cacheLimitDate(api), *chargers.toTypedArray() + ) + if (chargers.size == result.data.items.size && result.data.isComplete) { + val region = Mbr( + bounds.southwest.longitude, + bounds.southwest.latitude, + bounds.northeast.longitude, + bounds.northeast.latitude, 4326 + ).asPolygon() + savedRegionDao.insert( + SavedRegion( + region, api.id, time, + filtersSerialized, + false + ) + ) + } + } } + return CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged() } fun getChargepointsRadius( @@ -81,23 +194,121 @@ class ChargeLocationsRepository( zoom: Float, filters: FilterValues? ): LiveData>> { - return liveData { - val refData = referenceData.await() - val result = api.value!!.getChargepointsRadius(refData, location, radius, zoom, filters) + val api = api.value!! - emit(result) + val radiusMeters = radius.toDouble() * 1000 + val dbResult = if (filters == null) { + chargeLocationsDao.getChargeLocationsRadius( + location.latitude, + location.longitude, + radiusMeters, + api.id, + cacheLimitDate(api) + ) + } else { + queryWithFilters(api, filters, location, radiusMeters) + }.map { applyLocalClustering(it, zoom) } + val filtersSerialized = + filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() } + ?.serialize() + val savedRegionResult = savedRegionDao.savedRegionCoversRadius( + location.latitude, + location.longitude, + radiusMeters * 0.999, // to account for float rounding errors + api.id, + cacheSoftLimitDate(api), + filtersSerialized + ) + val useClustering = shouldUseServerSideClustering(zoom) + val apiResult = liveData { + val refData = referenceData.await() + val time = Instant.now() + val result = + api.getChargepointsRadius(refData, location, radius, zoom, useClustering, filters) + emit(applyLocalClustering(result, zoom)) + if (result.status == Status.SUCCESS) { + val chargers = result.data!!.items.filterIsInstance() + chargeLocationsDao.insertOrReplaceIfNoDetailedExists( + cacheLimitDate(api), *chargers.toTypedArray() + ) + if (chargers.size == result.data.items.size && result.data.isComplete) { + val region = Polygon( + savedRegionDao.makeCircle( + location.latitude, + location.longitude, + radiusMeters + ) + ) + savedRegionDao.insert( + SavedRegion( + region, api.id, time, + filtersSerialized, + false + ) + ) + } + } } + return CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged() + } + + private fun applyLocalClustering( + result: Resource, + zoom: Float + ): Resource> { + val list = result.data ?: return Resource(result.status, null, result.message) + val chargers = list.items.filterIsInstance() + + if (chargers.size != list.items.size) return Resource( + result.status, + list.items, + result.message + ) // list already contains clusters + + val clustered = applyLocalClustering(chargers, zoom) + return Resource(result.status, clustered, result.message) + } + + private fun applyLocalClustering( + chargers: List, + zoom: Float + ): List { + val clusterDistance = getClusterDistance(zoom) + + val chargersClustered = if (clusterDistance != null) { + Dispatchers.IO.run { + cluster(chargers, zoom, clusterDistance) + } + } else chargers + return chargersClustered } fun getChargepointDetail( id: Long ): LiveData> { - return liveData { + val dbResult = chargeLocationsDao.getChargeLocationById( + id, + prefs.dataSource, + cacheLimitDate(api.value!!) + ) + val apiResult = liveData { emit(Resource.loading(null)) val refData = referenceData.await() val result = api.value!!.getChargepointDetail(refData, id) emit(result) + if (result.status == Status.SUCCESS) { + chargeLocationsDao.insert(result.data!!) + } } + return PreferCacheLiveData(dbResult, apiResult, cacheSoftLimit) + } + + /** + * Numeric date for database limit required limit on some APIs + */ + private fun afterDate(): Long { + val cacheLimit = this.api.value!!.cacheLimit + return Instant.now().minus(cacheLimit).toEpochMilli() } fun getFilters(sp: StringProvider) = MediatorLiveData>>().apply { @@ -122,4 +333,79 @@ class ChargeLocationsRepository( } } } + + private fun queryWithFilters( + api: ChargepointApi, + filters: FilterValues, + bounds: LatLngBounds + ): LiveData> { + val region = + "Within(coordinates, BuildMbr(${bounds.southwest.longitude}, ${bounds.southwest.latitude}, ${bounds.northeast.longitude}, ${bounds.northeast.latitude}))" + return queryWithFilters(api, filters, region) + } + + private fun queryWithFilters( + api: ChargepointApi, + filters: FilterValues, + location: LatLng, + radius: Double + ): LiveData> { + val region = + "PtDistWithin(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326), ${radius})" + val order = + "ORDER BY Distance(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326))" + return queryWithFilters(api, filters, region, order) + } + + private fun queryWithFilters( + api: ChargepointApi, + filters: FilterValues, + regionSql: String, + orderSql: String? = null + ): LiveData> = referenceData.singleSwitchMap { refData -> + try { + val query = api.convertFiltersToSQL(filters, refData) + val after = cacheLimitDate(api) + val sql = StringBuilder().apply { + append("SELECT") + if (query.requiresChargeCardQuery or query.requiresChargepointQuery) { + append(" DISTINCT chargelocation.*") + } else { + append(" *") + } + append(" FROM chargelocation") + if (query.requiresChargepointQuery) { + append(" JOIN json_each(chargelocation.chargepoints) AS cp") + } + if (query.requiresChargeCardQuery) { + append(" JOIN json_each(chargelocation.chargecards) AS cc") + } + append(" WHERE dataSource == '${prefs.dataSource}'") + append(" AND $regionSql") + append(" AND timeRetrieved > $after") + append(query.query) + orderSql?.let { append(" " + orderSql) } + }.toString() + + chargeLocationsDao.getChargeLocationsCustom( + SimpleSQLiteQuery( + sql, + null + ) + ) + } catch (e: NotImplementedError) { + MutableLiveData() // in this case we cannot get a DB result + } + } + + + private fun cacheLimitDate(api: ChargepointApi): Long { + val cacheLimit = api.cacheLimit + return Instant.now().minus(cacheLimit).toEpochMilli() + } + + private fun cacheSoftLimitDate(api: ChargepointApi): Long { + val cacheLimit = maxOf(api.cacheLimit, Duration.ofDays(2)) + return Instant.now().minus(cacheLimit).toEpochMilli() + } } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/Database.kt b/app/src/main/java/net/vonforst/evmap/storage/Database.kt index 353023a2..eb36de4e 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/Database.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/Database.kt @@ -5,11 +5,13 @@ import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase import androidx.room.Database -import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import co.anbora.labs.spatia.builder.SpatiaBuilder +import co.anbora.labs.spatia.builder.SpatiaRoom +import co.anbora.labs.spatia.geometry.GeometryConverters import net.vonforst.evmap.api.goingelectric.GEChargeCard import net.vonforst.evmap.api.goingelectric.GEChargepoint import net.vonforst.evmap.api.openchargemap.OCMConnectionType @@ -31,16 +33,18 @@ import net.vonforst.evmap.model.* GEChargeCard::class, OCMConnectionType::class, OCMCountry::class, - OCMOperator::class - ], version = 19 + OCMOperator::class, + SavedRegion::class + ], version = 20 ) -@TypeConverters(Converters::class) +@TypeConverters(Converters::class, GeometryConverters::class) abstract class AppDatabase : RoomDatabase() { abstract fun chargeLocationsDao(): ChargeLocationsDao abstract fun favoritesDao(): FavoritesDao abstract fun filterValueDao(): FilterValueDao abstract fun filterProfileDao(): FilterProfileDao abstract fun recentAutocompletePlaceDao(): RecentAutocompletePlaceDao + abstract fun savedRegionDao(): SavedRegionDao // GoingElectric API specific abstract fun geReferenceDataDao(): GEReferenceDataDao @@ -51,21 +55,7 @@ abstract class AppDatabase : RoomDatabase() { companion object { private lateinit var context: Context private val database: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { - Room.databaseBuilder(context, AppDatabase::class.java, "evmap.db") - .addMigrations( - MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6, - MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11, - MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16, - MIGRATION_17, MIGRATION_18, MIGRATION_19 - ) - .addCallback(object : Callback() { - override fun onCreate(db: SupportSQLiteDatabase) { - // create default filter profile for each data source - db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('goingelectric', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)") - db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)") - } - }) - .build() + initDb(SpatiaRoom.databaseBuilder(context, AppDatabase::class.java, "evmap.db")) } fun getInstance(context: Context): AppDatabase { @@ -73,6 +63,38 @@ abstract class AppDatabase : RoomDatabase() { return database } + /** + * creates an in-memory AppDatabase instance - only for testing + */ + fun createInMemory(context: Context): AppDatabase { + return initDb(SpatiaRoom.inMemoryDatabaseBuilder(context, AppDatabase::class.java)) + } + + private fun initDb(builder: SpatiaRoom.Builder): AppDatabase { + return builder.addMigrations( + MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6, + MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11, + MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16, + MIGRATION_17, MIGRATION_18, MIGRATION_19, MIGRATION_20 + ) + .addCallback(object : Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + // create default filter profile for each data source + db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('goingelectric', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)") + db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)") + // initialize spatialite columns + db.query("SELECT RecoverGeometryColumn('ChargeLocation', 'coordinates', 4326, 'POINT', 'XY');") + .moveToNext() + db.query("SELECT CreateSpatialIndex('ChargeLocation', 'coordinates');") + .moveToNext() + db.query("SELECT RecoverGeometryColumn('SavedRegion', 'region', 4326, 'POLYGON', 'XY');") + .moveToNext() + db.query("SELECT CreateSpatialIndex('SavedRegion', 'region');") + .moveToNext() + } + }).build() + } + private val MIGRATION_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { // SQL for creating tables copied from build/generated/source/kapt/debug/net/vonforst/evmap/storage/AppDatbase_Impl @@ -383,5 +405,45 @@ abstract class AppDatabase : RoomDatabase() { db.execSQL("ALTER TABLE `ChargeLocation` ADD `chargerUrl` TEXT") } } + + private val MIGRATION_20 = object : Migration(19, 20) { + override fun migrate(db: SupportSQLiteDatabase) { + try { + db.beginTransaction() + + // init spatialite + db.query("SELECT InitSpatialMetaData();").moveToNext() + + // add geometry column and set it based on lat/lng columns + db.query("SELECT AddGeometryColumn('ChargeLocation', 'coordinates', 4326, 'POINT', 'XY');") + .moveToNext() + db.execSQL("UPDATE `ChargeLocation` SET `coordinates` = GeomFromText('POINT('||\"lng\"||' '||\"lat\"||')',4326);") + + // recreate table to remove lat/lng columns + db.execSQL( + "CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, PRIMARY KEY(`id`, `dataSource`))" + ) + db.query("SELECT AddGeometryColumn('ChargeLocationNew', 'coordinates', 4326, 'POINT', 'XY');") + .moveToNext() + db.query("SELECT CreateSpatialIndex('ChargeLocationNew', 'coordinates');") + .moveToNext() + + db.execSQL("INSERT INTO `ChargeLocationNew` SELECT `id`, `dataSource`, `name`, `coordinates`, `chargepoints`, `network`, `url`, `editUrl`, `verified`, `barrierFree`, `operator`, `generalInformation`, `amenities`, `locationDescription`, `photos`, `chargecards`, `license`, `timeRetrieved`, `isDetailed`, `city`, `country`, `postcode`, `street`, `fault_report_created`, `fault_report_description`, `twentyfourSeven`, `description`, `mostart`, `moend`, `tustart`, `tuend`, `westart`, `weend`, `thstart`, `thend`, `frstart`, `frend`, `sastart`, `saend`, `sustart`, `suend`, `hostart`, `hoend`, `freecharging`, `freeparking`, `descriptionShort`, `descriptionLong`, `chargepricecountry`, `chargepricenetwork`, `chargepriceplugTypes`, `networkUrl`, `chargerUrl` FROM `ChargeLocation`") + + db.execSQL("DROP TABLE `ChargeLocation`") + db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`") + + db.execSQL("CREATE TABLE IF NOT EXISTS `SavedRegion` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)"); + db.execSQL("CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `SavedRegion` (`filters`, `dataSource`)"); + db.query("SELECT AddGeometryColumn('SavedRegion', 'region', 4326, 'POLYGON', 'XY');") + .moveToNext() + db.query("SELECT CreateSpatialIndex('SavedRegion', 'region');") + .moveToNext() + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/FavoritesDao.kt b/app/src/main/java/net/vonforst/evmap/storage/FavoritesDao.kt index 819a5f7c..309d2f8f 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/FavoritesDao.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/FavoritesDao.kt @@ -19,7 +19,8 @@ interface FavoritesDao { @Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id") suspend fun getAllFavoritesAsync(): List - @Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE lat >= :lat1 AND lat <= :lat2 AND lng >= :lng1 AND lng <= :lng2") + @SkipQueryVerification + @Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE Within(chargelocation.coordinates, BuildMbr(:lng1, :lat1, :lng2, :lat2))") suspend fun getFavoritesInBoundsAsync( lat1: Double, lat2: Double, diff --git a/app/src/main/java/net/vonforst/evmap/storage/SavedRegionDao.kt b/app/src/main/java/net/vonforst/evmap/storage/SavedRegionDao.kt new file mode 100644 index 00000000..c2953790 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/storage/SavedRegionDao.kt @@ -0,0 +1,111 @@ +package net.vonforst.evmap.storage + +import androidx.lifecycle.LiveData +import androidx.lifecycle.map +import androidx.room.* +import co.anbora.labs.spatia.geometry.Geometry +import co.anbora.labs.spatia.geometry.LineString +import co.anbora.labs.spatia.geometry.Polygon +import net.vonforst.evmap.utils.circleAsEllipse +import java.time.Instant + +@Entity( + indices = [Index(value = ["filters", "dataSource"])] +) +data class SavedRegion( + val region: Polygon, + val dataSource: String, + val timeRetrieved: Instant, + val filters: String?, + val isDetailed: Boolean, + @PrimaryKey(autoGenerate = true) + val id: Long? = null +) + +@Dao +abstract class SavedRegionDao { + @SkipQueryVerification + @Query("SELECT GUnion(region) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)") + abstract fun getSavedRegion( + dataSource: String, + after: Long, + filters: String? = null, + isDetailed: Boolean = false + ): Geometry + + @SkipQueryVerification + @Query("SELECT Covers(GUnion(region), BuildMbr(:lng1, :lat1, :lng2, :lat2, 4326)) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND Intersects(region, BuildMbr(:lng1, :lat1, :lng2, :lat2, 4326)) AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)") + protected abstract fun savedRegionCoversInt( + lat1: Double, + lat2: Double, + lng1: Double, + lng2: Double, + dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false + ): LiveData + + @SkipQueryVerification + @Query("SELECT Covers(GUnion(region), MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND Intersects(region, MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)) AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)") + protected abstract fun savedRegionCoversRadiusInt( + lat: Double, + lng: Double, + radiusLat: Double, + radiusLng: Double, + dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false + ): LiveData + + fun savedRegionCovers( + lat1: Double, + lat2: Double, + lng1: Double, + lng2: Double, + dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false + ): LiveData { + return savedRegionCoversInt( + lat1, + lat2, + lng1, + lng2, + dataSource, + after, + filters, + isDetailed + ).map { it == 1 } + } + + fun savedRegionCoversRadius( + lat: Double, + lng: Double, + radius: Double, + dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false + ): LiveData { + val (radiusLat, radiusLng) = circleAsEllipse(lat, lng, radius) + return savedRegionCoversRadiusInt( + lat, + lng, + radiusLat, + radiusLng, + dataSource, + after, + filters, + isDetailed + ).map { it == 1 } + } + + @Insert + abstract suspend fun insert(savedRegion: SavedRegion) + + @Query("DELETE FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved <= :before") + abstract suspend fun deleteOutdated(dataSource: String, before: Long) + + @SkipQueryVerification + @Query("SELECT MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)") + protected abstract suspend fun makeEllipse( + lat: Double, lng: Double, + radiusLat: Double, radiusLng: Double + ): LineString + + suspend fun makeCircle(lat: Double, lng: Double, radius: Double): LineString { + val (radiusLat, radiusLng) = circleAsEllipse(lat, lng, radius) + return makeEllipse(lat, lng, radiusLat, radiusLng) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/TypeConverters.kt b/app/src/main/java/net/vonforst/evmap/storage/TypeConverters.kt index 09f9e0d5..f2fe6d9a 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/TypeConverters.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/TypeConverters.kt @@ -1,6 +1,7 @@ package net.vonforst.evmap.storage import androidx.room.TypeConverter +import co.anbora.labs.spatia.geometry.Point import com.car2go.maps.model.LatLng import com.car2go.maps.model.LatLngBounds import com.squareup.moshi.Moshi @@ -12,6 +13,7 @@ import net.vonforst.evmap.autocomplete.AutocompletePlaceType import net.vonforst.evmap.model.ChargeCardId import net.vonforst.evmap.model.Chargepoint import net.vonforst.evmap.model.ChargerPhoto +import net.vonforst.evmap.model.Coordinate import java.time.Instant import java.time.LocalTime @@ -154,4 +156,15 @@ class Converters { fun toAutocompletePlaceTypeList(value: String): List { return value.split(",").map { AutocompletePlaceType.valueOf(it) } } + + @TypeConverter + fun toCoordinate(value: Point): Coordinate { + if (value.srid != 4326) throw IllegalArgumentException("expected WGS-84") + return Coordinate(value.y, value.x) + } + + @TypeConverter + fun fromCoordinate(value: Coordinate): Point { + return Point(value.lng, value.lat) + } } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/ui/Clustering.kt b/app/src/main/java/net/vonforst/evmap/ui/Clustering.kt index 980e1759..499b2002 100644 --- a/app/src/main/java/net/vonforst/evmap/ui/Clustering.kt +++ b/app/src/main/java/net/vonforst/evmap/ui/Clustering.kt @@ -10,13 +10,10 @@ import net.vonforst.evmap.model.Coordinate fun cluster( - result: List, + locations: List, zoom: Float, clusterDistance: Int ): List { - val clusters = result.filterIsInstance() - val locations = result.filterIsInstance() - val clusterItems = locations.map { ChargepointClusterItem(it) } val algo = NonHierarchicalDistanceBasedAlgorithm() @@ -26,16 +23,18 @@ fun cluster( if (it.size == 1) { it.items.first().charger } else { - ChargeLocationCluster(it.size, Coordinate(it.position.latitude, it.position.longitude)) + ChargeLocationCluster( + it.size, + Coordinate(it.position.latitude, it.position.longitude), + it.items.map { it.charger }) } - } + clusters + } } private class ChargepointClusterItem(val charger: ChargeLocation) : ClusterItem { override fun getSnippet(): String? = null - override fun getTitle(): String? = charger.name + override fun getTitle(): String = charger.name override fun getPosition(): LatLng = LatLng(charger.coordinates.lat, charger.coordinates.lng) - } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/utils/LocationUtils.kt b/app/src/main/java/net/vonforst/evmap/utils/LocationUtils.kt index 6ce14fc2..cd30ab90 100644 --- a/app/src/main/java/net/vonforst/evmap/utils/LocationUtils.kt +++ b/app/src/main/java/net/vonforst/evmap/utils/LocationUtils.kt @@ -16,19 +16,29 @@ import kotlin.math.* * Adds a certain distance in meters to a location. Approximate calculation. */ fun Location.plusMeters(dx: Double, dy: Double): Pair { - val lat = this.latitude + (180 / Math.PI) * (dx / 6378137.0) - val lon = this.longitude + (180 / Math.PI) * (dy / 6378137.0) / cos(Math.toRadians(lat)) + val lat = this.latitude + (180 / Math.PI) * (dx / earthRadiusM) + val lon = this.longitude + (180 / Math.PI) * (dy / earthRadiusM) / cos(Math.toRadians(lat)) return Pair(lat, lon) } fun LatLng.plusMeters(dx: Double, dy: Double): LatLng { - val lat = this.latitude + (180 / Math.PI) * (dx / 6378137.0) - val lon = this.longitude + (180 / Math.PI) * (dy / 6378137.0) / cos(Math.toRadians(lat)) + val lat = this.latitude + (180 / Math.PI) * (dx / earthRadiusM) + val lon = this.longitude + (180 / Math.PI) * (dy / earthRadiusM) / cos(Math.toRadians(lat)) return LatLng(lat, lon) } const val earthRadiusM = 6378137.0 +/** + * Approximates a geodesic circle as an ellipse in geographical coordinates by giving its radius + * in latitude and longitude in degrees. + */ +fun circleAsEllipse(lat: Double, lng: Double, radius: Double): Pair { + val radiusLat = (180 / Math.PI) * (radius / earthRadiusM) + val radiusLon = (180 / Math.PI) * (radius / earthRadiusM) / cos(Math.toRadians(lat)) + return radiusLat to radiusLon +} + /** * Calculates the distance between two points on Earth in meters. * Latitude and longitude should be given in degrees. diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt index 136e61e0..c1c503df 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt @@ -1,9 +1,11 @@ package net.vonforst.evmap.viewmodel import android.app.Application +import android.graphics.Point import android.os.Parcelable import androidx.lifecycle.* import com.car2go.maps.AnyMap +import com.car2go.maps.Projection import com.car2go.maps.model.LatLng import com.car2go.maps.model.LatLngBounds import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike @@ -42,6 +44,7 @@ import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId import java.time.ZonedDateTime +import kotlin.math.roundToInt @Parcelize data class MapPosition(val bounds: LatLngBounds, val zoom: Float) : Parcelable @@ -66,6 +69,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle prefs ) private val availabilityRepo = AvailabilityRepository(application) + var mapProjection: Projection? = null val apiId = repo.api.map { it.id } @@ -516,34 +520,34 @@ class MapViewModel(application: Application, private val state: SavedStateHandle val mapPosition = data.first val filters = data.second + val bounds = extendBounds(mapPosition.bounds) if (filterStatus.value == FILTERS_FAVORITES) { // load favorites from local DB - val b = mapPosition.bounds - var chargers = db.favoritesDao().getFavoritesInBoundsAsync( - b.southwest.latitude, - b.northeast.latitude, - b.southwest.longitude, - b.northeast.longitude - ).map { it.charger } as List + val chargers = db.favoritesDao().getFavoritesInBoundsAsync( + bounds.southwest.latitude, + bounds.northeast.latitude, + bounds.southwest.longitude, + bounds.northeast.longitude + ).map { it.charger } val clusterDistance = getClusterDistance(mapPosition.zoom) - clusterDistance?.let { - chargers = cluster(chargers, mapPosition.zoom, clusterDistance) - } + val chargersClustered = clusterDistance?.let { + cluster(chargers, mapPosition.zoom, clusterDistance) + } ?: chargers filteredConnectors.value = null filteredMinPower.value = null filteredChargeCards.value = null - chargepoints.value = Resource.success(chargers) + chargepoints.value = Resource.success(chargersClustered) return@throttleLatest } - val result = repo.getChargepoints(mapPosition.bounds, mapPosition.zoom, filters) + val result = repo.getChargepoints(bounds, mapPosition.zoom, filters) chargepointsInternal?.let { chargepoints.removeSource(it) } chargepointsInternal = result chargepoints.addSource(result) { val apiId = apiId.value when (apiId) { - "going_electric" -> { + "goingelectric" -> { val chargeCardsVal = filters.getMultipleChoiceValue("chargecards")!! filteredChargeCards.value = if (chargeCardsVal.all) null else chargeCardsVal.values.map { it.toLong() } @@ -556,7 +560,8 @@ class MapViewModel(application: Application, private val state: SavedStateHandle }.toSet() filteredMinPower.value = filters.getSliderValue("min_power") } - "open_charge_map" -> { + + "openchargemap" -> { val connectorsVal = filters.getMultipleChoiceValue("connectors")!! filteredConnectors.value = if (connectorsVal.all) null else connectorsVal.values.map { @@ -578,6 +583,20 @@ class MapViewModel(application: Application, private val state: SavedStateHandle } } + /** + * expands LatLngBounds beyond the viewport (1.5x the width and height) + */ + private fun extendBounds(bounds: LatLngBounds): LatLngBounds { + val mapProjection = mapProjection ?: return bounds + val swPoint = mapProjection.toScreenLocation(bounds.southwest) + val nePoint = mapProjection.toScreenLocation(bounds.northeast) + val dx = ((nePoint.x - swPoint.x) * 0.25).roundToInt() + val dy = ((nePoint.y - swPoint.y) * 0.25).roundToInt() + val newSw = mapProjection.fromScreenLocation(Point(swPoint.x - dx, swPoint.y - dy)) + val newNe = mapProjection.fromScreenLocation(Point(nePoint.x + dx, nePoint.y + dy)) + return LatLngBounds(newSw, newNe) + } + fun reloadAvailability() { triggerAvailabilityRefresh.value = true } diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/Utils.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/Utils.kt index c1d80151..7847a582 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/Utils.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/Utils.kt @@ -143,4 +143,20 @@ suspend fun LiveData>.awaitFinished(): Resource { removeObserver(observer) } } +} + +inline fun LiveData.singleSwitchMap(crossinline transform: (X) -> LiveData?): MediatorLiveData { + val result = MediatorLiveData() + result.addSource(this@singleSwitchMap, object : Observer { + override fun onChanged(t: X) { + if (t == null) return + result.removeSource(this@singleSwitchMap) + transform(t)?.let { transformed -> + result.addSource(transformed) { + result.value = it + } + } + } + }) + return result } \ No newline at end of file