Compare commits

..

53 Commits
1.4.9 ... 1.5.1

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
johan12345
d472be1676 Release 1.4.10 2023-04-22 15:00:15 +02:00
Hosted Weblate
24fa85929e Added translation using Weblate (Romanian)
Added translation using Weblate (Romanian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Johan von Forstner <johan.forstner@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
2023-04-22 14:48:46 +02:00
johan12345
4a67ffd956 OpenChargeMap: do not show addressInfo.relatedUrl if it is identical to operatorInfo.websiteUrl 2023-04-20 20:22:44 +02:00
johan12345
fab66d1f84 OpenChargeMap: fix "exclude faults" filter and hide removed chargers
"exclude faults" has to be implemented as a local filter because otherwise chargers with unknown status (StatusTypeId == null) will also be filtered out
2023-04-20 20:12:54 +02:00
johan12345
0783c6c272 move ic_link to correct folder 2023-04-20 19:54:35 +02:00
johan12345
c5714c8592 OpenChargeMap: add networkUrl and chargerUrl
#273
2023-04-20 19:51:30 +02:00
iboboc
cb4b571721 Adding Romanian language support (#274)
* Adding Romanian language support

* Adding Romanian language support (fix for strings - plurals and add to supported list)

* Revert unused declaration.

---------

Co-authored-by: iboboc <Raluca2018>
2023-04-20 18:38:55 +02:00
Johan von Forstner
0bfa80bbe0 EnBW: expand list of countries 2023-04-14 21:04:30 +02:00
Johan von Forstner
d77f13682d enable portuguese locale 2023-04-07 23:01:15 +02:00
Johan von Forstner
0c19eb5833 minor fixes in Portuguese 2023-04-07 23:00:57 +02:00
Johan von Forstner
a5abedae55 Update translation files 2023-04-07 22:52:20 +02:00
108 changed files with 2893 additions and 412 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

@@ -9,7 +9,7 @@ apply plugin: 'kotlin-kapt'
apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
def supportedLocales = "en,de,fr,nb-rNO,nl"
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
android {
compileSdkVersion 33
@@ -20,8 +20,8 @@ android {
minSdkVersion 21
targetSdkVersion 33
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 166
versionName "1.4.9"
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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="data_sources_hint">Os dados do mapa são fornecidos pelo OpenStreetMap (Mapbox).</string>
<string name="donate_paypal">Doar com o PayPal</string>
<string name="donations_info" formatted="false">Acha que o EVMap é útil\? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Crezi ca EVMap este util? Sprijina dezvoltarea printr-o donatie pentru dezvoltator.</string>
<string name="donate_paypal">Doneaza cu PayPal</string>
<string name="data_sources_hint">Hartile din aplicatie sunt furnizate de OpenStreetMap (Mapbox).</string>
</resources>

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

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="auto_no_chargers_found">Não foram encontrados carregadores próximo de si</string>
<string name="auto_no_favorites_found">Nenhum favorito encontrado</string>
<string name="opened_on_phone">Aberto no telefone</string>
<string name="auto_location_permission_needed">Para usar o EVMap no Android Auto, permita o acesso à sua localização.</string>
<string name="open_in_app">Abrir na app</string>
<string name="auto_vehicle_data_permission_needed">Para esta funcionalidade, o EVMap precisa de aceder aos dados do seu veículo.</string>
<string name="auto_chargers_closeby">Carregadores próximos</string>
<string name="grant_on_phone">Conceda permissões no telefone</string>
<string name="auto_chargers_near_location">Perto de %s</string>
<string name="auto_favorites">Favoritos</string>
<string name="auto_chargeprice_vehicle_ambiguous">Vários veículos selecionados na app correspondem a este veículo (%1$s %2$s).</string>
<string name="selecting_none">todos os items desmarcados</string>
<string name="data_sources_hint">Também pode mudar entre o Google Maps e OpenStreetMap (Mapbox) nas definições da app.</string>
<string name="selecting_all">todos os items selecionados</string>
<string name="loading">Carregando…</string>
<string name="auto_multipage_goto">Página %d</string>
<string name="auto_multipage">(%d/%d)</string>
<string name="settings_android_auto_chargeprice_range">Escala de carregamento para comparação de preços</string>
<string name="donations_info" formatted="false">Acha que o EVMap é útil\? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.
\n
\nA Google cobra 15% de cada doação.</string>
<string name="auto_location_service">O EVMap está a funcionar no Android Auto e usando a sua localização.</string>
<string name="auto_fault_report_date">⚠️ Problemas (%s)</string>
<string name="auto_no_refresh_possible">Não é possível atualizar. Por favor volte atrás e reinicie.</string>
<string name="auto_prices">Preços</string>
<string name="auto_vehicle_data">Dados do veículo</string>
<string name="auto_charging_level">Nível de carregamento</string>
<string name="auto_no_data">Não disponível</string>
<string name="auto_speed">Velocidade</string>
<string name="auto_heading">Direção</string>
<string name="auto_settings">Definições</string>
<string name="welcome_android_auto">Suporte para Android Auto</string>
<string name="auto_range">Alcance</string>
<string name="welcome_android_auto_detail">Também pode usar o EVMap no Android Auto em carros compatíveis. Basta selecionar a app EVMap no menu do Android Auto.</string>
<string name="auto_chargeprice_vehicle_unavailable">O EVMap não pôde determinar o modelo do seu veículo.</string>
<string name="auto_chargeprice_vehicle_unknown">Nenhum dos veículos selecionados na app corresponde a este veículo (%1$s %2$s).</string>
<string name="auto_chargers_ahead">Apenas carregadores na direção do destino</string>
<string name="sounds_cool">Continuar</string>
</resources>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Permitir</string>
<string name="auto_location_permission_needed">Para usar o EVMap no seu carro, permita o acesso à sua localização.</string>
</resources>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

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,
@@ -61,7 +74,8 @@ fun buildDetails(
if (loc.network != null) DetailsAdapter.Detail(
R.drawable.ic_network,
R.string.network,
loc.network
loc.network,
clickable = loc.networkUrl != null
) else null,
if (loc.faultReport != null) DetailsAdapter.Detail(
R.drawable.ic_fault_report,
@@ -125,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 {
@@ -203,30 +203,46 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
val country = charger.chargepriceData?.country
?: charger.address?.country ?: return false
return when (charger.dataSource) {
// list of countries as of 2021/06/30, according to
// https://www.electrive.net/2021/06/30/enbw-expandiert-mit-ladenetz-in-drei-weitere-laender/
// list of countries as of 2023/04/14, according to
// https://www.enbw.com/elektromobilitaet/produkte/ladetarife
"goingelectric" -> country in listOf(
"Deutschland",
"Österreich",
"Schweiz",
"Frankreich",
"Belgien",
"Niederlande",
"Luxemburg",
"Liechtenstein",
"Dänemark",
"Frankreich",
"Italien",
)
"Kroatien",
"Liechtenstein",
"Luxemburg",
"Niederlande",
"Polen",
"Schweden",
"Slowakei",
"Slowenien",
"Spanien",
"Tschechien"
) && charger.network != "Tesla Supercharger"
"openchargemap" -> country in listOf(
"DE",
"AT",
"CH",
"FR",
"BE",
"NE",
"LU",
"DK",
"FR",
"IT",
"HR",
"LI",
"IT"
)
"LU",
"NE",
"PL",
"SE",
"SK",
"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

@@ -77,6 +77,8 @@ data class GEChargeLocation(
cost?.convert(),
null,
ChargepriceData(address.country, network, chargepoints.map { it.type }),
null,
null,
Instant.now(),
isDetailed
)
@@ -209,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))
@@ -148,18 +148,18 @@ class OpenChargeMapApiWrapper(
),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
operators = operators
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
var result = postprocessResult(
val result = postprocessResult(
response.body()!!,
minPower,
connectorsVal,
minConnectors,
excludeFaults,
refData,
zoom
)
@@ -202,8 +202,7 @@ class OpenChargeMapApiWrapper(
radius.toDouble(),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
operators = operators
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
@@ -214,6 +213,7 @@ class OpenChargeMapApiWrapper(
minPower,
connectorsVal,
minConnectors,
excludeFaults,
refData,
zoom
)
@@ -228,6 +228,7 @@ class OpenChargeMapApiWrapper(
minPower: Double?,
connectorsVal: MultipleChoiceFilterValue?,
minConnectors: Int?,
excludeFaults: Boolean?,
referenceData: OCMReferenceData,
zoom: Float
): List<ChargepointListItem> {
@@ -237,6 +238,8 @@ class OpenChargeMapApiWrapper(
.filter { it.power == null || it.power >= (minPower ?: 0.0) }
.filter { if (connectorsVal != null && !connectorsVal.all) it.connectionTypeId in connectorsVal.values.map { it.toLong() } else true }
.sumOf { it.quantity ?: 1 } >= (minConnectors ?: 0)
}.filter {
it.statusTypeId == null || (it.statusTypeId !in removedStatuses && if (excludeFaults == true) it.statusTypeId !in faultStatuses else true)
}.map { it.convert(referenceData, false) }.distinct() as List<ChargepointListItem>
// apply clustering
@@ -286,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

@@ -11,12 +11,15 @@ import java.time.Instant
import java.time.ZonedDateTime
// Unknown, Currently Available, Currently In Use, Operational
val noFaultStatuses = listOf(0, 10, 20, 50)
val noFaultStatuses = listOf(0L, 10L, 20L, 50L)
// Temporarily Unavailable, Partly Operational, Not Operational, Planned For Future Date, Removed (Decommissioned)
val faultStatuses = listOf(30L, 75L, 100L, 150L, 200L)
// Temporarily Unavailable, Partly Operational, Not Operational, Planned For Future Date
val faultStatuses = listOf(30L, 75L, 100L, 150L)
val faultReportCommentType = 1000L
// Removed (Decommissioned), Removed (Duplicate Listing)
val removedStatuses = listOf(200L, 210L)
data class OCMBoundingBox(
val sw_lat: Double, val sw_lng: Double,
val ne_lat: Double, val ne_lng: Double
@@ -71,10 +74,16 @@ data class OCMChargepoint(
addressInfo.countryISOCode(refData),
operatorId?.toString(),
connections.map { "${it.connectionTypeId},${it.currentTypeId}" }),
operatorInfo?.websiteUrl,
if (operatorInfo?.websiteUrl?.withoutTrailingSlash() != addressInfo.relatedUrl?.withoutTrailingSlash()) addressInfo.relatedUrl else null,
Instant.now(),
isDetailed
)
private fun String.withoutTrailingSlash(): String {
return this.replace(Regex("/$"), "")
}
private fun convertFaultReport(): FaultReport? {
if (statusTypeId in faultStatuses || connections.any { it.statusTypeId in faultStatuses }) {
if (userComments != null) {

View File

@@ -105,6 +105,8 @@ data class OSMChargingStation(
getCost(),
"© OpenStreetMap contributors",
null,
null,
null,
dataFetchTimestamp,
true,
)
@@ -118,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"
}
/**
@@ -191,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)
@@ -377,6 +375,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
null, extras
)
}
binding.detailView.btnChargerWebsite.setOnClickListener {
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))
}
@@ -387,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 {
@@ -552,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)
@@ -581,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
}
@@ -596,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 -> {
@@ -626,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()
}
@@ -697,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
)
)
@@ -713,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
)
)
@@ -728,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
)
)
@@ -808,6 +816,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
R.drawable.ic_payment -> {
showPaymentMethodsDialog(charger)
}
R.drawable.ic_network -> {
charger.networkUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
}
}
}
@@ -957,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)
@@ -979,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)
@@ -1089,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
)
)
@@ -1110,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
@@ -1129,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))
@@ -1183,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),
@@ -1230,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(
@@ -1260,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]
@@ -1288,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

@@ -62,8 +62,8 @@ data class ChargeLocation(
@Embedded val address: Address?,
val chargepoints: List<Chargepoint>,
val network: String?,
val url: String,
val editUrl: String?,
val url: String, // URL of this charger at the data source
val editUrl: String?, // URL to edit this charger at the data source
@Embedded(prefix = "fault_report_") val faultReport: FaultReport?,
val verified: Boolean,
val barrierFree: Boolean?,
@@ -78,6 +78,8 @@ data class ChargeLocation(
@Embedded val cost: Cost?,
val license: String?,
@Embedded(prefix = "chargeprice") val chargepriceData: ChargepriceData?,
val networkUrl: String?, // Website of the network
val chargerUrl: String?, // Website for this specific charging site. Might be an ad-hoc payment page.
val timeRetrieved: Instant,
val isDetailed: Boolean
) : ChargepointListItem(), Equatable, Parcelable {
@@ -136,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

@@ -32,7 +32,7 @@ import net.vonforst.evmap.model.*
OCMConnectionType::class,
OCMCountry::class,
OCMOperator::class
], version = 18
], version = 19
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
@@ -56,7 +56,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16,
MIGRATION_17, MIGRATION_18
MIGRATION_17, MIGRATION_18, MIGRATION_19
)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
@@ -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`")
@@ -376,5 +376,12 @@ abstract class AppDatabase : RoomDatabase() {
}
}
private val MIGRATION_19 = object : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `ChargeLocation` ADD `networkUrl` TEXT")
db.execSQL("ALTER TABLE `ChargeLocation` ADD `chargerUrl` TEXT")
}
}
}
}

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,10 @@
<vector android:height="24dp"
android:tint="#000000"
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="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z" />
</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" />
@@ -330,20 +339,6 @@
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="@+id/txtName" />
<Button
android:id="@+id/btnChargeprice"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/go_to_chargeprice"
android:transitionName="@string/shared_element_chargeprice"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:icon="@drawable/ic_chargeprice"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider1" />
<View
android:id="@+id/divider2"
android:layout_width="match_parent"
@@ -359,14 +354,15 @@
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:layout_constraintTop_toBottomOf="@+id/btnChargeprice" />
app:layout_constraintTop_toBottomOf="@+id/buttonsScroller" />
<TextView
android:id="@+id/textView8"
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}"
@@ -381,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"
@@ -393,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"
@@ -411,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"
@@ -424,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"
@@ -501,6 +498,57 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/textView7" />
<HorizontalScrollView
android:id="@+id/buttonsScroller"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider1"
app:layout_constrainedWidth="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"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btnChargeprice"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/go_to_chargeprice"
android:transitionName="@string/shared_element_chargeprice"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:icon="@drawable/ic_chargeprice" />
<Button
android:id="@+id/btnChargerWebsite"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/charger_website"
app:goneUnless="@{charger.data != null &amp;&amp; charger.data.chargerUrl != null}"
app:icon="@drawable/ic_link" />
</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>
@@ -295,4 +296,21 @@
<string name="developer_mode_disabled">Entwicklermodus deaktiviert</string>
<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

@@ -0,0 +1,303 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<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\" → \"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>
<string name="undo">Refazer</string>
<string name="rename">Renomear</string>
<string name="chargeprice_donation_dialog_detail">Você faz grande uso da comparação de preços. Ajude a cobrir os custos de acesso à informação apoiando o EVMap com uma doação.</string>
<string name="verified">verificado</string>
<string name="chargeprice_select_connector">Escolhe o conector</string>
<string name="verified_desc">O carregador foi marcado como funcional por um membro da comunidade %s</string>
<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="percent_format">%.0f%%</string>
<string name="pref_my_vehicle">Os meus veículos</string>
<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="many">%d planos selecionados</item>
<item quantity="other">%d de planos selecionados</item>
</plurals>
<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>
<string name="pref_applink_associate_summary">de goingelectric.de e openchargemap.org</string>
<string name="none">nenhum</string>
<string name="donate">Doar</string>
<string name="show_less">menos…</string>
<string name="all">todos</string>
<string name="show_more">mais…</string>
<string name="filters_deactivated">Filtros desativados</string>
<string name="favorites_empty_state">Carregadores guardados aparecem aqui</string>
<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">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>
<string name="menu_manage_filter_profiles">Gerir perfis de filtros</string>
<string name="go_to_chargeprice">Comparar preços</string>
<string name="filter_operators">Operadores</string>
<string name="location_error">Localização não encontrada. Verifique se a app tem permissão para usar aceder à sua localização</string>
<string name="filter_networks">Redes</string>
<string name="fault_report">Com problemas</string>
<string name="number_selected">%d selecionados</string>
<string name="cancel">Cancelar</string>
<string name="ok">OK</string>
<string name="filter_barrierfree">Não necessita de registo</string>
<string name="fault_report_date">Com problemas (atualizado: %s)</string>
<string name="filter_chargecards">Formas de pagamento</string>
<string name="pref_language">Língua da app</string>
<string name="all_selected">Todos selecionados</string>
<string name="edit">editar</string>
<string name="pref_darkmode">Modo escuro</string>
<string name="connection_error">Não foi possível carregar a lista de carregadores</string>
<string name="retry">Tentar novamente</string>
<string name="filter_open_247">Disponível 24/7</string>
<string name="filter_exclude_faults">Excluir carregadores com relatos de falhas</string>
<string name="charge_cards">Formas de pagamento</string>
<string name="and_n_others">e %d outros</string>
<string name="goingelectric_forum">Tópico no fórum GoingElectric.de</string>
<string name="contact">Contato</string>
<string name="menu_report_new_charger">Novo carregador</string>
<string name="category_holiday_home">Casa de férias</string>
<string name="pref_map_provider">Provedor do mapa</string>
<string name="twitter">Twitter</string>
<string name="category_public_authorities">Autoridades públicas</string>
<string name="category_private_charger">Carregador privado</string>
<string name="category_rest_area">Área de descanso</string>
<string name="edit_at_datasource">editado em %s</string>
<string name="categories">Categorias</string>
<string name="category_service_on_motorway">Área de serviço (autoestrada)</string>
<string name="category_service_off_motorway">Área de serviço (fora da autoestrada)</string>
<string name="category_railway_station">Estação de comboio</string>
<string name="category_shopping_mall">Centro comercial</string>
<string name="category_amusement_park">Parque de diversões</string>
<string name="category_airport">Aeroporto</string>
<string name="category_parking_multi">Garagem de estacionamento</string>
<string name="category_camping">Parque de campismo</string>
<string name="category_cinema">Cinema</string>
<string name="category_hotel">Hotel</string>
<string name="category_church">Igreja</string>
<string name="category_hospital">Hospital</string>
<string name="category_museum">Museu</string>
<string name="category_parking">Parque de estacionamento</string>
<string name="category_restaurant">Restaurante</string>
<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 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="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>
<string name="charging_barrierfree">Não necessita de registo</string>
<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>
<string name="chargeprice_blocking_fee">Taxa de bloqueio %s</string>
<string name="chargeprice_per_kwh">por kWh</string>
<string name="chargeprice_per_minute">por minuto</string>
<string name="pref_chargeprice_no_base_fee">Excluir planos com taxas mensais</string>
<string name="chargeprice_no_tariffs_found">O Chargeprice.app não encontrou planos de carregamento para este carregador</string>
<string name="chargeprice_min_spend">Gasto mínimo: %2$s%1$.2f/mês</string>
<string name="powered_by_chargeprice">informação da Chargeprice</string>
<string name="chargeprice_base_fee">Taxa base: %2$s%1$.2f/mês</string>
<string name="settings_chargeprice">Comparação de preços</string>
<string name="chargeprice_provider_customer_tariff">Apenas para clientes com subscrição</string>
<string name="chargeprice_battery_range">Carregar de %1$.0f%% até %2$.0f%%</string>
<string name="chargeprice_battery_range_from">Carregar de</string>
<string name="pref_chargeprice_show_provider_customer_tariffs">Incluir planos de subscrição</string>
<string name="chargeprice_select_car_first">Por favor escolha o modelo do seu carro nas definições primeiro</string>
<string name="chargeprice_battery_range_to">até</string>
<string name="chargeprice_stats">(%1$.0f kWh, %2$s aprox., ⌀ %3$.0f kW)</string>
<string name="chargeprice_connection_error">Não foi possível carregar os preços</string>
<string name="chargeprice_vehicle">Veículo</string>
<string name="chargeprice_title">Preços</string>
<string name="chargeprice_no_compatible_connectors">Não existem conectores compatíveis nesta estação de carregamento</string>
<string name="pref_chargeprice_currency">Moeda</string>
<string name="got_it">Continuar</string>
<string name="chargeprice_price_not_available">Preço não disponível</string>
<string name="pref_my_tariffs">Os meus planos de carregamento</string>
<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="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 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 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">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 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>
<string name="privacy_link">https://evmap.vonforst.net/en/privacy.html</string>
<string name="faq_link">https://evmap.vonforst.net/en/faq.html</string>
<string name="chargeprice_faq_link">https://evmap.vonforst.net/en/chargeprice_faq.html</string>
<string name="pref_search_delete_recent">Apagar pesquisas recentes</string>
<string name="required">obrigatório</string>
<string name="settings_data_sources">Fontes de informação</string>
<string name="settings_android_auto">Android Auto</string>
<string name="pref_map_rotate_gestures_enabled">Rotação do mapa</string>
<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_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>
<string name="pref_chargeprice_currency_eur">Euro (EUR)</string>
<string name="pref_chargeprice_currency_gbp">Libra esterlina (GBP)</string>
<string name="chargeprice_header_my_tariffs">Os meus planos</string>
<string name="chargeprice_header_other_tariffs">Outros planos</string>
<string name="developer_options">Opções de desenvolvedor</string>
<string name="prediction_help">A previsão é baseada em fatores como dia da semana, hora do dia e uso anterior para que você evite carregadores superlotados. Sem garantias de estar correta.</string>
<string name="disable_developer_mode">Desativar o modo de desenvolvedor</string>
<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>
<string name="developer_mode_enabled">Modo de desenvolvedor ativado</string>
<string name="pref_prediction_enabled_summary">para carregadores suportados
\n(atualmente apenas CC/DC na Alemanha)</string>
<string name="prediction_only">(apenas %s)</string>
<string name="prediction_dc_plugs_only">Conectores CC/DC</string>
<string name="pref_applink_associate">Abrir links suportados</string>
<string name="data_source_switched_to">Fonte de dados alterada para %s</string>
<string name="developer_mode_disabled">Modo de desenvolvedor desativado</string>
<string name="gps">GPS</string>
<string name="no_maps_app_found">Instale a app de navegação primeiro</string>
<string name="no_browser_app_found">Instale um navegador web primeiro</string>
<string name="connectors">Conectores</string>
<string name="address">Endereço</string>
<string name="operator">Operador</string>
<string name="network">Rede</string>
<string name="hours">Horário de abertura</string>
<string name="open_247"><b>Aberto 24/7</b></string>
<string name="closed"><b>Fechado</b></string>
<string name="open_closesat"><b>Aberto</b> · Fecha às %s</string>
<string name="closed_opensat"><b>Fechado</b> · Abre às %s</string>
<string name="app_name">EVMap</string>
<string name="title_activity_maps">EVMap</string>
<string name="closed_unfmt">Fechado</string>
<string name="holiday">Feriado</string>
<string name="cost">Custo</string>
<string name="cost_detail"><b>Carregamento:</b> %1$s · <b>Parque:</b> %2$s</string>
<string name="cost_detail_charging"><b>Carregamento %s</b></string>
<string name="cost_detail_parking"><b>Parque %s</b></string>
<string name="charging_free">Gratuito</string>
<string name="charging_paid">Pago</string>
<string name="parking_free">Gratuito</string>
<string name="parking_paid">Pago</string>
<string name="amenities">Facilidades</string>
<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="source">Fonte: %s</string>
<string name="search">Pesquisa</string>
<string name="menu_map">Mapa</string>
<string name="menu_favs">Favoritos</string>
<string name="menu_filter">Filtro</string>
<string name="not_implemented">ainda não implementado</string>
<string name="about">Sobre</string>
<string name="version">Versão</string>
<string name="github_link_title">Código-fonte</string>
<string name="oss_licenses">Licenças</string>
<string name="settings_ui">Interface</string>
<string name="settings_map">Mapa</string>
<string name="copyright">Direitos de autor</string>
<string name="other">Outro</string>
<string name="privacy">Privacidade</string>
<string name="settings">Definições</string>
<string name="fav_add">Guardar como favorito</string>
<string name="fav_remove">Remover dos favoritos</string>
<string name="pref_navigate_use_maps">Navegar agora</string>
<string name="pref_navigate_use_maps_on">O botão de navegação inicia a navegação com o Google Maps</string>
<string name="pref_navigate_use_maps_off">O botão de navegação abre a app dos mapas com a localização do carregador</string>
<string name="coordinates">Coordenadas</string>
<string name="share">Partilhar</string>
<string name="filter_free">Apenas carregadores gratuitos</string>
<string name="filter_min_power">Potência minima</string>
<string name="filter_free_parking">Apenas carregadores com parque gratuito</string>
<string name="filter_min_connectors">Número mínimo de conectores</string>
<string name="filter_connectors">Conectores</string>
<string name="plug_type_1">Tipo 1</string>
<string name="plug_type_2">Tipo 2</string>
<string name="plug_type_3">Tipo 3A</string>
<string name="plug_ccs">CCS</string>
<string name="plug_schuko">Schuko</string>
<string name="plug_chademo">CHAdeMO</string>
<string name="plug_cee_blau">CEE Azul</string>
<string name="plug_cee_rot">CEE Vermelho</string>
<string name="plug_roadster_hpc">Tesla Roadster (2008) HPC</string>
<string name="plug_supercharger">Supercarregador Tesla</string>
<string name="donation_failed">Algo correu mal 😕</string>
<string name="map_type_satellite">Satélite</string>
<string name="map_type_terrain">Terreno</string>
<string name="map_type">Tipo de mapa</string>
<string name="map_details">Detalhes do mapa</string>
<string name="map_type_normal">Padrão</string>
<string name="category_swimming_pool">Piscina</string>
<string name="category_supermarket">Supermercado</string>
<string name="category_petrol_station">Posto de combustível</string>
<string name="category_parking_underground">Parque de estacionamento subterrâneo</string>
<string name="category_zoo">Zoo</string>
<string name="category_caravan_site">Caravanas</string>
<string name="menu_apply">Aplicar filtros</string>
<string name="menu_save_profile">Guardar como perfil</string>
<string name="no_filters">Sem filtros</string>
<string name="welcome_1">Encontre carregadores elétricos perto de si</string>
<string name="close">Fechar</string>
<string name="edit_filter_profile">Editar “%s”</string>
<string name="pref_chargeprice_currency_hrk">Cuna croata (HRK)</string>
<string name="pref_chargeprice_currency_huf">Florim húngaro (HUF)</string>
<string name="pref_chargeprice_currency_isk">Coroa islandesa (ISK)</string>
<string name="pref_chargeprice_currency_nok">Coroa norueguesa (NOK)</string>
<string name="pref_chargeprice_currency_pln">Złoty polaco (PLN)</string>
<string name="pref_chargeprice_currency_sek">Coroa sueca (SEK)</string>
<string name="pref_chargeprice_currency_usd">Dólar americano (USD)</string>
<string name="pref_provider_google_maps">Google Maps</string>
<string name="pref_provider_osm_mapbox">OpenStreetMap (Mapbox)</string>
<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

@@ -0,0 +1,301 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EVMap</string>
<string name="title_activity_maps">EVMap</string>
<string name="connectors">Conectori</string>
<string name="no_maps_app_found">Instalati o aplicatie de navigatie</string>
<string name="no_browser_app_found">Instalati un browser web</string>
<string name="address">Adresa</string>
<string name="operator">Operator</string>
<string name="network">Retea</string>
<string name="hours">Program</string>
<string name="open_247"><![CDATA[<b>Deschis nonstop</b>]]></string>
<string name="closed"><![CDATA[<b>Inchis</b>]]></string>
<string name="open_closesat"><![CDATA[<b>Deschis</b> · Inchide la %s]]></string>
<string name="closed_opensat"><![CDATA[<b>Inchis</b> · Deschide la %s]]></string>
<string name="closed_unfmt">Inchis</string>
<string name="holiday">Sarbatoare</string>
<string name="cost">Cost</string>
<string name="cost_detail"><![CDATA[<b>Incarcare:</b> %1$s · <b>Parcare:</b> %2$s]]></string>
<string name="cost_detail_charging"><![CDATA[<b>%s incarcare</b>]]></string>
<string name="cost_detail_parking"><![CDATA[<b>%s parcare</b>]]></string>
<string name="charging_free">Gratuit</string>
<string name="charging_paid">Cu plata</string>
<string name="parking_free">Gratuit</string>
<string name="parking_paid">Cu plata</string>
<string name="amenities">Facilitati</string>
<string name="general_info">Informatii generale</string>
<string name="realtime_data_unavailable">Stare in tip real indisponibila</string>
<string name="realtime_data_loading">Verificare stare in timp real…</string>
<string name="realtime_data_source">Sursa verificare in timp real (beta): %s</string>
<string name="source">Sursa: %s</string>
<string name="search">Cautare</string>
<string name="menu_map">Harta</string>
<string name="menu_favs">Favorite</string>
<string name="menu_filter">Fitru</string>
<string name="not_implemented">indisponibil momentan</string>
<string name="about">Despre </string>
<string name="version">Versiune</string>
<string name="github_link_title">Cod sursa</string>
<string name="oss_licenses">Licente</string>
<string name="settings">Setari</string>
<string name="settings_ui">Interfata</string>
<string name="settings_map">Harta</string>
<string name="copyright">Copyright</string>
<string name="other">Altele</string>
<string name="privacy">Confidentialitate</string>
<string name="fav_add">Salveaza ca favorit</string>
<string name="fav_remove">Sterge din favorite</string>
<string name="pref_navigate_use_maps">Indicatii navigare</string>
<string name="pref_navigate_use_maps_on">Butonul de navigare porneste cu Google Maps</string>
<string name="pref_navigate_use_maps_off">Butonul de navigare deschide aplicatia de harti cu locatia statiei</string>
<string name="coordinates">Coordonate</string>
<string name="share">Distribuie</string>
<string name="filter_free">Doar statii gratuite</string>
<string name="filter_min_power">Putere minima</string>
<string name="filter_free_parking">Doar statii cu parcare gratuita</string>
<string name="filter_min_connectors">Numar minim de conectori</string>
<string name="filter_connectors">Conectori</string>
<string name="plug_type_1">Type 1</string>
<string name="plug_type_2">Type 2</string>
<string name="plug_type_3">Type 3A</string>
<string name="plug_ccs">CCS</string>
<string name="plug_schuko">Schuko</string>
<string name="plug_chademo">CHAdeMO</string>
<string name="plug_supercharger">Tesla Supercharger</string>
<string name="plug_cee_blau">CEE Blue</string>
<string name="plug_cee_rot">CEE Red</string>
<string name="plug_roadster_hpc">Tesla Roadster (2008) HPC</string>
<string name="all">toate</string>
<string name="none">niciunul</string>
<string name="show_more">mai mult…</string>
<string name="show_less">mai putin…</string>
<string name="favorites_empty_state">Statiile salvate apar aici</string>
<string name="donate">Doneaza</string>
<string name="donation_successful">Multumesc ❤️</string>
<string name="donation_failed">A aparut o eroare 😕</string>
<string name="map_type_normal">Implicit</string>
<string name="map_type_satellite">Satelit</string>
<string name="map_type_terrain">Teren</string>
<string name="map_type">Tip harta</string>
<string name="map_details">Detalii harta</string>
<string name="map_traffic">Trafic</string>
<string name="faq">Intrebari frecvente</string>
<string name="menu_filters_active">Filtre active</string>
<string name="filters_activated">Filtre activate</string>
<string name="filters_deactivated">Filtre dezactivate</string>
<string name="menu_edit_filters">Modificare filtre</string>
<string name="menu_manage_filter_profiles">Modifica profile filtre</string>
<string name="go_to_chargeprice">Compara preturi</string>
<string name="fault_report">Raport defectiune</string>
<string name="fault_report_date">raport defectiune (ultima actualizare: %s)</string>
<string name="filter_networks">Retele</string>
<string name="filter_operators">Operatori</string>
<string name="filter_chargecards">Metode de plata</string>
<string name="all_selected">Selectate toate</string>
<string name="number_selected">%d selectate</string>
<string name="edit">modifica</string>
<string name="cancel">Anulare</string>
<string name="ok">OK</string>
<string name="pref_language">Limba aplicatie</string>
<string name="pref_darkmode">Mod intunecat</string>
<string name="connection_error">Eroare conexiune</string>
<string name="location_error">Locatia nu a putut fi detectata. Verificati setarile</string>
<string name="retry">Reincearca</string>
<string name="filter_open_247">Disponibile nonstop</string>
<string name="filter_barrierfree">Disponibile fara inregistrare</string>
<string name="filter_exclude_faults">Exclude statiile raportate defecte</string>
<string name="charge_cards">Metode de plata</string>
<string name="and_n_others">si %d altele</string>
<string name="pref_map_provider">Furnizor harta</string>
<string name="twitter">Twitter</string>
<string name="goingelectric_forum">Forum conversatii pe GoingElectric.de</string>
<string name="contact">Contact</string>
<string name="menu_report_new_charger">Statie noua</string>
<string name="edit_at_datasource">modificat la %s</string>
<string name="categories">Categorii</string>
<string name="category_car_dealership">Reprezentanta auto</string>
<string name="category_service_on_motorway">Zona servicii (autostrada)</string>
<string name="category_service_off_motorway">Zona servicii (in afara autostrazii)</string>
<string name="category_railway_station">Statie tren</string>
<string name="category_public_authorities">Autoritati locale</string>
<string name="category_camping">Camping</string>
<string name="category_shopping_mall">Mall</string>
<string name="category_holiday_home">Casa de vacanta</string>
<string name="category_airport">Aerport</string>
<string name="category_amusement_park">Parc de distractii</string>
<string name="category_hotel">Hotel</string>
<string name="category_cinema">Cinema</string>
<string name="category_church">Biserica</string>
<string name="category_hospital">Spital</string>
<string name="category_museum">Museu</string>
<string name="category_parking_multi">Parcare etajata</string>
<string name="category_parking">Parcare</string>
<string name="category_private_charger">Statie de incarcare privata</string>
<string name="category_rest_area">Zona de odihna</string>
<string name="category_restaurant">Restaurant</string>
<string name="category_swimming_pool">Piscina</string>
<string name="category_supermarket">Supermarket</string>
<string name="category_petrol_station">Benzinarie</string>
<string name="category_parking_underground">Parcare subterana</string>
<string name="category_zoo">Gradina Zoo</string>
<string name="category_caravan_site">Camping rulote</string>
<string name="menu_apply">Aplica filtre</string>
<string name="menu_save_profile">Salveaza profil</string>
<string name="menu_reset">Sterge setari filtre</string>
<string name="no_filters">Fara filtre</string>
<string name="filter_custom">Filtre personalizate</string>
<string name="filter_favorites">Favorite</string>
<string name="reorder">reordonare</string>
<string name="delete">Sterge</string>
<string name="save_as_profile">Salveaza ca profil</string>
<string name="save_profile_enter_name">Completati nume profil:</string>
<string name="filterprofiles_empty_state">Nu sunt profile salvate</string>
<string name="welcome_to_evmap">Bine ati venit la EVMap</string>
<string name="welcome_1">Cauta statii de incarcare in apropiere</string>
<string name="welcome_2_title">Esti mereu la curent</string>
<string name="welcome_2">Fiecare culoare a statiei corespunde puterii maxime de incarcare</string>
<string name="welcome_2_detail">Puteti gasi si in sectiunea “Despre” → “Intrebari frecvente”</string>
<string name="donation_dialog_title">Multumim ca utilizati EVMap</string>
<string name="donation_dialog_detail">EVMap este libera si gratuita. Contributiile pe GitHub sunt apreciate. Pentru a acoperi costurile pentru acces la date, va rugam sa donati orice suma pentru dezvoltator.</string>
<string name="chargeprice_donation_dialog_title">Stii sa cauti ofertele cele mai bune!</string>
<string name="chargeprice_donation_dialog_detail">Stii sa folosesti optiunea de comparare preturi. Puteti ajuta pentru a acoperi costurile pentru accesul la aceste date donand pentru EVMap.</string>
<string name="deleted_filterprofile">“%s” a fost sters</string>
<string name="undo">Anuleaza</string>
<string name="rename">Redenumeste</string>
<string name="charging_barrierfree">Utilizabile fara inregistrare</string>
<plurals name="charge_cards_compatible_num">
<item quantity="one">%d metoda de plata compatibila</item>
<item quantity="other">%d metode de plata compatibile</item>
<item quantity="few">%d metode de plata compatibile</item>
</plurals>
<string name="navigate">Navigare</string>
<string name="verified">verificat</string>
<string name="verified_desc">Statia de incarcare a fost confirmata functionala de un mebru din comunitatea %s</string>
<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="chargeprice_select_connector">Alege conector</string>
<string name="chargeprice_provider_customer_tariff">Doar pentru clienti</string>
<string name="edit_on_goingelectric_info">Va rugam autentificati-va la GoingElectric.de daca pagina e goala</string>
<string name="percent_format">%.0f%%</string>
<string name="chargeprice_session_fee">taxa sesiune</string>
<string name="chargeprice_per_kwh">pe kWh</string>
<string name="chargeprice_per_minute">pe min</string>
<string name="chargeprice_blocking_fee">Taxa blocare &gt;%s</string>
<string name="chargeprice_no_tariffs_found">Fara pret pentru aceasta statie in Chargeprice.app</string>
<string name="powered_by_chargeprice">oferit de Chargeprice</string>
<string name="chargeprice_base_fee">Pret de baza: %2$s%1$.2f/luna</string>
<string name="chargeprice_min_spend">Suma minima: %2$s%1$.2f/luna</string>
<string name="settings_chargeprice">Comparatie preturi</string>
<string name="pref_my_vehicle">Masinile mele</string>
<string name="pref_chargeprice_no_base_fee">Exclude abonamente cu plata lunara</string>
<string name="pref_chargeprice_show_provider_customer_tariffs">Include abonament clienti</string>
<string name="chargeprice_select_car_first">Configurati modelul masinii in setari</string>
<string name="chargeprice_battery_range">Incarcare de la %1$.0f%% la %2$.0f%%</string>
<string name="chargeprice_battery_range_from">Incarcare de la</string>
<string name="chargeprice_battery_range_to">la</string>
<string name="chargeprice_stats">(%1$.0f kWh, aprox. %2$s, ⌀ %3$.0f kW)</string>
<string name="chargeprice_vehicle">Masina</string>
<string name="chargeprice_price_not_available">Pret indisponibil</string>
<string name="pref_chargeprice_show_provider_customer_tariffs_summary">Furnizorii de utilitati ofera uneori abonamente speciale pentru clientii lor</string>
<string name="close">Inchidere</string>
<string name="chargeprice_title">Preturi</string>
<string name="chargeprice_connection_error">Preturi nu au putut fi incarcate</string>
<string name="chargeprice_no_compatible_connectors">Nu sunt conectori compatibili la aceasta statie</string>
<string name="pref_chargeprice_currency">Moneda</string>
<string name="pref_my_tariffs">Abonamentele mele</string>
<plurals name="pref_my_tariffs_summary">
<item quantity="one">(va fi evidentiat in comparatia preturilor)</item>
<item quantity="other">(vor fi evidentiate in comparatia preturilor)</item>
<item quantity="few">(vor fi evidentiate in comparatia preturilor)</item>
</plurals>
<string name="chargeprice_all_tariffs_selected">toate abonamentele selectate</string>
<string name="license">Licenta</string>
<string name="settings_charger_data">Statii de incarcare</string>
<string name="pref_data_source">Sursa date</string>
<plurals name="chargeprice_some_tariffs_selected">
<item quantity="one">%d abonament selectat</item>
<item quantity="other">%d abonamente selectate</item>
<item quantity="few">%d abonamente selectate</item>
</plurals>
<string name="unknown_operator">Operator necunoscut</string>
<string name="data_sources_description">Alegeti o sursa pentru statiile de incarcare. Puteti modifica ulterior in setarile aplicatiei.</string>
<string name="data_source_goingelectric">GoingElectric.de</string>
<string name="data_source_openchargemap">Open Charge Map</string>
<string name="data_source_goingelectric_desc">Recomandat in tarile vorbitaore de limba germana. Descrieri in limba germana. Actualizat de comunitate.</string>
<string name="data_source_openchargemap_desc"><![CDATA[International, calitate variata. Descrieri in engleza sau in limba locala. Actualizat de comunitate si de autoritati in unele tari (ex. America de Nord, UK, Franta, Norvegia).]]></string>
<string name="next">urmatorul</string>
<string name="get_started">Incepe</string>
<string name="got_it">Am inteles</string>
<string name="lets_go">Sa incepem</string>
<string name="crash_report_text">Eroare EVMap. Trimiteti raportul de eroare la dezvoltator.</string>
<string name="crash_report_comment_prompt">Puteti adauga un comentariu aici:</string>
<string name="powered_by_mapbox">furnizat de Mapbox</string>
<string name="pref_search_provider">Furnizor cautare</string>
<string name="pref_search_provider_info"><![CDATA[Datele de cautare sunt costisitoare, in special de la Google Maps. Va rugam sa luati in considerare o donatie in sectiunea “Despre” → “Doneaza”.]]></string>
<string name="github_sponsors">Sponsori GitHub</string>
<string name="donate_desc">Sprijina dezvoltarea EVMap\'s cu o donatie</string>
<string name="github_sponsors_desc">Sprijina EVMap pe GitHub</string>
<string name="unnamed_filter_profile">Profile filtre fara nume</string>
<string name="privacy_link">https://evmap.vonforst.net/en/privacy.html</string>
<string name="faq_link">https://evmap.vonforst.net/en/faq.html</string>
<string name="chargeprice_faq_link">https://evmap.vonforst.net/en/chargeprice_faq.html</string>
<string name="required">obligatoriu</string>
<string name="edit_filter_profile">Modifica “%s”</string>
<string name="pref_search_delete_recent">Sterge rezultate cautare recenta</string>
<string name="deleted_recent_search_results">Rezultate cautare recenta au fost sterse</string>
<string name="settings_data_sources">Surse date</string>
<string name="help">Ajutor</string>
<string name="settings_android_auto">Android Auto</string>
<string name="pref_chargeprice_allow_unbalanced_load">Include incarcare nebalansata</string>
<string name="pref_chargeprice_allow_unbalanced_load_summary">Include incarcare monofazata AC cu mai mult de 4.5 kW</string>
<string name="pref_map_rotate_gestures_enabled">Rotire harta</string>
<string name="pref_map_rotate_gestures_on">Foloseste doua degete pentru a roti harta</string>
<string name="pref_map_rotate_gestures_off">Rotire dezactivata (nordul mereu sus)</string>
<string name="refresh_live_data">actualizare stare in timp real</string>
<string name="autocomplete_connection_error">Sugestiile nu au putut fi incarcate</string>
<string name="pref_language_device_default">Implicit dispozitiv</string>
<string name="pref_darkmode_device_default">Implicit dispozitiv</string>
<string name="pref_darkmode_always_on">permanent</string>
<string name="pref_darkmode_always_off">dezactivat</string>
<string name="pref_chargeprice_currency_chf">Franci elvetieni (CHF)</string>
<string name="pref_chargeprice_currency_czk">Coroane cehe (CZK)</string>
<string name="pref_chargeprice_currency_dkk">Coroane daneze (DKK)</string>
<string name="pref_chargeprice_currency_eur">Euro (EUR)</string>
<string name="pref_chargeprice_currency_gbp">Lire sterline (GBP)</string>
<string name="pref_chargeprice_currency_hrk">Croatian kuna (HRK)</string>
<string name="pref_chargeprice_currency_huf">Forinti maghiari (HUF)</string>
<string name="pref_chargeprice_currency_isk">Coroane islandeze (ISK)</string>
<string name="pref_chargeprice_currency_nok">Coroane norvegiene (NOK)</string>
<string name="pref_chargeprice_currency_pln">Zloti polonezi (PLN)</string>
<string name="pref_chargeprice_currency_sek">Coroane suedeze (SEK)</string>
<string name="pref_chargeprice_currency_usd">Dolari americani (USD)</string>
<string name="pref_provider_google_maps">Google Maps</string>
<string name="pref_provider_osm_mapbox">OpenStreetMap (Mapbox)</string>
<string name="about_contributors">Contribuitori</string>
<string name="about_contributors_text">Multumiri contribuitorilor pentru cod si traduceri pentru EVMap:</string>
<string name="utilization_prediction">Estimare utilizare</string>
<string name="prediction_help">Estimarea se bazeaza pe diversi factori cum ar fi ziua saptamanii, ora, utilizarile anterioare, astfel incat sa se evite supra aglomerarea. Fara garantie.</string>
<string name="prediction_time_colon">%s:</string>
<plurals name="prediction_number_available">
<item quantity="one">%1$d/%2$d disponibil</item>
<item quantity="other">%1$d/%2$d disponibile</item>
<item quantity="few">%1$d/%2$d disponibile</item>
</plurals>
<string name="pref_prediction_enabled">Arata estimare utilizare</string>
<string name="pref_prediction_enabled_summary">pentru statiile de incarcare suportate\n(momentan doar DC in Germania)</string>
<string name="prediction_only">(doar %s)</string>
<string name="prediction_dc_plugs_only">prize DC</string>
<string name="data_source_switched_to">Sursa date schimbat la %s</string>
<string name="pref_applink_associate">Linkuri suportate</string>
<string name="pref_applink_associate_summary">de la goingelectric.de si openchargemap.org</string>
<string name="chargeprice_header_my_tariffs">Abonamentele mele</string>
<string name="chargeprice_header_other_tariffs">Alte abonamente</string>
<string name="developer_mode_enabled">Activat mod dezvoltator</string>
<string name="developer_options">Optiuni dezvoltator</string>
<string name="disable_developer_mode">Dezactivare mod dezvoltator</string>
<string name="developer_mode_disabled">Mod dezvoltator dezactivat</string>
<string name="gps">GPS</string>
<string name="compass">Busola</string>
</resources>

View File

@@ -7,6 +7,8 @@
<item>@string/pref_language_fr</item>
<item>@string/pref_language_nb_rNO</item>
<item>@string/pref_language_nl</item>
<item>@string/pref_language_pt</item>
<item>@string/pref_language_ro</item>
</string-array>
<string-array name="pref_language_values" translatable="false">
<item>default</item>
@@ -15,6 +17,8 @@
<item>fr</item>
<item>nb-NO</item>
<item>nl</item>
<item>pt</item>
<item>ro</item>
</string-array>
<string-array name="pref_darkmode_names">
<item>@string/pref_darkmode_device_default</item>

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

@@ -14,6 +14,8 @@
<string name="pref_language_fr">Français</string>
<string name="pref_language_nb_rNO">Norsk Bokmål</string>
<string name="pref_language_nl">Nederlands</string>
<string name="pref_language_pt">Português</string>
<string name="pref_language_ro">Romana</string>
<string name="about_contributors_list">
Danilo Bargen\n
Altonss\n
@@ -21,9 +23,12 @@
Maximilian Goldschmidt\n
Wim Lamotte\n
Licaon_Kter\n
Celso Azevedo\n
pt2121\n
nautilusx
nautilusx\n
Bobby Galati
</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>
@@ -295,4 +296,21 @@
<string name="developer_mode_disabled">Developer mode disabled</string>
<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,7 @@
Verbesserungen:
- Neue Übersetzungen: Portugiesisch, Rumänisch
- Open Charge Map: Links zu Betreiber hinzugefügt
Fehler behoben:
- Open Charge Map: Gelöschte Ladestationen nicht anzeigen
- Open Charge Map: Filter "Störung ausschließen" korrigiert

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

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