mirror of
https://github.com/ev-map/EVMap.git
synced 2026-04-30 11:04:16 -04:00
add support for offline caching
This commit is contained in:
committed by
Johan von Forstner
parent
26136dc482
commit
edfce541f6
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Context>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,14 +428,15 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
} else {
|
||||
// try multiple search radii until we have enough chargers
|
||||
var chargers: List<ChargeLocation>? = 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()
|
||||
|
||||
@@ -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<out T : ReferenceData> {
|
||||
/**
|
||||
* Query for chargepoints within certain geographic bounds
|
||||
*/
|
||||
suspend fun getChargepoints(
|
||||
referenceData: ReferenceData,
|
||||
bounds: LatLngBounds,
|
||||
zoom: Float,
|
||||
useClustering: Boolean,
|
||||
filters: FilterValues?
|
||||
): Resource<List<ChargepointListItem>>
|
||||
): Resource<ChargepointList>
|
||||
|
||||
/**
|
||||
* 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<List<ChargepointListItem>>
|
||||
): Resource<ChargepointList>
|
||||
|
||||
/**
|
||||
* Fetches detailed data for a specific charging site
|
||||
*/
|
||||
suspend fun getChargepointDetail(
|
||||
referenceData: ReferenceData,
|
||||
id: Long
|
||||
@@ -34,8 +46,15 @@ interface ChargepointApi<out T : ReferenceData> {
|
||||
|
||||
fun getFilters(referenceData: ReferenceData, sp: StringProvider): List<Filter<FilterValue>>
|
||||
|
||||
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<ReferenceData> {
|
||||
}
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
data class FiltersSQLQuery(
|
||||
val query: String,
|
||||
val requiresChargepointQuery: Boolean,
|
||||
val requiresChargeCardQuery: Boolean
|
||||
)
|
||||
|
||||
data class ChargepointList(val items: List<ChargepointListItem>, val isComplete: Boolean) {
|
||||
companion object {
|
||||
fun empty() = ChargepointList(emptyList(), true)
|
||||
}
|
||||
}
|
||||
@@ -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<GEReferenceData> {
|
||||
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<List<ChargepointListItem>> {
|
||||
): Resource<ChargepointList> {
|
||||
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<List<ChargepointListItem>> {
|
||||
): Resource<ChargepointList> {
|
||||
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<GEChargepointListItem>,
|
||||
minPower: Int?,
|
||||
connectorsVal: MultipleChoiceFilterValue?,
|
||||
minConnectors: Int?,
|
||||
zoom: Float
|
||||
minConnectors: Int?
|
||||
): List<ChargepointListItem> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<List<OCMChargepoint>>
|
||||
@@ -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<List<OCMChargepoint>>
|
||||
@@ -105,11 +105,11 @@ class OpenChargeMapApiWrapper(
|
||||
baseurl: String = "https://api.openchargemap.io/v3/",
|
||||
context: Context? = null
|
||||
) : ChargepointApi<OCMReferenceData> {
|
||||
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<List<ChargepointListItem>> {
|
||||
): Resource<ChargepointList> {
|
||||
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<List<ChargepointListItem>> {
|
||||
): Resource<ChargepointList> {
|
||||
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<ChargepointListItem> {
|
||||
// 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<ChargepointListItem>
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<Chargepoint>,
|
||||
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<ChargeLocation>? = null
|
||||
) : ChargepointListItem()
|
||||
|
||||
@Parcelize
|
||||
|
||||
@@ -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<out T : FilterValue> : 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<T : FilterValue>(val filter: Filter<T>, 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
|
||||
131
app/src/main/java/net/vonforst/evmap/storage/CacheLiveData.kt
Normal file
131
app/src/main/java/net/vonforst/evmap/storage/CacheLiveData.kt
Normal file
@@ -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<T>(
|
||||
cache: LiveData<T>,
|
||||
api: LiveData<Resource<T>>,
|
||||
skipApi: LiveData<Boolean>? = null
|
||||
) :
|
||||
MediatorLiveData<Resource<T>>() {
|
||||
private var cacheResult: T? = null
|
||||
private var apiResult: Resource<T>? = 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<ChargeLocation>,
|
||||
val api: LiveData<Resource<ChargeLocation>>,
|
||||
cacheSoftLimit: Duration
|
||||
) :
|
||||
MediatorLiveData<Resource<ChargeLocation>>() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ChargeLocation>
|
||||
|
||||
@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<List<ChargeLocation>>
|
||||
|
||||
@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<List<ChargeLocation>>
|
||||
|
||||
@RawQuery(observedEntities = [ChargeLocation::class])
|
||||
abstract fun getChargeLocationsCustom(query: SupportSQLiteQuery): LiveData<List<ChargeLocation>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The ChargeLocationsRepository wraps the ChargepointApi and the DB to provide caching
|
||||
* functionality.
|
||||
* and clustering functionality.
|
||||
*/
|
||||
class ChargeLocationsRepository(
|
||||
api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
|
||||
@@ -36,6 +92,13 @@ class ChargeLocationsRepository(
|
||||
) {
|
||||
val api = MutableLiveData<ChargepointApi<ReferenceData>>().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<Resource<List<ChargepointListItem>>> {
|
||||
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<ChargeLocation>()
|
||||
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<Resource<List<ChargepointListItem>>> {
|
||||
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<ChargeLocation>()
|
||||
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<ChargepointList>,
|
||||
zoom: Float
|
||||
): Resource<List<ChargepointListItem>> {
|
||||
val list = result.data ?: return Resource(result.status, null, result.message)
|
||||
val chargers = list.items.filterIsInstance<ChargeLocation>()
|
||||
|
||||
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<ChargeLocation>,
|
||||
zoom: Float
|
||||
): List<ChargepointListItem> {
|
||||
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<Resource<ChargeLocation>> {
|
||||
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<List<Filter<FilterValue>>>().apply {
|
||||
@@ -122,4 +333,79 @@ class ChargeLocationsRepository(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryWithFilters(
|
||||
api: ChargepointApi<ReferenceData>,
|
||||
filters: FilterValues,
|
||||
bounds: LatLngBounds
|
||||
): LiveData<List<ChargeLocation>> {
|
||||
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<ReferenceData>,
|
||||
filters: FilterValues,
|
||||
location: LatLng,
|
||||
radius: Double
|
||||
): LiveData<List<ChargeLocation>> {
|
||||
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<ReferenceData>,
|
||||
filters: FilterValues,
|
||||
regionSql: String,
|
||||
orderSql: String? = null
|
||||
): LiveData<List<ChargeLocation>> = 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<ReferenceData>): Long {
|
||||
val cacheLimit = api.cacheLimit
|
||||
return Instant.now().minus(cacheLimit).toEpochMilli()
|
||||
}
|
||||
|
||||
private fun cacheSoftLimitDate(api: ChargepointApi<ReferenceData>): Long {
|
||||
val cacheLimit = maxOf(api.cacheLimit, Duration.ofDays(2))
|
||||
return Instant.now().minus(cacheLimit).toEpochMilli()
|
||||
}
|
||||
}
|
||||
@@ -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>): 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FavoriteWithDetail>
|
||||
|
||||
@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,
|
||||
|
||||
111
app/src/main/java/net/vonforst/evmap/storage/SavedRegionDao.kt
Normal file
111
app/src/main/java/net/vonforst/evmap/storage/SavedRegionDao.kt
Normal file
@@ -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<Int>
|
||||
|
||||
@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<Int>
|
||||
|
||||
fun savedRegionCovers(
|
||||
lat1: Double,
|
||||
lat2: Double,
|
||||
lng1: Double,
|
||||
lng2: Double,
|
||||
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
|
||||
): LiveData<Boolean> {
|
||||
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<Boolean> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<AutocompletePlaceType> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,10 @@ import net.vonforst.evmap.model.Coordinate
|
||||
|
||||
|
||||
fun cluster(
|
||||
result: List<ChargepointListItem>,
|
||||
locations: List<ChargeLocation>,
|
||||
zoom: Float,
|
||||
clusterDistance: Int
|
||||
): List<ChargepointListItem> {
|
||||
val clusters = result.filterIsInstance<ChargeLocationCluster>()
|
||||
val locations = result.filterIsInstance<ChargeLocation>()
|
||||
|
||||
val clusterItems = locations.map { ChargepointClusterItem(it) }
|
||||
|
||||
val algo = NonHierarchicalDistanceBasedAlgorithm<ChargepointClusterItem>()
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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<Double, Double> {
|
||||
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<Double, Double> {
|
||||
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.
|
||||
|
||||
@@ -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<ChargepointListItem>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -143,4 +143,20 @@ suspend fun <T> LiveData<Resource<T>>.awaitFinished(): Resource<T> {
|
||||
removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <X, Y> LiveData<X>.singleSwitchMap(crossinline transform: (X) -> LiveData<Y>?): MediatorLiveData<Y> {
|
||||
val result = MediatorLiveData<Y>()
|
||||
result.addSource(this@singleSwitchMap, object : Observer<X> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user