Compare commits

...

51 Commits

Author SHA1 Message Date
johan12345
67b29917c0 Use official Tesla API for availability data
does not work yet
2023-10-16 19:14:32 +02:00
johan12345
bc91c0571b Chargeprice: Fix switching between vehicles 2023-10-14 19:28:54 +02:00
johan12345
a83102a97e Tesla API: fix nullability 2023-10-14 19:14:07 +02:00
johan12345
f52a98540c Tesla API: add missing waiting estimate bucket 2023-10-14 19:11:42 +02:00
johan12345
e0d97e7219 fix NPE in PlaceSearchScreen 2023-10-14 19:10:16 +02:00
johan12345
3bbd20a57e possibly fix IllegalStateException 2023-10-14 19:05:39 +02:00
Hosted Weblate
3279c5eceb Translated using Weblate (Portuguese)
Currently translated at 100.0% (355 of 355 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-10-14 12:25:53 +02:00
Johan von Forstner
03d958ac2c Chargeprice: possibly fix IllegalStateException when switching vehicles 2023-10-07 20:10:50 +02:00
Johan von Forstner
b1fd370101 OpenChargeMap: use map.openchargemap.io as link
id query parameter added since
3330c9de96

#236
2023-10-07 19:36:19 +02:00
johan12345
bdc96fcd57 Android Auto: use simpler navigation intent URI without POI name
Workaround for TomTom GO, which seems to not handle intents with description correctly
2023-09-27 22:00:49 +02:00
johan12345
0d54e17eb4 update version code to retry Play Store release 2023-09-26 22:27:56 +02:00
johan12345
b1d0081fb7 re-enable CarAppTest
see https://github.com/robolectric/robolectric/issues/8404#issuecomment-1733309468
2023-09-26 17:47:03 +02:00
johan12345
1134499532 Release 1.7.0 2023-09-23 18:31:10 +02:00
johan12345
0417ade802 Android Auto: fix crash on Android 14 due to missing permission 2023-09-23 18:23:39 +02:00
johan12345
8fafabf6a8 update car app library 2023-09-21 19:24:33 +02:00
Hosted Weblate
1b3c35e94f Translated using Weblate (Portuguese)
Currently translated at 100.0% (355 of 355 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-09-20 19:56:03 +02:00
Hosted Weblate
23a3adc500 Translated using Weblate (German)
Currently translated at 99.7% (354 of 355 strings)

Co-authored-by: Johan von Forstner <johan.forstner@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2023-09-16 23:09:42 +02:00
Hosted Weblate
16c2dcc938 Translated using Weblate (Norwegian Bokmål)
Currently translated at 80.2% (284 of 354 strings)

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

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-09-16 23:04:37 +02:00
Hosted Weblate
50ae2123e9 Translated using Weblate (English)
Currently translated at 100.0% (354 of 354 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/en/
Translation: EVMap/Android
2023-09-16 23:04:36 +02:00
johan12345
72894399f6 adjustments for Android Auto 2023-09-16 22:59:10 +02:00
johan12345
77014d754f implement Tesla Supercharger cost in AA/AAOS 2023-09-16 22:59:10 +02:00
johan12345
66dbd6426f implement Tesla Login for Android Auto/AAOS 2023-09-16 22:59:10 +02:00
johan12345
e4127f4a56 improve prediction graph generation 2023-09-16 22:59:10 +02:00
johan12345
f9bf8b80f7 Android Auto/AAOS: Add availability prediction 2023-09-16 22:59:10 +02:00
johan12345
67eeb47d5f update dependencies 2023-09-16 13:01:55 +02:00
johan12345
3c6a7cd536 make navController.navigate() calls safe
https://nezspencer.medium.com/navigation-components-a-fix-for-navigation-action-cannot-be-found-in-the-current-destination-95b63e16152e
2023-09-16 12:57:34 +02:00
Hosted Weblate
31e3509369 Translated using Weblate (Portuguese)
Currently translated at 100.0% (353 of 353 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-09-15 22:01:35 +02:00
johan12345
b03f765216 SearchSelectScreen: catch IOExceptions 2023-09-15 21:45:20 +02:00
johan12345
9222dec613 Release 1.6.9 2023-09-14 20:26:08 +02:00
johan12345
71c36fbc8f MapFragment: Change default map location behavior
Only jump back to current location on app restart if we were previously looking at the current location (and have location permission enabled). Otherwise stay at the last viewed location.

This seems to be the same as what e.g. the Google Maps app does.

#191
2023-09-14 20:16:41 +02:00
johan12345
830477e664 Automotive FilterScreen: fix possible crash when profile is deleted 2023-09-13 22:44:23 +02:00
johan12345
3ce91a9c50 ChargerDetailScreen: fix nullability issue 2023-09-10 12:39:19 +02:00
johan12345
a3b2b94b25 ACRA: switch -normal build variants to use HTTP sending as well 2023-09-06 21:51:50 +02:00
johan12345
a7770e1c1b fix Github Actions build 2023-09-03 18:34:24 +02:00
johan12345
fcd51307cb Release 1.6.8 2023-09-03 18:11:57 +02:00
johan12345
ba4a9c29b2 ACRA: keep key-value format for email reports 2023-09-03 18:11:24 +02:00
johan12345
3463177ad2 EnBW: avoid endless loop introduced by d636cde7 2023-09-02 23:25:42 +02:00
johan12345
09deaf5080 AA/AAOS: add general info & amenities
(if room available)
2023-09-02 22:27:45 +02:00
johan12345
23f429bbea disable CarAppTest due to Robolectric incompatibility 2023-09-02 22:16:35 +02:00
johan12345
1184d3b6cc AA/AAOS FilterScreen: add delete button to rows 2023-09-02 22:09:07 +02:00
johan12345
c95a60807b Upgrade SDK to 34 & dependencies 2023-09-02 22:01:47 +02:00
johan12345
4b8cf82843 update Android Gradle plugin 2023-09-02 21:20:32 +02:00
johan12345
f33b9e8117 Merge branch 'rework-acra' 2023-09-02 17:39:46 +02:00
johan12345
cbc3040807 Rework units configuration
units (imperial or metric) can be configured globally, this applies to distances within the app, vehicle data, and the map scale bar

fixes #293
2023-09-02 17:39:37 +02:00
johan12345
92619ea95e ACRA: switch from email to HTTP sending for automotive flavor 2023-09-02 16:47:56 +02:00
johan12345
a7007284ff ACRA: introduce AAOS-friendly crash reporting screen 2023-09-02 16:38:02 +02:00
johan12345
7fce566052 add referral link to donate page 2023-08-27 19:53:32 +02:00
Johan von Forstner
0c44b4b074 Update FUNDING.yml 2023-08-27 19:52:48 +02:00
johan12345
a652d96f74 fix CarAppTest 2023-08-27 19:12:55 +02:00
johan12345
8a9b3ad948 Android Auto/Automotive: Make users explicitly accept the privacy policy 2023-08-27 19:05:04 +02:00
72 changed files with 1320 additions and 482 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
github: johan12345
custom: 'https://paypal.me/johan98'
custom: ['https://paypal.me/johan98', 'http://ts.la/johan94494']

View File

@@ -5,4 +5,6 @@
<string name="chargeprice_key" translatable="false">ci</string>
<string name="openchargemap_key" translatable="false">ci</string>
<string name="fronyx_key" translatable="false">ci</string>
<string name="acra_credentials" translatable="false">ci:ci</string>
<string name="tesla_credentials" translatable="false">ci:ci</string>
</resources>

View File

@@ -32,6 +32,7 @@ jobs:
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}

View File

@@ -13,16 +13,14 @@ apply plugin: 'pt.jcosta.resourceplaceholders'
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
android {
compileSdkVersion 33
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "net.vonforst.evmap"
compileSdk 34
minSdkVersion 21
targetSdkVersion 33
targetSdkVersion 34
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 194
versionName "1.6.7"
versionCode 204
versionName "1.7.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(',')
@@ -147,6 +145,20 @@ android {
if (fronyxKey != null) {
variant.resValue "string", "fronyx_key", fronyxKey
}
def acraKey = env.ACRA_CRASHREPORT_CREDENTIALS ?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
acraKey = decode(project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (acraKey != null) {
variant.resValue "string", "acra_credentials", acraKey
}
def teslaKey = env.TESLA_CREDENTIALS ?: project.findProperty("TESLA_CREDENTIALS")
if (teslaKey == null && project.hasProperty("TESLA_CREDENTIALS_ENCRYPTED")) {
teslaKey = decode(project.findProperty("TESLA_CREDENTIALS_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (teslaKey != null) {
variant.resValue "string", "tesla_credentials", teslaKey
}
}
packagingOptions {
@@ -165,16 +177,16 @@ configurations {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.core:core-ktx:1.12.0'
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.fragment:fragment-ktx:1.6.1"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.browser:browser:1.5.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.browser:browser:1.6.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation "androidx.work:work-runtime-ktx:2.8.1"
@@ -197,7 +209,7 @@ dependencies {
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
def carAppVersion = '1.3.0-rc01'
def carAppVersion = '1.4.0-beta02'
implementation "androidx.car.app:app:$carAppVersion"
normalImplementation "androidx.car.app:app-projected:$carAppVersion"
automotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
@@ -219,8 +231,8 @@ dependencies {
fossImplementation 'com.github.ev-map:mapbox-events-android:a21c324501'
// Google Places
googleImplementation 'com.google.android.libraries.places:places:3.1.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4'
googleImplementation 'com.google.android.libraries.places:places:3.2.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.1'
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
@@ -230,25 +242,25 @@ dependencies {
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.6.1"
def lifecycle_version = "2.6.2"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.5.1"
def room_version = "2.6.0-beta01"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation 'com.github.anboralabs:spatia-room:0.2.7'
// billing library
def billing_version = "6.0.0"
def billing_version = "6.0.1"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
// ACRA (crash reporting)
def acraVersion = "5.8.4"
implementation("ch.acra:acra-mail:$acraVersion")
def acraVersion = "5.11.1"
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
@@ -262,13 +274,12 @@ dependencies {
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
testImplementation 'org.robolectric:robolectric:4.9.2'
testImplementation 'org.robolectric:robolectric:4.10.3'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'org.robolectric:robolectric:4.9.2'
testGoogleImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'

View File

@@ -42,5 +42,9 @@ class DonateFragment : Fragment() {
binding.btnDonate.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
}
binding.referrals.referralTesla.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.tesla_referral_link))
}
}
}

View File

@@ -1,18 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/linearLayout2"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
android:fitsSystemWindows="true">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
@@ -21,31 +19,55 @@
</com.google.android.material.appbar.AppBarLayout>
<Button
android:id="@+id/btnDonate"
style="@style/Widget.Material3.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/donate_paypal"
app:icon="@drawable/ic_paypal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView20" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/textView20"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/donations_info"
app:layout_constraintBottom_toTopOf="@+id/btnDonate"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container"
app:layout_constraintVertical_chainStyle="packed" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_height="wrap_content"
android:layout_width="match_parent">
<Button
android:id="@+id/btnDonate"
style="@style/Widget.Material3.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp"
android:text="@string/donate_paypal"
app:icon="@drawable/ic_paypal"
app:layout_constraintBottom_toTopOf="@id/referrals"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView20" />
<TextView
android:id="@+id/textView20"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/donations_info"
app:layout_constraintBottom_toTopOf="@+id/btnDonate"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<include
android:id="@+id/referrals"
layout="@layout/fragment_donate_referral"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btnDonate" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</LinearLayout>

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
@@ -18,12 +19,17 @@ import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DonationAdapter
import net.vonforst.evmap.adapter.SingleViewAdapter
import net.vonforst.evmap.databinding.FragmentDonateBinding
import net.vonforst.evmap.databinding.FragmentDonateHeaderBinding
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
import net.vonforst.evmap.viewmodel.DonateViewModel
class DonateFragment : Fragment() {
private lateinit var binding: FragmentDonateBinding
private val vm: DonateViewModel by viewModels()
private lateinit var header: FragmentDonateHeaderBinding
private lateinit var referrals: FragmentDonateReferralBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -40,6 +46,9 @@ class DonateFragment : Fragment() {
binding.lifecycleOwner = this
binding.vm = vm
header = FragmentDonateHeaderBinding.inflate(inflater, container, false)
referrals = FragmentDonateReferralBinding.inflate(inflater, container, false)
return binding.root
}
@@ -51,25 +60,35 @@ class DonateFragment : Fragment() {
(requireActivity() as MapsActivity).appBarConfiguration
)
binding.productsList.apply {
adapter = DonationAdapter().apply {
onClickListener = {
vm.startPurchase(it, requireActivity())
}
val donationAdapter = DonationAdapter().apply {
onClickListener = {
vm.startPurchase(it, requireActivity())
}
}
binding.productsList.apply {
val joinedAdapter = ConcatAdapter(
SingleViewAdapter(header.root),
donationAdapter,
SingleViewAdapter(referrals.root)
)
adapter = joinedAdapter
layoutManager = LinearLayoutManager(context)
}
vm.products.observe(viewLifecycleOwner) {
print(it)
donationAdapter.submitList(it.data)
}
vm.purchaseSuccessful.observe(viewLifecycleOwner, Observer {
vm.purchaseSuccessful.observe(viewLifecycleOwner) {
Snackbar.make(view, R.string.donation_successful, Snackbar.LENGTH_LONG).show()
})
vm.purchaseFailed.observe(viewLifecycleOwner, Observer {
}
vm.purchaseFailed.observe(viewLifecycleOwner) {
Snackbar.make(view, R.string.donation_failed, Snackbar.LENGTH_LONG).show()
})
}
referrals.referralTesla.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.tesla_referral_link))
}
// 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

@@ -35,29 +35,16 @@
</com.google.android.material.appbar.AppBarLayout>
<TextView
android:id="@+id/textView20"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/donations_info"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/products_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:data="@{vm.products.data}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView20"
tools:listitem="@layout/item_donation" />
app:layout_constraintTop_toBottomOf="@id/toolbar_container"
tools:itemCount="1"
tools:listitem="@layout/fragment_donate_preview" />
<ProgressBar
android:id="@+id/progressBar3"

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView android:id="@+id/textView20"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/donations_info"
xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/fragment_donate_header" />
<include layout="@layout/item_donation" />
<include layout="@layout/item_donation" />
<include layout="@layout/item_donation" />
<include layout="@layout/fragment_donate_referral" />
</LinearLayout>

View File

@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
@@ -289,6 +290,10 @@
android:resource="@xml/shortcuts" />
</activity>
<activity android:name=".auto.OAuthLoginActivity">
</activity>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
@@ -327,7 +332,8 @@
android:name=".auto.CarAppService"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:exported="true">
android:exported="true"
android:foregroundServiceType="location">
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap
import android.app.Activity
import android.app.Application
import android.os.Build
import androidx.work.*
@@ -8,10 +9,11 @@ import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
import org.acra.config.dialog
import org.acra.config.httpSender
import org.acra.config.limiter
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import java.time.Duration
class EvMapApplication : Application(), Configuration.Provider {
@@ -33,10 +35,15 @@ class EvMapApplication : Application(), Configuration.Provider {
if (!BuildConfig.DEBUG) {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST
mailSender {
mailTo = "evmap+crashreport@vonforst.net"
// Vehicles often don't have an email app, so use HTTP to send instead
reportFormat = StringFormat.JSON
httpSender {
uri = getString(R.string.acra_backend_url)
val creds = getString(R.string.acra_credentials).split(":")
basicAuthLogin = creds[0]
basicAuthPassword = creds[1]
httpMethod = HttpSender.Method.POST
}
dialog {
@@ -45,6 +52,10 @@ class EvMapApplication : Application(), Configuration.Provider {
commentPrompt = getString(R.string.crash_report_comment_prompt)
resIcon = R.drawable.ic_launcher_foreground
resTheme = R.style.AppTheme
if (BuildConfig.FLAVOR_automotive == "automotive") {
reportDialogClass =
Class.forName("androidx.car.app.activity.CarAppActivity") as Class<out Activity>?
}
}
limiter {

View File

@@ -5,10 +5,13 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Typeface
import android.icu.util.LocaleData
import android.icu.util.ULocale
import android.os.Build
import android.os.Bundle
import android.text.*
import android.text.style.StyleSpan
import net.vonforst.evmap.storage.PreferenceDataSource
import java.util.*
fun Bundle.optDouble(name: String): Double? {
@@ -88,9 +91,25 @@ fun Context.isDarkMode() =
const val kmPerMile = 1.609344
const val meterPerFt = 0.3048
const val ftPerMile = 5280
const val ydPerMile = 1760
fun shouldUseImperialUnits(ctx: Context): Boolean {
val prefs = PreferenceDataSource(ctx)
return when (prefs.units) {
"metric" -> false
"imperial" -> true
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
when (LocaleData.getMeasurementSystem(ULocale.getDefault())) {
LocaleData.MeasurementSystem.US, LocaleData.MeasurementSystem.UK -> true
LocaleData.MeasurementSystem.SI -> false
else -> false
}
} else {
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
}
}
fun shouldUseImperialUnits(): Boolean {
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
}
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =

View File

@@ -209,8 +209,8 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
checkedItem = item
root.post {
notifyDataSetChanged()
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}

View File

@@ -139,7 +139,7 @@ fun buildDetails(
)
}
private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
teslaPricing.memberRates?.activePricebook?.parking?.let { parkingFee ->
ctx.getString(
R.string.tesla_pricing_blocking_fee,
@@ -147,7 +147,7 @@ private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Co
)
}
private fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
buildSpannedString {
teslaPricing.memberRates?.let { memberRates ->
append(

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap.api.availability
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
@@ -170,9 +171,18 @@ class AvailabilityRepository(context: Context) {
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
private val teslaAvailabilityDetector = run {
val (clientId, clientSecret) = context.getString(R.string.tesla_credentials).split(":")
TeslaAvailabilityDetector(
okhttp,
EncryptedPreferenceDataStore(context),
clientId,
clientSecret
)
}
private val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
teslaAvailabilityDetector,
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
@@ -199,4 +209,10 @@ class AvailabilityRepository(context: Context) {
}
return value ?: Resource.error(null, null)
}
fun isSupercharger(charger: ChargeLocation) =
teslaAvailabilityDetector.isChargerSupported(charger)
fun isTeslaSupported(charger: ChargeLocation) =
teslaAvailabilityDetector.isChargerSupported(charger) && teslaAvailabilityDetector.isSignedIn()
}

View File

@@ -105,20 +105,19 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
var markers =
api.getMarkers(lng - coordRange, lng + coordRange, lat - coordRange, lat + coordRange)
while (markers.any { it.grouped }) {
markers = markers.flatMap {
if (it.grouped) {
api.getMarkers(
it.viewPort.lowerLeftLon,
it.viewPort.upperRightLon,
it.viewPort.lowerLeftLat,
it.viewPort.upperRightLat
)
} else {
listOf(it)
}
markers = markers.flatMap {
if (it.grouped) {
api.getMarkers(
it.viewPort.lowerLeftLon,
it.viewPort.upperRightLon,
it.viewPort.lowerLeftLat,
it.viewPort.upperRightLat
)
} else {
listOf(it)
}
}
if (markers.any { it.grouped }) throw AvailabilityDetectorException("markers still grouped")
val nearest = markers.minByOrNull { marker ->
distanceBetween(marker.lat, marker.lon, lat, lng)

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap.api.availability
import android.net.Uri
import android.util.Base64
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
@@ -34,22 +35,24 @@ interface TeslaAuthenticationApi {
@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)
@Json(name = "redirect_uri") val redirectUri: String = "https://ev-map.app/void/callback",
scope: String = "openid offline_access vehicle_device_data",
@Json(name = "client_id") clientId: String,
@Json(name = "client_secret") clientSecret: String
) : OAuth2Request(scope, clientId, clientSecret)
@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)
scope: String = "openid offline_access vehicle_device_data",
@Json(name = "client_id") clientId: String,
@Json(name = "client_secret") clientSecret: String,
) : OAuth2Request(scope, clientId, clientSecret)
sealed class OAuth2Request(
val scope: String,
val clientId: String
val clientId: String,
val clientSecret: String
)
@JsonClass(generateAdapter = true)
@@ -84,24 +87,15 @@ interface TeslaAuthenticationApi {
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 buildSignInUri(clientId: String): Uri =
Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", "https://ev-map.app/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid offline_access vehicle_device_data")
.appendQueryParameter("state", "123").build()
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
)
}
val resultUrlPrefix = "https://ev-map.app/void/callback"
}
}
@@ -274,7 +268,7 @@ interface TeslaGraphQlApi {
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation?)
@JsonClass(generateAdapter = true)
data class ChargingSiteInformation(
@@ -432,6 +426,9 @@ interface TeslaGraphQlApi {
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
APPROXIMATELY_20_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_GREATER_THAN_25_MINUTES")
GREATER_THAN_25_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
UNKNOWN
}
@@ -484,6 +481,8 @@ fun Coordinate.asTeslaCoord() =
class TeslaAvailabilityDetector(
private val client: OkHttpClient,
private val tokenStore: TokenStore,
private val clientId: String,
private val clientSecret: String,
private val baseUrl: String? = null
) :
BaseAvailabilityDetector(client) {
@@ -531,7 +530,7 @@ class TeslaAvailabilityDetector(
TeslaGraphQlApi.VehicleMakeType.NON_TESLA
)
)
).data.charging.site
).data.charging.site ?: throw AvailabilityDetectorException("no candidates found.")
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
@@ -628,7 +627,9 @@ class TeslaAvailabilityDetector(
val response =
authApi.getToken(
TeslaAuthenticationApi.RefreshTokenRequest(
refreshToken
refreshToken,
clientId = clientId,
clientSecret = clientSecret
)
)
tokenStore.teslaAccessToken = response.accessToken
@@ -642,4 +643,6 @@ class TeslaAvailabilityDetector(
}
}
fun isSignedIn() = tokenStore.teslaRefreshToken != null
}

View File

@@ -0,0 +1,188 @@
package net.vonforst.evmap.api.fronyx
import android.content.Context
import com.squareup.moshi.JsonDataException
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.equivalentPlugTypes
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.Resource
import retrofit2.HttpException
import java.io.IOException
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
data class PredictionData(
val predictionGraph: Map<ZonedDateTime, Double>?,
val maxValue: Double,
val predictedChargepoints: List<Chargepoint>,
val isPercentage: Boolean,
val description: String?
)
class PredictionRepository(private val context: Context) {
private val predictionApi = FronyxApi(context.getString(R.string.fronyx_key))
private val prefs = PreferenceDataSource(context)
suspend fun getPredictionData(
charger: ChargeLocation,
availability: ChargeLocationStatus?,
filteredConnectors: Set<String>? = null
): PredictionData {
val fronyxPrediction = availability?.evseIds?.let { evseIds ->
getFronyxPrediction(charger, evseIds, filteredConnectors)
}?.data
val graph = buildPredictionGraph(availability, fronyxPrediction)
val predictedChargepoints = getPredictedChargepoints(charger, filteredConnectors)
val maxValue = getPredictionMaxValue(availability, fronyxPrediction, predictedChargepoints)
val isPercentage = predictionIsPercentage(availability, fronyxPrediction)
val description = getDescription(charger, predictedChargepoints)
return PredictionData(
graph, maxValue, predictedChargepoints, isPercentage, description
)
}
private suspend fun getFronyxPrediction(
charger: ChargeLocation,
evseIds: Map<Chargepoint, List<String>>,
filteredConnectors: Set<String>?
): Resource<List<FronyxEvseIdResponse>> {
if (!prefs.predictionEnabled) return Resource.success(null)
val allEvseIds =
evseIds.filterKeys {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors?.let { filtered ->
equivalentPlugTypes(
it.type
).any { filtered.contains(it) }
} ?: true
}.flatMap { it.value }
if (allEvseIds.isEmpty()) {
return Resource.success(emptyList())
}
try {
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
if (result.size == allEvseIds.size) {
return Resource.success(result)
} else {
return Resource.error("not all EVSEIDs found", null)
}
} catch (e: IOException) {
e.printStackTrace()
return Resource.error(e.message, null)
} catch (e: HttpException) {
e.printStackTrace()
return Resource.error(e.message, null)
} catch (e: AvailabilityDetectorException) {
e.printStackTrace()
return Resource.error(e.message, null)
} catch (e: JsonDataException) {
// malformed JSON response from fronyx API
e.printStackTrace()
return Resource.error(e.message, null)
}
}
private fun buildPredictionGraph(
availability: ChargeLocationStatus?,
prediction: List<FronyxEvseIdResponse>?
): Map<ZonedDateTime, Double>? {
val congestionHistogram = availability?.congestionHistogram
return 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 }
}
}
}
}
private fun getPredictedChargepoints(
charger: ChargeLocation,
filteredConnectors: Set<String>?
) =
charger.chargepoints.filter {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors?.let { filtered ->
equivalentPlugTypes(it.type).any {
filtered.contains(
it
)
}
} ?: true
}
private fun getPredictionMaxValue(
availability: ChargeLocationStatus?,
prediction: List<FronyxEvseIdResponse>?,
predictedChargepoints: List<Chargepoint>
): Double = if (availability?.congestionHistogram != null && prediction == null) {
1.0
} else {
predictedChargepoints.sumOf { it.count }.toDouble()
}
private fun predictionIsPercentage(
availability: ChargeLocationStatus?,
prediction: List<FronyxEvseIdResponse>?
) =
availability?.congestionHistogram != null && prediction == null
private fun getDescription(
charger: ChargeLocation,
predictedChargepoints: List<Chargepoint>
): String? {
val allChargepoints = charger.chargepoints
val predictedChargepointTypes = predictedChargepoints.map { it.type }.distinct()
return if (allChargepoints == predictedChargepoints) {
null
} else if (predictedChargepointTypes.size == 1) {
context.getString(
R.string.prediction_only,
nameForPlugType(context.stringProvider(), predictedChargepointTypes[0])
)
} else {
context.getString(
R.string.prediction_only,
context.getString(R.string.prediction_dc_plugs_only)
)
}
}
}

View File

@@ -64,8 +64,8 @@ data class OCMChargepoint(
addressInfo.toAddress(refData),
connections.map { it.convert(refData) },
operatorInfo?.title,
"https://openchargemap.org/site/poi/details/$id",
"https://openchargemap.org/site/poi/edit/$id",
"https://map.openchargemap.io/?id=$id",
"https://map.openchargemap.io/?id=$id",
convertFaultReport(),
recentlyVerified,
null,

View File

@@ -34,6 +34,7 @@ import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.checkFineLocationPermission
import org.acra.interaction.DialogInteraction
interface LocationAwareScreen {
@@ -44,13 +45,15 @@ interface LocationAwareScreen {
class CarAppService : androidx.car.app.CarAppService() {
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
private var foregroundStarted = false
override fun onCreate() {
super.onCreate()
fun ensureForegroundService() {
// we want to run as a foreground service to make sure we can use location
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
if (!foregroundStarted) {
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
foregroundStarted = true
}
}
private fun createNotificationChannel() {
@@ -122,11 +125,13 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
override fun onCreateScreen(intent: Intent): Screen {
handleActionsIntent(intent)
val mapScreen = MapScreen(carContext, this)
val screens = mutableListOf<Screen>(mapScreen)
handleActionsIntent(intent)?.let {
screens.add(it)
}
if (!prefs.dataSourceSet) {
screens.add(
ChooseDataSourceScreen(
@@ -149,6 +154,14 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
)
)
}
if (!prefs.privacyAccepted) {
screens.add(
AcceptPrivacyScreen(carContext)
)
}
handleACRAIntent(intent)?.let {
screens.add(it)
}
if (screens.size > 1) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
@@ -160,7 +173,13 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
return screens.last()
}
private fun handleActionsIntent(intent: Intent): Boolean {
private fun handleACRAIntent(intent: Intent): Screen? {
return if (intent.hasExtra(DialogInteraction.EXTRA_REPORT_CONFIG)) {
CrashReportScreen(carContext, intent)
} else null
}
private fun handleActionsIntent(intent: Intent): Screen? {
intent.data?.let {
if (it.host == "find_charger") {
val lat = it.getQueryParameter("latitude")?.toDouble()
@@ -169,15 +188,14 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
if (lat != null && lon != null) {
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
return true
return null
} else if (name != null) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
screenManager.push(PlaceSearchScreen(carContext, this, name))
return true
val screen = PlaceSearchScreen(carContext, this, name)
return screen
}
}
}
return false
return null
}
override fun onNewIntent(intent: Intent) {
@@ -206,6 +224,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
@SuppressLint("MissingPermission")
fun requestLocationUpdates() {
if (!locationPermissionGranted()) return
cas.ensureForegroundService()
Log.i(TAG, "Requesting location updates")
requestCarHardwareLocationUpdates()
requestPhoneLocationUpdates()

View File

@@ -12,6 +12,7 @@ import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.scale
import androidx.core.text.HtmlCompat
@@ -23,10 +24,16 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.adapter.formatTeslaParkingFee
import net.vonforst.evmap.adapter.formatTeslaPricing
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.fronyx.FronyxApi
import net.vonforst.evmap.api.fronyx.PredictionData
import net.vonforst.evmap.api.fronyx.PredictionRepository
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
@@ -45,6 +52,8 @@ import net.vonforst.evmap.viewmodel.awaitFinished
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -52,12 +61,17 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
private var prediction: PredictionData? = null
private var fronyxSupported = false
private var teslaSupported = false
val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val predictionRepo = PredictionRepository(ctx)
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
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
@@ -290,9 +304,106 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}.build())
}
}
if (rows.count() < maxRows && charger.generalInformation != null) {
rows.add(Row.Builder().apply {
setTitle(carContext.getString(R.string.general_info))
addText(charger.generalInformation)
}.build())
}
if (rows.count() < maxRows && charger.amenities != null) {
rows.add(Row.Builder().apply {
setTitle(carContext.getString(R.string.amenities))
addText(charger.amenities)
}.build())
}
if (rows.count() < maxRows && ((fronyxSupported && prefs.predictionEnabled) || teslaSupported)) {
rows.add(1, Row.Builder().apply {
setTitle(
if (fronyxSupported) {
carContext.getString(R.string.utilization_prediction) + " (" + carContext.getString(
R.string.powered_by_fronyx
) + ")"
} else carContext.getString(R.string.average_utilization)
)
generatePredictionGraph()?.let { addText(it) }
?: addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
}.build())
}
if (rows.count() < maxRows && teslaSupported) {
val teslaPricing = availability?.extraData as? TeslaGraphQlApi.Pricing
rows.add(3, Row.Builder().apply {
setTitle(carContext.getString(R.string.cost))
teslaPricing?.let {
var text = formatTeslaPricing(teslaPricing, carContext) as CharSequence
formatTeslaParkingFee(teslaPricing, carContext)?.let { text += "\n\n" + it }
addText(text)
} ?: {
addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
}
}.build())
}
return rows
}
private fun generatePredictionGraph(): CharSequence? {
val predictionData = prediction ?: return null
val graphData = predictionData.predictionGraph?.toList() ?: return null
val maxValue = predictionData.maxValue
val maxWidth = if (BuildConfig.FLAVOR_automotive == "automotive") 25 else 18
val step = maxOf(graphData.size.toFloat() / maxWidth, 1f)
val values = graphData.map { it.second }
val graph = buildGraph(values, step, maxValue, predictionData.isPercentage)
val measurer = TextMeasurer(carContext)
val width = measurer.measureText(graph)
val startTime = timeFormat.format(graphData[0].first)
val endTime = timeFormat.format(graphData.last().first)
val baseWidth = measurer.measureText(startTime + endTime)
val spaceWidth = measurer.measureText(" ")
val numSpaces = floor((width - baseWidth) / spaceWidth).toInt()
val legend = startTime + " ".repeat(numSpaces) + endTime
return graph + "\n" + legend
}
private fun buildGraph(
values: List<Double>,
step: Float,
maxValue: Double,
isPercentage: Boolean
): CharSequence {
val sparklines = "▁▂▃▄▅▆▇█"
val graph = SpannableStringBuilder()
var i = 0f
while (i.roundToInt() < values.size) {
val v = values[i.roundToInt()]
val fraction = v / maxValue
val sparkline = sparklines[(fraction * (sparklines.length - 1)).roundToInt()].toString()
val color = if (isPercentage) {
when (v) {
in 0.0..0.5 -> CarColor.GREEN
in 0.5..0.8 -> CarColor.YELLOW
else -> CarColor.RED
}
} else {
if (v < maxValue) CarColor.GREEN else CarColor.RED
}
graph.append(
sparkline,
ForegroundCarColorSpan.create(color),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
i += step
}
return graph
}
private fun generateCostStatusText(cost: Cost): CharSequence {
val string = SpannableString(cost.getStatusText(carContext, emoji = true))
// replace emoji with CarIcon
@@ -371,8 +482,10 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
} else {
append(nameForPlugType(carContext.stringProvider(), cp.type))
}
append(" ")
append(cp.formatPower())
cp.formatPower()?.let {
append(" ")
append(it)
}
}
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
@@ -407,7 +520,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val intent =
Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
Uri.parse("geo:${coord.lat},${coord.lng}")
)
carContext.startCarApp(intent)
}
@@ -463,12 +576,23 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
)
this@ChargerDetailScreen.photo = outImg
}
fronyxSupported = charger.chargepoints.any {
FronyxApi.isChargepointSupported(
charger,
it
)
} && !availabilityRepo.isSupercharger(charger)
teslaSupported = availabilityRepo.isTeslaSupported(charger)
invalidate()
availability = availabilityRepo.getAvailability(charger).data
invalidate()
prediction = predictionRepo.getPredictionData(charger, availability)
invalidate()
} else {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)

View File

@@ -0,0 +1,45 @@
package net.vonforst.evmap.auto
import android.content.Intent
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Template
import net.vonforst.evmap.R
import org.acra.dialog.CrashReportDialogHelper
/**
* ACRA-compatible crash reporting screen for the Car App Library
*
* only used on Android Automotive OS
*/
class CrashReportScreen(ctx: CarContext, intent: Intent) : Screen(ctx) {
val helper = CrashReportDialogHelper(ctx, intent)
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(R.string.crash_report_text)).apply {
setHeaderAction(Action.APP_ICON)
setTitle(carContext.getString(R.string.app_name))
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.ok))
.setFlags(Action.FLAG_PRIMARY)
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
helper.sendCrash(null, null)
screenManager.pop()
}.build()
)
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
helper.cancelReports()
screenManager.pop()
}.build()
)
}.build()
}
}

