Compare commits

...

42 Commits

Author SHA1 Message Date
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
johan12345
975ba2bcce Release 1.5.0 2023-05-28 22:43:29 +02:00
johan12345
dc067fd86b build speedup: enable non-transitive R classes and non-final resource IDs 2023-05-28 21:59:55 +02:00
johan12345
226ca3a60e update dependencies 2023-05-28 21:53:42 +02:00
johan12345
af63ee350b update Android Gradle plugin 2023-05-28 21:49:23 +02:00
johan12345
21d4060ac9 minor improvements for Tesla login 2023-05-28 00:11:43 +02:00
johan12345
3b9efa0302 install splashscreen before super.onCreate
fixes crashes after rotation on Android 7
reason: Splash screen does not use an AppCompat theme, therefore view state is not restored correctly
2023-05-27 23:23:41 +02:00
johan12345
95d93af0d6 some Kotlin code refactoring 2023-05-18 23:37:02 +02:00
johan12345
17a6a253d4 other dependency upgrades 2023-05-18 23:25:28 +02:00
johan12345
f73545c01e upgrade to Kotlin 1.8.20 2023-05-18 23:13:17 +02:00
johan12345
e4fa1f2c78 add Supercharger utilization graph
#272
2023-05-18 01:10:55 +02:00
johan12345
b2b5cc63e8 update Supercharger icon
so that Tesla logo is not overlapped by realtime data
2023-05-18 01:10:47 +02:00
johan12345
84ba62f755 add pricing information from Tesla
#272
2023-05-18 01:10:47 +02:00
johan12345
b29653049a fix typo 2023-05-17 23:16:23 +02:00
johan12345
4159491589 layout fix if neither Chargeprice nor charger website buttons are shown 2023-05-16 23:31:58 +02:00
johan12345
4e67f434cd disable Chargeprice for Tesla 2023-05-16 23:30:03 +02:00
johan12345
5e58d52a0d Tesla AvailabilityDetector: add notice to sign in 2023-05-16 21:33:59 +02:00
johan12345
eddc1f9b61 add link 2023-05-14 23:23:17 +02:00
johan12345
b5054b4dc9 fix tests 2023-05-14 23:11:31 +02:00
johan12345
926799bb1d Implement Tesla Supercharger AvailabilityDetector
#272
2023-05-14 22:31:07 +02:00
johan12345
f038138620 remove unnecessary line break 2023-05-14 18:14:03 +02:00
johan12345
1c44e5ae3d FusionEngine: reset when disabled 2023-05-14 18:08:48 +02:00
johan12345
c58543fe3f Add developer options fragment with location provider debug info
refs #276
2023-05-14 18:04:57 +02:00
johan12345
a5db42322f Fix issue if fused location provider is available but does not deliver any location updates
might resolve #276
2023-05-14 17:13:28 +02:00
johan12345
bb0d2e35d4 fix CarAppTest with Flipper 2023-05-06 23:31:06 +02:00
johan12345
38c8c5510f pt strings: fix lint hints 2023-05-06 17:36:12 +02:00
johan12345
8d1d15ad68 update copyright 2023-05-06 17:35:27 +02:00
johan12345
954203bf18 CI: update Java 2023-05-06 17:29:04 +02:00
johan12345
524e9fcfc0 Update Gradle plugin 2023-05-04 18:10:13 +02:00
johan12345
ae2041d26b fr, pt: add "many" plurals 2023-05-04 18:09:33 +02:00
johan12345
698c832518 update okhttp 2023-05-02 20:36:32 +02:00
johan12345
17c1a11675 Flipper: fix unit tests 2023-05-01 18:28:16 +02:00
johan12345
d04661e925 Replace deprecated Stetho with Flipper
https://github.com/facebookarchive/stetho -> archived
https://github.com/facebook/flipper
2023-05-01 17:47:54 +02:00
johan12345
02316fceb9 fix NewMotionAvailabilityDetectorTest 2023-05-01 17:11:52 +02:00
johan12345
9bf7a90302 fix broken NewMotion availability API
- domain changed to ui-map.shellrecharge.com
- zoom parameter is now required

