Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
074e0bf904 | ||
|
|
41ac223e97 | ||
|
|
f7196bcce0 | ||
|
|
4f6092e5dc | ||
|
|
dfd42e1ffd | ||
|
|
895b24d406 | ||
|
|
3dea7993f3 | ||
|
|
ca90f1b37f | ||
|
|
fe0843e653 | ||
|
|
0f42ae84de | ||
|
|
2748b0a3db | ||
|
|
14798dee6a | ||
|
|
1cb48f7e0e | ||
|
|
dc0f4d3eab | ||
|
|
8ae954f37b | ||
|
|
1ed3b73285 | ||
|
|
2ba6a86b34 | ||
|
|
463ff61420 | ||
|
|
81b4e77a66 | ||
|
|
d16d48bf8f | ||
|
|
edfce541f6 | ||
|
|
26136dc482 | ||
|
|
0d11e450ac | ||
|
|
265b530936 | ||
|
|
8c5c7aeb58 | ||
|
|
23873dccdb | ||
|
|
6006790ffb | ||
|
|
f5fc32f420 | ||
|
|
90c6357093 | ||
|
|
69ca8723a5 | ||
|
|
20400b630a | ||
|
|
b22ca736cb | ||
|
|
ea906ec969 | ||
|
|
ec2b6d4f28 | ||
|
|
e7c2683ee2 | ||
|
|
d76051ec3a |
@@ -1,7 +1,8 @@
|
||||
EVMap [](https://github.com/ev-map/EVMap/actions)
|
||||
=====
|
||||
|
||||
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
|
||||
<a href="https://ev-map.app" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/></a>
|
||||
|
||||
Android app to find electric vehicle charging stations.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 844 KiB After Width: | Height: | Size: 872 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 875 KiB After Width: | Height: | Size: 886 KiB |
|
Before Width: | Height: | Size: 844 KiB After Width: | Height: | Size: 872 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1005 KiB |
|
Before Width: | Height: | Size: 841 KiB After Width: | Height: | Size: 848 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 864 KiB After Width: | Height: | Size: 884 KiB |
|
Before Width: | Height: | Size: 841 KiB After Width: | Height: | Size: 848 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 114 KiB |
@@ -8,6 +8,7 @@ apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'androidx.navigation.safeargs.kotlin'
|
||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
apply plugin: 'pt.jcosta.resourceplaceholders'
|
||||
|
||||
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
|
||||
|
||||
@@ -20,8 +21,8 @@ android {
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 33
|
||||
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||
versionCode 170
|
||||
versionName "1.5.0"
|
||||
versionCode 184
|
||||
versionName "1.6.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
resConfigs supportedLocales.split(',')
|
||||
@@ -109,6 +110,9 @@ android {
|
||||
unitTests.includeAndroidResources true
|
||||
}
|
||||
|
||||
resourcePlaceholders {
|
||||
files = ['xml/shortcuts.xml']
|
||||
}
|
||||
namespace 'net.vonforst.evmap'
|
||||
|
||||
// add API keys from environment variable if not set in apikeys.xml
|
||||
@@ -152,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 {
|
||||
@@ -174,6 +184,7 @@ dependencies {
|
||||
implementation 'androidx.browser:browser:1.5.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
implementation "androidx.work:work-runtime-ktx:2.8.1"
|
||||
implementation 'com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
|
||||
@@ -199,7 +210,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'
|
||||
@@ -235,6 +246,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.7'
|
||||
|
||||
// billing library
|
||||
def billing_version = "6.0.0"
|
||||
@@ -257,6 +269,9 @@ dependencies {
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
|
||||
//noinspection GradleDependency
|
||||
testImplementation 'org.json:json:20080701'
|
||||
testImplementation 'org.robolectric:robolectric:4.9.2'
|
||||
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"
|
||||
@@ -265,6 +280,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,13 @@
|
||||
android:name="androidx.car.app.CarAppService"
|
||||
android:category="androidx.car.app.category.POI" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="net.vonforst.evmap" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -27,6 +27,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.car2go.maps.model.LatLng
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
@@ -121,6 +122,8 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
}
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
handleActionsIntent(intent)
|
||||
|
||||
val mapScreen = MapScreen(carContext, this)
|
||||
val screens = mutableListOf<Screen>(mapScreen)
|
||||
|
||||
@@ -157,6 +160,30 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
return screens.last()
|
||||
}
|
||||
|
||||
private fun handleActionsIntent(intent: Intent): Boolean {
|
||||
intent.data?.let {
|
||||
if (it.host == "find_charger") {
|
||||
val lat = it.getQueryParameter("latitude")?.toDouble()
|
||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||
val name = it.getQueryParameter("name")
|
||||
if (lat != null && lon != null) {
|
||||
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
|
||||
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
|
||||
return true
|
||||
} else if (name != null) {
|
||||
val screenManager = carContext.getCarService(ScreenManager::class.java)
|
||||
screenManager.push(PlaceSearchScreen(carContext, this, name))
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
handleActionsIntent(intent)
|
||||
}
|
||||
|
||||
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
|
||||
|
||||
private fun updateLocation(location: Location?) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -31,7 +31,11 @@ import java.io.IOException
|
||||
import java.time.Instant
|
||||
|
||||
@ExperimentalCarApi
|
||||
class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
|
||||
class PlaceSearchScreen(
|
||||
ctx: CarContext,
|
||||
val session: EVMapSession,
|
||||
val initialSearch: String = ""
|
||||
) : Screen(ctx),
|
||||
SearchTemplate.SearchCallback, LocationAwareScreen,
|
||||
DefaultLifecycleObserver {
|
||||
private val hardwareMan: CarHardwareManager by lazy {
|
||||
@@ -64,13 +68,15 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
update("")
|
||||
update(initialSearch)
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return SearchTemplate.Builder(this).apply {
|
||||
setHeaderAction(Action.BACK)
|
||||
setSearchHint(carContext.getString(R.string.search))
|
||||
setInitialSearchText(initialSearch)
|
||||
setShowKeyboardByDefault(initialSearch == "")
|
||||
resultList?.let {
|
||||
setItemList(buildItemList(it))
|
||||
} ?: setLoading(true)
|
||||
|
||||
@@ -54,6 +54,13 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="net.vonforst.evmap" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="distractionOptimized"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
@@ -23,6 +24,10 @@
|
||||
<application
|
||||
android:name=".EvMapApplication"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:dataExtractionRules="@xml/backup_rules_api31"
|
||||
android:fullBackupOnly="true"
|
||||
android:backupAgent=".storage.BackupAgent"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -264,6 +269,13 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="net.vonforst.evmap" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
@@ -278,6 +290,18 @@
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<!-- Remove WorkManagerInitializer as we implement getWorkManagerConfiguration in application class -->
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,6 +1,9 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.work.*
|
||||
import net.vonforst.evmap.storage.CleanupCacheWorker
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.updateAppLocale
|
||||
import net.vonforst.evmap.ui.updateNightMode
|
||||
@@ -9,8 +12,9 @@ import org.acra.config.limiter
|
||||
import org.acra.config.mailSender
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import java.time.Duration
|
||||
|
||||
class EvMapApplication : Application() {
|
||||
class EvMapApplication : Application(), Configuration.Provider {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val prefs = PreferenceDataSource(this)
|
||||
@@ -48,5 +52,20 @@ class EvMapApplication : Application() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cleanupCacheRequest = PeriodicWorkRequestBuilder<CleanupCacheWorker>(Duration.ofDays(1))
|
||||
.setConstraints(Constraints.Builder().apply {
|
||||
setRequiresBatteryNotLow(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setRequiresDeviceIdle(true)
|
||||
}
|
||||
}.build()).build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.REPLACE, cleanupCacheRequest
|
||||
)
|
||||
}
|
||||
|
||||
override fun getWorkManagerConfiguration(): Configuration {
|
||||
return Configuration.Builder().build()
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
|
||||
.createPendingIntent()
|
||||
} else if (query != null && query.isNotEmpty()) {
|
||||
} else if (!query.isNullOrEmpty()) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
@@ -171,6 +171,32 @@ class MapsActivity : AppCompatActivity(),
|
||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent.scheme == "net.vonforst.evmap") {
|
||||
intent.data?.let {
|
||||
if (it.host == "find_charger") {
|
||||
val lat = it.getQueryParameter("latitude")?.toDouble()
|
||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||
val name = it.getQueryParameter("name")
|
||||
if (lat != null && lon != null) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(
|
||||
MapFragmentArgs(
|
||||
latLng = LatLng(lat, lon),
|
||||
locationName = name
|
||||
).toBundle()
|
||||
)
|
||||
.createPendingIntent()
|
||||
} else if (name != null) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(locationName = name).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setDestination(R.id.map)
|
||||
|
||||
@@ -8,23 +8,36 @@ 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
|
||||
import java.time.Instant
|
||||
|
||||
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 +47,17 @@ interface ChargepointApi<out T : ReferenceData> {
|
||||
|
||||
fun getFilters(referenceData: ReferenceData, sp: StringProvider): List<Filter<FilterValue>>
|
||||
|
||||
fun convertFiltersToSQL(filters: FilterValues, referenceData: ReferenceData): FiltersSQLQuery
|
||||
|
||||
fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean
|
||||
|
||||
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 +88,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)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ interface TeslaOwnerApi {
|
||||
data class UserInfo(
|
||||
val email: String,
|
||||
@Json(name = "full_name") val fullName: String,
|
||||
@Json(name = "profile_image_url") val profileImageUrl: String
|
||||
@Json(name = "profile_image_url") val profileImageUrl: String?
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -96,7 +96,7 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
|
||||
false -> null // Response was false
|
||||
else -> {
|
||||
if (this.clazz == GEFaultReport::class.java) {
|
||||
GEFaultReport(null, null) as T
|
||||
GEFaultReport(null, "") as T
|
||||
} else {
|
||||
throw IllegalStateException("Non-false boolean for @JsonObjectOrFalse field")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.vonforst.evmap.api.goingelectric
|
||||
|
||||
import android.content.Context
|
||||
import android.database.DatabaseUtils
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import com.squareup.moshi.Moshi
|
||||
@@ -13,7 +14,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 +23,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 +127,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 +151,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 +218,9 @@ class GoingElectricApiWrapper(
|
||||
}
|
||||
} while (startkey != null && startkey < 10000)
|
||||
|
||||
val result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
|
||||
val result = postprocessResult(data, filters)
|
||||
|
||||
return Resource.success(result)
|
||||
return Resource.success(ChargepointList(result, startkey == null))
|
||||
}
|
||||
|
||||
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
|
||||
@@ -230,8 +231,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 +245,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 +309,24 @@ class GoingElectricApiWrapper(
|
||||
}
|
||||
} while (startkey != null && startkey < 10000)
|
||||
|
||||
val result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
|
||||
return Resource.success(result)
|
||||
val result = postprocessResult(data, filters)
|
||||
return Resource.success(ChargepointList(result, startkey == null))
|
||||
}
|
||||
|
||||
private fun postprocessResult(
|
||||
chargers: List<GEChargepointListItem>,
|
||||
minPower: Int?,
|
||||
connectorsVal: MultipleChoiceFilterValue?,
|
||||
minConnectors: Int?,
|
||||
zoom: Float
|
||||
filters: FilterValues?
|
||||
): List<ChargepointListItem> {
|
||||
// apply filters which GoingElectric does not support natively
|
||||
var result = chargers.filter { it ->
|
||||
val minPower = filters?.getSliderValue("min_power")
|
||||
val minConnectors = filters?.getSliderValue("min_connectors")
|
||||
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
val freecharging = filters?.getBooleanValue("freecharging")
|
||||
val freeparking = filters?.getBooleanValue("freeparking")
|
||||
val open247 = filters?.getBooleanValue("open_247")
|
||||
val barrierfree = filters?.getBooleanValue("barrierfree")
|
||||
|
||||
return chargers.filter { it ->
|
||||
// apply filters which GoingElectric does not support natively
|
||||
if (it is GEChargeLocation) {
|
||||
it.chargepoints
|
||||
.filter { it.power >= (minPower ?: 0) }
|
||||
@@ -329,19 +335,35 @@ class GoingElectricApiWrapper(
|
||||
} else {
|
||||
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!!)
|
||||
}.map {
|
||||
// infer some properties based on applied filters
|
||||
if (it is GEChargeLocation) {
|
||||
var inferred = it
|
||||
if (freecharging == true) {
|
||||
inferred = inferred.copy(
|
||||
cost = inferred.cost?.copy(freecharging = true)
|
||||
?: GECost(freecharging = true)
|
||||
)
|
||||
}
|
||||
if (freeparking == true) {
|
||||
inferred = inferred.copy(
|
||||
cost = inferred.cost?.copy(freeparking = true) ?: GECost(freeparking = true)
|
||||
)
|
||||
}
|
||||
if (open247 == true) {
|
||||
inferred = inferred.copy(
|
||||
openinghours = inferred.openinghours?.copy(twentyfourSeven = true)
|
||||
?: GEOpeningHours(twentyfourSeven = true)
|
||||
)
|
||||
}
|
||||
if (barrierfree == true) {
|
||||
inferred = inferred.copy(barrierFree = true)
|
||||
}
|
||||
inferred
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
return result
|
||||
}.map { it.convert(apikey, false) }
|
||||
}
|
||||
|
||||
override suspend fun getChargepointDetail(
|
||||
@@ -481,5 +503,98 @@ 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(",") {
|
||||
DatabaseUtils.sqlEscapeString(
|
||||
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(",") { DatabaseUtils.sqlEscapeString(it) }
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
|
||||
val chargecards = filters.getMultipleChoiceValue("chargecards")
|
||||
return filters.getBooleanValue("freecharging") == true
|
||||
|| filters.getBooleanValue("freeparking") == true
|
||||
|| filters.getBooleanValue("open_247") == true
|
||||
|| filters.getBooleanValue("barrierfree") == true
|
||||
|| (chargecards != null && !chargecards.all)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +86,10 @@ data class GEChargeLocation(
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GECost(
|
||||
val freecharging: Boolean,
|
||||
val freeparking: Boolean,
|
||||
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
|
||||
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
|
||||
val freecharging: Boolean = false,
|
||||
val freeparking: Boolean = false,
|
||||
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String? = null,
|
||||
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String? = null
|
||||
) {
|
||||
fun convert() = Cost(
|
||||
// In GE, freecharging = false can either mean "paid charging" or "no information
|
||||
@@ -104,8 +104,8 @@ data class GECost(
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GEOpeningHours(
|
||||
@Json(name = "24/7") val twentyfourSeven: Boolean,
|
||||
@JsonObjectOrFalse val description: String?,
|
||||
val days: GEOpeningHoursDays?
|
||||
@JsonObjectOrFalse val description: String? = null,
|
||||
val days: GEOpeningHoursDays? = null
|
||||
) {
|
||||
fun convert() = OpeningHours(twentyfourSeven, description, days?.convert())
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
package net.vonforst.evmap.api.openchargemap
|
||||
|
||||
import android.content.Context
|
||||
import android.database.DatabaseUtils
|
||||
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 +19,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 +31,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 +46,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 +106,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 +119,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 +131,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 +156,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 +176,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 +188,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 +211,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 +232,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 +322,70 @@ 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(",") {
|
||||
DatabaseUtils.sqlEscapeString(
|
||||
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 ->
|
||||
DatabaseUtils.sqlEscapeString(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)
|
||||
}
|
||||
|
||||
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
|
||||
val operators = filters.getMultipleChoiceValue("operators")
|
||||
return (operators != null && !operators.all)
|
||||
// TODO: it would be possible to implement this without requiring details if we extended the data structure to also save the operator ID in the DB
|
||||
}
|
||||
|
||||
}
|
||||
@@ -101,7 +101,7 @@ data class OCMChargepoint(
|
||||
connections.first { it.statusType != null && it.statusTypeId in faultStatuses }.statusType!!.title
|
||||
)
|
||||
}
|
||||
return FaultReport(null, null)
|
||||
return FaultReport(null, "")
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ class ChargepriceFragment : Fragment() {
|
||||
|
||||
val vehicleAdapter = CheckableChargepriceCarAdapter()
|
||||
headerBinding.vehicleSelection.adapter = vehicleAdapter
|
||||
val vehicleObserver: Observer<ChargepriceCar> = Observer {
|
||||
val vehicleObserver: Observer<ChargepriceCar?> = Observer {
|
||||
vehicleAdapter.setCheckedItem(it)
|
||||
}
|
||||
vm.vehicle.observe(viewLifecycleOwner, vehicleObserver)
|
||||
@@ -172,7 +172,7 @@ class ChargepriceFragment : Fragment() {
|
||||
|
||||
val connectorsAdapter = CheckableConnectorAdapter()
|
||||
|
||||
val observer: Observer<Chargepoint> = Observer {
|
||||
val observer: Observer<Chargepoint?> = Observer {
|
||||
connectorsAdapter.setCheckedItem(it)
|
||||
}
|
||||
vm.chargepoint.observe(viewLifecycleOwner, observer)
|
||||
|
||||
@@ -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,16 +885,39 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
binding.scaleView.apply {
|
||||
when (prefs.mapScale) {
|
||||
"both" -> {
|
||||
visibility = View.VISIBLE
|
||||
metersAndMiles()
|
||||
}
|
||||
|
||||
"meters" -> {
|
||||
visibility = View.VISIBLE
|
||||
metersOnly()
|
||||
}
|
||||
|
||||
"miles" -> {
|
||||
visibility = View.VISIBLE
|
||||
milesOnly()
|
||||
}
|
||||
|
||||
"off" -> visibility = View.GONE
|
||||
}
|
||||
}
|
||||
vm.mapPosition.observe(viewLifecycleOwner) {
|
||||
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
|
||||
}
|
||||
@@ -967,9 +990,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
vm.loadChargerById(chargerId)
|
||||
vm.chargerSparse.observe(
|
||||
viewLifecycleOwner,
|
||||
object : Observer<ChargeLocation> {
|
||||
override fun onChanged(value: ChargeLocation) {
|
||||
if (value.id == chargerId) {
|
||||
object : Observer<ChargeLocation?> {
|
||||
override fun onChanged(value: ChargeLocation?) {
|
||||
if (value?.id == chargerId) {
|
||||
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(
|
||||
LatLng(value.coordinates.lat, value.coordinates.lng), 16f
|
||||
)
|
||||
@@ -1003,44 +1026,26 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
} else {
|
||||
// mark location as search result
|
||||
vm.searchResult.value = PlaceWithBounds(latLng, boundingBox(latLng, 750.0))
|
||||
locationName?.let { binding.search.setText(it) }
|
||||
}
|
||||
|
||||
positionSet = true
|
||||
} else if (locationName != null) {
|
||||
lifecycleScope.launch {
|
||||
val address = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Geocoder(requireContext()).getFromLocationName(locationName, 1)
|
||||
?.getOrNull(0)
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
address?.let {
|
||||
val latLng = LatLng(it.latitude, it.longitude)
|
||||
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(latLng, 16f)
|
||||
map.moveCamera(cameraUpdate)
|
||||
val bboxSize = if (it.subAdminArea != null) {
|
||||
750.0 // this is a place within a city
|
||||
} else if (it.adminArea != null && it.adminArea != it.featureName) {
|
||||
4000.0 // this is a city
|
||||
} else if (it.adminArea != null) {
|
||||
100000.0 // this is a top-level administrative area (i.e. state)
|
||||
} else {
|
||||
500000.0 // this is a country
|
||||
}
|
||||
vm.searchResult.value = PlaceWithBounds(latLng, boundingBox(latLng, bboxSize))
|
||||
}
|
||||
}
|
||||
binding.search.setText(locationName)
|
||||
binding.search.requestFocus()
|
||||
binding.search.setSelection(locationName.length)
|
||||
}
|
||||
if (context.checkAnyLocationPermission()) {
|
||||
enableLocation(!positionSet, false)
|
||||
positionSet = true
|
||||
}
|
||||
if (!positionSet) {
|
||||
// center the camera on Europe
|
||||
// use position saved in preferences, fall back to default (Europe)
|
||||
val cameraUpdate =
|
||||
map.cameraUpdateFactory.newLatLngZoom(LatLng(50.113388, 9.252536), 3.5f)
|
||||
map.cameraUpdateFactory.newLatLngZoom(
|
||||
prefs.currentMapLocation,
|
||||
prefs.currentMapZoom
|
||||
)
|
||||
map.moveCamera(cameraUpdate)
|
||||
}
|
||||
|
||||
@@ -1378,6 +1383,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
removeLocationUpdates()
|
||||
vm.mapPosition.value?.let {
|
||||
prefs.currentMapLocation = it.bounds.center
|
||||
prefs.currentMapZoom = it.zoom
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -76,14 +76,21 @@ class AboutFragment : PreferenceFragmentCompat() {
|
||||
true
|
||||
}
|
||||
|
||||
"website" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.website_url))
|
||||
true
|
||||
}
|
||||
|
||||
"github_link" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link))
|
||||
true
|
||||
}
|
||||
|
||||
"privacy" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.privacy_link))
|
||||
true
|
||||
}
|
||||
|
||||
"faq" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link))
|
||||
true
|
||||
|
||||
@@ -41,8 +41,23 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings_data, rootKey)
|
||||
teslaAccountPreference = findPreference<Preference>("tesla_account")!!
|
||||
teslaAccountPreference = findPreference("tesla_account")!!
|
||||
refreshTeslaAccountStatus()
|
||||
|
||||
vm.chargerCacheCount.observe(this) {
|
||||
updateCacheSizeSummary()
|
||||
}
|
||||
vm.chargerCacheSize.observe(this) {
|
||||
updateCacheSizeSummary()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCacheSizeSummary() {
|
||||
val count = vm.chargerCacheCount.value ?: return
|
||||
val size = vm.chargerCacheSize.value ?: return
|
||||
val sizeMb = size.toFloat() / 1024 / 1024
|
||||
findPreference<Preference>("cache_size")!!.summary =
|
||||
getString(R.string.settings_cache_count_summary, count, sizeMb)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -114,6 +129,11 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
true
|
||||
}
|
||||
|
||||
"cache_clear" -> {
|
||||
vm.clearChargerCache()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
@@ -147,7 +167,7 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
teslaAccountPreference.summary = getString(R.string.logging_in)
|
||||
|
||||
val url = Uri.parse(result.getString("url"))
|
||||
val code = url.getQueryParameter("code")!!
|
||||
val code = url.getQueryParameter("code") ?: return
|
||||
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
|
||||
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
|
||||
lifecycleScope.launch {
|
||||
|
||||
@@ -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
|
||||
54
app/src/main/java/net/vonforst/evmap/storage/BackupAgent.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.app.backup.BackupAgent
|
||||
import android.app.backup.BackupDataInput
|
||||
import android.app.backup.BackupDataOutput
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.time.Instant
|
||||
|
||||
private const val backupFileName = "evmap-backup.db"
|
||||
|
||||
class BackupAgent : BackupAgent() {
|
||||
override fun onBackup(
|
||||
oldState: ParcelFileDescriptor,
|
||||
data: BackupDataOutput,
|
||||
newState: ParcelFileDescriptor
|
||||
) {
|
||||
// unused on Android M+, we don't support backups on older versions
|
||||
}
|
||||
|
||||
override fun onRestore(
|
||||
data: BackupDataInput,
|
||||
appVersionCode: Int,
|
||||
newState: ParcelFileDescriptor
|
||||
) {
|
||||
// unused on Android M+, we don't support backups on older versions
|
||||
}
|
||||
|
||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||
runBlocking {
|
||||
// creates a backup of the app database to evmap-backup.db
|
||||
AppDatabase.getInstance(applicationContext).createBackup(
|
||||
applicationContext,
|
||||
backupFileName
|
||||
)
|
||||
}
|
||||
super.onFullBackup(data)
|
||||
val backupDb = applicationContext.getDatabasePath(backupFileName)
|
||||
if (backupDb.exists()) backupDb.delete()
|
||||
}
|
||||
|
||||
override fun onRestoreFinished() {
|
||||
super.onRestoreFinished()
|
||||
// rename restored backup DB as evmap.db
|
||||
val backupDb = applicationContext.getDatabasePath(backupFileName)
|
||||
if (backupDb.exists()) {
|
||||
backupDb.renameTo(applicationContext.getDatabasePath("evmap.db"))
|
||||
}
|
||||
// clear cache age
|
||||
PreferenceDataSource(applicationContext).lastGeReferenceDataUpdate = Instant.EPOCH
|
||||
PreferenceDataSource(applicationContext).lastOcmReferenceDataUpdate = Instant.EPOCH
|
||||
}
|
||||
}
|
||||
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,103 @@
|
||||
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("DELETE FROM chargelocation WHERE dataSource == :dataSource AND timeRetrieved <= :before AND NOT EXISTS (SELECT 1 FROM favorite WHERE favorite.chargerId = chargelocation.id)")
|
||||
abstract suspend fun deleteOutdatedIfNotFavorite(dataSource: String, before: Long)
|
||||
|
||||
@Query("DELETE FROM chargelocation WHERE NOT EXISTS (SELECT 1 FROM favorite WHERE favorite.chargerId = chargelocation.id)")
|
||||
abstract suspend fun deleteAllIfNotFavorite()
|
||||
|
||||
@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>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM chargelocation")
|
||||
abstract fun getCount(): LiveData<Long>
|
||||
|
||||
@SkipQueryVerification
|
||||
@Query("SELECT SUM(pgsize) FROM dbstat WHERE name == \"ChargeLocation\"")
|
||||
abstract suspend fun getSize(): Long
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +105,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 +137,70 @@ 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 requiresDetail = filters?.let { api.filteringInSQLRequiresDetails(it) } ?: false
|
||||
val savedRegionResult = savedRegionDao.savedRegionCovers(
|
||||
bounds.southwest.latitude,
|
||||
bounds.northeast.latitude,
|
||||
bounds.southwest.longitude,
|
||||
bounds.northeast.longitude,
|
||||
api.id,
|
||||
cacheSoftLimitDate(api),
|
||||
filtersSerialized,
|
||||
requiresDetail
|
||||
)
|
||||
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 +209,115 @@ 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 requiresDetail = filters?.let { api.filteringInSQLRequiresDetails(it) } ?: false
|
||||
val savedRegionResult = savedRegionDao.savedRegionCoversRadius(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
radiusMeters * 0.999, // to account for float rounding errors
|
||||
api.id,
|
||||
cacheSoftLimitDate(api),
|
||||
filtersSerialized,
|
||||
requiresDetail
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
fun getFilters(sp: StringProvider) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
|
||||
@@ -122,4 +342,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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import java.time.Instant
|
||||
|
||||
class CleanupCacheWorker(appContext: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(appContext, workerParams) {
|
||||
override suspend fun doWork(): Result {
|
||||
val db = AppDatabase.getInstance(applicationContext)
|
||||
|
||||
val chargeLocations = db.chargeLocationsDao()
|
||||
val savedRegionDao = db.savedRegionDao()
|
||||
val now = Instant.now()
|
||||
|
||||
val dataSources = listOf("openchargemap", "goingelectric")
|
||||
for (dataSource in dataSources) {
|
||||
val api = createApi(dataSource, applicationContext)
|
||||
val limit = now.minus(api.cacheLimit).toEpochMilli()
|
||||
chargeLocations.deleteOutdatedIfNotFavorite(dataSource, limit)
|
||||
savedRegionDao.deleteOutdated(dataSource, limit)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@@ -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.SpatiaRoom
|
||||
import co.anbora.labs.spatia.geometry.GeometryConverters
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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 = 21
|
||||
)
|
||||
@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, MIGRATION_21
|
||||
)
|
||||
.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,78 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_21 = object : Migration(20, 21) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// clear cache with this update
|
||||
db.execSQL("DELETE FROM savedregion")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a backup of the database to evmap-backup.db.
|
||||
*
|
||||
* The backup excludes cached data which can easily be retrieved from the network on restore.
|
||||
*/
|
||||
suspend fun createBackup(context: Context, fileName: String) {
|
||||
val db = getInstance(context.applicationContext)
|
||||
val backupDb = initDb(
|
||||
SpatiaRoom.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
fileName
|
||||
)
|
||||
)
|
||||
backupDb.clearAllTables()
|
||||
|
||||
val favorites = db.favoritesDao().getAllFavoritesAsync()
|
||||
backupDb.chargeLocationsDao().insert(*favorites.map { it.charger }.toTypedArray())
|
||||
backupDb.favoritesDao().insert(*favorites.map { it.favorite }.toTypedArray())
|
||||
backupDb.filterProfileDao().insert(*db.filterProfileDao().getAllProfiles().toTypedArray())
|
||||
backupDb.filterValueDao().insert(*db.filterValueDao().getAllFilterValues().toTypedArray())
|
||||
backupDb.recentAutocompletePlaceDao()
|
||||
.insert(*db.recentAutocompletePlaceDao().getAllAsync().toTypedArray())
|
||||
backupDb.close()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,7 @@ data class FilterProfile(
|
||||
@Dao
|
||||
interface FilterProfileDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(profile: FilterProfile): Long
|
||||
suspend fun insert(vararg profile: FilterProfile)
|
||||
|
||||
@Update
|
||||
suspend fun update(vararg profiles: FilterProfile)
|
||||
@@ -30,6 +30,9 @@ interface FilterProfileDao {
|
||||
@Query("SELECT * FROM filterProfile WHERE dataSource = :dataSource AND id != $FILTERS_CUSTOM ORDER BY `order` ASC, `name` ASC")
|
||||
fun getProfiles(dataSource: String): LiveData<List<FilterProfile>>
|
||||
|
||||
@Query("SELECT * FROM filterProfile")
|
||||
suspend fun getAllProfiles(): List<FilterProfile>
|
||||
|
||||
@Query("SELECT * FROM filterProfile WHERE dataSource = :dataSource AND name = :name")
|
||||
suspend fun getProfileByName(name: String, dataSource: String): FilterProfile?
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ abstract class FilterValueDao {
|
||||
dataSource: String
|
||||
): List<SliderFilterValue>
|
||||
|
||||
@Query("SELECT * FROM booleanfiltervalue")
|
||||
protected abstract suspend fun getAllBooleanFilterValuesAsync(): List<BooleanFilterValue>
|
||||
|
||||
@Query("SELECT * FROM multiplechoicefiltervalue")
|
||||
protected abstract suspend fun getAllMultipleChoiceFilterValuesAsync(): List<MultipleChoiceFilterValue>
|
||||
|
||||
@Query("SELECT * FROM sliderfiltervalue")
|
||||
protected abstract suspend fun getAllSliderFilterValuesAsync(): List<SliderFilterValue>
|
||||
|
||||
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract fun getBooleanFilterValues(
|
||||
profile: Long,
|
||||
@@ -105,6 +114,11 @@ abstract class FilterValueDao {
|
||||
}
|
||||
}
|
||||
|
||||
open suspend fun getAllFilterValues(): List<FilterValue> =
|
||||
getAllBooleanFilterValuesAsync() +
|
||||
getAllMultipleChoiceFilterValuesAsync() +
|
||||
getAllSliderFilterValuesAsync()
|
||||
|
||||
@Transaction
|
||||
open suspend fun insert(vararg values: FilterValue) {
|
||||
values.forEach {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.Editor
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.model.LatLng
|
||||
@@ -224,21 +226,9 @@ class PreferenceDataSource(val context: Context) {
|
||||
}
|
||||
|
||||
var placeSearchResultAndroidAuto: LatLng?
|
||||
get() = if (sp.contains("place_search_result_android_auto_lat")) {
|
||||
LatLng(
|
||||
Double.fromBits(sp.getLong("place_search_result_android_auto_lat", 0L)),
|
||||
Double.fromBits(sp.getLong("place_search_result_android_auto_lng", 0L))
|
||||
)
|
||||
} else null
|
||||
get() = sp.getLatLng("place_search_result_android_auto")
|
||||
set(value) {
|
||||
if (value == null) {
|
||||
sp.edit().remove("place_search_result_android_auto_lat")
|
||||
.remove("place_search_result_android_auto_lng").apply()
|
||||
} else {
|
||||
sp.edit().putLong("place_search_result_android_auto_lat", value.latitude.toBits())
|
||||
.putLong("place_search_result_android_auto_lng", value.longitude.toBits())
|
||||
.apply()
|
||||
}
|
||||
sp.edit().putLatLng("place_search_result_android_auto", value).apply()
|
||||
}
|
||||
|
||||
var placeSearchResultAndroidAutoName: String?
|
||||
@@ -261,4 +251,38 @@ class PreferenceDataSource(val context: Context) {
|
||||
set(value) {
|
||||
sp.edit().putBoolean("dev_mode_enabled", value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
val mapScale: String
|
||||
get() = sp.getString("map_scale", null) ?: "both"
|
||||
|
||||
var currentMapLocation: LatLng
|
||||
get() = sp.getLatLng("current_map_location") ?: LatLng(50.113388, 9.252536)
|
||||
set(value) {
|
||||
sp.edit().putLatLng("current_map_location", value).apply()
|
||||
}
|
||||
|
||||
var currentMapZoom: Float
|
||||
get() = sp.getFloat("current_map_zoom", 3.5f)
|
||||
set(value) {
|
||||
sp.edit().putFloat("current_map_zoom", value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedPreferences.getLatLng(key: String): LatLng? =
|
||||
if (contains("${key}_lat") && contains("${key}_lng")) {
|
||||
LatLng(
|
||||
Double.fromBits(getLong("${key}_lat", 0L)),
|
||||
Double.fromBits(getLong("${key}_lng", 0L))
|
||||
)
|
||||
} else null
|
||||
|
||||
fun Editor.putLatLng(key: String, value: LatLng?): Editor {
|
||||
if (value == null) {
|
||||
remove("${key}_lat")
|
||||
remove("${key}_lng")
|
||||
} else {
|
||||
putLong("${key}_lat", value.latitude.toBits())
|
||||
putLong("${key}_lng", value.longitude.toBits())
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -80,4 +80,7 @@ abstract class RecentAutocompletePlaceDao {
|
||||
dataSource: String,
|
||||
limit: Int? = null
|
||||
): List<RecentAutocompletePlace>
|
||||
|
||||
@Query("SELECT * FROM recentautocompleteplace")
|
||||
abstract suspend fun getAllAsync(): List<RecentAutocompletePlace>
|
||||
}
|
||||
114
app/src/main/java/net/vonforst/evmap/storage/SavedRegionDao.kt
Normal file
@@ -0,0 +1,114 @@
|
||||
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)
|
||||
|
||||
@Query("DELETE FROM savedregion")
|
||||
abstract suspend fun deleteAll()
|
||||
|
||||
@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.
|
||||
|
||||
@@ -30,7 +30,7 @@ class ChargepriceViewModel(
|
||||
state.getLiveData("charger")
|
||||
}
|
||||
|
||||
val chargepoint: MutableLiveData<Chargepoint> by lazy {
|
||||
val chargepoint: MutableLiveData<Chargepoint?> by lazy {
|
||||
state.getLiveData("chargepoint")
|
||||
}
|
||||
|
||||
@@ -116,20 +116,18 @@ class ChargepriceViewModel(
|
||||
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
|
||||
value = state["chargePrices"] ?: Resource.loading(null)
|
||||
listOf(
|
||||
charger,
|
||||
batteryRange,
|
||||
batteryRangeSliderDragging,
|
||||
vehicleCompatibleConnectors,
|
||||
myTariffs, myTariffsAll
|
||||
myTariffs, myTariffsAll, charger
|
||||
).forEach {
|
||||
addSource(it.distinctUntilChanged()) {
|
||||
if (!batteryRangeSliderDragging.value!!) loadPrices()
|
||||
if (!batteryRangeSliderDragging.value!!) {
|
||||
loadPrices()
|
||||
state["chargePrices"] = this.value
|
||||
}
|
||||
}
|
||||
}
|
||||
observeForever {
|
||||
// persist data in case fragment gets recreated
|
||||
state["chargePrices"] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -155,7 +159,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
MutableLiveData<Set<Long>>()
|
||||
}
|
||||
|
||||
val chargerSparse: MutableLiveData<ChargeLocation> by lazy {
|
||||
val chargerSparse: MutableLiveData<ChargeLocation?> by lazy {
|
||||
state.getLiveData("chargerSparse")
|
||||
}
|
||||
val chargerDetails: LiveData<Resource<ChargeLocation>> = chargerSparse.switchMap { charger ->
|
||||
@@ -516,35 +520,36 @@ 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" -> {
|
||||
val chargeCardsVal = filters.getMultipleChoiceValue("chargecards")!!
|
||||
"goingelectric" -> {
|
||||
val chargeCardsVal =
|
||||
filters.getMultipleChoiceValue("chargecards") ?: return@addSource
|
||||
filteredChargeCards.value =
|
||||
if (chargeCardsVal.all) null else chargeCardsVal.values.map { it.toLong() }
|
||||
.toSet()
|
||||
@@ -556,7 +561,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 +584,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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package net.vonforst.evmap.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -9,6 +10,7 @@ import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import java.io.IOException
|
||||
|
||||
class SettingsViewModel(
|
||||
@@ -17,8 +19,9 @@ class SettingsViewModel(
|
||||
chargepriceApiUrl: String
|
||||
) :
|
||||
AndroidViewModel(application) {
|
||||
private var api = ChargepriceApi.create(chargepriceApiKey, chargepriceApiUrl)
|
||||
private var db = AppDatabase.getInstance(application)
|
||||
private val api = ChargepriceApi.create(chargepriceApiKey, chargepriceApiUrl)
|
||||
private val db = AppDatabase.getInstance(application)
|
||||
private val prefs = PreferenceDataSource(application)
|
||||
|
||||
val vehicles: MutableLiveData<Resource<List<ChargepriceCar>>> by lazy {
|
||||
MutableLiveData<Resource<List<ChargepriceCar>>>().apply {
|
||||
@@ -34,6 +37,20 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
val chargerCacheCount: LiveData<Long> by lazy {
|
||||
db.chargeLocationsDao().getCount()
|
||||
}
|
||||
|
||||
val chargerCacheSize: LiveData<Long> by lazy {
|
||||
MutableLiveData<Long>().apply {
|
||||
chargerCacheCount.observeForever {
|
||||
viewModelScope.launch {
|
||||
value = db.chargeLocationsDao().getSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadVehicles() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -61,4 +78,11 @@ class SettingsViewModel(
|
||||
db.recentAutocompletePlaceDao().deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearChargerCache() {
|
||||
viewModelScope.launch {
|
||||
db.savedRegionDao().deleteAll()
|
||||
db.chargeLocationsDao().deleteAllIfNotFavorite()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -31,6 +31,12 @@
|
||||
|
||||
<import type="net.vonforst.evmap.api.chargeprice.ChargepriceApi" />
|
||||
|
||||
<import type="android.text.format.DateUtils" />
|
||||
|
||||
<import type="java.time.Instant" />
|
||||
|
||||
<import type="java.time.Duration" />
|
||||
|
||||
<variable
|
||||
name="charger"
|
||||
type="Resource<ChargeLocation>" />
|
||||
@@ -99,7 +105,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:colorBackground"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtName"
|
||||
@@ -470,11 +476,27 @@
|
||||
tools:targetApi="o" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtLicense"
|
||||
android:id="@+id/txtTimeRetrieved"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:breakStrategy="balanced"
|
||||
android:text="@{@string/data_retrieved_at(DateUtils.getRelativeTimeSpanString(charger.data.timeRetrieved.toEpochMilli(), Instant.now().toEpochMilli(), 0))}"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textStyle="italic"
|
||||
app:goneUnless="@{charger.data.timeRetrieved == null || Duration.between(charger.data.timeRetrieved, Instant.now()).compareTo(Duration.ofHours(1)) > 0}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline2"
|
||||
app:layout_constraintStart_toStartOf="@+id/guideline"
|
||||
app:layout_constraintTop_toBottomOf="@+id/sourceButton"
|
||||
tools:text="Data retrieved 4 hours ago" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtLicense"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:breakStrategy="balanced"
|
||||
android:text="@{charger.data.license}"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
@@ -482,7 +504,7 @@
|
||||
app:goneUnless="@{charger.data.license != null}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline2"
|
||||
app:layout_constraintStart_toStartOf="@+id/guideline"
|
||||
app:layout_constraintTop_toBottomOf="@+id/sourceButton"
|
||||
app:layout_constraintTop_toBottomOf="@+id/txtTimeRetrieved"
|
||||
tools:text="The data is provided under the National Oman Open Data LicensE (NOODLE), Version 3.14, and may be used for any purpose whatsoever." />
|
||||
|
||||
<Button
|
||||
|
||||
@@ -237,9 +237,9 @@
|
||||
<string name="donate_desc">Unterstütze die Weiterentwicklung von EVMap mit einer einmaligen Spende</string>
|
||||
<string name="github_sponsors_desc">Unterstütze EVMap über GitHub Sponsors</string>
|
||||
<string name="unnamed_filter_profile">Unbenanntes Filterprofil</string>
|
||||
<string name="privacy_link">https://evmap.vonforst.net/de/privacy.html</string>
|
||||
<string name="faq_link">https://evmap.vonforst.net/de/faq.html</string>
|
||||
<string name="chargeprice_faq_link">https://evmap.vonforst.net/de/chargeprice_faq.html</string>
|
||||
<string name="privacy_link">https://ev-map.app/de/privacypolicy/</string>
|
||||
<string name="faq_link">https://ev-map.app/de/faq/</string>
|
||||
<string name="chargeprice_faq_link">https://ev-map.app/de/faq/#preisvergleichsfunktion</string>
|
||||
<string name="required">erforderlich</string>
|
||||
<string name="edit_filter_profile">„%s“ bearbeiten</string>
|
||||
<string name="pref_search_delete_recent">Suchverlauf löschen</string>
|
||||
@@ -313,4 +313,16 @@
|
||||
<string name="tesla_pricing_other_times">Andere Zeiten:</string>
|
||||
<string name="tesla_pricing_blocking_fee">Blockiergebühr: %s</string>
|
||||
<string name="average_utilization">Durchschnittliche Auslastung</string>
|
||||
<string name="website">Website</string>
|
||||
<string name="pref_map_scale">Kartenmaßstab</string>
|
||||
<string name="pref_map_scale_both">Meter und Meilen</string>
|
||||
<string name="pref_map_scale_meters">Meter</string>
|
||||
<string name="pref_map_scale_miles">Meilen</string>
|
||||
<string name="pref_map_scale_off">aus</string>
|
||||
<string name="data_retrieved_at">Daten abgerufen %s</string>
|
||||
<string name="settings_caching">Cache</string>
|
||||
<string name="settings_cache_count">Cache-Größe</string>
|
||||
<string name="settings_cache_clear">Cache leeren</string>
|
||||
<string name="settings_cache_clear_summary">Löscht alle gespeicherten Ladestationen außer Favoriten</string>
|
||||
<string name="settings_cache_count_summary">%d Ladestationen gespeichert, %.1f MB</string>
|
||||
</resources>
|
||||
@@ -115,7 +115,7 @@
|
||||
<string name="donate_desc">Soutenir le développement d\'EVMap par un don unique</string>
|
||||
<string name="github_sponsors_desc">Soutenir EVMap sur GitHub Sponsors</string>
|
||||
<string name="unnamed_filter_profile">Profil de filtrage sans nom</string>
|
||||
<string name="privacy_link">https://evmap.vonforst.net/en/privacy.html</string>
|
||||
<string name="privacy_link">https://ev-map.app/privacypolicy/</string>
|
||||
<string name="required">requis</string>
|
||||
<string name="edit_filter_profile">Modifier \"%s\"</string>
|
||||
<string name="pref_search_delete_recent">Supprimer les résultats de recherche récents</string>
|
||||
@@ -241,8 +241,8 @@
|
||||
<string name="unknown_operator">Opérateur inconnu</string>
|
||||
<string name="data_source_goingelectric_desc">Idéal dans les pays germanophones. Descriptions en allemand. Maintenu par la communauté.</string>
|
||||
<string name="data_source_openchargemap_desc">Couverture mondiale avec une qualité variable. Descriptions en anglais ou dans la langue locale. Données ouvertes maintenues par la communauté et provenant de sources gouvernementales dans certains pays (par exemple, Amérique du Nord, Royaume-Uni, France, Norvège).</string>
|
||||
<string name="faq_link">https://evmap.vonforst.net/en/faq.html</string>
|
||||
<string name="chargeprice_faq_link">https://evmap.vonforst.net/en/chargeprice_faq.html</string>
|
||||
<string name="faq_link">https://ev-map.app/faq/</string>
|
||||
<string name="chargeprice_faq_link">https://ev-map.app/faq/#price-comparison-feature</string>
|
||||
<string name="settings_data_sources">Sources de données</string>
|
||||
<string name="data_sources_description">Veuillez choisir une source de données pour les stations de recharge. Vous pourrez la modifier ultérieurement dans les paramètres de l\'application.</string>
|
||||
<string name="pref_search_provider_info">Les données pour la recherche de lieux, en particulier celles de Google Maps, sont relativement coûteuses à récupérer. Veuillez envisager de faire un don via \"À propos\" -> \"Faire un don\".</string>
|
||||
|
||||
@@ -265,9 +265,9 @@
|
||||
<string name="parking_free">Gratis</string>
|
||||
<string name="charging_paid">Betalt</string>
|
||||
<string name="parking_paid">Betalt</string>
|
||||
<string name="privacy_link">https://evmap.vonforst.net/en/privacy.html</string>
|
||||
<string name="faq_link">https://evmap.vonforst.net/en/faq.html</string>
|
||||
<string name="chargeprice_faq_link">https://evmap.vonforst.net/en/chargeprice_faq.html</string>
|
||||
<string name="privacy_link">https://ev-map.app/privacypolicy/</string>
|
||||
<string name="faq_link">https://ev-map.app/faq/</string>
|
||||
<string name="chargeprice_faq_link">https://ev-map.app/faq/#price-comparison-feature</string>
|
||||
<string name="about_contributors">Bidragsytere</string>
|
||||
<string name="about_contributors_text">Takk til alle som har kodet og oversatt EVMap:</string>
|
||||
<plurals name="prediction_number_available">
|
||||
|
||||
@@ -209,9 +209,9 @@
|
||||
<string name="github_sponsors_desc">Ondersteun EVMap op GitHub Spinsors</string>
|
||||
<string name="github_sponsors">GitHub Sponsors</string>
|
||||
<string name="unnamed_filter_profile">Naamloos filterprofiel</string>
|
||||
<string name="privacy_link">https://evmap.vonforst.net/en/privacy.html</string>
|
||||
<string name="faq_link">https://evmap.vonforst.net/en/faq.html</string>
|
||||
<string name="chargeprice_faq_link">https://evmap.vonforst.net/en/chargeprice_faq.html</string>
|
||||
<string name="privacy_link">https://ev-map.app/privacypolicy/</string>
|
||||
<string name="faq_link">https://ev-map.app/faq/</string>
|
||||
<string name="chargeprice_faq_link">https://ev-map.app/faq/#price-comparison-feature</string>
|
||||
<string name="required">verplicht</string>
|
||||
<string name="pref_search_delete_recent">Verwijder recente zoekresultaten</string>
|
||||
<string name="deleted_recent_search_results">Recente zoekresultaten zijn verwijderd</string>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<string name="fault_report_date">Com problemas (atualizado: %s)</string>
|
||||
<string name="filter_chargecards">Formas de pagamento</string>
|
||||
<string name="pref_language">Língua da app</string>
|
||||
<string name="all_selected">Todos selecionados</string>
|
||||
<string name="all_selected">Todas selecionadas</string>
|
||||
<string name="edit">editar</string>
|
||||
<string name="pref_darkmode">Modo escuro</string>
|
||||
<string name="connection_error">Não foi possível carregar a lista de carregadores</string>
|
||||
@@ -165,9 +165,9 @@
|
||||
<string name="unnamed_filter_profile">Filtro sem nome</string>
|
||||
<string name="deleted_recent_search_results">As pesquisas recentes foram eliminadas</string>
|
||||
<string name="help">Ajuda</string>
|
||||
<string name="privacy_link">https://evmap.vonforst.net/en/privacy.html</string>
|
||||
<string name="faq_link">https://evmap.vonforst.net/en/faq.html</string>
|
||||
<string name="chargeprice_faq_link">https://evmap.vonforst.net/en/chargeprice_faq.html</string>
|
||||
<string name="privacy_link">https://ev-map.app/privacypolicy/</string>
|
||||
<string name="faq_link">https://ev-map.app/faq/</string>
|
||||
<string name="chargeprice_faq_link">https://ev-map.app/faq/#price-comparison-feature</string>
|
||||
<string name="pref_search_delete_recent">Apagar pesquisas recentes</string>
|
||||
<string name="required">obrigatório</string>
|
||||
<string name="settings_data_sources">Fontes de informação</string>
|
||||
@@ -300,4 +300,34 @@
|
||||
<string name="pref_chargeprice_allow_unbalanced_load">Permitir carga não balanceada</string>
|
||||
<string name="pref_chargeprice_allow_unbalanced_load_summary">Permitir carregamento CA/AC monofásico (1 fase) com mais de 4.5 kW</string>
|
||||
<string name="charger_website">Website</string>
|
||||
<string name="realtime_data_login_needed">Conta Tesla necessária para informação em tempo real</string>
|
||||
<string name="charge_price_minute_format">%2$s%1$.2f/min</string>
|
||||
<string name="pref_tesla_account_disabled">Faça o login para ver informação em tempo real sobre os Tesla Superchargers. Não é necessário possuir um veículo Tesla</string>
|
||||
<string name="login">Login</string>
|
||||
<string name="login_error">Falha no login</string>
|
||||
<string name="pricing_up_to">até %s</string>
|
||||
<string name="tesla_pricing_other_times">Outros horários:</string>
|
||||
<string name="location_status">Estado do provedor de localização</string>
|
||||
<string name="logged_out">Desconectado</string>
|
||||
<string name="tesla_pricing_members">Veículos e membros da Tesla:</string>
|
||||
<string name="logging_in">Fazendo o login…</string>
|
||||
<string name="log_out">Sair</string>
|
||||
<string name="pref_tesla_account">Conta Tesla</string>
|
||||
<string name="pref_tesla_account_enabled">Conectado como %s</string>
|
||||
<string name="tesla_pricing_others">Outros clientes:</string>
|
||||
<string name="tesla_pricing_blocking_fee">Taxa de bloqueio: %s</string>
|
||||
<string name="average_utilization">Utilização média</string>
|
||||
<string name="tesla_pricing_owners">Apenas veículos Tesla:</string>
|
||||
<string name="website">Website</string>
|
||||
<string name="pref_map_scale_off">desativar</string>
|
||||
<string name="pref_map_scale_both">metros e milhas</string>
|
||||
<string name="pref_map_scale_meters">metros</string>
|
||||
<string name="pref_map_scale_miles">milhas</string>
|
||||
<string name="pref_map_scale">Barra de escala do mapa</string>
|
||||
<string name="data_retrieved_at">Informação atualizada %s</string>
|
||||
<string name="settings_cache_count">Tamanho da cache</string>
|
||||
<string name="settings_cache_clear">Limpar cache</string>
|
||||
<string name="settings_cache_count_summary">%d carregadores na base de dados, %.1f MB</string>
|
||||
<string name="settings_caching">Caching (base de dados local)</string>
|
||||
<string name="settings_cache_clear_summary">Elimina todos os carregadores guardados na base de dados local, com a exceção dos seus favoritos</string>
|
||||
</resources>
|
||||
@@ -238,9 +238,9 @@
|
||||
<string name="donate_desc">Sprijina dezvoltarea EVMap\'s cu o donatie</string>
|
||||
<string name="github_sponsors_desc">Sprijina EVMap pe GitHub</string>
|
||||
<string name="unnamed_filter_profile">Profile filtre fara nume</string>
|
||||
<string name="privacy_link">https://evmap.vonforst.net/en/privacy.html</string>
|
||||
<string name="faq_link">https://evmap.vonforst.net/en/faq.html</string>
|
||||
<string name="chargeprice_faq_link">https://evmap.vonforst.net/en/chargeprice_faq.html</string>
|
||||
<string name="privacy_link">https://ev-map.app/privacypolicy/</string>
|
||||
<string name="faq_link">https://ev-map.app/faq/</string>
|
||||
<string name="chargeprice_faq_link">https://ev-map.app/faq/#price-comparison-feature</string>
|
||||
<string name="required">obligatoriu</string>
|
||||
<string name="edit_filter_profile">Modifica “%s”</string>
|
||||
<string name="pref_search_delete_recent">Sterge rezultate cautare recenta</string>
|
||||
|
||||
@@ -66,4 +66,16 @@
|
||||
<item>goingelectric</item>
|
||||
<item>openchargemap</item>
|
||||
</string-array>
|
||||
<string-array name="pref_map_scale_names">
|
||||
<item>@string/pref_map_scale_both</item>
|
||||
<item>@string/pref_map_scale_meters</item>
|
||||
<item>@string/pref_map_scale_miles</item>
|
||||
<item>@string/pref_map_scale_off</item>
|
||||
</string-array>
|
||||
<string-array name="pref_map_scale_values" translatable="false">
|
||||
<item>both</item>
|
||||
<item>meters</item>
|
||||
<item>miles</item>
|
||||
<item>off</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -9,6 +9,7 @@
|
||||
<string name="github_sponsors_link">https://github.com/sponsors/johan12345/</string>
|
||||
<string name="chargeprice_api_url">https://api.chargeprice.app/v1/</string>
|
||||
<string name="fronyx_url">https://fronyx.io/</string>
|
||||
<string name="website_url">https://ev-map.app</string>
|
||||
<string name="pref_language_en">English</string>
|
||||
<string name="pref_language_de">Deutsch</string>
|
||||
<string name="pref_language_fr">Français</string>
|
||||
@@ -26,7 +27,8 @@
|
||||
Celso Azevedo\n
|
||||
pt2121\n
|
||||
nautilusx\n
|
||||
Bobby Galati
|
||||
Bobby Galati\n
|
||||
programmin1
|
||||
</string>
|
||||
<string name="hide_on_scroll_fab_behavior">net.vonforst.evmap.ui.HideOnScrollFabBehavior</string>
|
||||
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
|
||||
|
||||
@@ -237,9 +237,9 @@
|
||||
<string name="donate_desc">Support EVMap\'s development with a one-time donation</string>
|
||||
<string name="github_sponsors_desc">Support EVMap on GitHub Sponsors</string>
|
||||
<string name="unnamed_filter_profile">Unnamed filter profile</string>
|
||||
<string name="privacy_link">https://evmap.vonforst.net/en/privacy.html</string>
|
||||
<string name="faq_link">https://evmap.vonforst.net/en/faq.html</string>
|
||||
<string name="chargeprice_faq_link">https://evmap.vonforst.net/en/chargeprice_faq.html</string>
|
||||
<string name="privacy_link">https://ev-map.app/privacypolicy/</string>
|
||||
<string name="faq_link">https://ev-map.app/faq/</string>
|
||||
<string name="chargeprice_faq_link">https://ev-map.app/faq/#price-comparison-feature</string>
|
||||
<string name="required">required</string>
|
||||
<string name="edit_filter_profile">Edit “%s”</string>
|
||||
<string name="pref_search_delete_recent">Delete recent search results</string>
|
||||
@@ -313,4 +313,16 @@
|
||||
<string name="tesla_pricing_other_times">Other times:</string>
|
||||
<string name="tesla_pricing_blocking_fee">Blocking fee: %s</string>
|
||||
<string name="average_utilization">Average Utilization</string>
|
||||
<string name="website">Website</string>
|
||||
<string name="pref_map_scale">Map scale bar</string>
|
||||
<string name="pref_map_scale_both">meters and miles</string>
|
||||
<string name="pref_map_scale_meters">meters</string>
|
||||
<string name="pref_map_scale_miles">miles</string>
|
||||
<string name="pref_map_scale_off">off</string>
|
||||
<string name="data_retrieved_at">Data retrieved %s</string>
|
||||
<string name="settings_caching">Caching</string>
|
||||
<string name="settings_cache_count">Cache size</string>
|
||||
<string name="settings_cache_clear">Clear cache</string>
|
||||
<string name="settings_cache_clear_summary">Deletes all cached chargers except favorites</string>
|
||||
<string name="settings_cache_count_summary">%d chargers cached, %.1f MB</string>
|
||||
</resources>
|
||||
@@ -18,6 +18,11 @@
|
||||
android:key="contributors"
|
||||
android:title="@string/about_contributors" />
|
||||
|
||||
<Preference
|
||||
android:key="website"
|
||||
android:title="@string/website"
|
||||
android:summary="@string/website_url" />
|
||||
|
||||
<Preference
|
||||
android:key="faq"
|
||||
android:title="@string/faq" />
|
||||
|
||||
12
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
<exclude
|
||||
domain="sharedpref"
|
||||
path="encrypted_prefs.xml" />
|
||||
<include
|
||||
domain="database"
|
||||
path="evmap-backup.db" />
|
||||
</full-backup-content>
|
||||
25
app/src/main/res/xml/backup_rules_api31.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
<exclude
|
||||
domain="sharedpref"
|
||||
path="encrypted_prefs.xml" />
|
||||
<include
|
||||
domain="database"
|
||||
path="evmap-backup.db" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
<exclude
|
||||
domain="sharedpref"
|
||||
path="encrypted_prefs.xml" />
|
||||
<include
|
||||
domain="database"
|
||||
path="evmap-backup.db" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -43,4 +43,13 @@
|
||||
android:title="@string/pref_search_delete_recent" />
|
||||
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory android:title="@string/settings_caching">
|
||||
<Preference
|
||||
android:key="cache_size"
|
||||
android:title="@string/settings_cache_count" />
|
||||
<Preference
|
||||
android:key="cache_clear"
|
||||
android:title="@string/settings_cache_clear"
|
||||
android:summary="@string/settings_cache_clear_summary" />
|
||||
</PreferenceCategory>
|
||||
</PreferenceScreen>
|
||||
@@ -22,6 +22,13 @@
|
||||
android:summaryOn="@string/pref_map_rotate_gestures_on"
|
||||
android:summaryOff="@string/pref_map_rotate_gestures_off"
|
||||
android:defaultValue="true" />
|
||||
<ListPreference
|
||||
android:key="map_scale"
|
||||
android:title="@string/pref_map_scale"
|
||||
android:entries="@array/pref_map_scale_names"
|
||||
android:entryValues="@array/pref_map_scale_values"
|
||||
android:defaultValue="both"
|
||||
android:summary="%s" />
|
||||
<CheckBoxPreference
|
||||
android:key="navigate_use_maps"
|
||||
android:title="@string/pref_navigate_use_maps"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
(e.g. in the debug version). -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="net.vonforst.evmap"
|
||||
android:targetPackage="${applicationId}"
|
||||
android:targetClass="net.vonforst.evmap.MapsActivity">
|
||||
<extra
|
||||
android:name="favorites"
|
||||
@@ -21,4 +21,25 @@
|
||||
android:key="feature" />
|
||||
</capability-binding>
|
||||
</shortcut>
|
||||
<capability android:name="actions.intent.GET_CHARGING_STATION">
|
||||
<intent>
|
||||
<url-template android:value="net.vonforst.evmap://find_charger{?name,address,latitude,longitude}" />
|
||||
<!-- Eg. name = "Googleplex" -->
|
||||
<parameter
|
||||
android:name="chargingStation.name"
|
||||
android:key="name" />
|
||||
<!-- Eg. address = "1600 Amphitheatre Pkwy, Mountain View, CA 94043" -->
|
||||
<parameter
|
||||
android:name="chargingStation.address"
|
||||
android:key="address" />
|
||||
<!-- Eg. latitude = "37.3861" -->
|
||||
<parameter
|
||||
android:name="chargingStation.geo.latitude"
|
||||
android:key="latitude" />
|
||||
<!-- Eg. longitude = "-122.084" -->
|
||||
<parameter
|
||||
android:name="chargingStation.geo.longitude"
|
||||
android:key="longitude" />
|
||||
</intent>
|
||||
</capability>
|
||||
</shortcuts>
|
||||
@@ -14,6 +14,7 @@ buildscript {
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"
|
||||
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
|
||||
classpath "pt.jcosta.resourceplaceholders:plugin:0.7"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
2
fastlane/metadata/android/de-DE/changelogs/172.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
10
fastlane/metadata/android/de-DE/changelogs/180.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
Neue Funktionen:
|
||||
- Ladestationen werden im Cache gespeichert und sind auch offline verfügbar (bei GoingElectric.de: gemäß Nutzungsbedingungen nur für max. 24h)
|
||||
- EVMap mit Google Assistant starten - derzeit nur mit Spracheinstellung US English ("Hey Google, find charging stations near Berlin on EVMap")
|
||||
- Einstellung zur Anpassung des Kartenmaßstabs
|
||||
|
||||
Verbesserungen:
|
||||
- Wenn Standortzugriff deaktiviert: Kartenposition wird bei Neustart der App beibehalten
|
||||
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
3
fastlane/metadata/android/de-DE/changelogs/182.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
- Fehler im Caching-Algorithmus im Zusammenspiel mit bestimmten Filtern behoben
|
||||
2
fastlane/metadata/android/de-DE/changelogs/184.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fehler behoben:
|
||||
- Abstürze im Zusammenspiel mit bestimmten Filtern behoben
|
||||
@@ -15,4 +15,4 @@ EVMap ist ein Open-Source-Projekt und unter https://github.com/ev-map/EVMap zu f
|
||||
|
||||
Die App ist kein offizielles Angebot von GoingElectric.de oder Open Charge Map, sondern nutzt die öffentlichen APIs dieser Seiten.
|
||||
|
||||
Eine Liste der benötigten Berechtigungen mit Beschreibung gibt es unter diesem Link: https://evmap.vonforst.net/de/permissions.html
|
||||
Eine Liste der benötigten Berechtigungen mit Beschreibung gibt es unter diesem Link: https://ev-map.app/de/faq/#permissions
|
||||
|
Before Width: | Height: | Size: 875 KiB After Width: | Height: | Size: 886 KiB |
|
Before Width: | Height: | Size: 844 KiB After Width: | Height: | Size: 872 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 140 KiB |
2
fastlane/metadata/android/en-US/changelogs/172.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
10
fastlane/metadata/android/en-US/changelogs/180.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
New features:
|
||||
- Chargers are saved in cache and available offline (for GoingElectric.de: limited to 24 hours according to their terms of use)
|
||||
- Launch EVMap through Google Assistant ("Hey Google, find charging stations near Berlin on EVMap") - currently only available in US English locale
|
||||
- Setting to configure map scale bar
|
||||
|
||||
Improvements:
|
||||
- If location access disabled: Map position will be kept after restarting app
|
||||
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
3
fastlane/metadata/android/en-US/changelogs/182.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
- Fixed error in caching algorithm when some filters are active
|
||||
2
fastlane/metadata/android/en-US/changelogs/184.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Bugfixes:
|
||||
- Fixed crashes when some filters are active
|
||||
@@ -15,4 +15,4 @@ EVMap is an open source project and can be found at https://github.com/ev-map/EV
|
||||
|
||||
This app is not an official product of GoingElectric.de or Open Charge Map, it only uses their public APIs.
|
||||
|
||||
A list of necessary permissions with explanations is available here: https://evmap.vonforst.net/en/permissions.html
|
||||
A list of necessary permissions with explanations is available here: https://ev-map.app/faq/#permissions
|
||||
|
Before Width: | Height: | Size: 864 KiB After Width: | Height: | Size: 884 KiB |
|
Before Width: | Height: | Size: 841 KiB After Width: | Height: | Size: 848 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 114 KiB |