View File

@@ -41,7 +41,9 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
page = 0
} else {
page = paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
val index =
paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
page = index.takeUnless { it == -1 } ?: 0
}
invalidate()
}
@@ -204,6 +206,37 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
Row.IMAGE_TYPE_ICON
)
setOnClickListener { onItemClick(it.id) }
if (carContext.carAppApiLevel >= 6) {
// Delete action
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_delete
)
).build()
)
setOnClickListener {
lifecycleScope.launch {
db.filterProfileDao().delete(it)
if (prefs.filterStatus == it.id) {
prefs.filterStatus = FILTERS_DISABLED
}
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
it.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
}
}
}.build())
}
}.build())
}
if (page < paginatedProfiles.size - 1) {
@@ -293,7 +326,8 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
setActionStrip(ActionStrip.Builder().apply {
val currentProfile = vm.filterProfile.value
if (currentProfile != null) {
if (currentProfile != null && carContext.carAppApiLevel < 6) {
// Delete action (when row actions are not available)
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(

View File

@@ -348,7 +348,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
DistanceSpan.create(
roundValueToDistance(
distanceMeters,
energyLevel?.distanceDisplayUnit?.value
energyLevel?.distanceDisplayUnit?.value,
carContext
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE

View File

@@ -0,0 +1,31 @@
package net.vonforst.evmap.auto
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.add
import androidx.fragment.app.commit
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import net.vonforst.evmap.R
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
class OAuthLoginActivity : AppCompatActivity(R.layout.activity_oauth_login) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
add<OAuthLoginFragment>(R.id.fragment_container_view, args = intent.extras)
}
}
LocalBroadcastManager.getInstance(this).registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
finish()
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
}
}

View File

@@ -23,6 +23,7 @@ class PermissionScreen(
Action.Builder()
.setTitle(carContext.getString(R.string.grant_on_phone))
.setBackgroundColor(CarColor.PRIMARY)
.setFlags(Action.FLAG_PRIMARY)
.setOnClickListener(ParkedOnlyOnClickListener.create {
requestPermissions()
})

View File

@@ -105,7 +105,8 @@ class PlaceSearchScreen(
DistanceSpan.create(
roundValueToDistance(
it,
energyLevel?.distanceDisplayUnit?.value
energyLevel?.distanceDisplayUnit?.value,
carContext
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
@@ -115,7 +116,7 @@ class PlaceSearchScreen(
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
val placeDetails = getDetails(place.id) ?: return@launch
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
@@ -225,9 +226,9 @@ class PlaceSearchScreen(
}
}
suspend fun getDetails(id: String): PlaceWithBounds {
suspend fun getDetails(id: String): PlaceWithBounds? {
val provider = currentProvider!!
val result = resultList!!.find { it.id == id }!!
val result = resultList?.find { it.id == id } ?: return null
val recentPlace = recentResults.find { it.id == id }
if (recentPlace != null) return recentPlace.asPlaceWithBounds()

View File

@@ -7,8 +7,11 @@ import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import okio.IOException
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
SearchTemplate.SearchCallback {
@@ -22,9 +25,20 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
override fun onGetTemplate(): Template {
if (fullList == null) {
lifecycleScope.launch {
fullList = loadData()
filterList()
invalidate()
try {
fullList = loadData()
filterList()
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.generic_connection_error,
CarToast.LENGTH_LONG
).show()
screenManager.pop()
}
}
}
}

View File

@@ -1,10 +1,13 @@
package net.vonforst.evmap.auto
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
import android.net.Uri
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.CarToast
@@ -12,15 +15,26 @@ import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.launch
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.TeslaOwnerApi
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.storage.PreferenceDataSource
import okhttp3.OkHttpClient
import java.io.IOException
import java.time.Instant
import kotlin.math.max
import kotlin.math.min
@@ -123,6 +137,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
val encryptedPrefs = EncryptedPreferenceDataStore(ctx)
val db = AppDatabase.getInstance(ctx)
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
@@ -132,6 +147,8 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
val searchProviderValues =
carContext.resources.getStringArray(R.array.pref_search_provider_values)
var teslaLoggingIn = false
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_data_sources))
@@ -181,9 +198,124 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
}
}
}.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.pref_prediction_enabled))
.addText(carContext.getString(R.string.pref_prediction_enabled_summary))
.setToggle(Toggle.Builder {
prefs.predictionEnabled = it
}.setChecked(prefs.predictionEnabled).build())
.build()
)
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_tesla_account))
addText(
if (encryptedPrefs.teslaRefreshToken != null) {
carContext.getString(
R.string.pref_tesla_account_enabled,
encryptedPrefs.teslaEmail
)
} else if (teslaLoggingIn) {
carContext.getString(R.string.logging_in)
} else {
carContext.getString(R.string.pref_tesla_account_disabled)
}
)
if (encryptedPrefs.teslaRefreshToken != null) {
setOnClickListener {
teslaLogout()
}
} else {
setOnClickListener(ParkedOnlyOnClickListener.create {
teslaLogin()
})
}
}.build())
}.build())
}.build()
}
private fun teslaLogin() {
val (clientId, _) = carContext.getString(R.string.tesla_credentials).split(":")
val args = OAuthLoginFragmentArgs(
TeslaAuthenticationApi.buildSignInUri(clientId = clientId).toString(),
TeslaAuthenticationApi.resultUrlPrefix,
"#000000"
).toBundle()
val intent = Intent(carContext, OAuthLoginActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtras(args)
LocalBroadcastManager.getInstance(carContext)
.registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val url = IntentCompat.getParcelableExtra(
intent,
OAuthLoginFragment.EXTRA_URL,
Uri::class.java
)
teslaGetAccessToken(url!!)
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
carContext.startActivity(intent)
if (BuildConfig.FLAVOR_automotive != "automotive") {
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
}
}
private fun teslaGetAccessToken(url: Uri) {
teslaLoggingIn = true
invalidate()
val code = url.getQueryParameter("code") ?: return
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val (clientId, clientSecret) = carContext.getString(R.string.tesla_credentials).split(":")
val request = TeslaAuthenticationApi.AuthCodeRequest(
code,
clientId = clientId,
clientSecret = clientSecret
)
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 = "user@example.com"
encryptedPrefs.teslaAccessToken = response.accessToken
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
encryptedPrefs.teslaRefreshToken = response.refreshToken
} catch (e: IOException) {
CarToast.makeText(
carContext,
R.string.generic_connection_error,
CarToast.LENGTH_SHORT
).show()
} finally {
teslaLoggingIn = false
}
invalidate()
}
}
private fun teslaLogout() {
// sign out
encryptedPrefs.teslaRefreshToken = null
encryptedPrefs.teslaAccessToken = null
encryptedPrefs.teslaAccessTokenExpiry = -1
encryptedPrefs.teslaEmail = null
CarToast.makeText(carContext, R.string.logged_out, CarToast.LENGTH_SHORT).show()
invalidate()
}
}
class ChooseDataSourceScreen(
@@ -703,6 +835,34 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
}
}
class AcceptPrivacyScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
override fun onGetTemplate(): Template {
val textWithoutLink = HtmlCompat.fromHtml(
carContext.getString(R.string.accept_privacy),
HtmlCompat.FROM_HTML_MODE_LEGACY
).toString()
return MessageTemplate.Builder(textWithoutLink).apply {
setTitle(carContext.getString(R.string.privacy))
addAction(Action.Builder()
.setTitle(carContext.getString(R.string.ok))
.setFlags(Action.FLAG_PRIMARY)
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
prefs.privacyAccepted = true
screenManager.pop()
}.build()
)
addAction(Action.Builder()
.setTitle(carContext.getString(R.string.privacy))
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.privacy_link))
}).build()
)
}.build()
}
}
class DeveloperOptionsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)

