Compare commits

...

36 Commits
1.5.0 ... 1.6.2

Author SHA1 Message Date
johan12345
074e0bf904 Release 1.6.2 2023-06-12 08:35:02 +02:00
johan12345
41ac223e97 properly escape strings in SQL queries 2023-06-12 08:33:33 +02:00
Hosted Weblate
f7196bcce0 Translated using Weblate (Portuguese)
Currently translated at 100.0% (313 of 313 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-06-11 21:56:23 +02:00
johan12345
4f6092e5dc remove extra spatialite library 2023-06-11 21:20:38 +02:00
johan12345
dfd42e1ffd upgrade spatia-room 2023-06-11 21:17:31 +02:00
johan12345
895b24d406 Release 1.6.1 2023-06-11 20:16:26 +02:00
johan12345
3dea7993f3 clear cache with next update 2023-06-11 19:54:36 +02:00
johan12345
ca90f1b37f GoingElectricApi: infer some details based on applied filters 2023-06-11 19:34:19 +02:00
johan12345
fe0843e653 fix bug in caching algorithm that caused chargers to disappear
some filters require details that we do not get in normal queries
2023-06-11 19:19:37 +02:00
johan12345
0f42ae84de fix NPE 2023-06-11 19:17:53 +02:00
johan12345
2748b0a3db make faultReport: true result in non-null value 2023-06-11 19:05:33 +02:00
johan12345
14798dee6a Release 1.6.0 2023-06-10 15:13:19 +02:00
johan12345
1cb48f7e0e update dependencies 2023-06-10 15:11:50 +02:00
Hosted Weblate
dc0f4d3eab Translated using Weblate (Portuguese)
Currently translated at 100.0% (307 of 307 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-06-10 14:42:30 +02:00
johan12345
8ae954f37b add @programmin1 to contributors list 2023-06-10 14:31:49 +02:00
johan12345
1ed3b73285 Beta release 1.6.0 2023-06-10 14:31:49 +02:00
johan12345
2ba6a86b34 display current cache size in settings 2023-06-10 14:31:49 +02:00
johan12345
463ff61420 display timeRetrieved in detail view if older than 1 hour 2023-06-10 14:31:49 +02:00
johan12345
81b4e77a66 exclude cached data from DB in backup 2023-06-10 14:31:49 +02:00
johan12345
d16d48bf8f delete outdated cached chargers from DB 2023-06-10 14:31:49 +02:00
johan12345
edfce541f6 add support for offline caching 2023-06-10 14:31:49 +02:00
johan12345
26136dc482 fix NPE 2023-06-10 13:04:57 +02:00
johan12345
0d11e450ac if location is not available, keep last map position across app restarts
addresses #191
2023-06-08 22:36:24 +02:00
Hosted Weblate
265b530936 Translated using Weblate (Portuguese)
Currently translated at 100.0% (302 of 302 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-06-08 22:00:52 +02:00
johan12345
8c5c7aeb58 Implement GET_CHARGING_STATION Google Assistant app action
fixes #185
only works with en-US locale for now
2023-06-08 21:55:08 +02:00
johan12345
23873dccdb add setting to configure map scale bar 2023-06-08 13:04:36 +02:00
johan12345
6006790ffb fix crash in case Tesla login is cancelled 2023-06-08 12:28:30 +02:00
Hosted Weblate
f5fc32f420 Translated using Weblate (Portuguese)
Currently translated at 100.0% (301 of 301 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-06-06 23:39:39 +02:00
johan12345
90c6357093 make shortcuts work again in debug version of app 2023-06-06 21:57:08 +02:00
johan12345
69ca8723a5 ChargepriceFragment: fix nullability issue in observer 2023-06-06 18:53:55 +02:00
johan12345
20400b630a add references to new website
#111
2023-06-03 18:48:49 +02:00
johan12345
b22ca736cb update phone screenshots 2023-05-31 21:05:46 +02:00
johan12345
ea906ec969 Release 1.5.1 2023-05-29 09:46:44 +02:00
johan12345
ec2b6d4f28 Chargeprice: fix crash with StackOverflowError 2023-05-29 09:40:54 +02:00
johan12345
e7c2683ee2 Tesla: profile_image_url is nullable 2023-05-29 09:40:54 +02:00
johan12345
d76051ec3a add backup_rules 2023-05-28 23:20:03 +02:00
104 changed files with 1791 additions and 281 deletions

View File

@@ -1,7 +1,8 @@
EVMap [![Build Status](https://github.com/ev-map/EVMap/actions/workflows/tests.yml/badge.svg)](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.

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

After

Width:  |  Height:  |  Size: 872 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 KiB

After

Width:  |  Height:  |  Size: 886 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

After

Width:  |  Height:  |  Size: 872 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1005 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 KiB

After

Width:  |  Height:  |  Size: 848 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

After

Width:  |  Height:  |  Size: 884 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 KiB

After

Width:  |  Height:  |  Size: 848 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

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

View File

@@ -1,24 +0,0 @@
package com.johan.evmap
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.johan.evmap", appContext.packageName)
}
}

View File

@@ -0,0 +1,95 @@
package com.johan.evmap.storage
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import co.anbora.labs.spatia.geometry.Mbr
import co.anbora.labs.spatia.geometry.MultiPolygon
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.SavedRegion
import net.vonforst.evmap.storage.SavedRegionDao
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.viewmodel.await
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.time.ZoneOffset
import java.time.ZonedDateTime
@RunWith(AndroidJUnit4::class)
class SavedRegionDaoTest {
private lateinit var database: AppDatabase
private lateinit var dao: SavedRegionDao
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = AppDatabase.createInMemory(context)
dao = database.savedRegionDao()
}
@After
fun tearDown() {
database.close()
}
@Test
fun testGetSavedRegion() {
val ds = "test"
val ts1 = ZonedDateTime.of(2023, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC).toInstant()
val region1 = Mbr(9.0, 53.0, 10.0, 54.0, 4326).asPolygon()
runBlocking {
dao.insert(
SavedRegion(
region1,
ds, ts1, null, false
)
)
}
assertEquals(region1, dao.getSavedRegion(ds, 0))
runBlocking {
assertTrue(dao.savedRegionCovers(53.1, 53.2, 9.1, 9.2, ds, 0).await())
assertTrue(dao.savedRegionCoversRadius(53.05, 9.15, 10.0, ds, 0).await())
assertFalse(dao.savedRegionCovers(52.1, 52.2, 9.1, 9.2, ds, 0).await())
}
val ts2 = ZonedDateTime.of(2023, 1, 1, 1, 0, 0, 0, ZoneOffset.UTC).toInstant()
val region2 = Mbr(9.0, 55.0, 10.0, 56.0, 4326).asPolygon()
runBlocking {
dao.insert(
SavedRegion(
region2,
ds, ts2, null, false
)
)
}
assertEquals(MultiPolygon(listOf(region1, region2)), dao.getSavedRegion(ds, 0))
assertEquals(region2, dao.getSavedRegion(ds, ts1.toEpochMilli()))
runBlocking {
assertTrue(dao.savedRegionCovers(53.1, 53.2, 9.1, 9.2, ds, 0).await())
assertTrue(dao.savedRegionCoversRadius(53.05, 9.15, 10.0, ds, 0).await())
assertFalse(dao.savedRegionCovers(53.1, 55.2, 9.1, 9.2, ds, 0).await())
}
}
@Test
fun testMakeCircle() {
val lat = 53.0
val lng = 10.0
val radius = 10000.0
val circle = runBlocking { dao.makeCircle(lat, lng, radius) }
for (point in circle.points) {
assertEquals(radius, distanceBetween(lat, lng, point.y, point.x), 10.0)
}
}
}

View File

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

View File

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

View File

@@ -428,14 +428,15 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
} else {
// try multiple search radii until we have enough chargers
var chargers: List<ChargeLocation>? = null
for (radius in listOf(searchRadius, searchRadius * 10, searchRadius * 50)) {
val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50)
for (radius in radiusValues) {
val response = repo.getChargepointsRadius(
searchLocation,
radius,
zoom = 16f,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR) {
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
loadingError = true
this@MapScreen.chargers = null
invalidate()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -422,7 +422,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val charger = vm.charger.value?.data
if (charger?.editUrl != null) {
(activity as? MapsActivity)?.openUrl(charger.editUrl)
if (vm.apiId.value == "going_electric") {
if (vm.apiId.value == "goingelectric") {
// instructions specific to GoingElectric
Toast.makeText(
requireContext(),
@@ -606,6 +606,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
val chargepoints = res.data
if (chargepoints != null) {
updateMap(chargepoints)
}
when (res.status) {
Status.ERROR -> {
val view = view ?: return@Observer
@@ -625,11 +629,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
Status.LOADING -> {
}
}
val chargepoints = res.data
if (chargepoints != null) {
updateMap(chargepoints)
}
})
vm.useMiniMarkers.observe(viewLifecycleOwner) {
vm.chargepoints.value?.data?.let { updateMap(it) }
@@ -861,6 +860,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onMapReady(map: AnyMap) {
this.map = map
vm.mapProjection = map.projection
val context = this.context ?: return
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
@@ -885,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() {

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ data class ChargeLocation(
val id: Long,
val dataSource: String,
val name: String,
@Embedded val coordinates: Coordinate,
val coordinates: Coordinate,
@Embedded val address: Address?,
val chargepoints: List<Chargepoint>,
val network: String?,
@@ -351,7 +351,8 @@ abstract class ChargerPhoto(open val id: String) : Parcelable {
data class ChargeLocationCluster(
val clusterCount: Int,
val coordinates: Coordinate
val coordinates: Coordinate,
val items: List<ChargeLocation>? = null
) : ChargepointListItem()
@Parcelize

View File

@@ -6,6 +6,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.storage.FilterProfile
import java.net.URLEncoder
import kotlin.reflect.KClass
sealed class Filter<out T : FilterValue> : Equatable {
@@ -51,6 +52,8 @@ sealed class FilterValue : BaseObservable(), Equatable {
var profile: Long = FILTERS_CUSTOM
abstract fun hasSameValueAs(other: FilterValue): Boolean
abstract fun serializeValue(): String
}
@Entity(
@@ -72,6 +75,8 @@ data class BooleanFilterValue(
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is BooleanFilterValue && other.value == this.value
}
override fun serializeValue(): String = value.toString()
}
@Entity(
@@ -99,6 +104,12 @@ data class MultipleChoiceFilterValue(
!this.all && other.values == this.values
}
}
override fun serializeValue(): String = if (all) {
"ALL"
} else {
"[" + values.sorted().joinToString(",") { URLEncoder.encode(it, "UTF-8") } + "]"
}
}
@Entity(
@@ -120,6 +131,8 @@ data class SliderFilterValue(
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is SliderFilterValue && other.value == this.value
}
override fun serializeValue() = value.toString()
}
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
@@ -138,6 +151,9 @@ fun FilterValues.getMultipleChoiceFilter(key: String) =
fun FilterValues.getMultipleChoiceValue(key: String) =
this.find { it.value.key == key }?.value as MultipleChoiceFilterValue?
fun FilterValues.serialize() = this.sortedBy { it.value.key }
.joinToString(",") { it.value.key + "=" + it.value.serializeValue() }
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L
const val FILTERS_FAVORITES = -3L

View File

@@ -0,0 +1,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
}
}

View File

@@ -0,0 +1,131 @@
package net.vonforst.evmap.storage
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import java.time.Duration
import java.time.Instant
/**
* LiveData implementation that allows loading data both from a cache and an API.
*
* It gives the cache result while loading, and then switches to the API result if the API call was
* successful.
*/
class CacheLiveData<T>(
cache: LiveData<T>,
api: LiveData<Resource<T>>,
skipApi: LiveData<Boolean>? = null
) :
MediatorLiveData<Resource<T>>() {
private var cacheResult: T? = null
private var apiResult: Resource<T>? = null
private var skipApiResult: Boolean = false
init {
updateValue()
addSource(cache) {
cacheResult = it
removeSource(cache)
updateValue()
}
if (skipApi == null) {
addSource(api) {
apiResult = it
updateValue()
}
} else {
addSource(skipApi) { skip ->
removeSource(skipApi)
skipApiResult = skip
updateValue()
if (!skip) {
addSource(api) {
apiResult = it
updateValue()
}
}
}
}
}
private fun updateValue() {
val api = apiResult
val cache = cacheResult
if (api == null && cache == null) {
Log.d("CacheLiveData", "both API and cache are still loading")
// both API and cache are still loading
value = Resource.loading(null)
} else if (cache != null && api == null) {
Log.d("CacheLiveData", "cache has finished loading before API")
// cache has finished loading before API
if (skipApiResult) {
value = Resource.success(cache)
} else {
value = Resource.loading(cache)
}
} else if (cache == null && api != null) {
Log.d("CacheLiveData", "API has finished loading before cache")
// API has finished loading before cache
value = when (api.status) {
Status.SUCCESS -> api
Status.ERROR -> Resource.loading(api.data)
Status.LOADING -> api // should not occur
}
} else if (cache != null && api != null) {
Log.d("CacheLiveData", "Both cache and API have finished loading")
// Both cache and API have finished loading
value = when (api.status) {
Status.SUCCESS -> api
Status.ERROR -> Resource.error(api.message, cache)
Status.LOADING -> api // should not occur
}
}
}
}
/**
* LiveData implementation that allows loading data both from a cache and an API.
*
* It first tries loading from cache, and if the result is newer than `cacheSoftLimit` it does not
* reload from the API.
*/
class PreferCacheLiveData(
cache: LiveData<ChargeLocation>,
val api: LiveData<Resource<ChargeLocation>>,
cacheSoftLimit: Duration
) :
MediatorLiveData<Resource<ChargeLocation>>() {
init {
value = Resource.loading(null)
addSource(cache) { cacheRes ->
removeSource(cache)
if (cacheRes != null) {
if (cacheRes.isDetailed && cacheRes.timeRetrieved > Instant.now() - cacheSoftLimit) {
value = Resource.success(cacheRes)
} else {
value = Resource.loading(cacheRes)
loadFromApi(cacheRes)
}
} else {
loadFromApi(null)
}
}
}
private fun loadFromApi(
cache: ChargeLocation?
) {
addSource(api) { apiRes ->
value = when (apiRes.status) {
Status.SUCCESS -> apiRes
Status.ERROR -> Resource.error(apiRes.message, cache)
Status.LOADING -> Resource.loading(cache)
}
}
}
}

View File

@@ -1,34 +1,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()
}
}

View File

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

View File

@@ -5,11 +5,13 @@ import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import co.anbora.labs.spatia.builder.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()
}
}

View File

@@ -19,7 +19,8 @@ interface FavoritesDao {
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id")
suspend fun getAllFavoritesAsync(): List<FavoriteWithDetail>
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE lat >= :lat1 AND lat <= :lat2 AND lng >= :lng1 AND lng <= :lng2")
@SkipQueryVerification
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE Within(chargelocation.coordinates, BuildMbr(:lng1, :lat1, :lng2, :lat2))")
suspend fun getFavoritesInBoundsAsync(
lat1: Double,
lat2: Double,

View File

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

View File

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

View File

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

View File

@@ -80,4 +80,7 @@ abstract class RecentAutocompletePlaceDao {
dataSource: String,
limit: Int? = null
): List<RecentAutocompletePlace>
@Query("SELECT * FROM recentautocompleteplace")
abstract suspend fun getAllAsync(): List<RecentAutocompletePlace>
}

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

View File

@@ -1,6 +1,7 @@
package net.vonforst.evmap.storage
import androidx.room.TypeConverter
import co.anbora.labs.spatia.geometry.Point
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.squareup.moshi.Moshi
@@ -12,6 +13,7 @@ import net.vonforst.evmap.autocomplete.AutocompletePlaceType
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.Coordinate
import java.time.Instant
import java.time.LocalTime
@@ -154,4 +156,15 @@ class Converters {
fun toAutocompletePlaceTypeList(value: String): List<AutocompletePlaceType> {
return value.split(",").map { AutocompletePlaceType.valueOf(it) }
}
@TypeConverter
fun toCoordinate(value: Point): Coordinate {
if (value.srid != 4326) throw IllegalArgumentException("expected WGS-84")
return Coordinate(value.y, value.x)
}
@TypeConverter
fun fromCoordinate(value: Coordinate): Point {
return Point(value.lng, value.lat)
}
}

View File

@@ -10,13 +10,10 @@ import net.vonforst.evmap.model.Coordinate
fun cluster(
result: List<ChargepointListItem>,
locations: List<ChargeLocation>,
zoom: Float,
clusterDistance: Int
): List<ChargepointListItem> {
val clusters = result.filterIsInstance<ChargeLocationCluster>()
val locations = result.filterIsInstance<ChargeLocation>()
val clusterItems = locations.map { ChargepointClusterItem(it) }
val algo = NonHierarchicalDistanceBasedAlgorithm<ChargepointClusterItem>()
@@ -26,16 +23,18 @@ fun cluster(
if (it.size == 1) {
it.items.first().charger
} else {
ChargeLocationCluster(it.size, Coordinate(it.position.latitude, it.position.longitude))
ChargeLocationCluster(
it.size,
Coordinate(it.position.latitude, it.position.longitude),
it.items.map { it.charger })
}
} + clusters
}
}
private class ChargepointClusterItem(val charger: ChargeLocation) : ClusterItem {
override fun getSnippet(): String? = null
override fun getTitle(): String? = charger.name
override fun getTitle(): String = charger.name
override fun getPosition(): LatLng = LatLng(charger.coordinates.lat, charger.coordinates.lng)
}

View File

@@ -16,19 +16,29 @@ import kotlin.math.*
* Adds a certain distance in meters to a location. Approximate calculation.
*/
fun Location.plusMeters(dx: Double, dy: Double): Pair<Double, Double> {
val lat = this.latitude + (180 / Math.PI) * (dx / 6378137.0)
val lon = this.longitude + (180 / Math.PI) * (dy / 6378137.0) / cos(Math.toRadians(lat))
val lat = this.latitude + (180 / Math.PI) * (dx / earthRadiusM)
val lon = this.longitude + (180 / Math.PI) * (dy / earthRadiusM) / cos(Math.toRadians(lat))
return Pair(lat, lon)
}
fun LatLng.plusMeters(dx: Double, dy: Double): LatLng {
val lat = this.latitude + (180 / Math.PI) * (dx / 6378137.0)
val lon = this.longitude + (180 / Math.PI) * (dy / 6378137.0) / cos(Math.toRadians(lat))
val lat = this.latitude + (180 / Math.PI) * (dx / earthRadiusM)
val lon = this.longitude + (180 / Math.PI) * (dy / earthRadiusM) / cos(Math.toRadians(lat))
return LatLng(lat, lon)
}
const val earthRadiusM = 6378137.0
/**
* Approximates a geodesic circle as an ellipse in geographical coordinates by giving its radius
* in latitude and longitude in degrees.
*/
fun circleAsEllipse(lat: Double, lng: Double, radius: Double): Pair<Double, Double> {
val radiusLat = (180 / Math.PI) * (radius / earthRadiusM)
val radiusLon = (180 / Math.PI) * (radius / earthRadiusM) / cos(Math.toRadians(lat))
return radiusLat to radiusLon
}
/**
* Calculates the distance between two points on Earth in meters.
* Latitude and longitude should be given in degrees.

View File

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

View File

@@ -1,9 +1,11 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import android.graphics.Point
import android.os.Parcelable
import androidx.lifecycle.*
import com.car2go.maps.AnyMap
import com.car2go.maps.Projection
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
@@ -42,6 +44,7 @@ import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.math.roundToInt
@Parcelize
data class MapPosition(val bounds: LatLngBounds, val zoom: Float) : Parcelable
@@ -66,6 +69,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
prefs
)
private val availabilityRepo = AvailabilityRepository(application)
var mapProjection: Projection? = null
val apiId = repo.api.map { it.id }
@@ -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
}

View File

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

View File

@@ -143,4 +143,20 @@ suspend fun <T> LiveData<Resource<T>>.awaitFinished(): Resource<T> {
removeObserver(observer)
}
}
}
inline fun <X, Y> LiveData<X>.singleSwitchMap(crossinline transform: (X) -> LiveData<Y>?): MediatorLiveData<Y> {
val result = MediatorLiveData<Y>()
result.addSource(this@singleSwitchMap, object : Observer<X> {
override fun onChanged(t: X) {
if (t == null) return
result.removeSource(this@singleSwitchMap)
transform(t)?.let { transformed ->
result.addSource(transformed) {
result.value = it
}
}
}
})
return result
}

View File

@@ -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&lt;ChargeLocation&gt;" />
@@ -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

View File

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

View File

@@ -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\" -&gt; \"Faire un don\".</string>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
Fehler behoben:
- Abstürze behoben

View 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

View File

@@ -0,0 +1,3 @@
Fehler behoben:
- Abstürze behoben
- Fehler im Caching-Algorithmus im Zusammenspiel mit bestimmten Filtern behoben

View File

@@ -0,0 +1,2 @@
Fehler behoben:
- Abstürze im Zusammenspiel mit bestimmten Filtern behoben

View File

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

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 KiB

After

Width:  |  Height:  |  Size: 886 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

After

Width:  |  Height:  |  Size: 872 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -0,0 +1,2 @@
Bugfixes:
- Fixed crashes

View 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

View File

@@ -0,0 +1,3 @@
Bugfixes:
- Fixed crashes
- Fixed error in caching algorithm when some filters are active

View File

@@ -0,0 +1,2 @@
Bugfixes:
- Fixed crashes when some filters are active

View File

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

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

After

Width:  |  Height:  |  Size: 884 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 KiB

After

Width:  |  Height:  |  Size: 848 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Some files were not shown because too many files have changed in this diff Show More