add support for offline caching

This commit is contained in:
johan12345
2022-09-10 19:44:51 +02:00
committed by Johan von Forstner
parent 26136dc482
commit edfce541f6
20 changed files with 1061 additions and 166 deletions

View File

@@ -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"

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View 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)
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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,

View 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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}