View File

@@ -4,7 +4,9 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Typeface
import android.net.Uri
import android.text.TextPaint
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
@@ -19,7 +21,11 @@ import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.ftPerMile
import net.vonforst.evmap.getPackageInfoCompat
import net.vonforst.evmap.kmPerMile
import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.ydPerMile
import java.util.*
import kotlin.math.roundToInt
@@ -69,33 +75,26 @@ val emptyCarIcon: CarIcon by lazy {
).asCarIcon()
}
private const val kmPerMile = 1.609344
private const val ftPerMile = 5280
private const val ydPerMile = 1760
fun getDefaultDistanceUnit(): Int {
return if (usesImperialUnits(Locale.getDefault())) {
fun getDefaultDistanceUnit(ctx: Context): Int {
return if (shouldUseImperialUnits(ctx)) {
CarUnit.MILE
} else {
CarUnit.KILOMETER
}
}
fun usesImperialUnits(locale: Locale): Boolean {
return locale.country in listOf("US", "GB", "MM", "LR")
|| locale.country == "" && locale.language == "en"
}
fun getDefaultSpeedUnit(): Int {
return when (Locale.getDefault().country) {
"US", "GB", "MM", "LR" -> CarUnit.MILES_PER_HOUR
else -> CarUnit.KILOMETERS_PER_HOUR
fun getDefaultSpeedUnit(ctx: Context): Int {
return if (shouldUseImperialUnits(ctx)) {
CarUnit.MILES_PER_HOUR
} else {
CarUnit.KILOMETERS_PER_HOUR
}
}
fun formatCarUnitDistance(value: Float?, unit: Int?): String {
fun formatCarUnitDistance(value: Float?, unit: Int?, ctx: Context): String {
if (value == null) return ""
return when (unit ?: getDefaultDistanceUnit()) {
return when (unit ?: getDefaultDistanceUnit(ctx)) {
// distance units: base unit is meters
CarUnit.METER -> "%.0f m".format(value)
CarUnit.KILOMETER -> "%.1f km".format(value / 1000)
@@ -105,9 +104,9 @@ fun formatCarUnitDistance(value: Float?, unit: Int?): String {
}
}
fun formatCarUnitSpeed(value: Float?, unit: Int?): String {
fun formatCarUnitSpeed(value: Float?, unit: Int?, ctx: Context): String {
if (value == null) return ""
return when (unit ?: getDefaultSpeedUnit()) {
return when (unit ?: getDefaultSpeedUnit(ctx)) {
// speed units: base unit is meters per second
CarUnit.METERS_PER_SEC -> "%.0f m/s".format(value)
CarUnit.KILOMETERS_PER_HOUR -> "%.0f km/h".format(value * 3.6)
@@ -116,9 +115,9 @@ fun formatCarUnitSpeed(value: Float?, unit: Int?): String {
}
}
fun roundValueToDistance(value: Double, unit: Int? = null): Distance {
fun roundValueToDistance(value: Double, unit: Int? = null, ctx: Context): Distance {
// value is in meters
when (unit ?: getDefaultDistanceUnit()) {
when (unit ?: getDefaultDistanceUnit(ctx)) {
CarUnit.MILE -> {
// imperial system
val miles = value / 1000 / kmPerMile
@@ -261,4 +260,17 @@ class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
.build()
}
}
class TextMeasurer(ctx: CarContext) {
val textPaint = TextPaint()
init {
textPaint.textSize = ctx.resources.displayMetrics.density * 24
textPaint.typeface = Typeface.DEFAULT
}
fun measureText(text: CharSequence): Float {
return textPaint.measureText(text, 0, text.length)
}
}

View File

@@ -139,7 +139,8 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
setText(
formatCarUnitDistance(
energyLevel.rangeRemainingMeters.value,
energyLevel.distanceDisplayUnit.value
energyLevel.distanceDisplayUnit.value,
carContext
)
)
setImage(
@@ -173,7 +174,8 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
setText(
formatCarUnitSpeed(
rawSpeed,
speed.speedDisplayUnit.value
speed.speedDisplayUnit.value,
carContext
)
)
setImage(
@@ -183,7 +185,8 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
setText(
formatCarUnitSpeed(
speed.displaySpeedMetersPerSecond.value,
speed.speedDisplayUnit.value
speed.speedDisplayUnit.value,
carContext
)
)
setImage(

View File

@@ -32,6 +32,7 @@ import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
import net.vonforst.evmap.databinding.FragmentChargepriceHeaderBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
import net.vonforst.evmap.viewmodel.Status
@@ -81,7 +82,7 @@ class ChargepriceFragment : Fragment() {
}
.setPositiveButton(R.string.donate) { di, _ ->
di.dismiss()
findNavController().navigate(R.id.action_chargeprice_to_donateFragment)
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToDonateFragment())
}
.show()
}
@@ -167,7 +168,7 @@ class ChargepriceFragment : Fragment() {
chargepriceAdapter.myTariffsAll = it
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
it?.data?.let { chargepriceAdapter.submitList(it) }
chargepriceAdapter.submitList(it?.data ?: emptyList())
}
val connectorsAdapter = CheckableConnectorAdapter()
@@ -197,7 +198,7 @@ class ChargepriceFragment : Fragment() {
}
binding.btnSettings.setOnClickListener {
findNavController().navigate(R.id.action_chargeprice_to_chargepriceSettingsFragment)
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToChargepriceSettingsFragment())
}
headerBinding.batteryRange.setLabelFormatter { value: Float ->

View File

@@ -77,6 +77,8 @@ import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.model.*
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.*
import net.vonforst.evmap.utils.boundingBox
@@ -276,7 +278,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
try {
findNavController().navigate(R.id.action_map_to_opensource_donations)
findNavController().safeNavigate(MapFragmentDirections.actionMapToOpensourceDonations())
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
} catch (ignored: IllegalStateException) {
@@ -285,7 +287,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
/*if (!prefs.update060AndroidAutoDialogShown) {
try {
navController.navigate(R.id.action_map_to_update_060_androidauto)
navController.safeNavigate(MapFragmentDirections.actionMapToUpdate060AndroidAuto())
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
}
@@ -374,10 +376,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val charger = vm.charger.value?.data ?: return@setOnClickListener
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().navigate(
R.id.action_map_to_chargepriceFragment,
ChargepriceFragmentArgs(charger).toBundle(),
null, extras
findNavController().safeNavigate(
MapFragmentDirections.actionMapToChargepriceFragment(charger),
extras
)
}
binding.detailView.btnChargerWebsite.setOnClickListener {
@@ -385,9 +386,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
binding.detailView.btnLogin.setOnClickListener {
findNavController().navigate(
R.id.settings_data,
DataSettingsFragmentArgs(true).toBundle()
findNavController().safeNavigate(
MapFragmentDirections.actionMapToDataSettings(true)
)
}
binding.detailView.imgPredictionSource.setOnClickListener {
@@ -910,23 +910,19 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.scaleView.apply {
when (prefs.mapScale) {
"both" -> {
visibility = View.VISIBLE
if (prefs.showMapScale) {
visibility = View.VISIBLE
if (prefs.mapScaleMetersAndMiles) {
metersAndMiles()
} else {
if (shouldUseImperialUnits(requireContext())) {
milesOnly()
} else {
metersOnly()
}
}
"meters" -> {
visibility = View.VISIBLE
metersOnly()
}
"miles" -> {
visibility = View.VISIBLE
milesOnly()
}
"off" -> visibility = View.GONE
} else {
visibility = View.GONE
}
}
vm.mapPosition.observe(viewLifecycleOwner) {
@@ -1046,7 +1042,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.search.requestFocus()
binding.search.setSelection(locationName.length)
}
if (context.checkAnyLocationPermission()) {
if (context.checkAnyLocationPermission() && prefs.currentMapMyLocationEnabled) {
enableLocation(!positionSet, false)
positionSet = true
}
@@ -1228,26 +1224,29 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
MenuCompat.setGroupDividerEnabled(popup.menu, true)
popup.setForceShowIcon(true)
popup.setOnMenuItemClickListener {
val navController = requireView().findNavController()
when (it.itemId) {
R.id.menu_edit_filters -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
lifecycleScope.launch {
vm.copyFiltersToCustom()
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
navController.safeNavigate(
MapFragmentDirections.actionMapToFilterFragment()
)
}
true
}
R.id.menu_manage_filter_profiles -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
requireView().findNavController().navigate(
R.id.action_map_to_filterProfilesFragment
navController.safeNavigate(
MapFragmentDirections.actionMapToFilterProfilesFragment()
)
true
}
else -> {
val profileId = profilesMap.inverse[it]
if (profileId != null) {
@@ -1403,6 +1402,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
prefs.currentMapLocation = it.bounds.center
prefs.currentMapZoom = it.zoom
}
vm.myLocationEnabled.value?.let {
prefs.currentMapMyLocationEnabled = it
}
}
override fun onDestroy() {

View File

@@ -21,6 +21,7 @@ import androidx.viewpager2.widget.ViewPager2
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.*
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
class OnboardingFragment : Fragment() {
@@ -82,7 +83,7 @@ class OnboardingFragment : Fragment() {
fun goToNext() {
if (binding.viewPager.currentItem == adapter.itemCount - 1) {
findNavController().navigate(R.id.action_onboarding_to_map)
findNavController().safeNavigate(OnboardingFragmentDirections.actionOnboardingToMap())
} else {
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
}

View File

@@ -1,6 +1,7 @@
package net.vonforst.evmap.fragment.oauth
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
@@ -13,17 +14,25 @@ import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.localbroadcastmanager.content.LocalBroadcastManager
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
import java.lang.IllegalStateException
class OAuthLoginFragment : Fragment() {
companion object {
val ACTION_OAUTH_RESULT = "oauth_result"
val EXTRA_URL = "url"
}
private lateinit var webView: WebView
override fun onCreate(savedInstanceState: Bundle?) {
@@ -43,10 +52,24 @@ class OAuthLoginFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
val navController = try {
findNavController()
} catch (e: IllegalStateException) {
null
// standalone in OAuthLoginActivity
}
if (navController != null) {
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
} else {
toolbar.title = getString(R.string.login)
toolbar.navigationIcon =
AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_back)
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
}
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
val uri = Uri.parse(args.url)
@@ -68,7 +91,12 @@ class OAuthLoginFragment : Fragment() {
val result = Bundle()
result.putString("url", url.toString())
setFragmentResult(args.url, result)
findNavController().popBackStack()
context?.let {
LocalBroadcastManager.getInstance(it).sendBroadcast(
Intent(ACTION_OAUTH_RESULT).putExtra(EXTRA_URL, url)
)
}
navController?.popBackStack()
}
return url.host != uri.host

View File

@@ -16,6 +16,7 @@ import com.mikepenz.aboutlibraries.LibsBuilder
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -108,11 +109,11 @@ class AboutFragment : PreferenceFragmentCompat() {
"donate" -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
findNavController().navigate(R.id.action_about_to_donateFragment)
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToDonateFragment())
true
}
"github_sponsors" -> {
findNavController().navigate(R.id.action_about_to_github_sponsors)
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToGithubSponsors())
true
}
"twitter" -> {

View File

@@ -28,7 +28,7 @@ class AndroidAutoSettingsFragment : BaseSettingsFragment() {
setPreferencesFromResource(R.xml.settings_android_auto, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
"chargeprice_battery_range_android_auto_min", "chargeprice_battery_range_android_auto_max" -> {
updateRangePreferenceSummary()

View File

@@ -101,11 +101,12 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
setPreferencesFromResource(R.xml.settings_chargeprice, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
"chargeprice_my_vehicle" -> {
updateMyVehiclesSummary()
}
"chargeprice_my_tariffs" -> {
updateMyTariffsSummary()
}

View File

@@ -86,7 +86,7 @@ class DataSettingsFragment : BaseSettingsFragment() {
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
"search_provider" -> {
if (prefs.searchProvider == "google") {
@@ -139,52 +139,49 @@ class DataSettingsFragment : BaseSettingsFragment() {
}
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 (clientId, _) = getString(R.string.tesla_credentials).split(":")
val uri = TeslaAuthenticationApi.buildSignInUri(clientId = clientId)
val args = OAuthLoginFragmentArgs(
uri.toString(),
"https://auth.tesla.com/void/callback",
TeslaAuthenticationApi.resultUrlPrefix,
"#000000"
).toBundle()
setFragmentResultListener(uri.toString()) { _, result ->
teslaGetAccessToken(result, codeVerifier)
teslaGetAccessToken(result)
}
findNavController().navigate(R.id.oauth_login, args)
}
private fun teslaGetAccessToken(result: Bundle, codeVerifier: String) {
private fun teslaGetAccessToken(result: Bundle) {
teslaAccountPreference.summary = getString(R.string.logging_in)
val url = Uri.parse(result.getString("url"))
val code = url.getQueryParameter("code") ?: return
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
val (clientId, clientSecret) = getString(R.string.tesla_credentials).split(":")
val request = TeslaAuthenticationApi.AuthCodeRequest(
code,
clientId = clientId,
clientSecret = clientSecret
)
lifecycleScope.launch {
try {
val time = Instant.now().epochSecond
val response =
TeslaAuthenticationApi.create(okhttp).getToken(request)
val userResponse =
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
// val userResponse =
// TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
encryptedPrefs.teslaEmail = userResponse.response.email
encryptedPrefs.teslaEmail = "user@example.com"
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()
Snackbar.make(it, R.string.generic_connection_error, Snackbar.LENGTH_SHORT)
.show()
}
}
refreshTeslaAccountStatus()

View File

@@ -25,7 +25,7 @@ class DeveloperSettingsFragment : BaseSettingsFragment() {
setPreferencesFromResource(R.xml.settings_developer, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
}

View File

@@ -20,7 +20,7 @@ class SettingsFragment : BaseSettingsFragment() {
findPreference<Preference>("developer_options")?.isVisible = prefs.developerModeEnabled
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
}
}

View File

@@ -35,7 +35,7 @@ class UiSettingsFragment : BaseSettingsFragment() {
langPref.value = getAppLocale()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
"darkmode" -> {
updateNightMode(prefs)

View File

@@ -7,6 +7,7 @@ import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.MaterialDialogFragment
@@ -30,11 +31,11 @@ class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
}
binding.btnDonate.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().navigate(R.id.action_opensource_donations_to_donate)
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToDonate())
}
binding.btnGithubSponsors.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().navigate(R.id.action_opensource_donations_to_github_sponsors)
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToGithubSponsors())
}
}

View File

@@ -0,0 +1,17 @@
package net.vonforst.evmap.navigation
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.Navigator
fun NavController.safeNavigate(
direction: NavDirections,
navigatorExtras: Navigator.Extras? = null
) {
currentDestination?.getAction(direction.actionId) ?: return
if (navigatorExtras != null) {
navigate(direction, navigatorExtras)
} else {
navigate(direction)
}
}

View File

@@ -14,6 +14,24 @@ import java.time.Instant
class PreferenceDataSource(val context: Context) {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
init {
if (sp.contains("map_scale")) {
// migration
val mapScale = sp.getString("map_scale", null)
sp.edit().putBoolean("map_scale_show", mapScale != "off")
.putBoolean("map_scale_meters_and_miles", mapScale == "both")
.putString(
"units", when (mapScale) {
"meters" -> "metric"
"miles" -> "imperial"
else -> "default"
}
)
.remove("map_scale")
.apply()
}
}
var dataSource: String
get() = sp.getString("data_source", "goingelectric")!!
set(value) {
@@ -243,8 +261,11 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putBoolean("show_chargers_ahead_android_auto", value).apply()
}
val predictionEnabled: Boolean
var predictionEnabled: Boolean
get() = sp.getBoolean("prediction_enabled", true)
set(value) {
sp.edit().putBoolean("prediction_enabled", value).apply()
}
var developerModeEnabled: Boolean
get() = sp.getBoolean("dev_mode_enabled", false)
@@ -252,8 +273,14 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putBoolean("dev_mode_enabled", value).apply()
}
val mapScale: String
get() = sp.getString("map_scale", null) ?: "both"
val showMapScale: Boolean
get() = sp.getBoolean("map_scale_show", true)
val mapScaleMetersAndMiles: Boolean
get() = sp.getBoolean("map_scale_meters_and_miles", true)
val units: String
get() = sp.getString("units", null) ?: "default"
var currentMapLocation: LatLng
get() = sp.getLatLng("current_map_location") ?: LatLng(50.113388, 9.252536)
@@ -267,6 +294,12 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putFloat("current_map_zoom", value).apply()
}
var currentMapMyLocationEnabled: Boolean
get() = sp.getBoolean("current_map_my_location_enabled", false)
set(value) {
sp.edit().putBoolean("current_map_my_location_enabled", value).apply()
}
var privacyAccepted: Boolean
get() = sp.getBoolean("privacy_accepted", false)
set(value) {

View File

@@ -309,9 +309,9 @@ fun time(value: Int): String {
else "%d:%02d h".format(h, min)
}
fun distance(meters: Number?): String? {
fun distance(meters: Number?, ctx: Context): String? {
if (meters == null) return null
if (shouldUseImperialUnits()) {
if (shouldUseImperialUnits(ctx)) {
val ft = meters.toDouble() / meterPerFt
val mi = meters.toDouble() / 1e3 / kmPerMile
return when {

View File

@@ -116,6 +116,7 @@ class ChargepriceViewModel(
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
value = state["chargePrices"] ?: Resource.loading(null)
listOf(
vehicle,
batteryRange,
batteryRangeSliderDragging,
vehicleCompatibleConnectors,

View File

@@ -24,6 +24,8 @@ import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.fronyx.FronyxApi
import net.vonforst.evmap.api.fronyx.FronyxEvseIdResponse
import net.vonforst.evmap.api.fronyx.FronyxStatus
import net.vonforst.evmap.api.fronyx.PredictionData
import net.vonforst.evmap.api.fronyx.PredictionRepository
import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.openchargemap.OCMConnection
@@ -250,155 +252,12 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
it.data?.extraData as? TeslaGraphQlApi.Pricing
}
val predictionApi = FronyxApi(application.getString(R.string.fronyx_key))
private val predictionRepository = PredictionRepository(application)
val prediction: LiveData<Resource<List<FronyxEvseIdResponse>>> by lazy {
availability.switchMap { av ->
if (!prefs.predictionEnabled) return@switchMap null
av.data?.evseIds?.let { evseIds ->
liveData {
emit(Resource.loading(null))
val charger = charger.value?.data ?: return@liveData
val allEvseIds =
evseIds.filterKeys {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors.value?.let { filtered ->
equivalentPlugTypes(
it.type
).any { filtered.contains(it) }
} ?: true
}.flatMap { it.value }
if (allEvseIds.isEmpty()) {
emit(Resource.success(emptyList()))
return@liveData
}
try {
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
if (result.size == allEvseIds.size) {
emit(Resource.success(result))
} else {
emit(Resource.error("not all EVSEIDs found", null))
}
} catch (e: IOException) {
emit(Resource.error(e.message, null))
e.printStackTrace()
} catch (e: HttpException) {
emit(Resource.error(e.message, null))
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
emit(Resource.error(e.message, null))
e.printStackTrace()
} catch (e: JsonDataException) {
// malformed JSON response from fronyx API
emit(Resource.error(e.message, null))
e.printStackTrace()
}
}
} ?: liveData { emit(Resource.success(null)) }
}
}
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 }
}
}
}
}
}
}
private val predictedChargepoints = charger.map {
it.data?.let { charger ->
charger.chargepoints.filter {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors.value?.let { filtered ->
equivalentPlugTypes(it.type).any {
filtered.contains(
it
)
}
} ?: true
}
}
}
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
}
}
}
val predictionDescription: LiveData<String?> by lazy {
predictedChargepoints.map { predictedChargepoints ->
if (predictedChargepoints == null) return@map null
val allChargepoints = charger.value?.data?.chargepoints ?: return@map null
val predictedChargepointTypes = predictedChargepoints.map { it.type }.distinct()
if (allChargepoints == predictedChargepoints) {
null
} else if (predictedChargepointTypes.size == 1) {
application.getString(
R.string.prediction_only,
nameForPlugType(application.stringProvider(), predictedChargepointTypes[0])
)
} else {
application.getString(
R.string.prediction_only,
application.getString(R.string.prediction_dc_plugs_only)
)
}
val predictionData: LiveData<PredictionData> = availability.switchMap { av ->
liveData {
val charger = charger.value?.data ?: return@liveData
emit(predictionRepository.getPredictionData(charger, av.data, filteredConnectors.value))
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_container_view"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:fitsSystemWindows="true" />

View File

@@ -37,6 +37,8 @@
<import type="java.time.Duration" />
<import type="net.vonforst.evmap.api.fronyx.PredictionData" />
<variable
name="charger"
type="Resource&lt;ChargeLocation&gt;" />
@@ -50,20 +52,8 @@
type="Resource&lt;ChargeLocationStatus&gt;" />
<variable
name="predictionGraph"
type="Map&lt;ZonedDateTime, Double&gt;" />
<variable
name="predictionMaxValue"
type="Double" />
<variable
name="predictionIsPercentage"
type="Boolean" />
<variable
name="predictionDescription"
type="String" />
name="predictionData"
type="PredictionData" />
<variable
name="filteredAvailability"
@@ -147,7 +137,7 @@
android:gravity="end"
android:maxLines="1"
android:minWidth="50dp"
android:text="@{BindingAdaptersKt.distance(distance)}"
android:text="@{BindingAdaptersKt.distance(distance, context)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
@@ -367,11 +357,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@{predictionIsPercentage ? @string/average_utilization : @string/utilization_prediction}"
android:text="@{predictionData.isPercentage ? @string/average_utilization : @string/utilization_prediction}"
tools:text="@string/utilization_prediction"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{predictionGraph != null}"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider2" />
@@ -381,9 +371,9 @@
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="@{predictionDescription}"
android:text="@{predictionData.description}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
app:layout_constraintStart_toEndOf="@+id/textView8"
@@ -395,7 +385,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/help"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:icon="@drawable/ic_help"
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView8"
@@ -407,13 +397,13 @@
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_marginTop="8dp"
app:data="@{predictionGraph}"
app:goneUnless="@{predictionGraph != null}"
app:data="@{predictionData.predictionGraph}"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView8"
app:maxValue="@{predictionMaxValue}"
app:isPercentage="@{predictionIsPercentage}"
app:maxValue="@{predictionData.maxValue}"
app:isPercentage="@{predictionData.isPercentage}"
tools:itemCount="3"
tools:layoutManager="LinearLayoutManager"
tools:listitem="@layout/item_connector"
@@ -427,7 +417,7 @@
android:adjustViewBounds="true"
android:background="?selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toBottomOf="@+id/prediction"
app:srcCompat="@drawable/ic_powered_by_fronyx"
@@ -439,7 +429,7 @@
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:goneUnless="@{predictionGraph != null}"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:layout_constraintTop_toBottomOf="@+id/imgPredictionSource" />
<ImageView

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:orientation="vertical">
<TextView
android:id="@+id/textView20"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/referrals"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary" />
<TextView
android:id="@+id/textView21"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/referrals_info" />
<Button
android:id="@+id/referral_tesla"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/referral_tesla"
app:icon="@drawable/ic_tesla" />
</LinearLayout>

View File

@@ -197,10 +197,7 @@
app:charger="@{vm.charger}"
app:availability="@{vm.availability}"
app:filteredAvailability="@{vm.filteredAvailability}"
app:predictionGraph="@{vm.predictionGraph}"
app:predictionMaxValue="@{vm.predictionMaxValue}"
app:predictionIsPercentage="@{vm.predictionIsPercentage}"
app:predictionDescription="@{vm.predictionDescription}"
app:predictionData="@{vm.predictionData}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}"

View File

@@ -62,7 +62,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@{BindingAdaptersKt.distance(item.distanceMeters)}"
android:text="@{BindingAdaptersKt.distance(item.distanceMeters, context)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{item.distanceMeters != null}"
app:layout_constraintEnd_toEndOf="@+id/icon"

View File

@@ -97,7 +97,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@{BindingAdaptersKt.distance(item.distance)}"
android:text="@{BindingAdaptersKt.distance(item.distance, context)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{item.distance != null}"
app:layout_constraintEnd_toStartOf="@id/btnDelete"

View File

@@ -32,6 +32,9 @@
<action
android:id="@+id/action_map_to_opensource_donations"
app:destination="@id/opensource_donations" />
<action
android:id="@+id/action_map_to_data_settings"
app:destination="@id/settings_data" />
<argument
android:name="locationName"
android:defaultValue="@null"

View File

@@ -275,6 +275,7 @@
<string name="about_contributors">Mitwirkende</string>
<string name="about_contributors_text">Dank an alle Mitwirkenden für ihre Beiträge von Code und Übersetzungen für EVMap:</string>
<string name="utilization_prediction">Auslastungsprognose</string>
<string name="powered_by_fronyx">powered by fronyx</string>
<string name="prediction_help">Die Prognose basiert auf Faktoren wie Wochentag, Uhrzeit und Nutzung in der Vergangenheit. So kannst du stark ausgelastete Ladesäulen vermeiden. Keine Garantie.</string>
<string name="prediction_time_colon">%s Uhr:</string>
<plurals name="prediction_number_available">
@@ -314,11 +315,12 @@
<string name="tesla_pricing_blocking_fee">Blockiergebühr: %s</string>
<string name="average_utilization">Durchschnittliche Auslastung</string>
<string name="website">Website</string>
<string name="pref_map_scale">Kartenmaßstab</string>
<string name="pref_map_scale_both">Meter und Meilen</string>
<string name="pref_map_scale_meters">Meter</string>
<string name="pref_map_scale_miles">Meilen</string>
<string name="pref_map_scale_off">aus</string>
<string name="pref_map_scale">Kartenmaßstab zeigen</string>
<string name="pref_map_scale_meters_and_miles">Meilen und Meter am Kartenmaßstab</string>
<string name="pref_units">Einheiten</string>
<string name="pref_units_default">Geräteeinstellung verwenden</string>
<string name="pref_units_metric">Metrisch</string>
<string name="pref_units_imperial">Imperial</string>
<string name="data_retrieved_at">Daten abgerufen %s</string>
<string name="settings_caching">Cache</string>
<string name="settings_cache_count">Cache-Größe</string>
@@ -347,7 +349,7 @@
<string name="auto_heading">Fahrtrichtung</string>
<string name="auto_settings">Einstellungen</string>
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="welcome_android_auto_detail">Auf unterstützten Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="sounds_cool">Klingt cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%1$s %2$s).</string>
@@ -361,4 +363,8 @@
<string name="auto_multipage">(%d/%d)</string>
<string name="reload">Neu laden</string>
<string name="accept_privacy"><![CDATA[Ich habe die <a href=\"%s\">Datenschutzerklärung</a> von EVMap gelesen und bin damit einverstanden.]]></string>
<string name="referrals">Empfehlungslinks</string>
<string name="referrals_info">Du kannst auch einen der Empfehlungslinks unten benutzen, um den Entwickler mit deinem Kauf zu unterstützen.</string>
<string name="referral_tesla">Tesla</string>
<string name="generic_connection_error">Daten konnten nicht geladen werden</string>
</resources>

View File

@@ -331,4 +331,18 @@
<string name="loading">Laster inn …</string>
<string name="auto_multipage_goto">Side %d</string>
<string name="auto_multipage">(%d/%d)</string>
<string name="charge_price_minute_format">%2$s%1$.2f/min</string>
<string name="website">Nettside</string>
<string name="pref_tesla_account_enabled">Innlogget som %s</string>
<string name="pref_units">Enheter</string>
<string name="log_out">Logg ut</string>
<string name="logging_in">Logger inn …</string>
<string name="realtime_data_login_needed">Tesla-konto kreves for sanntidsdata</string>
<string name="generic_connection_error">Kunne ikke laste inn data</string>
<string name="logged_out">Utlogget</string>
<string name="pref_tesla_account">Tesla-konto</string>
<string name="tesla_pricing_others">Andre kunder:</string>
<string name="referral_tesla">Tesla</string>
<string name="pref_units_default">Enhetsforvalg</string>
<string name="login">Logg inn</string>
</resources>

View File

@@ -13,7 +13,7 @@
<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="chargeprice_select_connector">Escolha 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>
@@ -58,7 +58,7 @@
<string name="fault_report_date">Com problemas (atualizado: %s)</string>
<string name="filter_chargecards">Formas de pagamento</string>
<string name="pref_language">Língua da app</string>
<string name="all_selected">Todas selecionadas</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>
@@ -76,7 +76,7 @@
<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="edit_at_datasource">Editar 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>
@@ -96,7 +96,7 @@
<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_2">Cada cor corresponde à potência máxima do carregador</string>
<string name="welcome_to_evmap">Bem-vindo(a) ao EVMap</string>
<string name="pref_darkmode_always_off">Sempre desligado</string>
<string name="welcome_2_title">Escolha a potência</string>
@@ -153,14 +153,14 @@
<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="pref_search_provider">Provedor 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="pref_search_provider_info">As pesquisas são caras, especialmente se o Google Maps for utilizado. 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>
@@ -319,17 +319,13 @@
<string name="average_utilization">Utilização média</string>
<string name="tesla_pricing_owners">Apenas veículos Tesla:</string>
<string name="website">Website</string>
<string name="pref_map_scale_off">desativar</string>
<string name="pref_map_scale_both">metros e milhas</string>
<string name="pref_map_scale_meters">metros</string>
<string name="pref_map_scale_miles">milhas</string>
<string name="pref_map_scale">Barra de escala do mapa</string>
<string name="pref_map_scale">Mostrar barra de escala do mapa</string>
<string name="data_retrieved_at">Informação atualizada %s</string>
<string name="settings_cache_count">Tamanho da cache</string>
<string name="settings_cache_clear">Limpar cache</string>
<string name="settings_cache_count_summary">%d carregadores na base de dados, %.1f MB</string>
<string name="settings_caching">Caching (base de dados local)</string>
<string name="settings_cache_clear_summary">Elimina todos os carregadores guardados na base de dados local, com a exceção dos seus favoritos</string>
<string name="settings_cache_clear_summary">Elimina todos os carregadores guardados localmente, com a exceção dos seus favoritos</string>
<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>
@@ -366,4 +362,14 @@
<string name="sounds_cool">Continuar</string>
<string name="reload">Recarregar</string>
<string name="accept_privacy"><![CDATA[Li e aceito a <a href=\"%s\">política de privacidade</a> do EVMap.]]></string>
<string name="referrals">Links de afiliado</string>
<string name="referral_tesla">Tesla</string>
<string name="pref_units">Unidades</string>
<string name="pref_map_scale_meters_and_miles">Milhas e metros na barra de escala do mapa</string>
<string name="pref_units_default">Padrão do dispositivo</string>
<string name="pref_units_metric">Métrico</string>
<string name="pref_units_imperial">Imperial</string>
<string name="referrals_info">Também pode usar um dos seguintes links de afiliado para apoiar o desenvolvedor da app com a sua compra.</string>
<string name="generic_connection_error">Não foi possível carregar a informação</string>
<string name="powered_by_fronyx">previsão feita por fronyx</string>
</resources>

View File

@@ -66,16 +66,14 @@
<item>goingelectric</item>
<item>openchargemap</item>
</string-array>
<string-array name="pref_map_scale_names">
<item>@string/pref_map_scale_both</item>
<item>@string/pref_map_scale_meters</item>
<item>@string/pref_map_scale_miles</item>
<item>@string/pref_map_scale_off</item>
<string-array name="pref_units_names">
<item>@string/pref_units_default</item>
<item>@string/pref_units_metric</item>
<item>@string/pref_units_imperial</item>
</string-array>
<string-array name="pref_map_scale_values" translatable="false">
<item>both</item>
<item>meters</item>
<item>miles</item>
<item>off</item>
<string-array name="pref_units_values" translatable="false">
<item>default</item>
<item>metric</item>
<item>imperial</item>
</string-array>
</resources>

View File

@@ -33,5 +33,7 @@
<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="donate_link" translatable="false">https://ev-map.app/donate/</string>
<string name="tesla_referral_link" translatable="false">http://ts.la/johan94494</string>
<string name="copyright_summary">©20202023 Johan von Forstner and contributors</string>
<string name="acra_backend_url" translatable="false">https://acra.muc.vonforst.net/report</string>
</resources>

View File

@@ -275,6 +275,7 @@
<string name="about_contributors">Contributors</string>
<string name="about_contributors_text">Thanks to all contributors for their coding and translation contributions to EVMap:</string>
<string name="utilization_prediction">Utilization prediction</string>
<string name="powered_by_fronyx">powered by fronyx</string>
<string name="prediction_help">The prediction is based on factors like day of the week, time of day and past usage, so that you can avoid overcrowded chargers. No guarantee.</string>
<string name="prediction_time_colon">%s:</string>
<plurals name="prediction_number_available">
@@ -304,7 +305,7 @@
<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">Log in</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>
@@ -314,11 +315,12 @@
<string name="tesla_pricing_blocking_fee">Blocking fee: %s</string>
<string name="average_utilization">Average Utilization</string>
<string name="website">Website</string>
<string name="pref_map_scale">Map scale bar</string>
<string name="pref_map_scale_both">meters and miles</string>
<string name="pref_map_scale_meters">meters</string>
<string name="pref_map_scale_miles">miles</string>
<string name="pref_map_scale_off">off</string>
<string name="pref_map_scale">Show map scale bar</string>
<string name="pref_map_scale_meters_and_miles">Miles and meters on map scale bar</string>
<string name="pref_units">Units</string>
<string name="pref_units_default">Device default</string>
<string name="pref_units_metric">Metric</string>
<string name="pref_units_imperial">Imperial</string>
<string name="data_retrieved_at">Data retrieved %s</string>
<string name="settings_caching">Caching</string>
<string name="settings_cache_count">Cache size</string>
@@ -361,4 +363,8 @@
<string name="auto_multipage">(%d/%d)</string>
<string name="reload">Reload</string>
<string name="accept_privacy"><![CDATA[I have read and accepted EVMap\'s <a href=\"%s\">privacy policy</a>.]]></string>
<string name="referrals">Referral links</string>
<string name="referrals_info">You can also use one of the referral links below to support the developer with your purchase.</string>
<string name="referral_tesla">Tesla</string>
<string name="generic_connection_error">Could not load data</string>
</resources>

View File

@@ -8,6 +8,13 @@
android:entryValues="@array/pref_language_values"
android:defaultValue="default"
android:summary="%s" />
<ListPreference
android:key="units"
android:title="@string/pref_units"
android:entries="@array/pref_units_names"
android:entryValues="@array/pref_units_values"
android:defaultValue="default"
android:summary="%s" />
<ListPreference
android:key="darkmode"
@@ -22,13 +29,15 @@
android:summaryOn="@string/pref_map_rotate_gestures_on"
android:summaryOff="@string/pref_map_rotate_gestures_off"
android:defaultValue="true" />
<ListPreference
android:key="map_scale"
<CheckBoxPreference
android:key="map_scale_show"
android:title="@string/pref_map_scale"
android:entries="@array/pref_map_scale_names"
android:entryValues="@array/pref_map_scale_values"
android:defaultValue="both"
android:summary="%s" />
android:defaultValue="true" />
<CheckBoxPreference
android:key="map_scale_meters_and_miles"
android:title="@string/pref_map_scale_meters_and_miles"
android:defaultValue="true" />
<CheckBoxPreference
android:key="navigate_use_maps"
android:title="@string/pref_navigate_use_maps"

View File

@@ -10,14 +10,17 @@ import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ApplicationProvider
import net.vonforst.evmap.FakeAndroidKeyStore
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.internal.DoNotInstrument
@RunWith(RobolectricTestRunner::class)
@DoNotInstrument
@Config(sdk = [33]) // Robolectric does not yet support SDK 34
class CarAppTest {
private val testCarContext =
TestCarContext.createCarContext(ApplicationProvider.getApplicationContext()).apply {
@@ -43,7 +46,7 @@ class CarAppTest {
val screenCreated =
testCarContext.getCarService(TestScreenManager::class.java).screensPushed.last()
// location permission required
assert(screenCreated is PermissionScreen)
// accept privacy required
assert(screenCreated is AcceptPrivacyScreen)
}
}

View File

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

@@ -29,6 +29,9 @@ be put into the app in the form of a resource file called `apikeys.xml` under
<string name="fronyx_key" translatable="false">
insert your Fronyx key here
</string>
<string name="acra_credentials" translatable="false">
insert your ACRA crash reporting credentials here
</string>
</resources>
```
@@ -146,6 +149,21 @@ in German.
</details>
### **Tesla**
[API documentation](https://developer.tesla.com/docs/fleet-api)
<details>
<summary>How to obtain an API key</summary>
1. [Sign up](https://www.tesla.com/teslaaccount) for a Tesla account
2. In the [Tesla Developer Portal](https://developer.tesla.com/), click on "Request app access"
3. Enter the details of your app
4. You will receive a *Client ID* and *Client Secret*. Enter them both into `tesla_credentials`,
separated by a colon (`:`).
</details>
Pricing providers
-----------------
@@ -186,4 +204,12 @@ The API is not publically available, contact [fronyx](https://fronyx.io/contact-
key and documentation.
If you don't want to test this functionality, simply leave the API key blank.
</details>
</details>
Crash reporting
---------------
Crash reporting for release builds is done using [ACRA](https://github.com/ACRA/acra).
This should not be needed for debugging.
If you still want to try it out, you can host any compatible backend such as
[Acrarium](https://github.com/F43nd1r/Acrarium/) yourself.

View File

@@ -0,0 +1,8 @@
Verbesserungen:
- Neue Einstellung für Maßeinheiten
- Anpassungen für Android 14
- Android Auto: Weitere Detailbeschreibungen zu den Ladestationen
- Android Auto: Löschbutton in der Filterliste
Fehler behoben:
- Fehler beim Laden der EnBW Echtzeitdaten

View File

@@ -0,0 +1,5 @@
Verbesserungen:
- Beim Start der App wird nun der zuletzt gesehene Kartenausschnitt gezeigt
Fehler behoben:
- Abstürze behoben

View File

@@ -0,0 +1,5 @@
Neue Funktionen:
- Auslastungsprognose auch unter Android Auto verfügbar
Fehler behoben:
- Abstürze behoben

View File

@@ -0,0 +1,8 @@
Improvements:
- New setting for units of measurement
- Adjustments for Android 14
- Android Auto: More detailed descriptions of chargers
- Android Auto: Delete button in filter list
Bugfixes:
- Errors loading realtime data from EnBW

View File

@@ -0,0 +1,5 @@
Improvements:
- When starting the app, the last viewed map area will be shown
Bugfixes:
- Fixed crashes

View File

@@ -0,0 +1,5 @@
New features:
- Availability prediction also available on Android Auto
Bugfixes:
- Fixed crashes