fixes #278
2023-05-01 17:05:43 +02:00
johan12345
2697389b49 fix portuguese plurals 2023-05-01 16:19:07 +02:00
Hosted Weblate
cd0e381707 Translated using Weblate (Norwegian Bokmål)
Currently translated at 83.8% (238 of 284 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/nb_NO/
Translation: EVMap/Android
2023-05-01 13:06:23 +02:00
Hosted Weblate
e5ed5eeafe Translated using Weblate (Portuguese)
Currently translated at 100.0% (284 of 284 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (283 of 283 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-05-01 13:06:23 +02:00
johan12345
b25c61fbea update Android Gradle plugin 2023-05-01 13:03:01 +02:00
94 changed files with 2079 additions and 389 deletions

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 11
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Decrypt keystore

View File

@@ -21,7 +21,7 @@ jobs:
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 11
java-version: 17
distribution: 'zulu'
cache: 'gradle'

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2022 Johan von Forstner
Copyright (c) 2020-2023 Johan von Forstner and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,23 +1,25 @@
<svg id="Ebene_5" data-name="Ebene 5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>.cls-1,.cls-2{fill:none;}.cls-2{stroke:#000;stroke-miterlimit:10;stroke-width:2px;}
</style>
</defs>
<title>connector_supercharger</title>
<path class="cls-1" d="M12,12H36V36H12Z" />
<path class="cls-2"
d="M13.45,17.08a8.24,8.24,0,0,1-3.11.6,8.34,8.34,0,0,1-6-14.18H16.3a8.35,8.35,0,0,1,1.07,10.33" />
<circle cx="10.34" cy="9.34" r="1.67" />
<circle cx="15.35" cy="9.34" r="1.67" />
<circle cx="12.84" cy="13.51" r="1.67" />
<circle cx="7.84" cy="13.51" r="1.67" />
<circle cx="5.34" cy="9.34" r="1.67" />
<circle cx="7.84" cy="5.59" r="1" />
<circle cx="12.84" cy="5.59" r="1.04" />
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_5" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24"
style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{display:none;fill:none;}
.st1{fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:10;}
</style>
<path class="st0" d="M12,12h24v24H12V12z" />
<path class="st1"
d="M6.2,13.8C4.1,10.6,4.6,6.3,7.3,3.5h12c1.5,1.6,2.4,3.7,2.4,5.9c0,4.6-3.8,8.3-8.4,8.3c-1.1,0-2.1-0.2-3.1-0.6" />
<circle cx="13.3" cy="9.3" r="1.7" />
<circle cx="8.3" cy="9.3" r="1.7" />
<circle cx="10.8" cy="13.5" r="1.7" />
<circle cx="15.8" cy="13.5" r="1.7" />
<circle cx="18.3" cy="9.3" r="1.7" />
<circle cx="15.8" cy="5.6" r="1" />
<circle cx="10.8" cy="5.6" r="1" />
<g id="T">
<path id="path35"
d="M18.18,22.23l1-5.48c.93,0,1.22.1,1.27.52a2.15,2.15,0,0,0,.93-.7,6.91,6.91,0,0,0-2.46-.6l-.71.88h0L17.46,16a7,7,0,0,0-2.46.6,2.22,2.22,0,0,0,.94.7c0-.42.33-.52,1.26-.52l1,5.48" />
<path id="path37"
d="M18.18,15.72a7.9,7.9,0,0,1,3.28.66,2.65,2.65,0,0,0,.2-.4,9.24,9.24,0,0,0-7,0,2.61,2.61,0,0,0,.19.4,7.94,7.94,0,0,1,3.29-.66h0" />
</g>
</svg>
<path id="path35" d="M5.4,22.3l1-5.5c0.9,0,1.3,0.1,1.3,0.5c0.4-0.1,0.7-0.4,0.9-0.7C7.8,16.3,7,16,6.1,16l-0.8,0.8l0,0L4.7,16
c-0.8,0-1.7,0.3-2.5,0.6c0.2,0.3,0.6,0.6,0.9,0.7c0.1-0.4,0.3-0.5,1.3-0.5L5.4,22.3" />
<path id="path37" d="M5.5,15.7L5.5,15.7c1.1,0,2.3,0.2,3.3,0.7c0.1-0.1,0.1-0.3,0.2-0.4c-2.2-0.9-4.8-0.9-7,0
c0.1,0.1,0.1,0.3,0.2,0.4C3.2,15.9,4.3,15.7,5.5,15.7" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -20,8 +20,8 @@ android {
minSdkVersion 21
targetSdkVersion 33
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 168
versionName "1.4.10"
versionCode 172
versionName "1.5.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(',')
@@ -90,6 +90,12 @@ android {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs).configureEach {
kotlinOptions {
jvmTarget = "1.8"
}
}
buildFeatures {
dataBinding = true
viewBinding true
@@ -156,24 +162,25 @@ configurations {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.5"
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation "androidx.activity:activity-ktx:1.7.2"
implementation "androidx.fragment:fragment-ktx:1.5.7"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.android.material:material:1.8.0'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.browser:browser:1.5.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
implementation 'com.squareup.moshi:moshi-adapters:1.13.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.11.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
implementation 'com.squareup.moshi:moshi-adapters:1.15.0'
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
@@ -208,7 +215,7 @@ dependencies {
fossImplementation 'com.github.ev-map:mapbox-events-android:a21c324501'
// Google Places
googleImplementation 'com.google.android.libraries.places:places:3.0.0'
googleImplementation 'com.google.android.libraries.places:places:3.1.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4'
// Mapbox Geocoding
@@ -219,18 +226,18 @@ dependencies {
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.5.1"
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.5.0"
def room_version = "2.5.1"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// billing library
def billing_version = "5.1.0"
def billing_version = "6.0.0"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
@@ -241,26 +248,27 @@ dependencies {
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
implementation 'com.facebook.stetho:stetho:1.6.0'
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
debugImplementation 'com.facebook.flipper:flipper:0.190.0'
debugImplementation 'com.facebook.soloader:soloader:0.10.5'
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.190.0'
// testing
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'org.robolectric:robolectric:4.9'
testGoogleImplementation 'org.robolectric:robolectric:4.9.2'
testGoogleImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
}
private static String decode(String s, String key) {

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.facebook.flipper.android.diagnostics.FlipperDiagnosticActivity"
android:exported="true" />
</application>
</manifest>

View File

@@ -0,0 +1,42 @@
package net.vonforst.evmap
import android.content.Context
import android.os.Build
import com.facebook.flipper.android.AndroidFlipperClient
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin
import com.facebook.flipper.plugins.inspector.DescriptorMapping
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
import com.facebook.soloader.SoLoader
import okhttp3.OkHttpClient
private val networkFlipperPlugin = NetworkFlipperPlugin()
fun addDebugInterceptors(context: Context) {
if (Build.FINGERPRINT == "robolectric") return
SoLoader.init(context, false)
val client = AndroidFlipperClient.getInstance(context)
client.addPlugin(InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()))
client.addPlugin(networkFlipperPlugin)
client.addPlugin(DatabasesFlipperPlugin(context))
client.addPlugin(SharedPreferencesFlipperPlugin(context))
client.start()
}
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder {
// Flipper does not work during unit tests - so check whether we are running tests first
var isRunningTest = true
try {
Class.forName("org.junit.Test")
} catch (e: ClassNotFoundException) {
isRunningTest = false
}
if (!isRunningTest) {
this.addNetworkInterceptor(FlipperOkhttpInterceptor(networkFlipperPlugin))
}
return this
}

View File

@@ -275,7 +275,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.maxByOrNull { it.power }
prices = result.data!!.map { cp ->
prices = result.data!!.mapNotNull { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
@@ -287,7 +287,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
chargepointPrices = filteredPrices
)
}
}.filterNotNull()
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||

View File

@@ -23,8 +23,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.iconForPlugType
@@ -57,6 +57,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val imageSize = 128 // images should be 128dp according to docs
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
@@ -465,7 +466,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
invalidate()
availability = getAvailability(charger).data
availability = availabilityRepo.getAvailability(charger).data
invalidate()
} else {

View File

@@ -24,8 +24,8 @@ import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
@@ -79,6 +79,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val searchRadius = 5 // kilometers
private val distanceUpdateThreshold = Duration.ofSeconds(15)
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
@@ -325,7 +326,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
// power
val power = charger.maxPower;
val power = charger.maxPower
if (power != null) {
if (text.isNotEmpty()) text.append(" · ")
text.append("${power.roundToInt()} kW")
@@ -577,7 +578,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
// when the list is scrolled, load corresponding availabilities
if (startIndex == visibleStart && endIndex == visibleEnd && !availabilities.isEmpty()) return
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
if (startIndex == -1 || endIndex == -1) return
if (availabilityUpdateCoroutine != null) return
@@ -606,7 +607,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
lifecycleScope.async {
val availability = getAvailability(it).data
val availability = availabilityRepo.getAvailability(it).data
val date = ZonedDateTime.now()
availabilities[it.id] = date to availability
}

View File

@@ -38,10 +38,10 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
): List<AutocompletePlace> {
val request = FindAutocompletePredictionsRequest.builder().apply {
if (location != null) {
setLocationBias(calcLocationBias(location))
setOrigin(LatLng(location.latitude, location.longitude))
locationBias = calcLocationBias(location)
origin = LatLng(location.latitude, location.longitude)
}
setSessionToken(token)
sessionToken = token
setQuery(query)
}.build()
try {
@@ -92,10 +92,11 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
}
}
override fun getAttributionString(): Int = R.string.places_powered_by_google
override fun getAttributionString(): Int =
com.google.android.libraries.places.R.string.places_powered_by_google
override fun getAttributionImage(dark: Boolean): Int =
if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light
if (dark) com.google.android.libraries.places.R.drawable.places_powered_by_google_dark else com.google.android.libraries.places.R.drawable.places_powered_by_google_light
private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds {
val radius = 100e3 // meters

View File

@@ -3,12 +3,8 @@ package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.MultiSelectListPreference
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.RangeSliderPreference
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
import java.text.NumberFormat
class AndroidAutoSettingsFragment : BaseSettingsFragment() {

View File

@@ -23,6 +23,8 @@
<application
android:name=".EvMapApplication"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/backup_rules_api31"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"

View File

@@ -1,7 +1,6 @@
package net.vonforst.evmap
import android.app.Application
import com.facebook.stetho.Stetho
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
@@ -24,8 +23,8 @@ class EvMapApplication : Application() {
prefs.language = null
}
Stetho.initializeWithDefaults(this);
init(applicationContext)
addDebugInterceptors(applicationContext)
if (!BuildConfig.DEBUG) {
initAcra {

View File

@@ -55,8 +55,8 @@ class MapsActivity : AppCompatActivity(),
private lateinit var prefs: PreferenceDataSource
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_maps)
@@ -207,7 +207,7 @@ class MapsActivity : AppCompatActivity(),
intent.data = Uri.parse("google.navigation:q=${coord.lat},${coord.lng}")
intent.`package` = "com.google.android.apps.maps"
if (prefs.navigateUseMaps && intent.resolveActivity(packageManager) != null) {
startActivity(intent);
startActivity(intent)
} else {
// fallback: generic geo intent
showLocation(charger)
@@ -223,7 +223,7 @@ class MapsActivity : AppCompatActivity(),
})"
)
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent);
startActivity(intent)
} else {
val cb = fragmentCallback ?: return
Snackbar.make(
@@ -262,7 +262,7 @@ class MapsActivity : AppCompatActivity(),
fun shareUrl(url: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
setType("text/plain")
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(intent)

View File

@@ -90,7 +90,7 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
class ChargepriceAdapter() :
DataBindingAdapter<ChargePrice>() {
val viewPool = RecyclerView.RecycledViewPool();
val viewPool = RecyclerView.RecycledViewPool()
var meta: ChargepriceChargepointMeta? = null
set(value) {
field = value

View File

@@ -1,8 +1,13 @@
package net.vonforst.evmap.adapter
import android.content.Context
import android.graphics.Typeface
import android.text.Spannable
import android.text.style.StyleSpan
import androidx.core.text.HtmlCompat
import androidx.core.text.buildSpannedString
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
import net.vonforst.evmap.bold
import net.vonforst.evmap.joinToSpannedString
import net.vonforst.evmap.model.ChargeCard
@@ -10,6 +15,7 @@ import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.OpeningHoursDays
import net.vonforst.evmap.plus
import net.vonforst.evmap.ui.currency
import net.vonforst.evmap.utils.formatDMS
import net.vonforst.evmap.utils.formatDecimal
import java.time.ZoneId
@@ -41,11 +47,18 @@ fun buildDetails(
loc: ChargeLocation?,
chargeCards: Map<Long, ChargeCard>?,
filteredChargeCards: Set<Long>?,
teslaPricing: TeslaGraphQlApi.Pricing?,
ctx: Context
): List<DetailsAdapter.Detail> {
if (loc == null) return emptyList()
return listOfNotNull(
if (teslaPricing != null) DetailsAdapter.Detail(
R.drawable.ic_tesla,
R.string.cost,
formatTeslaPricing(teslaPricing, ctx),
formatTeslaParkingFee(teslaPricing, ctx)
) else null,
if (loc.address != null) DetailsAdapter.Detail(
R.drawable.ic_address,
R.string.address,
@@ -126,6 +139,128 @@ fun buildDetails(
)
}
private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
teslaPricing.memberRates?.activePricebook?.parking?.let { parkingFee ->
ctx.getString(
R.string.tesla_pricing_blocking_fee,
formatTeslaPricingRate(parkingFee.rates, parkingFee.currencyCode, parkingFee.uom, ctx)
)
}
private fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
buildSpannedString {
teslaPricing.memberRates?.let { memberRates ->
append(
ctx.getString(if (teslaPricing.userRates != null) R.string.tesla_pricing_members else R.string.tesla_pricing_owners),
StyleSpan(Typeface.BOLD),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
append(formatTeslaPricingRates(memberRates, ctx))
}
teslaPricing.userRates?.let { userRates ->
append("\n\n")
append(
ctx.getString(R.string.tesla_pricing_others),
StyleSpan(Typeface.BOLD),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
append(formatTeslaPricingRates(userRates, ctx))
}
}
private fun formatTeslaPricingRates(rates: TeslaGraphQlApi.Rates, ctx: Context) =
buildSpannedString {
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
if (rates.activePricebook.charging.touRates.enabled) {
// time-of-day-based rates
val ratesByTime = rates.activePricebook.charging.touRates.activeRatesByTime
val distinctRates =
ratesByTime.map { it.rates }.distinct().sortedByDescending { it.max() }
if (distinctRates.size == 2) {
// special case: only list periods with higher price
val highPriceTimes = ratesByTime.filter { it.rates == distinctRates[0] }
append("\n")
append(highPriceTimes.joinToString(", ") {
timeFmt.format(it.startTime) + " - " + timeFmt.format(it.endTime)
} + ": ", StyleSpan(Typeface.ITALIC), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
append(
formatTeslaPricingRate(
distinctRates[0],
rates.activePricebook.charging.currencyCode,
rates.activePricebook.charging.uom,
ctx
)
)
append("\n")
append(
ctx.getString(R.string.tesla_pricing_other_times),
StyleSpan(Typeface.ITALIC),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
append(" ")
append(
formatTeslaPricingRate(
distinctRates[1],
rates.activePricebook.charging.currencyCode,
rates.activePricebook.charging.uom,
ctx
)
)
} else {
// general case
ratesByTime.forEach { rate ->
append("\n")
append(
timeFmt.format(rate.startTime) + " - " + timeFmt.format(rate.endTime) + ": ",
StyleSpan(Typeface.ITALIC),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
append(
formatTeslaPricingRate(
rate.rates,
rates.activePricebook.charging.currencyCode,
rates.activePricebook.charging.uom,
ctx
)
)
}
}
} else {
// fixed rates
append(" ")
append(
formatTeslaPricingRate(
rates.activePricebook.charging.rates,
rates.activePricebook.charging.currencyCode,
rates.activePricebook.charging.uom,
ctx
)
)
}
}
private fun formatTeslaPricingRate(
rates: List<Double>,
currencyCode: String,
uom: String,
ctx: Context
): String {
if (rates.isEmpty()) return ""
val rate = rates.max()
val value = ctx.getString(
when (uom) {
"kwh" -> R.string.charge_price_kwh_format
"min" -> R.string.charge_price_minute_format
else -> return ""
}, rate, currency(currencyCode)
)
return if (rates.size > 1) {
ctx.getString(R.string.pricing_up_to, value)
} else {
value
}
}
fun formatChargeCards(
chargecards: List<ChargeCardId>,
chargecardData: Map<Long, ChargeCard>?,

View File

@@ -12,6 +12,7 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.memory.MemoryCache
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.model.ChargerPhoto
@@ -70,6 +71,7 @@ class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener?
memoryKeys[item.id] = metadata.memoryCacheKey
}
)
allowHardware(!BuildConfig.DEBUG)
}
}
}

View File

@@ -10,7 +10,7 @@ class RateLimitInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.url.host == "my.newmotion.com") {
if (request.url.host == "ui-map.shellrecharge.com") {
// limit requests sent to NewMotion to 3 per second
rateLimiter.acquire(1)

View File

@@ -1,14 +1,16 @@
package net.vonforst.evmap.api.availability
import com.facebook.stetho.okhttp3.StethoInterceptor
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.cartesianProduct
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
@@ -133,7 +135,9 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String,
val evseIds: Map<Chargepoint, List<String>>? = null
val evseIds: Map<Chargepoint, List<String>>? = null,
val congestionHistogram: List<Double>? = null,
val extraData: Any? = null // API-specific data
) {
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
val statusFiltered = status.filterKeys {
@@ -158,38 +162,41 @@ private val cookieManager = CookieManager().apply {
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
}
private val okhttp = OkHttpClient.Builder()
.addInterceptor(RateLimitInterceptor())
.addNetworkInterceptor(StethoInterceptor())
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
class AvailabilityRepository(context: Context) {
private val okhttp = OkHttpClient.Builder()
.addInterceptor(RateLimitInterceptor())
.addDebugInterceptors()
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
private val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
if (!ad.isChargerSupported(charger)) continue
try {
value = Resource.success(ad.getAvailability(charger))
break
} catch (e: IOException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: HttpException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
value = Resource.error(e.message, null)
e.printStackTrace()
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
if (!ad.isChargerSupported(charger)) continue
try {
value = Resource.success(ad.getAvailability(charger))
break
} catch (e: IOException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: HttpException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
value = Resource.error(e.message, null)
e.printStackTrace()
}
}
}
return value ?: Resource.error(null, null)
}
return value ?: Resource.error(null, null)
}

View File

@@ -142,7 +142,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
) < maxDistance
}
var details = markers.filter {
val details = markers.filter {
// only include stations from same operator
it.operator == nearest.operator && it.stationId != null
}.map {
@@ -223,7 +223,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
"Slowenien",
"Spanien",
"Tschechien"
)
) && charger.network != "Tesla Supercharger"
"openchargemap" -> country in listOf(
"DE",
"AT",
@@ -242,7 +242,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
"SI",
"ES",
"CZ"
)
) && charger.chargepriceData?.network !in listOf("23", "3534")
else -> false
}
}

View File

@@ -15,12 +15,13 @@ private const val coordRange = 0.005 // range of latitude and longitude for loa
private const val maxDistance = 40 // max distance between reported positions in meters
interface NewMotionApi {
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}")
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}/{zoom}")
suspend fun getMarkers(
@Path("lngMin") lngMin: Double,
@Path("lngMax") lngMax: Double,
@Path("latMin") latMin: Double,
@Path("latMax") latMax: Double
@Path("latMax") latMax: Double,
@Path("zoom") zoom: Int = 22
): List<NMMarker>
@GET("locations/{id}")
@@ -76,7 +77,7 @@ interface NewMotionApi {
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): NewMotionApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://my.newmotion.com/api/map/v2/")
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
.addConverterFactory(MoshiConverterFactory.create())
.client(client)
.build()
@@ -181,7 +182,11 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
override fun isChargerSupported(charger: ChargeLocation): Boolean {
// NewMotion is our fallback
return true
return when (charger.dataSource) {
"goingelectric" -> charger.network != "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network !in listOf("23", "3534")
else -> false
}
}
}

View File

@@ -0,0 +1,628 @@
package net.vonforst.evmap.api.availability
import android.util.Base64
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.Coordinate
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Instant
import java.time.LocalTime
import java.util.Collections
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
interface TeslaAuthenticationApi {
@POST("oauth2/v3/token")
suspend fun getToken(@Body request: OAuth2Request): OAuth2Response
@JsonClass(generateAdapter = true)
class AuthCodeRequest(
val code: String,
@Json(name = "code_verifier") val codeVerifier: String,
@Json(name = "redirect_uri") val redirectUri: String = "https://auth.tesla.com/void/callback",
scope: String = "openid email offline_access",
@Json(name = "client_id") clientId: String = "ownerapi"
) : OAuth2Request(scope, clientId)
@JsonClass(generateAdapter = true)
class RefreshTokenRequest(
@Json(name = "refresh_token") val refreshToken: String,
scope: String = "openid email offline_access",
@Json(name = "client_id") clientId: String = "ownerapi"
) : OAuth2Request(scope, clientId)
sealed class OAuth2Request(
val scope: String,
val clientId: String
)
@JsonClass(generateAdapter = true)
data class OAuth2Response(
@Json(name = "access_token") val accessToken: String,
@Json(name = "token_type") val tokenType: String,
@Json(name = "expires_in") val expiresIn: Long,
@Json(name = "refresh_token") val refreshToken: String,
)
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): TeslaAuthenticationApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://auth.tesla.com")
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder()
.add(
PolymorphicJsonAdapterFactory.of(
OAuth2Request::class.java,
"grant_type"
)
.withSubtype(AuthCodeRequest::class.java, "authorization_code")
.withSubtype(RefreshTokenRequest::class.java, "refresh_token")
.withDefaultValue(null)
)
.build()
)
)
.client(client)
.build()
return retrofit.create(TeslaAuthenticationApi::class.java)
}
fun generateCodeVerifier(): String {
val code = ByteArray(64)
SecureRandom().nextBytes(code)
return Base64.encodeToString(
code,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
}
fun generateCodeChallenge(codeVerifier: String): String {
val bytes = codeVerifier.toByteArray()
val messageDigest = MessageDigest.getInstance("SHA-256")
messageDigest.update(bytes, 0, bytes.size)
return Base64.encodeToString(
messageDigest.digest(),
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
}
}
}
interface TeslaOwnerApi {
@GET("/api/1/users/me")
suspend fun getUserInfo(): UserInfoResponse
@JsonClass(generateAdapter = true)
data class UserInfoResponse(
val response: UserInfo
)
@JsonClass(generateAdapter = true)
data class UserInfo(
val email: String,
@Json(name = "full_name") val fullName: String,
@Json(name = "profile_image_url") val profileImageUrl: String?
)
companion object {
fun create(client: OkHttpClient, token: String, baseUrl: String? = null): TeslaOwnerApi {
val clientWithInterceptor = client.newBuilder()
.addInterceptor { chain ->
// add API key to every request
val request = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.header("User-Agent", "okhttp/4.9.2")
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
.header("Accept", "*/*")
.build()
chain.proceed(request)
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://owner-api.teslamotors.com")
.addConverterFactory(MoshiConverterFactory.create())
.client(clientWithInterceptor)
.build()
return retrofit.create(TeslaOwnerApi::class.java)
}
}
}
interface TeslaGraphQlApi {
@POST("/graphql")
suspend fun getNearbyChargingSites(
@Body request: GetNearbyChargingSitesRequest,
@Query("operationName") operationName: String = "GetNearbyChargingSites",
@Query("deviceLanguage") deviceLanguage: String = "en",
@Query("deviceCountry") deviceCountry: String = "US",
@Query("ttpLocale") ttpLocale: String = "en_US",
@Query("vin") vin: String = "",
): GetNearbyChargingSitesResponse
@POST("/graphql")
suspend fun getChargingSiteInformation(
@Body request: GetChargingSiteInformationRequest,
@Query("operationName") operationName: String = "getChargingSiteInformation",
@Query("deviceLanguage") deviceLanguage: String = "en",
@Query("deviceCountry") deviceCountry: String = "US",
@Query("ttpLocale") ttpLocale: String = "en_US",
@Query("vin") vin: String = "",
): GetChargingSiteInformationResponse
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesRequest(
override val variables: GetNearbyChargingSitesVariables,
override val operationName: String = "GetNearbyChargingSites",
override val query: String =
"\n query GetNearbyChargingSites(\$args: GetNearbyChargingSitesRequestType!) {\n charging {\n nearbySites(args: \$args) {\n sitesAndDistances {\n ...ChargingNearbySitesFragment\n }\n }\n }\n}\n \n fragment ChargingNearbySitesFragment on ChargerSiteAndDistanceType {\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n availableStalls {\n value\n }\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n drivingDistanceMiles {\n value\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n haversineDistanceMiles {\n value\n }\n id {\n text\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n trtId {\n value\n }\n totalStalls {\n value\n }\n siteType\n accessType\n waitEstimateBucket\n hasHighCongestion\n}\n \n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n "
) : GraphQlRequest()
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesVariables(val args: GetNearbyChargingSitesArgs)
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesArgs(
val userLocation: Coordinate,
val northwestCorner: Coordinate,
val southeastCorner: Coordinate,
val openToNonTeslasFilter: OpenToNonTeslasFilterValue,
val languageCode: String = "en",
val countryCode: String = "US",
//val vin: String = "",
//val maxCount: Int = 100
)
@JsonClass(generateAdapter = true)
data class OpenToNonTeslasFilterValue(val value: Boolean)
@JsonClass(generateAdapter = true)
data class Coordinate(val latitude: Double, val longitude: Double)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationRequest(
override val variables: GetChargingSiteInformationVariables,
override val operationName: String = "getChargingSiteInformation",
override val query: String =
"\n query getChargingSiteInformation(\$id: ChargingSiteIdentifierInputType!, \$vehicleMakeType: ChargingVehicleMakeTypeEnum, \$deviceCountry: String!, \$deviceLanguage: String!) {\n charging {\n site(\n id: \$id\n deviceCountry: \$deviceCountry\n deviceLanguage: \$deviceLanguage\n vehicleMakeType: \$vehicleMakeType\n ) {\n siteStatic {\n ...SiteStaticFragmentNoHoldAmt\n }\n siteDynamic {\n ...SiteDynamicFragment\n }\n pricing(vehicleMakeType: \$vehicleMakeType) {\n userRates {\n ...ChargingActiveRateFragment\n }\n memberRates {\n ...ChargingActiveRateFragment\n }\n hasMembershipPricing\n hasMSPPricing\n canDisplayCombinedComparison\n }\n holdAmount(vehicleMakeType: \$vehicleMakeType) {\n currencyCode\n holdAmount\n }\n congestionPriceHistogram(vehicleMakeType: \$vehicleMakeType) {\n ...CongestionPriceHistogramFragment\n }\n }\n }\n}\n \n fragment SiteStaticFragmentNoHoldAmt on ChargingSiteStaticType {\n address {\n ...AddressFragment\n }\n amenities\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n id {\n text\n }\n accessCode {\n value\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n name\n openToPublic\n chargers {\n id {\n text\n }\n label {\n value\n }\n }\n publicStallCount\n timeZone {\n id\n version\n }\n fastchargeSiteId {\n value\n }\n siteType\n accessType\n isMagicDockSupportedSite\n trtId {\n value\n }\n}\n \n fragment AddressFragment on EnergySvcAddressType {\n streetNumber {\n value\n }\n street {\n value\n }\n district {\n value\n }\n city {\n value\n }\n state {\n value\n }\n postalCode {\n value\n }\n country\n}\n \n\n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n \n\n fragment SiteDynamicFragment on ChargingSiteDynamicType {\n id {\n text\n }\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n chargersAvailable {\n value\n }\n chargerDetails {\n charger {\n id {\n text\n }\n label {\n value\n }\n name\n }\n availability\n }\n waitEstimateBucket\n currentCongestion\n}\n \n\n fragment ChargingActiveRateFragment on ChargingActiveRateType {\n activePricebook {\n charging {\n ...ChargingUserRateFragment\n }\n parking {\n ...ChargingUserRateFragment\n }\n priceBookID\n }\n}\n \n fragment ChargingUserRateFragment on ChargingUserRateType {\n currencyCode\n programType\n rates\n buckets {\n start\n end\n }\n bucketUom\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n uom\n vehicleMakeType\n}\n \n\n fragment CongestionPriceHistogramFragment on HistogramData {\n axisLabels {\n index\n value\n }\n regionLabels {\n index\n value {\n ...ChargingPriceFragment\n ... on HistogramRegionLabelValueString {\n value\n }\n }\n }\n chargingUom\n parkingUom\n parkingRate {\n ...ChargingPriceFragment\n }\n data\n activeBar\n maxRateIndex\n whenRateChanges\n dataAttributes {\n congestionThreshold\n label\n }\n}\n \n fragment ChargingPriceFragment on ChargingPrice {\n currencyCode\n price\n}\n"
) : GraphQlRequest()
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationVariables(
val id: ChargingSiteIdentifier,
val vehicleMakeType: VehicleMakeType,
val deviceLanguage: String = "en",
val deviceCountry: String = "US",
val ttpLocale: String = "en_US"
)
@JsonClass(generateAdapter = true)
data class ChargingSiteIdentifier(
val id: String,
val type: ChargingSiteIdentifierType = ChargingSiteIdentifierType.SITE_ID
)
enum class ChargingSiteIdentifierType {
SITE_ID
}
enum class VehicleMakeType {
TESLA, NON_TESLA
}
sealed class GraphQlRequest {
abstract val operationName: String
abstract val query: String
abstract val variables: Any?
}
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesResponse(val data: GetNearbyChargingSitesResponseData)
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesResponseData(val charging: GetNearbyChargingSitesResponseDataCharging)
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesResponseDataCharging(val nearbySites: GetNearbyChargingSitesResponseDataChargingNearbySites)
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesResponseDataChargingNearbySites(val sitesAndDistances: List<ChargingSite>)
@JsonClass(generateAdapter = true)
data class ChargingSite(
val activeOutages: List<Outage>,
val availableStalls: Value<Int>?,
val centroid: Coordinate,
val drivingDistanceMiles: Value<Double>?,
val entryPoint: Coordinate,
val haversineDistanceMiles: Value<Double>,
val id: Text,
val localizedSiteName: Value<String>,
val maxPowerKw: Value<Int>,
val totalStalls: Value<Int>
// TODO: siteType, accessType
)
@JsonClass(generateAdapter = true)
data class Outage(val message: String /* TODO: */)
@JsonClass(generateAdapter = true)
data class Value<T : Any>(val value: T)
@JsonClass(generateAdapter = true)
data class Text(val text: String)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationResponse(val data: GetChargingSiteInformationResponseData)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation)
@JsonClass(generateAdapter = true)
data class ChargingSiteInformation(
val siteDynamic: SiteDynamic,
val siteStatic: SiteStatic,
val pricing: Pricing,
val congestionPriceHistogram: CongestionPriceHistogram,
)
@JsonClass(generateAdapter = true)
data class SiteDynamic(
val activeOutages: List<Outage>,
val chargerDetails: List<ChargerDetail>,
val chargersAvailable: Value<Int>?,
val currentCongestion: Double,
val id: Text,
val waitEstimateBucket: WaitEstimateBucket
)
@JsonClass(generateAdapter = true)
data class ChargerDetail(
val availability: ChargerAvailability,
val charger: ChargerId
)
@JsonClass(generateAdapter = true)
data class ChargerId(
val id: Text,
val label: Value<String>,
val name: String?
) {
val labelNumber
get() = label.value.replace(Regex("""\D"""), "").toInt()
val labelLetter
get() = label.value.replace(Regex("""\d"""), "")
}
@JsonClass(generateAdapter = true)
data class SiteStatic(
val accessCode: Value<String>?,
val centroid: Coordinate,
val chargers: List<ChargerId>,
val entryPoint: Coordinate,
val fastchargeSiteId: Value<Long>,
val id: Text,
val isMagicDockSupportedSite: Boolean,
val localizedSiteName: Value<String>,
val maxPowerKw: Value<Int>,
val name: String,
val openToPublic: Boolean,
val publicStallCount: Int
// TODO: siteType, accessType, address, amenities, timeZone
)
@JsonClass(generateAdapter = true)
data class Pricing(
val canDisplayCombinedComparison: Boolean,
val hasMSPPricing: Boolean,
val hasMembershipPricing: Boolean,
val memberRates: Rates?, // rates for Tesla drivers & non-Tesla drivers with subscription
val userRates: Rates? // rates without subscription
)
@JsonClass(generateAdapter = true)
data class Rates(
val activePricebook: Pricebook
)
@JsonClass(generateAdapter = true)
data class Pricebook(
val charging: PricebookDetails,
val parking: PricebookDetails,
val priceBookID: Long
)
@JsonClass(generateAdapter = true)
data class PricebookDetails(
val bucketUom: String, // unit of measurement for buckets (typically "kw")
val buckets: List<Bucket>, // buckets of charging power (used for minute-based pricing)
val currencyCode: String,
val programType: String,
val rates: List<Double>,
val touRates: TouRates,
val uom: String, // unit of measurement ("kwh" or "min")
val vehicleMakeType: String
)
@JsonClass(generateAdapter = true)
data class Bucket(
val start: Int,
val end: Int
)
@JsonClass(generateAdapter = true)
data class TouRates(
val activeRatesByTime: List<ActiveRatesByTime>,
val enabled: Boolean
)
@JsonClass(generateAdapter = true)
data class ActiveRatesByTime(
val startTime: LocalTime,
val endTime: LocalTime,
val rates: List<Double>
)
@JsonClass(generateAdapter = true)
data class CongestionPriceHistogram(
val data: List<Double>,
val dataAttributes: List<CongestionHistogramDataAttributes>
)
@JsonClass(generateAdapter = true)
data class CongestionHistogramDataAttributes(
val congestionThreshold: String, // "LEVEL_1"
val label: String // "1AM", "2AM", etc.
)
enum class ChargerAvailability {
@Json(name = "CHARGER_AVAILABILITY_AVAILABLE")
AVAILABLE,
@Json(name = "CHARGER_AVAILABILITY_OCCUPIED")
OCCUPIED,
@Json(name = "CHARGER_AVAILABILITY_DOWN")
DOWN,
@Json(name = "CHARGER_AVAILABILITY_UNKNOWN")
UNKNOWN;
fun toStatus() = when (this) {
AVAILABLE -> ChargepointStatus.AVAILABLE
OCCUPIED -> ChargepointStatus.OCCUPIED
DOWN -> ChargepointStatus.FAULTED
UNKNOWN -> ChargepointStatus.UNKNOWN
}
}
enum class WaitEstimateBucket {
@Json(name = "WAIT_ESTIMATE_BUCKET_NO_WAIT")
NO_WAIT,
@Json(name = "WAIT_ESTIMATE_BUCKET_LESS_THAN_5_MINUTES")
LESS_THAN_5_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_5_MINUTES")
APPROXIMATELY_5_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_10_MINUTES")
APPROXIMATELY_10_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_15_MINUTES")
APPROXIMATELY_15_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
APPROXIMATELY_20_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
UNKNOWN
}
companion object {
fun create(
client: OkHttpClient,
baseUrl: String? = null,
token: suspend () -> String
): TeslaGraphQlApi {
val clientWithInterceptor = client.newBuilder()
.addInterceptor { chain ->
val t = runBlocking { token() }
// add API key to every request
val request = chain.request().newBuilder()
.header("Authorization", "Bearer $t")
.header("User-Agent", "okhttp/4.9.2")
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
.header("Accept", "*/*")
.build()
chain.proceed(request)
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://akamai-apigateway-charging-ownership.tesla.com")
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(LocalTimeAdapter()).build()
)
)
.client(clientWithInterceptor)
.build()
return retrofit.create(TeslaGraphQlApi::class.java)
}
}
}
internal class LocalTimeAdapter {
@FromJson
fun fromJson(value: String?): LocalTime? = value?.let {
if (it == "24:00") LocalTime.MAX else LocalTime.parse(it)
}
@ToJson
fun toJson(value: LocalTime?): String? = value?.toString()
}
fun Coordinate.asTeslaCoord() =
TeslaGraphQlApi.Coordinate(this.lat, this.lng)
class TeslaAvailabilityDetector(
private val client: OkHttpClient,
private val tokenStore: TokenStore,
private val baseUrl: String? = null
) :
BaseAvailabilityDetector(client) {
private val authApi = TeslaAuthenticationApi.create(client, null)
private var api: TeslaGraphQlApi? = null
interface TokenStore {
var teslaRefreshToken: String?
var teslaAccessToken: String?
var teslaAccessTokenExpiry: Long
}
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val api = initApi()
val req = TeslaGraphQlApi.GetNearbyChargingSitesRequest(
TeslaGraphQlApi.GetNearbyChargingSitesVariables(
TeslaGraphQlApi.GetNearbyChargingSitesArgs(
location.coordinates.asTeslaCoord(),
TeslaGraphQlApi.Coordinate(
location.coordinates.lat + coordRange,
location.coordinates.lng - coordRange
),
TeslaGraphQlApi.Coordinate(
location.coordinates.lat - coordRange,
location.coordinates.lng + coordRange
),
TeslaGraphQlApi.OpenToNonTeslasFilterValue(false)
)
)
)
val results = api.getNearbyChargingSites(
req,
req.operationName
).data.charging.nearbySites.sitesAndDistances
val result =
results.minByOrNull { it.haversineDistanceMiles.value }
?: throw AvailabilityDetectorException("no candidates found.")
val details = api.getChargingSiteInformation(
TeslaGraphQlApi.GetChargingSiteInformationRequest(
TeslaGraphQlApi.GetChargingSiteInformationVariables(
TeslaGraphQlApi.ChargingSiteIdentifier(result.id.text),
TeslaGraphQlApi.VehicleMakeType.NON_TESLA
)
)
).data.charging.site
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
val scV2CCSConnectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power <= 150
}
val scV3Connectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power > 150
}
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
"charger has unknown connectors"
)
val statusSorted = details.siteDynamic.chargerDetails.sortedBy { it.charger.labelLetter }
.sortedBy { it.charger.labelNumber }
val statusMap = emptyMap<Chargepoint, List<ChargepointStatus>>().toMutableMap()
var i = 0
for (connector in scV2Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.availability.toStatus() }
i += connector.count
}
if (scV2CCSConnectors.isNotEmpty()) {
i = 0
for (connector in scV2CCSConnectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.availability.toStatus() }
i += connector.count
}
}
for (connector in scV3Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.availability.toStatus() }
i += connector.count
}
val indexOfMidnight =
details.congestionPriceHistogram.dataAttributes.indexOfFirst { it.label == "12AM" }
val congestionHistogram = indexOfMidnight.takeIf { it >= 0 }?.let { index ->
val data = details.congestionPriceHistogram.data.toMutableList()
Collections.rotate(data, -index)
data
}
return ChargeLocationStatus(
statusMap,
"Tesla",
congestionHistogram = congestionHistogram,
extraData = details.pricing
)
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
return when (charger.dataSource) {
"goingelectric" -> charger.network == "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
else -> false
}
}
private suspend fun initApi(): TeslaGraphQlApi {
return api ?: run {
val newApi = TeslaGraphQlApi.create(client, baseUrl) {
val now = Instant.now().epochSecond
val token =
tokenStore.teslaAccessToken.takeIf { tokenStore.teslaAccessTokenExpiry > now }
?: run {
val refreshToken = tokenStore.teslaRefreshToken
?: throw IOException("not signed in")
val response =
authApi.getToken(
TeslaAuthenticationApi.RefreshTokenRequest(
refreshToken
)
)
tokenStore.teslaAccessToken = response.accessToken
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
response.accessToken
}
token
}
api = newApi
newApi
}
}
}

View File

@@ -1,13 +1,13 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import jsonapi.Document
import jsonapi.JsonApiFactory
import jsonapi.retrofit.DocumentConverterFactory
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.model.ChargeLocation
import okhttp3.Cache
import okhttp3.OkHttpClient
@@ -77,10 +77,10 @@ interface ChargepriceApi {
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
@@ -127,14 +127,16 @@ interface ChargepriceApi {
charger.chargepriceData?.country?.let { isCountrySupported(it, charger.dataSource) }
?: false
val networkSupported = charger.chargepriceData?.network?.let {
if (charger.dataSource == "openchargemap") {
it !in listOf(
when (charger.dataSource) {
"openchargemap" -> it !in listOf(
"1", // unknown operator
"44", // private residence/individual
"45" // business owner at location
"45", // business owner at location
"23", "3534" // Tesla
)
} else {
true
"goingelectric" -> it != "Tesla Supercharger"
else -> true
}
} ?: false
val powerAvailable = charger.chargepoints.all { it.hasKnownPower() }

View File

@@ -157,9 +157,9 @@ data class ChargepriceCar(
get() = id_!!
val compatibleEvmapConnectors: List<String>
get() = dcChargePorts.map {
get() = dcChargePorts.mapNotNull {
plugMapping[it]
}.filterNotNull().plus(acConnectors)
}.plus(acConnectors)
}
@JsonClass(generateAdapter = true)
@@ -228,7 +228,7 @@ internal object RelationshipsParceler : Parceler<Relationships?> {
if (parcel.readInt() == 0) return null
val nMembers = parcel.readInt()
val members = (0 until nMembers).map { _ ->
val members = (0 until nMembers).associate { _ ->
val key = parcel.readString()!!
val value = if (parcel.readInt() == 0) {
val type = parcel.readString()
@@ -247,7 +247,7 @@ internal object RelationshipsParceler : Parceler<Relationships?> {
Relationship.ToMany(ris)
}
key to value
}.toMap()
}
return Relationships(members)
}
@@ -299,12 +299,12 @@ data class ChargepointPrice(
}
fun time(value: Int): String {
val h = floor(value.toDouble() / 60).toInt();
val min = ceil(value.toDouble() % 60).toInt();
if (h == 0 && min > 0) return "${min}min";
val h = floor(value.toDouble() / 60).toInt()
val min = ceil(value.toDouble() % 60).toInt()
return if (h == 0 && min > 0) "${min}min";
// be slightly sloppy (3:01 is shown as 3h) to save space
else if (h > 0 && (min == 0 || min == 1)) return "${h}h";
else return "%d:%02dh".format(h, min);
else if (h > 0 && (min == 0 || min == 1)) "${h}h";
else "%d:%02dh".format(h, min)
}
// based on https://github.com/chargeprice/chargeprice-client/blob/d420bb2f216d9ad91a210a36dd0859a368a8229a/src/views/priceList.js

View File

@@ -1,9 +1,9 @@
package net.vonforst.evmap.api.fronyx
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.Cache
@@ -49,10 +49,10 @@ private interface FronyxApiRetrofit {
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
cache(Cache(context.cacheDir, cacheSize))
}
}.build()

View File

@@ -14,12 +14,12 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
annotations: MutableSet<out Annotation>,
moshi: Moshi
): JsonAdapter<*>? {
if (Types.getRawType(type) == GEChargepointListItem::class.java) {
return ChargepointListItemJsonAdapter(
return if (Types.getRawType(type) == GEChargepointListItem::class.java) {
ChargepointListItemJsonAdapter(
moshi
)
} else {
return null
null
}
}

View File

@@ -3,7 +3,6 @@ package net.vonforst.evmap.api.goingelectric
import android.content.Context
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -11,6 +10,7 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
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
@@ -104,10 +104,10 @@ interface GoingElectricApi {
chain.proceed(original)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
@@ -146,7 +146,7 @@ class GoingElectricApiWrapper(
val minPower = filters?.getSliderValue("min_power")
val minConnectors = filters?.getSliderValue("min_connectors")
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
@@ -217,7 +217,7 @@ class GoingElectricApiWrapper(
}
} while (startkey != null && startkey < 10000)
var result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
val result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
return Resource.success(result)
}
@@ -240,7 +240,7 @@ class GoingElectricApiWrapper(
val minPower = filters?.getSliderValue("min_power")
val minConnectors = filters?.getSliderValue("min_connectors")
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
@@ -404,11 +404,11 @@ class GoingElectricApiWrapper(
val networks = refData.networks
val chargeCards = refData.chargecards
val plugMap = plugs.map { plug ->
plug to nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
}.toMap()
val networkMap = networks.map { it to it }.toMap()
val chargecardMap = chargeCards.map { it.id.toString() to it.name }.toMap()
val plugMap = plugs.associateWith { plug ->
nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
}
val networkMap = networks.associateWith { it }
val chargecardMap = chargeCards.associate { it.id.toString() to it.name }
val categoryMap = mapOf(
"Autohaus" to sp.getString(R.string.category_car_dealership),
"Autobahnraststätte" to sp.getString(R.string.category_service_on_motorway),

View File

@@ -211,6 +211,7 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) {
"Typ1" -> Chargepoint.TYPE_1
"Typ2" -> Chargepoint.TYPE_2_UNKNOWN
"Typ3" -> Chargepoint.TYPE_3
"Tesla Supercharger CCS" -> Chargepoint.CCS_UNKNOWN
"CCS" -> Chargepoint.CCS_UNKNOWN
"Schuko" -> Chargepoint.SCHUKO
"CHAdeMO" -> Chargepoint.CHADEMO

View File

@@ -3,11 +3,11 @@ package net.vonforst.evmap.api.openchargemap
import android.content.Context
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.facebook.stetho.okhttp3.StethoInterceptor
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
@@ -83,7 +83,7 @@ interface OpenChargeMapApi {
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.cacheDir, cacheSize))
@@ -154,7 +154,7 @@ class OpenChargeMapApiWrapper(
return Resource.error(response.message(), null)
}
var result = postprocessResult(
val result = postprocessResult(
response.body()!!,
minPower,
connectorsVal,
@@ -289,8 +289,8 @@ class OpenChargeMapApiWrapper(
): List<Filter<FilterValue>> {
val refData = referenceData as OCMReferenceData
val operatorsMap = refData.operators.map { it.id.toString() to it.title }.toMap()
val plugMap = refData.connectionTypes.map { it.id.toString() to it.title }.toMap()
val operatorsMap = refData.operators.associate { it.id.toString() to it.title }
val plugMap = refData.connectionTypes.associate { it.id.toString() to it.title }
return listOf(
// supported by OCM API

View File

@@ -120,7 +120,7 @@ data class OSMChargingStation(
// If that is missing as well, use a generic "Charging Station" string.
return tags["name"]
?: tags["operator"]
?: "Charging Station";
?: "Charging Station"
}
/**
@@ -193,7 +193,7 @@ data class OSMChargingStation(
*/
fun parseOutputPower(rawOutput: String?): Double? {
if (rawOutput == null) {
return null;
return null
}
val pattern = Regex("([0-9.,]+)\\s*(kW|kVA)", setOf(RegexOption.IGNORE_CASE))
val matchResult = pattern.matchEntire(rawOutput) ?: return null

View File

@@ -176,10 +176,10 @@ enum class AutocompletePlaceType {
companion object {
fun valueOfOrNull(value: String): AutocompletePlaceType? {
try {
return valueOf(value)
return try {
valueOf(value)
} catch (e: IllegalArgumentException) {
return null
null
}
}
}

View File

@@ -114,7 +114,7 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
override fun getAttributionString(): Int = R.string.powered_by_mapbox
override fun getAttributionImage(dark: Boolean): Int =
if (dark) R.drawable.mapbox_logo_icon else R.drawable.mapbox_logo
if (dark) com.mapbox.mapboxsdk.R.drawable.mapbox_logo_icon else R.drawable.mapbox_logo
}
private fun BoundingBox.toLatLngBounds(): LatLngBounds {

View File

@@ -123,12 +123,12 @@ class ChargepriceFragment : Fragment() {
val charger = fragmentArgs.charger
vm.charger.value = charger
if (vm.chargepoint.value == null) {
vm.chargepoint.value = charger.chargepointsMerged.get(0)
vm.chargepoint.value = charger.chargepointsMerged[0]
}
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)

View File

@@ -80,8 +80,8 @@ class FilterProfilesFragment : Fragment() {
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPos = viewHolder.bindingAdapterPosition;
val toPos = target.bindingAdapterPosition;
val fromPos = viewHolder.bindingAdapterPosition
val toPos = target.bindingAdapterPosition
val list = vm.filterProfiles.value?.toMutableList()
if (list != null) {

View File

@@ -40,8 +40,6 @@ import androidx.transition.TransitionInflater
import androidx.transition.TransitionManager
import coil.load
import coil.memory.MemoryCache
import coil.size.OriginalSize
import coil.size.SizeResolver
import com.car2go.maps.AnyMap
import com.car2go.maps.MapFragment
import com.car2go.maps.OnMapReadyCallback
@@ -75,6 +73,7 @@ import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.bold
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.fragment.preference.DataSettingsFragmentArgs
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
@@ -130,16 +129,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
val state = bottomSheetBehavior.state
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
if (bottomSheetCollapsible) {
when (state) {
STATE_COLLAPSED -> vm.chargerSparse.value = null
STATE_HIDDEN -> vm.searchResult.value = null
else -> if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
} else if (state == STATE_COLLAPSED) {
vm.chargerSparse.value = null
} else if (state == STATE_HIDDEN) {
vm.searchResult.value = null
}
}
}
@@ -164,6 +161,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
println(binding.detailView.sourceButton)
binding.lifecycleOwner = this
binding.vm = vm
@@ -248,7 +246,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
mapFragment!!.getMapAsync(this)
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
bottomSheetBehavior = from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
@@ -381,6 +379,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val charger = vm.charger.value?.data ?: return@setOnClickListener
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
binding.detailView.btnLogin.setOnClickListener {
findNavController().navigate(
R.id.settings_data,
DataSettingsFragmentArgs(true).toBundle()
)
}
binding.detailView.imgPredictionSource.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url))
}
@@ -391,7 +395,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.show()
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
bottomSheetBehavior.state = STATE_ANCHOR_POINT
}
setupSearchAutocomplete()
binding.detailAppBar.toolbar.setNavigationOnClickListener {
@@ -556,7 +560,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun setupObservers() {
bottomSheetBehavior.addBottomSheetCallback(object :
BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback() {
BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (bottomSheetBehavior.state == STATE_HIDDEN) {
map?.setPadding(0, mapTopPadding, 0, 0)
@@ -585,9 +589,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
})
vm.chargerSparse.observe(viewLifecycleOwner, Observer {
vm.chargerSparse.observe(viewLifecycleOwner) {
if (it != null) {
if (vm.bottomSheetState.value != BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT) {
if (vm.bottomSheetState.value != STATE_ANCHOR_POINT) {
bottomSheetBehavior.state =
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_ANCHOR_POINT
}
@@ -600,7 +604,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
bottomSheetBehavior.state = STATE_HIDDEN
unhighlightAllMarkers()
}
})
}
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
when (res.status) {
Status.ERROR -> {
@@ -630,23 +634,23 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.useMiniMarkers.observe(viewLifecycleOwner) {
vm.chargepoints.value?.data?.let { updateMap(it) }
}
vm.favorites.observe(viewLifecycleOwner, Observer {
vm.favorites.observe(viewLifecycleOwner) {
updateFavoriteToggle()
})
vm.searchResult.observe(viewLifecycleOwner, Observer { place ->
}
vm.searchResult.observe(viewLifecycleOwner) { place ->
displaySearchResult(place, moveCamera = true)
})
vm.layersMenuOpen.observe(viewLifecycleOwner, Observer { open ->
}
vm.layersMenuOpen.observe(viewLifecycleOwner) { open ->
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
binding.layersSheet.visibility = if (open) View.VISIBLE else View.INVISIBLE
updateBackPressedCallback()
})
vm.mapType.observe(viewLifecycleOwner, Observer {
}
vm.mapType.observe(viewLifecycleOwner) {
map?.setMapType(it)
})
vm.mapTrafficEnabled.observe(viewLifecycleOwner, Observer {
}
vm.mapTrafficEnabled.observe(viewLifecycleOwner) {
map?.setTrafficEnabled(it)
})
}
updateBackPressedCallback()
}
@@ -701,7 +705,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
@@ -717,7 +721,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = true,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
@@ -732,7 +736,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
@@ -964,10 +968,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.chargerSparse.observe(
viewLifecycleOwner,
object : Observer<ChargeLocation> {
override fun onChanged(item: ChargeLocation?) {
if (item?.id == chargerId) {
override fun onChanged(value: ChargeLocation) {
if (value.id == chargerId) {
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(
LatLng(item.coordinates.lat, item.coordinates.lng), 16f
LatLng(value.coordinates.lat, value.coordinates.lng), 16f
)
map.moveCamera(cameraUpdate)
vm.chargerSparse.removeObserver(this)
@@ -986,9 +990,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
if (res.data == null) return
for (item in res.data) {
override fun onChanged(value: Resource<List<ChargepointListItem>>) {
if (value.data == null) return
for (item in value.data) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
@@ -1096,7 +1100,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = highlight,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
@@ -1117,7 +1121,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav =
charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
animator.animateMarkerDisappear(
marker, tint, highlight, fault, multi, fav,
vm.useMiniMarkers.value == true
@@ -1136,7 +1140,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val highlight = charger.id == vm.chargerSparse.value?.id
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
val fav =
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
val marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
@@ -1190,13 +1195,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val filterBadge = filterView?.findViewById<TextView>(R.id.filter_badge)
if (filterBadge != null) {
// set up badge showing number of active filters
vm.filtersCount.observe(viewLifecycleOwner, Observer {
vm.filtersCount.observe(viewLifecycleOwner) {
filterBadge.visibility = if (it > 0) View.VISIBLE else View.GONE
filterBadge.text = it.toString()
})
}
}
filterView?.setOnClickListener {
var profilesMap: MutableBiMap<Long, MenuItem> = HashBiMap()
val profilesMap: MutableBiMap<Long, MenuItem> = HashBiMap()
val popup = PopupMenu(
ContextThemeWrapper(requireContext(), R.style.RoundedPopup),
@@ -1237,7 +1242,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
vm.filterProfiles.observe(viewLifecycleOwner, { profiles ->
vm.filterProfiles.observe(viewLifecycleOwner) { profiles ->
popup.menu.removeGroup(R.id.menu_group_filter_profiles)
val noFiltersItem = popup.menu.add(
@@ -1267,25 +1272,28 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
profilesMap[FILTERS_CUSTOM] = customItem
profilesMap[FILTERS_FAVORITES] = favoritesItem
popup.menu.setGroupCheckable(R.id.menu_group_filter_profiles, true, true);
popup.menu.setGroupCheckable(R.id.menu_group_filter_profiles, true, true)
val manageFiltersItem = popup.menu.findItem(R.id.menu_manage_filter_profiles)
manageFiltersItem.isVisible = profiles.isNotEmpty()
vm.filterStatus.observe(viewLifecycleOwner, Observer { id ->
vm.filterStatus.observe(viewLifecycleOwner) { id ->
when (id) {
FILTERS_DISABLED -> {
customItem.isVisible = false
noFiltersItem.isChecked = true
}
FILTERS_CUSTOM -> {
customItem.isVisible = true
customItem.isChecked = true
}
FILTERS_FAVORITES -> {
customItem.isVisible = false
favoritesItem.isChecked = true
}
else -> {
customItem.isVisible = false
val item = profilesMap[id]
@@ -1295,8 +1303,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// else unknown ID -> wait for filterProfiles to update
}
}
})
})
}
}
popup.setTouchModal(false)
popup.show()
}

View File

@@ -0,0 +1,91 @@
package net.vonforst.evmap.fragment.oauth
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
class OAuthLoginFragment : Fragment() {
private lateinit var webView: WebView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.fragment_oauth_login, container, false)
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
val uri = Uri.parse(args.url)
webView = view.findViewById(R.id.webView)
args.color?.let { webView.setBackgroundColor(Color.parseColor(it)) }
val progress = view.findViewById<LinearProgressIndicator>(R.id.progress_indicator)
CookieManager.getInstance().removeAllCookies(null)
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val url = request.url
if (url.toString().startsWith(args.resultUrlPrefix)) {
val result = Bundle()
result.putString("url", url.toString())
setFragmentResult(args.url, result)
findNavController().popBackStack()
}
return url.host != uri.host
}
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
progress.show()
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
progress.hide()
webView.background = null
}
}
webView.settings.javaScriptEnabled = true
webView.loadUrl(args.url)
}
}

View File

@@ -2,6 +2,7 @@ package net.vonforst.evmap.fragment.preference
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
@@ -15,9 +16,13 @@ import com.mikepenz.aboutlibraries.LibsBuilder
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.PreferenceDataSource
class AboutFragment : PreferenceFragmentCompat() {
private lateinit var prefs: PreferenceDataSource
private var developerOptionsCounter = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialFadeThrough()
@@ -33,6 +38,8 @@ class AboutFragment : PreferenceFragmentCompat() {
(requireActivity() as MapsActivity).appBarConfiguration
)
prefs = PreferenceDataSource(requireContext())
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
}
@@ -45,6 +52,21 @@ class AboutFragment : PreferenceFragmentCompat() {
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
"version" -> {
if (!prefs.developerModeEnabled) {
developerOptionsCounter += 1
if (developerOptionsCounter >= 7) {
prefs.developerModeEnabled = true
Toast.makeText(
requireContext(),
getString(R.string.developer_mode_enabled),
Toast.LENGTH_SHORT
).show()
}
}
true
}
"contributors" -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.about_contributors)
@@ -53,6 +75,7 @@ class AboutFragment : PreferenceFragmentCompat() {
.show()
true
}
"github_link" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link))
true

View File

@@ -12,14 +12,18 @@ import com.google.android.material.transition.MaterialFadeThrough
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.storage.PreferenceDataSource
abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener {
protected lateinit var prefs: PreferenceDataSource
protected lateinit var encryptedPrefs: EncryptedPreferenceDataStore
protected abstract val isTopLevel: Boolean
override fun onCreate(savedInstanceState: Bundle?) {
prefs = PreferenceDataSource(requireContext())
encryptedPrefs = EncryptedPreferenceDataStore(requireContext())
super.onCreate(savedInstanceState)
if (isTopLevel) {
@@ -40,8 +44,6 @@ abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
(requireActivity() as MapsActivity).appBarConfiguration
)
prefs = PreferenceDataSource(requireContext())
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
}

View File

@@ -4,10 +4,8 @@ import android.content.SharedPreferences
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import androidx.fragment.app.viewModels
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.MultiSelectDialogPreference
@@ -92,9 +90,9 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
private fun updateMyVehiclesSummary() {
vm.vehicles.value?.data?.let { cars ->
val vehicles = cars.filter { it.id in prefs.chargepriceMyVehicles }
val summary = vehicles.map {
val summary = vehicles.joinToString(", ") {
"${it.brand} ${it.name}"
}.joinToString(", ")
}
myVehiclePreference.summary = summary
}
}

View File

@@ -1,14 +1,28 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.TeslaOwnerApi
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
import okhttp3.OkHttpClient
import okio.IOException
import java.time.Instant
class DataSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
@@ -23,8 +37,38 @@ class DataSettingsFragment : BaseSettingsFragment() {
}
})
private lateinit var teslaAccountPreference: Preference
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_data, rootKey)
teslaAccountPreference = findPreference<Preference>("tesla_account")!!
refreshTeslaAccountStatus()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
arguments?.let {
val args = DataSettingsFragmentArgs.fromBundle(it)
if (args.startTeslaLogin) {
teslaLogin()
arguments = null
}
}
}
override fun onResume() {
super.onResume()
refreshTeslaAccountStatus()
}
private fun refreshTeslaAccountStatus() {
teslaAccountPreference.summary =
if (encryptedPrefs.teslaRefreshToken != null) {
getString(R.string.pref_tesla_account_enabled, encryptedPrefs.teslaEmail)
} else {
getString(R.string.pref_tesla_account_disabled)
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
@@ -60,7 +104,86 @@ class DataSettingsFragment : BaseSettingsFragment() {
vm.deleteRecentSearchResults()
true
}
"tesla_account" -> {
if (encryptedPrefs.teslaRefreshToken != null) {
teslaLogout()
} else {
teslaLogin()
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
private fun teslaLogin() {
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
val uri = Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
.appendQueryParameter("client_id", "ownerapi")
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid email offline_access")
.appendQueryParameter("state", "123").build()
val args = OAuthLoginFragmentArgs(
uri.toString(),
"https://auth.tesla.com/void/callback",
"#000000"
).toBundle()
setFragmentResultListener(uri.toString()) { _, result ->
teslaGetAccessToken(result, codeVerifier)
}
findNavController().navigate(R.id.oauth_login, args)
}
private fun teslaGetAccessToken(result: Bundle, codeVerifier: String) {
teslaAccountPreference.summary = getString(R.string.logging_in)
val url = Uri.parse(result.getString("url"))
val code = url.getQueryParameter("code")!!
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
lifecycleScope.launch {
try {
val time = Instant.now().epochSecond
val response =
TeslaAuthenticationApi.create(okhttp).getToken(request)
val userResponse =
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
encryptedPrefs.teslaEmail = userResponse.response.email
encryptedPrefs.teslaAccessToken = response.accessToken
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
encryptedPrefs.teslaRefreshToken = response.refreshToken
} catch (e: IOException) {
view?.let {
Snackbar.make(it, R.string.connection_error, Snackbar.LENGTH_SHORT).show()
}
}
refreshTeslaAccountStatus()
}
}
private fun teslaLogout() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(getString(R.string.pref_tesla_account_enabled, encryptedPrefs.teslaEmail))
.setPositiveButton(R.string.ok) { _, _ -> }
.setNegativeButton(R.string.log_out) { _, _ ->
// sign out
encryptedPrefs.teslaRefreshToken = null
encryptedPrefs.teslaAccessToken = null
encryptedPrefs.teslaAccessTokenExpiry = -1
encryptedPrefs.teslaEmail = null
view?.let { Snackbar.make(it, R.string.logged_out, Snackbar.LENGTH_SHORT).show() }
refreshTeslaAccountStatus()
}
.show()
}
}

View File

@@ -0,0 +1,102 @@
package net.vonforst.evmap.fragment.preference
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.text.format.DateUtils
import android.view.View
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import net.vonforst.evmap.R
class DeveloperSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
private val locationManager: LocationManager by lazy {
requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_developer, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val locationPref = findPreference<Preference>("location_status")!!
val coarseGranted = ContextCompat.checkSelfPermission(
requireContext(),
android.Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val fineGranted = ContextCompat.checkSelfPermission(
requireContext(),
android.Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
locationPref.summary = buildString {
append("Coarse location permission: ")
appendLine(if (coarseGranted) "granted" else "not granted")
append("Fine location permission: ")
appendLine(if (fineGranted) "granted" else "not granted")
appendLine()
if (coarseGranted) {
append("Last network location: ")
appendLine(printLocation(locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)))
}
if (fineGranted) {
append("Last GPS location: ")
appendLine(printLocation(locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.allProviders.contains(
LocationManager.FUSED_PROVIDER
)
) {
append("Last fused location: ")
append(printLocation(locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)))
} else {
append("System's fused location provider not available")
}
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
"disable_developer_mode" -> {
prefs.developerModeEnabled = false
Toast.makeText(
requireContext(),
getString(R.string.developer_mode_disabled),
Toast.LENGTH_SHORT
).show()
findNavController().popBackStack()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
fun printLocation(location: Location?): String {
if (location == null) return "not available"
return buildString {
append("%.4f".format(location.latitude))
append(",")
append("%.4f".format(location.longitude))
append(" (")
append(DateUtils.getRelativeTimeSpanString(location.time))
append(")")
}
}
}

View File

@@ -2,20 +2,22 @@ package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.preference.Preference
import net.vonforst.evmap.R
class SettingsFragment : BaseSettingsFragment() {
override val isTopLevel = true
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.settings)
addPreferencesFromResource(R.xml.settings_variantspecific)
findPreference<Preference>("developer_options")?.isVisible = prefs.developerModeEnabled
}
override fun onResume() {
super.onResume()
findPreference<Preference>("developer_options")?.isVisible = prefs.developerModeEnabled
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {

View File

@@ -31,6 +31,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var gpsLocation: Location? = null
private var networkLocation: Location? = null
private var fusedLocation: Location? = null
private val supportsSystemFusedProvider: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.allProviders.contains(
@@ -101,7 +102,6 @@ class FusionEngine(context: Context) : LocationEngine(context),
try {
enableFused(gpsInterval)
checkLastKnownFused()
return
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for fused provider", e)
}
@@ -143,6 +143,9 @@ class FusionEngine(context: Context) : LocationEngine(context),
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
override fun disable() {
locationManager.removeUpdates(this)
gpsLocation = null
networkLocation = null
fusedLocation = null
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
@@ -235,15 +238,16 @@ class FusionEngine(context: Context) : LocationEngine(context),
override fun onLocationChanged(location: Location) {
if (LocationManager.FUSED_PROVIDER == location.provider) {
fusedLocation = location
requests.forEach { it.listener.onLocationChanged(location) }
} else if (LocationManager.GPS_PROVIDER == location.provider) {
gpsLocation = location
if (gpsLocation.isBetterThan(networkLocation)) {
if (gpsLocation.isBetterThan(networkLocation) && fusedLocation == null) {
requests.forEach { it.listener.onLocationChanged(location) }
}
} else if (LocationManager.NETWORK_PROVIDER == location.provider) {
networkLocation = location
if (networkLocation.isBetterThan(gpsLocation)) {
if (networkLocation.isBetterThan(gpsLocation) && fusedLocation == null) {
requests.forEach { it.listener.onLocationChanged(location) }
}
}

View File

@@ -138,9 +138,9 @@ data class ChargeLocation(
get() = chargepoints.sumOf { it.count }
fun formatChargepoints(sp: StringProvider): String {
return chargepointsMerged.map {
return chargepointsMerged.joinToString(" · ") {
"${it.count} × ${nameForPlugType(sp, it.type)} ${it.formatPower()}"
}.joinToString(" · ")
}
}
}

View File

@@ -23,6 +23,6 @@ data class Favorite(
)
data class FavoriteWithDetail(
@Embedded() val favorite: Favorite,
@Embedded val favorite: Favorite,
@Embedded val charger: ChargeLocation
)

View File

@@ -78,7 +78,7 @@ abstract class AppDatabase : RoomDatabase() {
// SQL for creating tables copied from build/generated/source/kapt/debug/net/vonforst/evmap/storage/AppDatbase_Impl
db.execSQL("CREATE TABLE IF NOT EXISTS `BooleanFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `MultipleChoiceFilterValue` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, PRIMARY KEY(`key`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))");
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))")
}
}
@@ -87,8 +87,8 @@ abstract class AppDatabase : RoomDatabase() {
// recreate ChargeLocation table to make postcode nullable
db.beginTransaction()
try {
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT NOT NULL, `country` TEXT NOT NULL, `postcode` TEXT, `street` TEXT NOT NULL, `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, PRIMARY KEY(`id`))");
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`");
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT NOT NULL, `country` TEXT NOT NULL, `postcode` TEXT, `street` TEXT NOT NULL, `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, PRIMARY KEY(`id`))")
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`")
db.execSQL("DROP TABLE `ChargeLocation`")
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.setTransactionSuccessful()
@@ -109,8 +109,8 @@ abstract class AppDatabase : RoomDatabase() {
// recreate ChargeLocation table to make other address fields nullable
db.beginTransaction()
try {
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` 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, PRIMARY KEY(`id`))");
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`");
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` 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, PRIMARY KEY(`id`))")
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`")
db.execSQL("DROP TABLE `ChargeLocation`")
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.setTransactionSuccessful()
@@ -160,7 +160,7 @@ abstract class AppDatabase : RoomDatabase() {
// add profile column to existing filtervalue tables
db.execSQL("CREATE TABLE `BooleanFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE `MultipleChoiceFilterValueNew` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )");
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
for (table in listOf(
"BooleanFilterValue",
@@ -202,7 +202,7 @@ abstract class AppDatabase : RoomDatabase() {
//////////////////////////////////////////
db.execSQL("CREATE TABLE `OCMConnectionType` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))")
db.execSQL("CREATE TABLE `OCMCountry` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))")
db.execSQL("CREATE TABLE `OCMOperator` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))");
db.execSQL("CREATE TABLE `OCMOperator` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))")
//////////////////////////////////////////
// rename GoingElectric-specific tables //
@@ -295,7 +295,7 @@ abstract class AppDatabase : RoomDatabase() {
// update ChargeLocation table to change primary key
db.execSQL(
"CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT 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, `lat` REAL NOT NULL, `lng` REAL 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, PRIMARY KEY(`id`, `dataSource`))"
);
)
val columnList =
"`id`,`dataSource`,`name`,`chargepoints`,`network`,`url`,`editUrl`,`verified`,`barrierFree`,`operator`,`generalInformation`,`amenities`,`locationDescription`,`photos`,`chargecards`,`license`,`lat`,`lng`,`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`"
db.execSQL("INSERT INTO `ChargeLocationNew`($columnList) SELECT $columnList FROM `ChargeLocation`")
@@ -311,7 +311,7 @@ abstract class AppDatabase : RoomDatabase() {
private val MIGRATION_14 = object : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `RecentAutocompletePlace` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))");
db.execSQL("CREATE TABLE IF NOT EXISTS `RecentAutocompletePlace` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))")
}
}
@@ -321,7 +321,7 @@ abstract class AppDatabase : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) {
try {
db.beginTransaction()
db.execSQL("CREATE TABLE IF NOT EXISTS `Favorite` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE RESTRICT )");
db.execSQL("CREATE TABLE IF NOT EXISTS `Favorite` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE RESTRICT )")
val cursor = db.query("SELECT * FROM `ChargeLocation`")
while (cursor.moveToNext()) {
@@ -361,7 +361,7 @@ abstract class AppDatabase : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) {
try {
db.beginTransaction()
db.execSQL("CREATE TABLE IF NOT EXISTS `FavoriteNew` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )");
db.execSQL("CREATE TABLE IF NOT EXISTS `FavoriteNew` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )")
val columnList =
"`favoriteId`,`chargerId`,`chargerDataSource`"
db.execSQL("INSERT INTO `FavoriteNew`($columnList) SELECT $columnList FROM `Favorite`")

View File

@@ -0,0 +1,45 @@
package net.vonforst.evmap.storage
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import net.vonforst.evmap.api.availability.TeslaAvailabilityDetector
/**
* Encrypted data storage for sensitive data such as API access tokens.
* This will not be included in backups.
*/
class EncryptedPreferenceDataStore(context: Context) : TeslaAvailabilityDetector.TokenStore {
val sp = EncryptedSharedPreferences.create(
context,
"encrypted_prefs",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
override var teslaRefreshToken: String?
get() = sp.getString(
"tesla_refresh_token", null
)
set(value) {
sp.edit().putString("tesla_refresh_token", value).apply()
}
override var teslaAccessToken: String?
get() = sp.getString("tesla_access_token", null)
set(value) {
sp.edit().putString("tesla_access_token", value).apply()
}
override var teslaAccessTokenExpiry: Long
get() = sp.getLong("tesla_access_token_expiry", -1)
set(value) {
sp.edit().putLong("tesla_access_token_expiry", value).apply()
}
var teslaEmail: String?
get() = sp.getString("tesla_email", null)
set(value) {
sp.edit().putString("tesla_email", value).apply()
}
}

View File

@@ -68,7 +68,7 @@ class Converters {
@TypeConverter
fun toChargerPhotoList(value: String): List<ChargerPhoto>? {
return chargerPhotoListAdapter.fromJson(value)?.filterNotNull()
return chargerPhotoListAdapter.fromJson(value)
}
@TypeConverter

View File

@@ -14,6 +14,7 @@ import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import net.vonforst.evmap.R
import java.time.Duration
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@@ -28,8 +29,8 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
private val dp = context.resources.displayMetrics.density
private val sp = context.resources.displayMetrics.scaledDensity
var zeroHeight = 4 * dp
var barWidth = (16 * dp).roundToInt()
var barMargin = (2 * dp).roundToInt()
var barWidth = 16 * dp
var barMargin = 2 * dp
var legendWidth = 12 * dp
var legendLineLength = 4 * dp
var legendLineWidth = 1 * dp
@@ -42,24 +43,27 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
var barDrawable =
AppCompatResources.getDrawable(context, R.drawable.bar_graph)!!
var colorAvailable = ContextCompat.getColor(context, R.color.available)
var colorSomeAvailable = ContextCompat.getColor(context, R.color.some_available)
var colorUnavailable = ContextCompat.getColor(context, R.color.unavailable)
var data: Map<ZonedDateTime, Int>? = null
var data: Map<ZonedDateTime, Double>? = null
set(value) {
field = value
invalidate()
}
var maxValue: Int? = null
var maxValue: Double? = null
set(value) {
field = value
invalidate()
}
var isPercentage: Boolean = false
var activeAlpha = 0.87f
var inactiveAlpha = 0.60f
private val legendPaint = Paint().apply {
val ta = context.theme.obtainStyledAttributes(intArrayOf(R.attr.colorControlNormal))
val ta =
context.theme.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.colorControlNormal))
color = ta.getColor(0, 0)
strokeWidth = legendLineWidth
textSize = legendWidth - legendLineLength
@@ -110,22 +114,28 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
plusMinutes((minutesRound - minute).toLong())
}
data = (0..20).associate {
now.plusMinutes(15L * it) to (Math.random() * 8).roundToInt()
now.plusMinutes(15L * it) to (Math.random() * 8)
}
maxValue = 8
maxValue = 8.0
}
val data = data?.toSortedMap() ?: return
if (data.isEmpty()) return
val maxValue = maxValue ?: data.maxOf { it.value }
val graphWidth = graphBounds?.width() ?: return
val n = data.size
val barMarginFactor = 0.1f
barWidth = graphWidth / (n + barMarginFactor * (n - 1))
barMargin = barWidth * barMarginFactor
drawGraph(canvas, data, maxValue)
drawBubble(canvas, data, maxValue)
}
private fun drawGraph(
canvas: Canvas,
data: SortedMap<ZonedDateTime, Int>,
maxValue: Int
data: SortedMap<ZonedDateTime, Double>,
maxValue: Double
) {
val graphBounds = graphBounds ?: return
@@ -139,26 +149,30 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
)
legendPaint.textAlign = Paint.Align.CENTER
data.entries.forEachIndexed { i, (t, v) ->
val height =
zeroHeight + (graphBounds.height() - zeroHeight) * v.toFloat() / maxValue
zeroHeight + (graphBounds.height() - zeroHeight) * v.toFloat() / maxValue.toFloat()
val left = graphBounds.left + (barWidth + barMargin) * i
if (left + barWidth > graphBounds.right) return@forEachIndexed
barDrawable.setBounds(
left,
0,
graphBounds.bottom - height.roundToInt(),
left + barWidth,
barWidth.roundToInt(),
graphBounds.bottom
)
canvas.translate(left, 0f)
barDrawable.alpha =
((if (i == selectedBar) activeAlpha else inactiveAlpha) * 255).roundToInt()
barDrawable.setTint(getColor(v, maxValue))
barDrawable.draw(canvas)
canvas.translate(-left, 0f)
val center = left.toFloat() + barWidth / 2
if (t.minute == 0) {
val center = left + barWidth / 2
if (shouldDrawLabel(t, data)) {
drawLine(
center, graphBounds.bottom.toFloat(),
center, graphBounds.bottom + legendLineLength, legendPaint
@@ -196,19 +210,44 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
)
legendPaint.textAlign = Paint.Align.LEFT
drawText(
this@BarGraphView.maxValue.toString(),
graphBounds.right.toFloat() + legendLineLength,
graphBounds.top + (legendWidth - legendLineLength) / 3,
legendPaint
)
if (!isPercentage) {
drawText(
maxValue.roundToInt().toString(),
graphBounds.right.toFloat() + legendLineLength,
graphBounds.top + (legendWidth - legendLineLength) / 3,
legendPaint
)
}
}
}
private fun getColor(v: Int, maxValue: Int) =
if (v < maxValue) colorAvailable else colorUnavailable
private fun shouldDrawLabel(t: ZonedDateTime, data: SortedMap<ZonedDateTime, Double>): Boolean {
val ts = data.keys.toList()
return if (Duration.between(ts[0], ts[1]) > Duration.ofMinutes(31)) {
// label every 6 hours
t.hour % 6 == 0
} else {
// label every 15 minutes
t.minute == 0
}
}
private fun drawBubble(canvas: Canvas, data: SortedMap<ZonedDateTime, Int>, maxValue: Int) {
private fun getColor(v: Double, maxValue: Double) =
if (isPercentage) {
when (v) {
in 0.0..0.5 -> colorAvailable
in 0.5..0.8 -> colorSomeAvailable
else -> colorUnavailable
}
} else {
if (v < maxValue) colorAvailable else colorUnavailable
}
private fun drawBubble(
canvas: Canvas,
data: SortedMap<ZonedDateTime, Double>,
maxValue: Double
) {
val bubbleBounds = bubbleBounds ?: return
val graphBounds = graphBounds ?: return
val d = data.toList()
@@ -221,12 +260,16 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
R.string.prediction_time_colon,
t.withZoneSameInstant(ZoneId.systemDefault()).format(timeFormat)
)
val availableformat = context.resources.getQuantityString(
R.plurals.prediction_number_available,
maxValue - v,
maxValue - v,
maxValue
)
val availableformat = if (isPercentage) {
"%.0f %%".format(v * 100)
} else {
context.resources.getQuantityString(
R.plurals.prediction_number_available,
(maxValue - v).roundToInt(),
(maxValue - v).roundToInt(),
maxValue.roundToInt()
)
}
val text = SpannableString("$tformat $availableformat").apply {
setSpan(
ForegroundColorSpan(getColor(v, maxValue)),
@@ -297,7 +340,7 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
private fun updateSelectedBar(x: Int) {
val graphBounds = graphBounds ?: return
val bar = (x - graphBounds.left) / (barWidth + barMargin)
val bar = ((x - graphBounds.left) / (barWidth + barMargin)).roundToInt()
if (bar != selectedBar) {
selectedBar = bar
invalidate()

View File

@@ -110,9 +110,9 @@ private fun activeTint(
val color = context.theme.obtainStyledAttributes(
intArrayOf(
if (isColored) {
R.attr.colorPrimary
androidx.appcompat.R.attr.colorPrimary
} else {
R.attr.colorControlNormal
androidx.appcompat.R.attr.colorControlNormal
}
)
)
@@ -169,9 +169,10 @@ fun setBackgroundTintAvailability(view: View, available: List<ChargepointStatus>
@BindingAdapter("selectableItemBackground")
fun applySelectableItemBackground(view: View, apply: Boolean) {
if (apply) {
view.context.obtainStyledAttributes(intArrayOf(R.attr.selectableItemBackground)).use {
view.background = it.getDrawable(0)
}
view.context.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.selectableItemBackground))
.use {
view.background = it.getDrawable(0)
}
} else {
view.background = null
}
@@ -263,7 +264,8 @@ private fun availabilityColor(
ContextCompat.getColor(context, R.color.charging)
}
} else {
val ta = context.theme.obtainStyledAttributes(intArrayOf(R.attr.colorControlNormal))
val ta =
context.theme.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.colorControlNormal))
ta.getColor(0, 0)
}
@@ -295,16 +297,16 @@ fun currency(currency: String): String {
"GBP" -> "£"
"HRK" -> "kn"
"HUF" -> "Ft"
"ISK" -> "Kr"
"ISK" -> "kr"
else -> currency
}
}
fun time(value: Int): String {
val h = floor(value.toDouble() / 60).toInt();
val min = ceil(value.toDouble() % 60).toInt();
return if (h == 0 && min > 0) "$min min";
else "%d:%02d h".format(h, min);
val h = floor(value.toDouble() / 60).toInt()
val min = ceil(value.toDouble() % 60).toInt()
return if (h == 0 && min > 0) "$min min"
else "%d:%02d h".format(h, min)
}
fun distance(meters: Number?): String? {
@@ -371,9 +373,10 @@ fun tariffBackground(context: Context, myTariff: Boolean, brandingColor: String?
return drawable
}
else -> {
context.obtainStyledAttributes(intArrayOf(R.attr.selectableItemBackground)).use {
return it.getDrawable(0)
}
context.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.selectableItemBackground))
.use {
return it.getDrawable(0)
}
}
}
}
@@ -420,4 +423,9 @@ fun setImageTint(view: ImageView, @ColorInt tint: Int?) {
} else {
view.imageTintList = null
}
}
@BindingAdapter("isPercentage")
fun setIsPercentage(view: BarGraphView, value: Boolean) {
view.isPercentage = value
}

View File

@@ -15,9 +15,9 @@ class CheckableConstraintLayout(ctx: Context, attrs: AttributeSet) : ConstraintL
override fun setChecked(b: Boolean) {
if (b != checked) {
checked = b;
refreshDrawableState();
onCheckedChangeListener?.invoke(this, checked);
checked = b
refreshDrawableState()
onCheckedChangeListener?.invoke(this, checked)
}
}

View File

@@ -1,4 +1,4 @@
package net.vonforst.evmap.ui;
package net.vonforst.evmap.ui
import com.car2go.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem

View File

@@ -101,12 +101,12 @@ class HideOnExpandFabBehavior(context: Context, attrs: AttributeSet) :
type,
consumed
)
if (dyConsumed > 0 && child.getVisibility() == View.VISIBLE) {
if (dyConsumed > 0 && child.visibility == View.VISIBLE) {
// User scrolled down and the FAB is currently visible -> hide the FAB
child.hide();
} else if (dyConsumed < 0 && child.getVisibility() != View.VISIBLE) {
child.hide()
} else if (dyConsumed < 0 && child.visibility != View.VISIBLE) {
// User scrolled up and the FAB is currently not visible -> show the FAB
child.show();
child.show()
}
}
}

View File

@@ -98,12 +98,12 @@ class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
type,
consumed
)
if (dyConsumed > 0 && child.getVisibility() == View.VISIBLE) {
if (dyConsumed > 0 && child.visibility == View.VISIBLE) {
// User scrolled down and the FAB is currently visible -> hide the FAB
child.hide();
} else if (dyConsumed < 0 && child.getVisibility() != View.VISIBLE) {
child.hide()
} else if (dyConsumed < 0 && child.visibility != View.VISIBLE) {
// User scrolled up and the FAB is currently not visible -> show the FAB
child.show();
child.show()
}
}
}

View File

@@ -35,7 +35,10 @@ class ClusterIconGenerator(context: Context) : IconGenerator(context) {
)
id = R.id.amu_text
setPadding(twelveDpi, twelveDpi, twelveDpi, twelveDpi)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_AppCompat)
TextViewCompat.setTextAppearance(
this,
androidx.appcompat.R.style.TextAppearance_AppCompat
)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
}
}
@@ -64,7 +67,7 @@ class ChargerIconGenerator(
// 340 items:
// large: (21 sizes, 5 colors, multi on/off) + highlight + fault + fav (only with scale = 1)
// mini: (11 sizes, 5 colors) + highlight (only with scale = 1)
private val cacheSize = (scaleResolution + 8) * 5 * 2 + (scaleResolutionMini + 2) * 5;
private val cacheSize = (scaleResolution + 8) * 5 * 2 + (scaleResolutionMini + 2) * 5
private val cache = LruCache<BitmapData, BitmapDescriptor>(cacheSize)
private val icon = R.drawable.ic_map_marker_charging
private val multiIcon = R.drawable.ic_map_marker_charging_multiple

View File

@@ -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
}
}
}
@@ -153,7 +151,7 @@ class ChargepriceViewModel(
value = Resource.loading(null)
} else {
val myTariffs = prefs.chargepriceMyTariffs
value = Resource.success(cps.data!!.map { cp ->
value = Resource.success(cps.data!!.mapNotNull { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == getChargepricePlugType(chargepoint) && it.power == chargepoint.power
@@ -165,7 +163,7 @@ class ChargepriceViewModel(
chargepointPrices = filteredPrices
)
}
}.filterNotNull()
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||

View File

@@ -7,9 +7,9 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.model.FavoriteWithDetail
@@ -19,6 +19,7 @@ import net.vonforst.evmap.utils.distanceBetween
class FavoritesViewModel(application: Application) :
AndroidViewModel(application) {
private var db = AppDatabase.getInstance(application)
private val availabilityRepo = AvailabilityRepository(application)
val favorites: LiveData<List<FavoriteWithDetail>> by lazy {
db.favoritesDao().getAllFavorites()
@@ -53,7 +54,7 @@ class FavoritesViewModel(application: Application) :
chargers.map { charger ->
async {
data[charger.id] = getAvailability(charger)
data[charger.id] = availabilityRepo.getAvailability(charger)
availability.value = data
}
}.awaitAll()

View File

@@ -14,8 +14,9 @@ import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.fronyx.FronyxApi
@@ -30,12 +31,16 @@ import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.utils.distanceBetween
import retrofit2.HttpException
import java.io.IOException
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
@Parcelize
@@ -53,12 +58,14 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
AndroidViewModel(application) {
private val db = AppDatabase.getInstance(application)
private val prefs = PreferenceDataSource(application)
private val encryptedPrefs = EncryptedPreferenceDataStore(application)
private val repo = ChargeLocationsRepository(
createApi(prefs.dataSource, application),
viewModelScope,
db,
prefs
)
private val availabilityRepo = AvailabilityRepository(application)
val apiId = repo.api.map { it.id }
@@ -202,7 +209,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
triggerAvailabilityRefresh.switchMap {
liveData {
emit(Resource.loading(null))
emit(getAvailability(charger))
emit(availabilityRepo.getAvailability(charger))
}
}
}
@@ -230,6 +237,10 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}
val teslaPricing = availability.map {
it.data?.extraData as? TeslaGraphQlApi.Pricing
}
val predictionApi = FronyxApi(application.getString(R.string.fronyx_key))
val prediction: LiveData<Resource<List<FronyxEvseIdResponse>>> by lazy {
@@ -280,33 +291,45 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}
val predictionGraph: LiveData<Map<ZonedDateTime, Int>?> by lazy {
prediction.map {
it.data?.let { responses ->
if (responses.isEmpty()) {
null
} else {
val evseIds = responses.map { it.evseId }
val groupByTimestamp = responses.flatMap { response ->
response.predictions.map {
Triple(
it.timestamp,
response.evseId,
it.status
)
val predictionGraph: LiveData<Map<ZonedDateTime, Double>?> =
MediatorLiveData<Map<ZonedDateTime, Double>?>().apply {
listOf(prediction, availability).forEach {
addSource(it) {
val congestionHistogram = availability.value?.data?.congestionHistogram
val prediction = prediction.value?.data
value = if (congestionHistogram != null && prediction == null) {
congestionHistogram.mapIndexed { i, value ->
LocalTime.of(i, 0).atDate(LocalDate.now())
.atZone(ZoneId.systemDefault()) to value
}.toMap()
} else {
prediction?.let { responses ->
if (responses.isEmpty()) {
null
} else {
val evseIds = responses.map { it.evseId }
val groupByTimestamp = responses.flatMap { response ->
response.predictions.map {
Triple(
it.timestamp,
response.evseId,
it.status
)
}
}
.groupBy { it.first } // group by timestamp
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
groupByTimestamp.mapValues {
it.value.count {
it.second == FronyxStatus.UNAVAILABLE
}.toDouble()
}.ifEmpty { null }
}
}
}
.groupBy { it.first } // group by timestamp
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
groupByTimestamp.mapValues {
it.value.count {
it.second == FronyxStatus.UNAVAILABLE
}
}.ifEmpty { null }
}
}
}
}
@@ -326,9 +349,25 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}
val predictionMaxValue: LiveData<Int> by lazy {
predictedChargepoints.map {
it?.sumOf { it.count } ?: 0
val predictionMaxValue: LiveData<Double> = MediatorLiveData<Double>().apply {
listOf(prediction, availability).forEach {
addSource(it) {
value =
if (availability.value?.data?.congestionHistogram != null && prediction.value?.data == null) {
1.0
} else {
(predictedChargepoints.value?.sumOf { it.count } ?: 0).toDouble()
}
}
}
}
val predictionIsPercentage: LiveData<Boolean> = MediatorLiveData<Boolean>().apply {
listOf(prediction, availability).forEach {
addSource(it) {
value =
availability.value?.data?.congestionHistogram != null && prediction.value?.data == null
}
}
}
@@ -389,11 +428,17 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}
private var hasTeslaLogin: MutableLiveData<Boolean> = state.getLiveData("hasTeslaLogin")
fun reloadPrefs() {
filterStatus.value = prefs.filterStatus
if (prefs.dataSource != apiId.value) {
repo.api.value = createApi(prefs.dataSource, getApplication())
}
if (hasTeslaLogin.value != (encryptedPrefs.teslaAccessToken != null)) {
hasTeslaLogin.value = encryptedPrefs.teslaAccessToken != null
reloadAvailability()
}
}
fun toggleFilters() {

View File

@@ -110,7 +110,7 @@ fun <T> throttleLatest(
suspend fun <T> LiveData<T>.await(): T {
return suspendCancellableCoroutine { continuation ->
val observer = object : Observer<T> {
override fun onChanged(value: T?) {
override fun onChanged(value: T) {
if (value == null) return
removeObserver(this)
continuation.resume(value, null)

View File

@@ -4,35 +4,35 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.45,17.08a8.24,8.24 0,0 1,-3.11 0.6,8.34 8.34,0 0,1 -6,-14.18H16.3a8.35,8.35 0,0 1,1.07 10.33"
android:pathData="M6.2,13.8C4.1,10.6 4.6,6.3 7.3,3.5h12c1.5,1.6 2.4,3.7 2.4,5.9c0,4.6 -3.8,8.3 -8.4,8.3c-1.1,0 -2.1,-0.2 -3.1,-0.6"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000" />
android:strokeColor="#000000" />
<path
android:fillColor="#FF000000"
android:pathData="M10.34,9.34m-1.67,0a1.67,1.67 0,1 1,3.34 0a1.67,1.67 0,1 1,-3.34 0" />
android:pathData="M13.3,9.3m-1.7,0a1.7,1.7 0,1 1,3.4 0a1.7,1.7 0,1 1,-3.4 0" />
<path
android:fillColor="#FF000000"
android:pathData="M15.35,9.34m-1.67,0a1.67,1.67 0,1 1,3.34 0a1.67,1.67 0,1 1,-3.34 0" />
android:pathData="M8.3,9.3m-1.7,0a1.7,1.7 0,1 1,3.4 0a1.7,1.7 0,1 1,-3.4 0" />
<path
android:fillColor="#FF000000"
android:pathData="M12.84,13.51m-1.67,0a1.67,1.67 0,1 1,3.34 0a1.67,1.67 0,1 1,-3.34 0" />
android:pathData="M10.8,13.5m-1.7,0a1.7,1.7 0,1 1,3.4 0a1.7,1.7 0,1 1,-3.4 0" />
<path
android:fillColor="#FF000000"
android:pathData="M7.84,13.51m-1.67,0a1.67,1.67 0,1 1,3.34 0a1.67,1.67 0,1 1,-3.34 0" />
android:pathData="M15.8,13.5m-1.7,0a1.7,1.7 0,1 1,3.4 0a1.7,1.7 0,1 1,-3.4 0" />
<path
android:fillColor="#FF000000"
android:pathData="M5.34,9.34m-1.67,0a1.67,1.67 0,1 1,3.34 0a1.67,1.67 0,1 1,-3.34 0" />
android:pathData="M18.3,9.3m-1.7,0a1.7,1.7 0,1 1,3.4 0a1.7,1.7 0,1 1,-3.4 0" />
<path
android:fillColor="#FF000000"
android:pathData="M7.84,5.59m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
android:pathData="M15.8,5.6m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path
android:fillColor="#FF000000"
android:pathData="M12.84,5.59m-1.04,0a1.04,1.04 0,1 1,2.08 0a1.04,1.04 0,1 1,-2.08 0" />
android:pathData="M10.8,5.6m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path
android:fillColor="#FF000000"
android:pathData="M18.18,22.23l1,-5.48c0.93,0 1.22,0.1 1.27,0.52a2.15,2.15 0,0 0,0.93 -0.7,6.91 6.91,0 0,0 -2.46,-0.6l-0.71,0.88h0L17.46,16a7,7 0,0 0,-2.46 0.6,2.22 2.22,0 0,0 0.94,0.7c0,-0.42 0.33,-0.52 1.26,-0.52l1,5.48" />
android:pathData="M5.4,22.3l1,-5.5c0.9,0 1.3,0.1 1.3,0.5c0.4,-0.1 0.7,-0.4 0.9,-0.7C7.8,16.3 7,16 6.1,16l-0.8,0.8l0,0L4.7,16c-0.8,0 -1.7,0.3 -2.5,0.6c0.2,0.3 0.6,0.6 0.9,0.7c0.1,-0.4 0.3,-0.5 1.3,-0.5L5.4,22.3" />
<path
android:fillColor="#FF000000"
android:pathData="M18.18,15.72a7.9,7.9 0,0 1,3.28 0.66,2.65 2.65,0 0,0 0.2,-0.4 9.24,9.24 0,0 0,-7 0,2.61 2.61,0 0,0 0.19,0.4 7.94,7.94 0,0 1,3.29 -0.66h0" />
android:pathData="M5.5,15.7L5.5,15.7c1.1,0 2.3,0.2 3.3,0.7c0.1,-0.1 0.1,-0.3 0.2,-0.4c-2.2,-0.9 -4.8,-0.9 -7,0c0.1,0.1 0.1,0.3 0.2,0.4C3.2,15.9 4.3,15.7 5.5,15.7" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z" />
</vector>

View File

@@ -0,0 +1,15 @@
<vector android:height="24dp"
android:viewportHeight="253.5"
android:viewportWidth="254.58"
android:width="24.10225dp"
xmlns:android="http://schemas.android.com/apk/res/android"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#e82127"
android:pathData="M127.31,253 L162.78,53.48c33.81,0 44.48,3.71 46.02,18.84 0,0 22.68,-8.46 34.13,-25.64C198.28,26 153.42,25.07 153.42,25.07l-26.18,31.88 0.06,-0 -26.18,-31.88c0,0 -44.86,0.93 -89.5,21.62 11.43,17.18 34.12,25.64 34.12,25.64 1.55,-15.14 12.2,-18.84 45.79,-18.87l35.76,199.55"
android:strokeColor="#00000000" />
<path
android:fillColor="#e82127"
android:pathData="m127.29,15.86c36.09,-0.28 77.4,5.58 119.69,24.01 5.65,-10.17 7.11,-14.67 7.11,-14.67C207.86,6.92 164.57,0.66 127.29,0.5 90.01,0.66 46.72,6.92 0.5,25.21c0,0 2.06,5.54 7.1,14.67 42.28,-18.43 83.6,-24.29 119.69,-24.01h0"
android:strokeColor="#00000000" />
</vector>

View File

@@ -45,11 +45,15 @@
<variable
name="predictionGraph"
type="Map&lt;ZonedDateTime, Integer&gt;" />
type="Map&lt;ZonedDateTime, Double&gt;" />
<variable
name="predictionMaxValue"
type="Integer" />
type="Double" />
<variable
name="predictionIsPercentage"
type="Boolean" />
<variable
name="predictionDescription"
@@ -59,6 +63,10 @@
name="filteredAvailability"
type="Resource&lt;ChargeLocationStatus&gt;" />
<variable
name="teslaPricing"
type="net.vonforst.evmap.api.availability.TeslaGraphQlApi.Pricing" />
<variable
name="chargeCards"
type="java.util.Map&lt;Long, ChargeCard&gt;" />
@@ -279,7 +287,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
app:data="@{DetailsAdapterKt.buildDetails(charger.data, chargeCards, filteredChargeCards, context)}"
app:data="@{DetailsAdapterKt.buildDetails(charger.data, chargeCards, filteredChargeCards, teslaPricing, context)}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
@@ -311,10 +319,11 @@
android:id="@+id/textView13"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="right|end"
android:text="@{availability.status == Status.SUCCESS ? @string/realtime_data_source(availability.data.source) : availability.status == Status.LOADING ? @string/realtime_data_loading : @string/realtime_data_unavailable}"
android:text="@{availability.status == Status.SUCCESS ? @string/realtime_data_source(availability.data.source) : availability.status == Status.LOADING ? @string/realtime_data_loading : availability.message == &quot;not signed in&quot; ? @string/realtime_data_login_needed : @string/realtime_data_unavailable}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintEnd_toStartOf="@+id/btnLogin"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/connectors"
tools:text="Echtzeitdaten nicht verfügbar" />
@@ -352,7 +361,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/utilization_prediction"
android:text="@{predictionIsPercentage ? @string/average_utilization : @string/utilization_prediction}"
tools:text="@string/utilization_prediction"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{predictionGraph != null}"
@@ -367,7 +377,7 @@
android:layout_marginEnd="8dp"
android:text="@{predictionDescription}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{predictionGraph != null}"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
app:layout_constraintStart_toEndOf="@+id/textView8"
@@ -379,7 +389,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/help"
app:goneUnless="@{predictionGraph != null}"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:icon="@drawable/ic_help"
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView8"
@@ -397,6 +407,7 @@
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView8"
app:maxValue="@{predictionMaxValue}"
app:isPercentage="@{predictionIsPercentage}"
tools:itemCount="3"
tools:layoutManager="LinearLayoutManager"
tools:listitem="@layout/item_connector"
@@ -410,7 +421,7 @@
android:adjustViewBounds="true"
android:background="?selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
app:goneUnless="@{predictionGraph != null}"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toBottomOf="@+id/prediction"
app:srcCompat="@drawable/ic_powered_by_fronyx"
@@ -496,7 +507,8 @@
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider1"
app:layout_constrainedWidth="true"
android:fillViewport="true">
android:fillViewport="true"
app:goneUnless="@{charger.data != null &amp;&amp; (ChargepriceApi.isChargerSupported(charger.data) || charger.data.chargerUrl != null)}">
<LinearLayout
android:layout_width="wrap_content"
@@ -526,6 +538,17 @@
</LinearLayout>
</HorizontalScrollView>
<Button
android:id="@+id/btnLogin"
style="@style/Widget.Material3.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="@string/login"
app:goneUnless="@{availability.status == Status.ERROR &amp;&amp; availability.message == &quot;not signed in&quot;}"
app:layout_constraintBottom_toBottomOf="@+id/textView13"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/textView13" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -199,12 +199,14 @@
app:filteredAvailability="@{vm.filteredAvailability}"
app:predictionGraph="@{vm.predictionGraph}"
app:predictionMaxValue="@{vm.predictionMaxValue}"
app:predictionIsPercentage="@{vm.predictionIsPercentage}"
app:predictionDescription="@{vm.predictionDescription}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}"
app:expanded="@{vm.bottomSheetExpanded}"
app:apiName="@{vm.apiName}" />
app:apiName="@{vm.apiName}"
app:teslaPricing="@{vm.teslaPricing}" />
</androidx.core.widget.NestedScrollView>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:indeterminate="true"
app:hideAnimationBehavior="inward"
app:indicatorColor="@color/colorSecondary"
app:showAnimationBehavior="outward" />
</com.google.android.material.appbar.AppBarLayout>
<WebView
android:id="@+id/webView"
android:layout_height="match_parent"
android:layout_width="match_parent" />
</LinearLayout>

View File

@@ -78,7 +78,12 @@
android:id="@+id/settings_data"
android:name="net.vonforst.evmap.fragment.preference.DataSettingsFragment"
android:label="@string/settings_data_sources"
tools:layout="@layout/fragment_preference" />
tools:layout="@layout/fragment_preference">
<argument
android:name="startTeslaLogin"
app:argType="boolean"
android:defaultValue="false" />
</fragment>
<fragment
android:id="@+id/settings_chargeprice"
android:name="net.vonforst.evmap.fragment.preference.ChargepriceSettingsFragment"
@@ -89,6 +94,11 @@
android:name="net.vonforst.evmap.fragment.preference.AndroidAutoSettingsFragment"
android:label="@string/settings_android_auto"
tools:layout="@layout/fragment_preference" />
<fragment
android:id="@+id/settings_developer"
android:name="net.vonforst.evmap.fragment.preference.DeveloperSettingsFragment"
android:label="@string/developer_options"
tools:layout="@layout/fragment_preference" />
<navigation
android:id="@+id/favs"
app:startDestination="@id/favs_frag">
@@ -164,4 +174,19 @@
app:popUpTo="@id/onboarding"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/oauth_login"
android:name="net.vonforst.evmap.fragment.oauth.OAuthLoginFragment"
android:label="@string/login">
<argument
android:name="url"
app:argType="string" />
<argument
android:name="resultUrlPrefix"
app:argType="string" />
<argument
android:name="color"
app:argType="string"
app:nullable="true" />
</fragment>
</navigation>

View File

@@ -26,6 +26,7 @@
<string name="amenities">Ausstattung</string>
<string name="general_info">Allgemein</string>
<string name="realtime_data_unavailable">Echtzeitstatus nicht verfügbar</string>
<string name="realtime_data_login_needed">Tesla-Account für Echtzeitdaten benötigt</string>
<string name="realtime_data_loading">Prüfe Echtzeitstatus…</string>
<string name="realtime_data_source">Quelle Echtzeitdaten (beta): %s</string>
<string name="source">Quelle: %s</string>
@@ -42,7 +43,6 @@
<string name="settings_ui">Oberfläche</string>
<string name="settings_map">Karte</string>
<string name="copyright">Copyright</string>
<string name="copyright_summary">©20202023 Johan von Forstner</string>
<string name="other">Sonstiges</string>
<string name="privacy">Datenschutzerklärung</string>
<string name="fav_add">Als Favorit speichern</string>
@@ -175,6 +175,7 @@
<string name="charge_price_format">%1$.2f %2$s</string>
<string name="charge_price_average_format">⌀ %1$.2f %2$s/kWh</string>
<string name="charge_price_kwh_format">%1$.2f %2$s/kWh</string>
<string name="charge_price_minute_format">%1$.2f %2$s/min</string>
<string name="chargeprice_select_connector">Anschluss auswählen</string>
<string name="chargeprice_provider_customer_tariff">Nur für Energiekunden</string>
<string name="percent_format">%.0f%%</string>
@@ -296,4 +297,20 @@
<string name="gps">GPS</string>
<string name="compass">Kompass</string>
<string name="charger_website">Website</string>
<string name="location_status">Standortdienste-Status</string>
<string name="pref_tesla_account">Tesla-Account</string>
<string name="pref_tesla_account_enabled">Angemeldet als %s</string>
<string name="pref_tesla_account_disabled">Anmelden, um Echtzeitdaten für Tesla Supercharger zu sehen. Kein Tesla-Fahrzeug notwendig</string>
<string name="logging_in">Anmelden…</string>
<string name="log_out">Abmelden</string>
<string name="logged_out">Abgemeldet</string>
<string name="login">Login</string>
<string name="login_error">Login fehlgeschlagen</string>
<string name="tesla_pricing_owners">Nur Tesla-Fahrzeuge:</string>
<string name="tesla_pricing_members">Tesla-Fahrzeuge &amp; Mitglieder:</string>
<string name="tesla_pricing_others">Andere Kunden:</string>
<string name="pricing_up_to">bis zu %s</string>
<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>
</resources>

View File

@@ -81,7 +81,7 @@
<string name="verified">vérifié</string>
<plurals name="charge_cards_compatible_num">
<item quantity="one">%d mode de paiement compatible</item>
<item quantity="many">%d modes de paiement compatibles</item>
<item quantity="many">%d de modes de paiement compatibles</item>
<item quantity="other">%d modes de paiement compatibles</item>
</plurals>
<string name="verified_desc">Le fonctionnement du chargeur a été confirmé au moins une fois par un membre de la communauté %s</string>
@@ -104,7 +104,7 @@
<string name="pref_data_source">Source des données</string>
<plurals name="chargeprice_some_tariffs_selected">
<item quantity="one">%d tarif sélectionné</item>
<item quantity="many">%d tarifs sélectionnés</item>
<item quantity="many">%d de tarifs sélectionnés</item>
<item quantity="other">%d tarifs sélectionnés</item>
</plurals>
<string name="data_source_openchargemap">Open Charge Map</string>
@@ -154,7 +154,6 @@
<string name="fault_report_date">Rapport d\'anomalie (dernière mise à jour : %s)</string>
<string name="menu_report_new_charger">Nouveau chargeur</string>
<string name="filter_connectors">Connecteurs</string>
<string name="copyright_summary">©20202023 Johan von Forstner</string>
<string name="other">Autre</string>
<string name="pref_navigate_use_maps_off">Le bouton de navigation lance lapplication de cartes à lemplacement du chargeur</string>
<string name="settings_map">Carte</string>

View File

@@ -93,7 +93,6 @@
<string name="realtime_data_unavailable">Sanntidsstatus utilgjengelig</string>
<string name="other">Andre</string>
<string name="cost_detail"><b>Lading:</b> %1$s · <b>Parkering:</b> %2$s</string>
<string name="copyright_summary">©20202023 Johan von Forstner</string>
<string name="pref_navigate_use_maps_on">Navigasjonsnkappen starter ruteveiledning på Google Maps</string>
<string name="filter_free_parking">Kun ladere med gratis parkering</string>
<string name="filter_min_power">Min. effekt</string>
@@ -296,4 +295,6 @@
<string name="developer_options">Utvikleralternativer</string>
<string name="data_source_switched_to">Datakilde byttet til %s</string>
<string name="developer_mode_enabled">Utviklermodus påslått</string>
<string name="menu_reset">Tilbakestill filterinnstillinger</string>
<string name="charger_website">Nettside</string>
</resources>

View File

@@ -53,7 +53,6 @@
<string name="settings_ui">Interface</string>
<string name="settings_map">Kaart</string>
<string name="copyright">Copyright</string>
<string name="copyright_summary">©20202023 Johan von Forstner</string>
<string name="other">Andere</string>
<string name="privacy">Privacy</string>
<string name="pref_navigate_use_maps_off">Navigatieknop opent de kaart app met de locatie van het laadstation</string>

View File

@@ -1,12 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="copyright_summary">©20202023 Johan von Forstner</string>
<string name="menu_reset">Repor filtros</string>
<string name="filter_custom">Filtro modificado</string>
<string name="filter_favorites">Favoritos</string>
<string name="reorder">reordenar</string>
<string name="delete">Apagar</string>
<string name="welcome_2_detail">Também pode encontrar esta informação em \"Sobre\" → \"Questões frequentes\"</string>
<string name="welcome_2_detail">Também pode encontrar esta informação em \"Sobre\" → \"Perguntas frequentes\"</string>
<string name="chargeprice_donation_dialog_title">Você é um verdadeiro caçador de pechinchas!</string>
<string name="donation_dialog_title">Obrigado por usar o EVMap</string>
<string name="deleted_filterprofile">“%s” removido</string>
@@ -24,9 +23,10 @@
<string name="pref_chargeprice_show_provider_customer_tariffs_summary">As empresas de serviços públicos às vezes oferecem planos especiais para os seus clientes</string>
<plurals name="chargeprice_some_tariffs_selected">
<item quantity="one">%d plano selecionado</item>
<item quantity="other">%d planos selecionados</item>
<item quantity="many">%d planos selecionados</item>
<item quantity="other">%d de planos selecionados</item>
</plurals>
<string name="data_sources_description">Escolha uma fonte de informação para as estações de carregamento. Pode alterar mais tarde nas definições da app.</string>
<string name="data_sources_description">Escolha uma fonte para as estações de carregamento. Pode alterar mais tarde nas definições da app.</string>
<string name="about_contributors_text">Obrigado a todos os contribuidores de código e traduções para o EVMap:</string>
<string name="utilization_prediction">Previsão de utilização</string>
<string name="prediction_time_colon">%s:</string>
@@ -41,7 +41,7 @@
<string name="donation_successful">Obrigado ❤️</string>
<string name="category_car_dealership">Stand de carros</string>
<string name="map_traffic">Trânsito</string>
<string name="faq">Questões frequentes</string>
<string name="faq">Perguntas frequentes</string>
<string name="menu_filters_active">Filtros ativos</string>
<string name="menu_edit_filters">Editar filtros</string>
<string name="filters_activated">Filtros ativados</string>
@@ -96,9 +96,9 @@
<string name="save_profile_enter_name">Insira o nome do perfil com este filtro:</string>
<string name="save_as_profile">Guardar como perfil</string>
<string name="filterprofiles_empty_state">Não existem filtros guardados</string>
<string name="welcome_2">Cada cor corresponde à uma potência máxima</string>
<string name="welcome_2">Cada cor corresponde a potência máxima do carregador</string>
<string name="welcome_to_evmap">Bem-vindo ao EVMap</string>
<string name="pref_darkmode_always_off">sempre desligado</string>
<string name="pref_darkmode_always_off">Sempre desligado</string>
<string name="welcome_2_title">Escolha a potência</string>
<string name="navigate">Navegar</string>
<string name="donation_dialog_detail">O EVMap é gratuito e de código aberto. Contribuições de código no GitHub são bem-vindas. Para ajudar a cobrir os custos de operação, por favor considere fazer uma doação ao criador da app.</string>
@@ -106,6 +106,7 @@
<plurals name="charge_cards_compatible_num">
<item quantity="one">%d forma de pagamento compatível</item>
<item quantity="other">%d formas de pagamento compatíveis</item>
<item quantity="many">%d de formas de pagamento compatíveis</item>
</plurals>
<string name="chargeprice_session_fee">custo da sessão</string>
<string name="edit_on_goingelectric_info">Por favor faça o login em GoingElectric.de se esta página estiver vazia</string>
@@ -136,30 +137,31 @@
<plurals name="pref_my_tariffs_summary">
<item quantity="one" tools:ignore="ImpliedQuantity">(será destacado na comparação de preços)</item>
<item quantity="other">(serão destacados na comparação de preços)</item>
<item quantity="many">(serão destacados na comparação de preços)</item>
</plurals>
<string name="data_source_goingelectric">GoingElectric.de</string>
<string name="chargeprice_all_tariffs_selected">todos os planos selecionados</string>
<string name="chargeprice_all_tariffs_selected">Todos os planos selecionados</string>
<string name="license">Licença</string>
<string name="unknown_operator">Operador desconhecido</string>
<string name="settings_charger_data">Estações de carregamento</string>
<string name="data_source_goingelectric_desc">Boa escolha para países de lingua alemã. Descrições em alemão. Mantido pela comunidade.</string>
<string name="data_source_goingelectric_desc">Boa escolha para países de língua alemã. Descrições em alemão. Mantido pela comunidade.</string>
<string name="pref_data_source">Fonte da informação</string>
<string name="data_source_openchargemap">Open Charge Map</string>
<string name="next">próximo</string>
<string name="data_source_openchargemap_desc">Mundial, com vários níveis de qualidade. Descrições em inglês ou lingua local. Mantido pela comunidade e usa informação governamental publica em alguns países (ex: América do Norte, Reino Unido, França, Noruega, etc).</string>
<string name="data_source_openchargemap_desc">Mundial, com vários níveis de qualidade. Descrições em inglês ou língua local. Mantido pela comunidade e usa informação governamental publica em alguns países (ex: América do Norte, Reino Unido, França, Noruega, etc).</string>
<string name="get_started">Começar</string>
<string name="lets_go">Vamos lá</string>
<string name="crash_report_text">O EVMap encontrou um problema. Por favor envie um relatório do erro para o criador da app.</string>
<string name="crash_report_comment_prompt">Pode adicionar um comentário abaixo:</string>
<string name="pref_search_provider">Fornecedor da pesquisa</string>
<string name="powered_by_mapbox">via Mapbox</string>
<string name="github_sponsors">Patrocinadores no GitHub</string>
<string name="github_sponsors">GitHub Sponsors</string>
<string name="donate_desc">Apoie o desenvolvimento do EVMap com uma única doação</string>
<string name="pref_map_rotate_gestures_on">Use dois dedos para girar o mapa</string>
<string name="pref_map_rotate_gestures_off">Rotação desligada (norte sempre para cima)</string>
<string name="refresh_live_data">atualizar estado em tempo real</string>
<string name="pref_search_provider_info">As pesquisas são caras, especialmente quando o Google Maps é usado. Por favor considere doar através de \"Sobre\" → \"Doar\".</string>
<string name="github_sponsors_desc">Apoie o EVMap no GitHub Sponsors</string>
<string name="github_sponsors_desc">Apoie o EVMap através do GitHub</string>
<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>
@@ -174,7 +176,7 @@
<string name="autocomplete_connection_error">Não foi possível carregar as sugestões</string>
<string name="pref_language_device_default">Língua do dispositivo</string>
<string name="pref_darkmode_device_default">Padrão do dispositivo</string>
<string name="pref_darkmode_always_on">sempre ligado</string>
<string name="pref_darkmode_always_on">Sempre ligado</string>
<string name="pref_chargeprice_currency_chf">Franco suíço (CHF)</string>
<string name="pref_chargeprice_currency_czk">Coroa checa (CZK)</string>
<string name="pref_chargeprice_currency_dkk">Coroa dinamarquesa (DKK)</string>
@@ -188,6 +190,7 @@
<string name="compass">Compasso</string>
<plurals name="prediction_number_available">
<item quantity="one">%1$d/%2$d disponível</item>
<item quantity="many">%1$d/%2$d disponíveis</item>
<item quantity="other">%1$d/%2$d disponíveis</item>
</plurals>
<string name="pref_prediction_enabled">Mostrar previsões de utilização</string>
@@ -227,7 +230,7 @@
<string name="general_info">Informação geral</string>
<string name="realtime_data_unavailable">Estado em tempo real não disponível</string>
<string name="realtime_data_loading">Verificado estado em tempo real…</string>
<string name="realtime_data_source">Fonte do estado em tempo real (beta) %s</string>
<string name="realtime_data_source">Fonte do estado em tempo real (beta): %s</string>
<string name="source">Fonte: %s</string>
<string name="search">Pesquisa</string>
<string name="menu_map">Mapa</string>
@@ -296,4 +299,5 @@
<string name="about_contributors">Contribuidores</string>
<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>
</resources>

View File

@@ -42,7 +42,6 @@
<string name="settings_ui">Interfata</string>
<string name="settings_map">Harta</string>
<string name="copyright">Copyright</string>
<string name="copyright_summary">©20202023 Johan von Forstner</string>
<string name="other">Altele</string>
<string name="privacy">Confidentialitate</string>
<string name="fav_add">Salveaza ca favorit</string>

View File

@@ -15,6 +15,7 @@
<color name="charger_low">#607d8b</color>
<color name="available">#4caf50</color>
<color name="charging">#00bcd4</color>
<color name="some_available">#ffc107</color>
<color name="unavailable">#f44336</color>
<color name="unknown">#9e9e9e</color>
<color name="status_bar_scrim">#C3000000</color>

View File

@@ -30,4 +30,5 @@
</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>
<string name="copyright_summary">©20202023 Johan von Forstner and contributors</string>
</resources>

View File

@@ -26,6 +26,7 @@
<string name="amenities">Amenities</string>
<string name="general_info">General info</string>
<string name="realtime_data_unavailable">Real-time status unavailable</string>
<string name="realtime_data_login_needed">Tesla account needed for real-time data</string>
<string name="realtime_data_loading">Checking real-time status…</string>
<string name="realtime_data_source">Real-time status source (beta): %s</string>
<string name="source">Source: %s</string>
@@ -42,7 +43,6 @@
<string name="settings_ui">Interface</string>
<string name="settings_map">Map</string>
<string name="copyright">Copyright</string>
<string name="copyright_summary">©20202023 Johan von Forstner</string>
<string name="other">Other</string>
<string name="privacy">Privacy</string>
<string name="fav_add">Save as favorite</string>
@@ -175,6 +175,7 @@
<string name="charge_price_format">%2$s%1$.2f</string>
<string name="charge_price_average_format">⌀ %2$s%1$.2f/kWh</string>
<string name="charge_price_kwh_format">%2$s%1$.2f/kWh</string>
<string name="charge_price_minute_format">%2$s%1$.2f/min</string>
<string name="chargeprice_select_connector">Choose connector</string>
<string name="chargeprice_provider_customer_tariff">Only for tie-in customers</string>
<string name="edit_on_goingelectric_info">Please log in at GoingElectric.de if this page is empty</string>
@@ -296,4 +297,20 @@
<string name="gps">GPS</string>
<string name="compass">Compass</string>
<string name="charger_website">Website</string>
<string name="location_status">Location provider status</string>
<string name="pref_tesla_account">Tesla account</string>
<string name="pref_tesla_account_enabled">Logged in as %s</string>
<string name="pref_tesla_account_disabled">Log in to see real-time data for Tesla Superchargers. No Tesla vehicle necessary</string>
<string name="logging_in">Logging in…</string>
<string name="log_out">Log out</string>
<string name="logged_out">Logged out</string>
<string name="login">Login</string>
<string name="login_error">Login failed</string>
<string name="tesla_pricing_owners">Tesla vehicles only:</string>
<string name="tesla_pricing_members">Tesla vehicles &amp; members:</string>
<string name="tesla_pricing_others">Other customers:</string>
<string name="pricing_up_to">up to %s</string>
<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>
</resources>

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.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.db" />
</cloud-backup>
<device-transfer>
<include
domain="sharedpref"
path="." />
<exclude
domain="sharedpref"
path="encrypted_prefs.xml" />
<include
domain="database"
path="evmap.db" />
</device-transfer>
</data-extraction-rules>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Preference
android:fragment="net.vonforst.evmap.fragment.preference.UiSettingsFragment"
android:title="@string/settings_ui"
@@ -12,4 +13,9 @@
android:fragment="net.vonforst.evmap.fragment.preference.ChargepriceSettingsFragment"
android:title="@string/settings_chargeprice"
android:icon="@drawable/ic_chargeprice" />
<Preference
android:key="developer_options"
android:fragment="net.vonforst.evmap.fragment.preference.DeveloperSettingsFragment"
android:title="@string/developer_options"
android:icon="@drawable/ic_developer" />
</PreferenceScreen>

View File

@@ -15,6 +15,10 @@
android:title="@string/pref_prediction_enabled"
android:defaultValue="true"
android:summary="@string/pref_prediction_enabled_summary" />
<Preference
android:key="tesla_account"
android:title="@string/pref_tesla_account" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_map">

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:key="location_status"
android:title="@string/location_status" />
<Preference
android:key="disable_developer_mode"
android:title="@string/disable_developer_mode" />
</PreferenceScreen>

View File

@@ -0,0 +1,10 @@
package net.vonforst.evmap
import android.content.Context
import okhttp3.OkHttpClient
fun addDebugInterceptors(context: Context) {
}
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder = this

View File

@@ -0,0 +1,102 @@
package net.vonforst.evmap
import java.io.InputStream
import java.io.OutputStream
import java.security.Key
import java.security.KeyStore
import java.security.KeyStoreSpi
import java.security.Provider
import java.security.SecureRandom
import java.security.Security
import java.security.cert.Certificate
import java.security.spec.AlgorithmParameterSpec
import java.util.Date
import java.util.Enumeration
import javax.crypto.KeyGenerator
import javax.crypto.KeyGeneratorSpi
import javax.crypto.SecretKey
/**
* https://proandroiddev.com/testing-jetpack-security-with-robolectric-9f9cf2aa4f61
*
* To use, add to your test:
*
* companion object {
* @JvmStatic
* @BeforeClass
* fun beforeClass() {
* FakeAndroidKeyStore.setup
* }
* }
* */
object FakeAndroidKeyStore {
val setup by lazy {
Security.addProvider(object : Provider("AndroidKeyStore", 1.0, "") {
init {
put("KeyStore.AndroidKeyStore", FakeKeyStore::class.java.name)
put("KeyGenerator.AES", FakeAesKeyGenerator::class.java.name)
}
})
}
@Suppress("unused")
class FakeKeyStore : KeyStoreSpi() {
private val wrapped = KeyStore.getInstance(KeyStore.getDefaultType())
override fun engineIsKeyEntry(alias: String?): Boolean = wrapped.isKeyEntry(alias)
override fun engineIsCertificateEntry(alias: String?): Boolean =
wrapped.isCertificateEntry(alias)
override fun engineGetCertificate(alias: String?): Certificate =
wrapped.getCertificate(alias)
override fun engineGetCreationDate(alias: String?): Date = wrapped.getCreationDate(alias)
override fun engineDeleteEntry(alias: String?) = wrapped.deleteEntry(alias)
override fun engineSetKeyEntry(
alias: String?,
key: Key?,
password: CharArray?,
chain: Array<out Certificate>?
) =
wrapped.setKeyEntry(alias, key, password, chain)
override fun engineSetKeyEntry(
alias: String?,
key: ByteArray?,
chain: Array<out Certificate>?
) = wrapped.setKeyEntry(alias, key, chain)
override fun engineStore(stream: OutputStream?, password: CharArray?) =
wrapped.store(stream, password)
override fun engineSize(): Int = wrapped.size()
override fun engineAliases(): Enumeration<String> = wrapped.aliases()
override fun engineContainsAlias(alias: String?): Boolean = wrapped.containsAlias(alias)
override fun engineLoad(stream: InputStream?, password: CharArray?) =
wrapped.load(stream, password)
override fun engineGetCertificateChain(alias: String?): Array<Certificate> =
wrapped.getCertificateChain(alias)
override fun engineSetCertificateEntry(alias: String?, cert: Certificate?) =
wrapped.setCertificateEntry(alias, cert)
override fun engineGetCertificateAlias(cert: Certificate?): String =
wrapped.getCertificateAlias(cert)
override fun engineGetKey(alias: String?, password: CharArray?): Key? =
wrapped.getKey(alias, password)
}
@Suppress("unused")
class FakeAesKeyGenerator : KeyGeneratorSpi() {
private val wrapped = KeyGenerator.getInstance("AES")
override fun engineInit(random: SecureRandom?) = Unit
override fun engineInit(params: AlgorithmParameterSpec?, random: SecureRandom?) = Unit
override fun engineInit(keysize: Int, random: SecureRandom?) = Unit
override fun engineGenerateKey(): SecretKey = wrapped.generateKey()
}
}

View File

@@ -43,8 +43,8 @@ class NewMotionAvailabilityDetectorTest {
"nm/markers" -> {
val urlTail = segments.subList(2, segments.size).joinToString("/")
val id = when (urlTail) {
"9.56608/9.576080000000001/54.5066/54.516600000000004" -> 2105
"9.539283999999999/9.549284/54.471699/54.481699000000006" -> 18284
"9.56608/9.576080000000001/54.5066/54.516600000000004/22" -> 2105
"9.539283999999999/9.549284/54.471699/54.481699000000006/22" -> 18284
else -> -1
}
return okResponse("/newmotion/$id/markers.json")

View File

@@ -35,16 +35,18 @@ class ChargepriceApiTest {
override fun dispatch(request: RecordedRequest): MockResponse {
val segments = request.requestUrl!!.pathSegments
val urlHead = segments.subList(0, 2).joinToString("/")
when (urlHead) {
return when (urlHead) {
"ge/chargepoints" -> {
val id = request.requestUrl!!.queryParameter("ge_id")
return okResponse("/chargers/$id.json")
okResponse("/chargers/$id.json")
}
"cp/charge_prices" -> {
val body = request.body.readUtf8()
return okResponse("/chargeprice/2105.json")
okResponse("/chargeprice/2105.json")
}
else -> return notFoundResponse
else -> notFoundResponse
}
}
}

View File

@@ -40,7 +40,7 @@ class FronyxApiTest {
val ids = request.requestUrl!!.queryParameter("evseIds")!!.split(",")
return okResponse(
"/fronyx/${
ids.map { it.replace("*", "_") }.joinToString(",")
ids.joinToString(",") { it.replace("*", "_") }
}.json"
)
}

View File

@@ -8,6 +8,8 @@ import androidx.car.app.testing.TestCarContext
import androidx.car.app.testing.TestScreenManager
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ApplicationProvider
import net.vonforst.evmap.FakeAndroidKeyStore
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
@@ -22,6 +24,11 @@ class CarAppTest {
updateHandshakeInfo(HandshakeInfo("auto.testing", 1))
}
@Before
fun before() {
FakeAndroidKeyStore.setup
}
@Test
fun onCreateScreen_returnsExpectedScreen() {
val service = Robolectric.setupService(CarAppService::class.java)

View File

@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.7.21'
ext.kotlin_version = '1.8.21'
ext.about_libs_version = '8.9.4'
ext.nav_version = '2.5.3'
repositories {
@@ -10,7 +10,7 @@ buildscript {
gradlePluginPortal()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.1'
classpath 'com.android.tools.build:gradle:8.0.2'
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"

View File

@@ -0,0 +1,10 @@
Neue Funktionen:
- Echtzeitdaten zu Verfügbarkeit und Preisen für Tesla Supercharger (Login mit Tesla-Account erforderlich)
Verbesserungen:
- Übersetzungen aktualisiert
Fehler behoben:
- Datenquelle NewMotion für Echtzeitdaten funktionierte nicht mehr
- Verschieben der Karte zur eigenen Position funktionierte auf manchen Geräten nicht
- Abstürze auf älteren Geräten behoben

View File

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

View File

@@ -0,0 +1,10 @@
New features:
- Real-time availability and prices for Tesla Superchargers (requires login with Tesla account)
Improvements:
- Updated translations
Bugfixes:
- Realtime data from NewMotion was not working anymore
- Moving the map to own location did not work on some devices
- Fixed crashes on some older devices

View File

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

View File

@@ -14,3 +14,6 @@
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true

View File

@@ -1,6 +1,6 @@
#Sat Aug 06 15:33:46 CEST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME