Compare commits

...

186 Commits

Author SHA1 Message Date
johan12345
2e1e22a44b adjust disk size 2024-05-12 22:29:49 +02:00
johan12345
d638a88f4d update SDK 2024-05-12 18:57:02 +02:00
johan12345
b9c08a8e75 change screenshot size 2024-05-12 15:13:11 +02:00
johan12345
ab2a77d25a remove CleanStatusBar (not needed on ATD) 2024-05-12 14:33:45 +02:00
johan12345
e0ddc9f734 use androidx takeScreenshot 2024-05-10 17:52:28 +02:00
johan12345
168fa244d3 fix name of output file 2024-05-10 00:08:25 +02:00
johan12345
76388ccc6e no emulator metrics 2024-05-09 23:37:16 +02:00
johan12345
76aac7dae4 use ATD system image 2024-05-09 23:35:02 +02:00
johan12345
ee8b586079 implement automatic screenshot generation 2024-05-09 23:17:09 +02:00
Hosted Weblate
360e7767bd Translated using Weblate (Portuguese)
Currently translated at 100.0% (366 of 366 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2024-05-08 22:27:16 +02:00
Jean-Baptiste
5f0c9fd31d Enable automatic per app language (#340)
* Enable automatic per app language

* fix getAppLocale

---------

Co-authored-by: johan12345 <johan.forstner@gmail.com>
2024-05-08 22:19:09 +02:00
johan12345
536c884f23 fix crash introduced by 00b26d224f 2024-05-03 20:34:09 +02:00
johan12345
7daf5a0adb fix jumping position of slider in ChargepriceFragment
fixes #328
2024-04-28 19:30:57 +02:00
johan12345
862f2b06d8 remove broken resourcePlaceholders plugin, hardcode targetPackage in shortcuts.xml 2024-04-28 19:18:32 +02:00
johan12345
198a9ecc48 Fix snackbar action button color
fixes #337
2024-04-28 19:05:28 +02:00
johan12345
2762a32105 improve look of text input dialog
fixes #336
2024-04-28 18:52:41 +02:00
johan12345
8a83a80e75 Block ability to update filter profile name with nothing
fixes #335
2024-04-28 18:29:10 +02:00
johan12345
75e8569964 dismiss popupMenu when fragment is destroyed
fixes #331
2024-04-27 13:32:17 +02:00
johan12345
00b26d224f fix #329: Layer button reappears after screen rotation 2024-04-27 12:43:34 +02:00
Jean-Baptiste
836f42b299 Fix color of layer button (#334)
* Fix color of layer button

* keep the layers icon gray

---------

Co-authored-by: johan12345 <johan.forstner@gmail.com>
2024-04-27 12:24:19 +02:00
johan12345
3de994f09d update copyright in LICENSE 2024-04-26 22:41:39 +02:00
Jean-Baptiste
d78eda9d97 Update copyright (#333) 2024-04-26 22:40:45 +02:00
johan12345
ed4be05aed fix tests 2024-04-24 21:53:26 +02:00
johan12345
45de4c8ff0 Release 1.8.2 2024-04-24 21:21:00 +02:00
johan12345
b5b785be07 Czech strings CDATA 2024-04-24 21:17:21 +02:00
Hosted Weblate
0b8589d599 Translated using Weblate (Czech)
Currently translated at 100.0% (366 of 366 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (366 of 366 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2024-04-24 21:16:36 +02:00
johan12345
ba757831f3 GoingElectricApi: catch cases where status != "ok" 2024-04-24 21:11:09 +02:00
johan12345
1990152836 Add option to disable native Chargeprice integration 2024-04-18 20:41:02 +02:00
johan12345
50fd433439 hide immediate navigation setting if Google Maps is not installed
fixes #326
2024-04-17 21:26:18 +02:00
johan12345
381e6f3d98 OCM: extract operator name using ReferenceData
fixes #323
2024-04-14 18:58:15 +02:00
johan12345
9c5582f19c update AnyMaps and android-spatialite
to make sure FORTIFY_SOURCE flag is used
2024-04-14 18:38:30 +02:00
johan12345
1c16d8cbb6 fix string 2024-04-07 16:02:18 +02:00
Hosted Weblate
1734f1c09e Translated using Weblate (Portuguese)
Currently translated at 100.0% (363 of 363 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2024-04-07 16:01:17 +02:00
Hosted Weblate
ebf0f82597 Translated using Weblate (Czech)
Currently translated at 100.0% (363 of 363 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (363 of 363 strings)

Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2024-04-07 16:01:11 +02:00
Johan von Forstner
85a38a6da1 fix NPE during MapViewModel init 2024-04-05 14:42:36 +02:00
johan12345
60ca97179c update referral links 2024-03-17 19:14:04 +01:00
johan12345
4413cba9fa fix NPE? 2024-03-17 19:14:04 +01:00
Jean-Baptiste
e2e6a3060b Fix Node JS deprecations in Github Actions (#322)
* Bump setup-java to v4 in release action

* Bump setup-java to v4 in tests action
2024-01-31 21:08:36 +01:00
johan12345
7e507cad70 Release 1.8.1 2024-01-31 20:31:16 +01:00
johan12345
3b8aca3eff Merge branch 'master' of github.com:johan12345/EVMap 2024-01-31 20:29:01 +01:00
Jean-Baptiste
7a525d3f28 Move buildConfig property in app module (#321)
* Remove build config from gradle.properties

* Add build config property in build.gradle.kts
2024-01-31 20:28:40 +01:00
johan12345
b0d7f465bc close connector details dialog when bottom sheet is closed 2024-01-31 20:13:08 +01:00
johan12345
5bf839d4e9 fix czech string again 2024-01-31 20:02:42 +01:00
Hosted Weblate
73247b5a23 Translated using Weblate (Czech)
Currently translated at 100.0% (363 of 363 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (363 of 363 strings)

Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2024-01-31 20:01:36 +01:00
johan12345
526addd010 catch HttpException in SearchSelectScreen 2024-01-31 19:58:45 +01:00
Johan von Forstner
287c5f60bc Update FUNDING.yml 2024-01-28 16:12:26 +01:00
johan12345
b68ab8f960 fix NPE 2024-01-28 13:31:02 +01:00
johan12345
17d8bfc46a update referral links 2024-01-27 12:25:14 +01:00
johan12345
640f98c90f Release 1.8.0 2024-01-26 21:54:37 +01:00
johan12345
b1cf9809f6 add Czech language
fix issues in certain strings

disable MissingQuantity lint check due to https://github.com/WeblateOrg/weblate/issues/7888
2024-01-26 21:40:03 +01:00
Hosted Weblate
c171c859af Update translation files
Updated by "Squash Git commits" hook in Weblate.

Update translation files

Updated by "Squash Git commits" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android-automotive/
Translate-URL: https://hosted.weblate.org/projects/evmap/android-fdroid/
Translation: EVMap/Android (strings specific to F-Droid variant)
Translation: EVMap/Android (strings specific to the Android Automotive OS app)
2024-01-26 21:23:37 +01:00
johan12345
75c8114676 update build tools 2024-01-26 21:13:36 +01:00
johan12345
1d10ffeb52 show donation dialogs slightly more often
#320
2024-01-26 21:00:54 +01:00
johan12345
0e278bfedb rework from DialogFragment into included view 2024-01-26 20:28:19 +01:00
johan12345
c6395feaa3 Add details about voltage and amperage of chargers
shown in connector details dialog
(not supported by GoingElectric)
fixes #151
2024-01-26 20:28:19 +01:00
johan12345
4b38c0de2d Realtime data: add time of last change
shown in connector details dialog
fixes #275
2024-01-26 20:28:19 +01:00
johan12345
22d24f3bd0 add connector details dialog
relevant for #275 and #151
2024-01-26 20:27:29 +01:00
johan12345
d8e8475666 additional referral link 2024-01-18 23:59:04 +01:00
johan12345
c3148796d4 fix string 2024-01-09 21:46:04 +01:00
Hosted Weblate
8551966348 Translated using Weblate (French)
Currently translated at 100.0% (356 of 356 strings)

Co-authored-by: Altons <marsupilami450@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/fr/
Translation: EVMap/Android
2024-01-09 21:33:32 +01:00
Hosted Weblate
5f8be9dc0c Translated using Weblate (Portuguese)
Currently translated at 100.0% (357 of 357 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2024-01-09 21:33:15 +01:00
Johan von Forstner
0dc1a0270e docs updates 2023-12-28 16:06:12 +01:00
Johan von Forstner
609d984df1 Tesla availability detectors: fix bug
numMissing may have been < 0
2023-12-25 18:25:47 +01:00
Johan von Forstner
5830965d3a make charger name copyable
fixes #315
2023-12-25 18:22:37 +01:00
Johan von Forstner
b613b4d626 remove unused variable 2023-12-24 14:25:51 +01:00
Johan von Forstner
2e96ebbcd1 Android Auto: fix NPE when image fails to load 2023-12-24 13:10:57 +01:00
Johan von Forstner
579ce088dc Mapbox: support traffic on any map style
60b6d4f821
fixes #311
2023-12-24 13:02:22 +01:00
Johan von Forstner
be15be00bd add support for map.openchargemap.io links
fixes #313
2023-12-24 12:11:30 +01:00
johan12345
3266c623eb update Android Gradle Plugin 2023-12-16 22:17:25 +01:00
johan12345
f6feb2cf8c fix crash when no browser is installed 2023-12-16 15:23:55 +01:00
Hosted Weblate
ac1a0e01e3 Translated using Weblate (Portuguese)
Currently translated at 100.0% (356 of 356 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-12-16 15:10:47 +01:00
johan12345
7b038ad850 fix WorkManagerConfiguration 2023-12-16 15:10:16 +01:00
johan12345
d02c9cc005 update other dependencies 2023-12-16 14:47:16 +01:00
johan12345
c83ecf1e5a update car app library 2023-12-16 14:42:09 +01:00
johan12345
8287084818 fix wrong detail layout after device rotation
fixes #305
2023-11-26 19:13:31 +01:00
johan12345
8f433a02a0 Implement TeslaGuestAvailabilityDetector
works without sign-in (using data from https://www.tesla.com/de_DE/findus), but only for Superchargers that are open to all cars and have the ad-hoc payment site activated (e.g. Germany, UK)
2023-11-19 19:00:32 +01:00
Hosted Weblate
de6890e27e Translated using Weblate (Portuguese)
Currently translated at 100.0% (356 of 356 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-11-11 18:18:43 +01:00
johan12345
55b3a10919 catch HttpException in more places 2023-11-11 18:08:55 +01:00
johan12345
08b6902020 add car-app-testing libraries in foss variant as well 2023-11-11 16:43:48 +01:00
johan12345
7183475f31 Upgrade Robolectric for API 34, move CarAppTest from testGoogle to test 2023-11-11 16:27:21 +01:00
johan12345
ca6ff94c1f Github Actions: fix version code extraction from build.gradle.kts 2023-11-05 22:53:23 +01:00
Hosted Weblate
c02c259162 Translated using Weblate (German)
Currently translated at 100.0% (355 of 355 strings)

Co-authored-by: nautilusx <translate@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2023-11-05 22:41:19 +01:00
johan12345
a44718ded2 build.gradle.kts: Fix API key extraction 2023-11-05 22:38:52 +01:00
johan12345
4f268f5e83 build.gradle.kts: Fix API key extraction 2023-11-05 22:34:44 +01:00
johan12345
99b4841545 build.gradle.kts: Fix API key extraction 2023-11-05 22:21:38 +01:00
johan12345
7ad7d7da30 Release 1.7.1 2023-11-05 22:14:31 +01:00
johan12345
0e80f2bf82 Add long-press to copy charger address/coordinates 2023-11-05 21:43:40 +01:00
johan12345
b5a6ceb5f9 rework favorites deletion undo logic to fix #304 2023-11-05 20:53:33 +01:00
Johan von Forstner
c655cae405 Tesla API: fix sorting
refs e7b42e2c
2023-11-04 18:51:26 +01:00
Johan von Forstner
fb2e510220 update other dependencies 2023-11-04 17:03:21 +01:00
Johan von Forstner
c170270557 update car app library 2023-11-04 16:55:15 +01:00
Johan von Forstner
e7b42e2c19 Tesla: charger label is now nullable 2023-11-04 16:45:00 +01:00
johan12345
fc85e631c9 ensure Open Source licenses with AboutLibraries 2023-10-26 12:15:20 +02:00
johan12345
7192c9ebfa update AboutLibraries 2023-10-26 12:15:20 +02:00
Johan von Forstner
c652265ea1 Update Android Auto docs
fixes #303 (Android Auto for phone screens app was deprecated, now there is a new way to access settings)
2023-10-22 19:05:16 +02:00
johan12345
0320238dc9 add @Jean-BaptisteC to contributors 2023-10-19 16:48:48 +02:00
johan12345
e814c088bf replace deprecated Gradle property
fixes #302
2023-10-19 16:47:00 +02:00
Jean-Baptiste
b60b2d70b9 Update Github workflows (#301) 2023-10-19 16:38:27 +02:00
johan12345
3905656ea7 fix some lint warnings 2023-10-18 15:23:07 +02:00
johan12345
1f88e5fbdd fix some lint warnings 2023-10-18 15:09:50 +02:00
johan12345
646469e9ea update Gradle plugin 2023-10-18 13:24:00 +02:00
Jean-Baptiste
cce7c69d74 Migrate to build kotlin script (#298)
* Migrate to build KTS

* fix version code

* fix API keys

* fix kotlinVersion <-> kotlin_version

* fix supportedLocales

* Migrate to build KTS

* upgrade to Gradle 8.4

* updates

* variable renaming

* fix packagingOptions

---------

Co-authored-by: Johan von Forstner <johan.forstner@gmail.com>
2023-10-18 12:54:24 +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
Johan von Forstner
c48f33e265 Release 1.6.7 2023-08-21 19:26:30 +02:00
johan12345
8ba4897026 Android Automotive: update donate link 2023-08-21 19:22:45 +02:00
Johan von Forstner
42916d71ca Onboarding: Do not select GoingElectric by default
instead make the user actively choose the data source
#291
2023-08-03 16:47:20 +02:00
Johan von Forstner
5ca7524e8b OpenChargeMap: Map "Tesla Model S/X" plug to Chargepoint.SUPERCHARGER
#6
2023-08-03 16:34:10 +02:00
Johan von Forstner
c37f72a26b reduce vertical padding around dialogs
#292
2023-08-02 20:46:16 +02:00
Johan von Forstner
6f0113c50d adjustments to data source selection screen for smaller screens 2023-08-02 19:58:05 +02:00
Johan von Forstner
f99ea7ca9e Fix formatting of Portuguese string 2023-08-02 16:21:02 +02:00
Johan von Forstner
788db0c10f fix popup menu theme in dark mode
see #287
2023-08-02 16:09:48 +02:00
Hosted Weblate
db2213a50f Translated using Weblate (Portuguese)
Currently translated at 100.0% (349 of 349 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-08-02 16:08:45 +02:00
Johan von Forstner
ace4126035 more uppercase consistency 2023-08-02 15:45:47 +02:00
Johan von Forstner
d5d6e4f314 GoingElectric: nullability fix 2023-08-01 20:23:54 +02:00
Johan von Forstner
55999d15e6 upper/lower case consistency
as mentioned in comment in #287
2023-07-30 20:01:11 +02:00
Johan von Forstner
b61ca609d3 GoingElectric: fix "Tesla Supercharger CCS" filter
"Tesla Supercharger CCS" is a bit peculiar - it is available as a filter, but the API just returns "CCS" in the charging station details. So we cannot use it for filtering as it won't work in the local database. So we join them into a single filter option. If you want to find Tesla Superchargers with CCS, you can still do that using the network filter.
2023-07-27 10:16:22 +02:00
johan12345
b0afad2144 fix nullability issue in TeslaAvailabilityDetector 2023-07-19 20:59:47 +02:00
johan12345
94842954e3 Release 1.6.6 2023-07-16 11:23:10 +02:00
johan12345
1bee5f7e13 upgrade Coil to 2.4.0 2023-07-16 11:22:02 +02:00
johan12345
d636cde70e EnBwAvailabilityDetector: zoom in until no markers are grouped 2023-07-14 19:25:53 +02:00
johan12345
2a4497fe7a fix NPE in EnBwAvailabilityDetector 2023-07-14 19:23:57 +02:00
johan12345
10e3287d82 fix division by zero error 2023-07-14 18:48:26 +02:00
Johan von Forstner
a0f7a389c8 CI: fix file name 2023-07-13 21:52:45 +02:00
johan12345
1cabb8dccf Release 1.6.5 2023-07-13 21:35:08 +02:00
johan12345
85079bb888 fix SQL query for minConnectors filter 2023-07-13 21:20:05 +02:00
johan12345
10bfc21f54 add padding for privacy policy checkbox 2023-07-13 21:11:43 +02:00
johan12345
ae33fce637 add fossAutomotive variant to CI 2023-07-02 17:45:17 +02:00
johan12345
773d57b9a9 update explanations of variants
refs #281
2023-07-02 17:45:07 +02:00
Johan von Forstner
022f570322 add checkbox to accept privacy policy during onboarding 2023-07-01 22:01:35 +02:00
johan12345
a6c2b30325 Android Auto: Show the charger icon in the result row
fixes #284
2023-07-01 21:32:24 +02:00
johan12345
2210e65e5c Add reload button in detail view
fixes #287
2023-07-01 21:10:11 +02:00
johan12345
024e3cef35 add menu item to manually reload map content
#287
2023-07-01 19:50:31 +02:00
johan12345
687ef2ec0f add vehicle brand look-up table for Android Auto
fixes #289
2023-07-01 19:40:57 +02:00
johan12345
9e61dce7be enable fossAutomotive variant
refs #281
2023-07-01 12:16:41 +02:00
johan12345
aad7a320d0 make Android Auto app also available in foss build
The only Google dependency it references is the Car App Library, which is open source. GMS is not imported by the Android Auto app.
Of course it won't actually be usable on devices without the Android Auto app, which itself requires GMS.

preparation for #281
2023-07-01 12:16:41 +02:00
johan12345
96df684b80 remove unneeded overrideLibrary in manifest 2023-07-01 12:16:41 +02:00
Johan von Forstner
7903c027c7 AvailabilityDetector: Further increase max merging distance
to 60m
2023-06-22 17:10:48 +02:00
201 changed files with 5440 additions and 1808 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
github: johan12345
custom: 'https://paypal.me/johan98'
custom: ['https://paypal.me/johan98', 'https://ev-map.app/donate/']

View File

@@ -5,4 +5,5 @@
<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>
</resources>

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Java environment
uses: actions/setup-java@v2
uses: actions/setup-java@v4
with:
java-version: 17
distribution: 'zulu'
@@ -22,7 +22,7 @@ jobs:
- name: Decrypt keystore
run: openssl aes-256-cbc -K ${{ secrets.encrypted_53968681344a_key }} -iv ${{ secrets.encrypted_53968681344a_iv }} -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
- name: Extract version code
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s\+[0-9]*" app/build.gradle | awk '{ print $2 }' | tr -d \''"\\')" >> $GITHUB_ENV
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s*=\s*[0-9]\+" app/build.gradle.kts | awk '{ print $3 }' | tr -d \''"\\')" >> $GITHUB_ENV
- name: Build app release
env:
@@ -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 }}
@@ -76,3 +77,12 @@ jobs:
asset_path: app/build/outputs/apk/googleAutomotive/release/app-google-automotive-release.apk
asset_name: app-google-automotive-release.apk
asset_content_type: application/vnd.android.package-archive
- name: upload Foss Automotive artifact
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fossAutomotive/release/app-foss-automotive-release.apk
asset_name: app-foss-automotive-release.apk
asset_content_type: application/vnd.android.package-archive

115
.github/workflows/screenshots.yml vendored Normal file
View File

@@ -0,0 +1,115 @@
on:
push:
branches:
- '*'
name: Generate Screenshots
jobs:
screenshot:
name: Generate screenshots
runs-on: ubuntu-latest
strategy:
matrix:
device:
- profile: pixel_6
api-level: 34
display_size: 1080x2336 # subtracted the 64px bottom navigation bar
type: phone
- profile: pixel_tablet
api-level: 34
display_size: 2560x1488 # subtracted the 64px navigation bar and 48px status bar
type: tenInch
env:
ANDROID_USER_HOME: /home/runner/.android
steps:
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Check out code
uses: actions/checkout@v4
- name: Retrieve debug keystore
env:
DEBUG_KEYSTORE_BASE64: ${{ secrets.DEBUG_KEYSTORE_BASE64 }}
run: |
mkdir ~/.config/.android
echo $DEBUG_KEYSTORE_BASE64 | base64 --decode > ~/.config/.android/debug.keystore
- name: Set up Java environment
uses: actions/setup-java@v3
with:
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Setup Android SDK
run: |
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "cmdline-tools;latest"
rm -r $ANDROID_HOME/cmdline-tools/latest
mv $ANDROID_HOME/cmdline-tools/latest-2 $ANDROID_HOME/cmdline-tools/latest
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --version
- name: AVD cache
uses: actions/cache@v3
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: ${{ runner.os }}-avd-api${{ matrix.device.api-level }}-${{ matrix.device.profile }}
- name: create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.device.api-level }}
target: google_atd
arch: x86_64
profile: ${{ matrix.device.profile }}
force-avd-creation: false
ram-size: 2048M
disk-size: 4096M
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
disable-animations: true
script: echo "Generated AVD snapshot for caching."
- name: Build app
run: ./gradlew assembleGoogleNormalDebug assembleGoogleNormalAndroidTest
env:
GOINGELECTRIC_API_KEY: ${{ secrets.GOINGELECTRIC_API_KEY }}
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
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 }}
- name: Run emulator and generate screenshots
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.device.api-level }}
target: google_atd
arch: x86_64
profile: ${{ matrix.device.profile }}
force-avd-creation: false
ram-size: 2048M
disk-size: 4096M
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
disable-animations: true
script: |
adb shell pm clear net.vonforst.evmap.debug || true
adb shell wm size ${{ matrix.device.display_size }}
adb logcat -c
adb logcat *:E &
fastlane screengrab --app_apk_path app/build/outputs/apk/googleNormal/debug/app-google-normal-debug.apk --test_apk_path app/build/outputs/apk/androidTest/googleNormal/debug/app-google-normal-debug-androidTest.apk --tests_package_name=net.vonforst.evmap.debug.test --app_package_name net.vonforst.evmap.debug -p net.vonforst.evmap.screenshot --use_timestamp_suffix false --clear_previous_screenshots true --device_type=${{ matrix.device.type }} -q en-US,de-DE,fr-FR,nb-rNO,nl-NL,pt-PT,ro-RO
- name: Upload screenshots as artifacts
uses: actions/upload-artifact@v3
with:
name: screenshots-${{ matrix.device.profile }}-${{ matrix.device.api-level }}
path: fastlane/metadata/android

View File

@@ -13,13 +13,13 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
buildvariant: [ FossNormal, GoogleNormal, GoogleAutomotive ]
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
steps:
- name: Check out code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Java environment
uses: actions/setup-java@v2
uses: actions/setup-java@v4
with:
java-version: 17
distribution: 'zulu'

View File

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

View File

@@ -37,19 +37,33 @@ Development setup
The App is developed using Android Studio and should pretty much work out-of-the-box when you clone
the Git repository and open the project with Android Studio.
The only exception is that you need to obtain some free API keys for the different data sources that
The only exception is that you need to obtain some API keys for the different data sources that
EVMap uses and put them into the app in the form of a resource file called `apikeys.xml` under
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
features and how they can be obtained in our [documentation page](doc/api_keys.md).
There are three different build flavors, `googleNormal`, `fossNormal` and `googleAutomotive`.
- The `foss` variant only uses Mapbox data and should run on most Android devices, even without
- The `foss` variants only use Mapbox data and should run on most Android devices, even without
Google Play Services.
- `fossNormal` is intended to run on smartphones and tablets, and also includes the Android
Auto app for use on the car display (however for that to work, the Android Auto app is
necessary, which in turn does require Google Play Services).
- `fossAutomotive` can be installed directly on
[Android Automotive OS (AAOS)](https://source.android.com/docs/automotive/start/what_automotive)
headunits without Google services.
It does not provide the usual smartphone UI, and requires an implementation of the
[AOSP template app host](https://source.android.com/docs/automotive/hmi/aosp_host)
to be installed. If you are an OEM and would like to distribute EVMap to your AAOS vehicles,
please [get in touch](mailto:evmap@vonforst.net).
- The `google` variants also include access to Google Maps data.
- `googleNormal` is intended to run on smartphones and tablets, and also includes the Android
Auto app for use on the car display.
- `googleAutomotive` variant is intended to be installed directly on car infotainment systems
using the Google-flavored Android Automotive OS. It does not provide the usual smartphone UI.
- `googleAutomotive` can be installed directly on car infotainment systems running the
Google-flavored Android Automotive OS (Google Automotive Services /
["Google built-in"](https://built-in.google/cars/)).
It does not provide the usual smartphone UI, and requires the
[Google Automotive App Host](https://play.google.com/store/apps/details?id=com.google.android.apps.automotive.templates.host)
to run, which should be preinstalled on those cars and can be updated through the Play Store.
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
app.

View File

@@ -1,300 +0,0 @@
plugins {
id 'com.adarshr.test-logger' version '3.1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlin-kapt'
apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
apply plugin: 'pt.jcosta.resourceplaceholders'
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
android {
compileSdkVersion 33
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 33
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 188
versionName "1.6.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(',')
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
}
signingConfigs {
release {
def isRunningOnCI = System.getenv("CI") == "true"
if (isRunningOnCI) {
// configure keystore
storeFile = file("../_ci/keystore.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEYSTORE_ALIAS")
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}
flavorDimensions "dependencies", "automotive"
productFlavors {
foss {
dimension "dependencies"
}
google {
dimension "dependencies"
versionNameSuffix "-google"
}
normal {
dimension "automotive"
}
automotive {
dimension "automotive"
versionNameSuffix "-automotive"
versionCode defaultConfig.versionCode + 1
minSdkVersion 29
}
}
variantFilter { variant ->
def names = variant.flavors*.name
// Android Automotive OS app is always based on Google variant
if (names.contains("automotive") && !names.contains("google")) {
setIgnore(true)
}
}
compileOptions {
coreLibraryDesugaringEnabled true
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs).configureEach {
kotlinOptions {
jvmTarget = "1.8"
}
}
buildFeatures {
dataBinding = true
viewBinding true
}
lint {
disable 'NullSafeMutableLiveData'
warning 'MissingTranslation'
}
testOptions {
unitTests.includeAndroidResources true
}
resourcePlaceholders {
files = ['xml/shortcuts.xml']
}
namespace 'net.vonforst.evmap'
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all { variant ->
ext.env = System.getenv()
def goingelectricKey = env.GOINGELECTRIC_API_KEY ?: project.findProperty("GOINGELECTRIC_API_KEY")
if (goingelectricKey != null) {
variant.resValue "string", "goingelectric_key", goingelectricKey
}
def openchargemapKey = env.OPENCHARGEMAP_API_KEY ?: project.findProperty("OPENCHARGEMAP_API_KEY")
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
openchargemapKey = decode(project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (openchargemapKey != null) {
variant.resValue "string", "openchargemap_key", openchargemapKey
}
def googleMapsKey = env.GOOGLE_MAPS_API_KEY ?: project.findProperty("GOOGLE_MAPS_API_KEY")
if (googleMapsKey != null && variant.flavorName.startsWith('google')) {
variant.resValue "string", "google_maps_key", googleMapsKey
}
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(project.findProperty("MAPBOX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (mapboxKey != null) {
variant.resValue "string", "mapbox_key", mapboxKey
}
def chargepriceKey = env.CHARGEPRICE_API_KEY ?: project.findProperty("CHARGEPRICE_API_KEY")
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
chargepriceKey = decode(project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (chargepriceKey != null) {
variant.resValue "string", "chargeprice_key", chargepriceKey
}
def fronyxKey = env.FRONYX_API_KEY ?: project.findProperty("FRONYX_API_KEY")
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
fronyxKey = decode(project.findProperty("FRONYX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (fronyxKey != null) {
variant.resValue "string", "fronyx_key", fronyxKey
}
}
packagingOptions {
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
}
}
configurations {
googleNormalImplementation {}
googleAutomotiveImplementation {}
}
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-splashscreen:1.0.1'
implementation "androidx.activity:activity-ktx:1.7.2"
implementation "androidx.fragment:fragment-ktx:1.5.7"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.android.material:material:1.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.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation "androidx.work:work-runtime-ktx:2.8.1"
implementation 'com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.11.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
implementation 'com.squareup.moshi:moshi-adapters:1.15.0'
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:4.1.0'
implementation 'io.michaelrocks.bimap:bimap:1.1.0'
implementation 'com.google.guava:guava:29.0-android'
implementation 'com.github.pengrad:mapscaleview:1.6.0'
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
def carAppVersion = '1.3.0-rc01'
googleImplementation "androidx.car.app:app:$carAppVersion"
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
// AnyMaps
def anyMapsVersion = '8f1226e1c5'
implementation "com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion"
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
exclude group: 'com.google.android.gms', module: 'play-services-location'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-core'
}
// original version of mapbox-android-core
googleImplementation 'com.mapbox.mapboxsdk:mapbox-android-core:2.0.1'
// patched version that removes build-time dependency on GMS (-> no Google location services)
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'
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
// navigation library
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.5.1"
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"
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")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
debugImplementation 'com.facebook.flipper:flipper:0.190.0'
debugImplementation 'com.facebook.soloader:soloader:0.10.5'
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.190.0'
// testing
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
testImplementation 'org.robolectric:robolectric:4.9.2'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'org.robolectric:robolectric:4.9.2'
testGoogleImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.arch.core:core-testing:2.2.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
}
private static String decode(String s, String key) {
return new String(xorWithKey(s.decodeBase64(), key.getBytes()), "UTF-8");
}
private static byte[] xorWithKey(byte[] a, byte[] key) {
byte[] out = new byte[a.length];
for (int i = 0; i < a.length; i++) {
out[i] = (byte) (a[i] ^ key[i%key.length]);
}
return out;
}

363
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,363 @@
import java.util.Base64
plugins {
id("com.adarshr.test-logger") version "3.1.0"
id("com.android.application")
id("kotlin-android")
id("kotlin-parcelize")
id("kotlin-kapt")
id("androidx.navigation.safeargs.kotlin")
id("com.mikepenz.aboutlibraries.plugin")
}
android {
defaultConfig {
applicationId = "net.vonforst.evmap"
compileSdk = 34
minSdk = 21
targetSdk = 34
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode = 212
versionName = "1.8.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
val isRunningOnCI = System.getenv("CI") == "true"
if (isRunningOnCI) {
// configure keystore
storeFile = file("../_ci/keystore.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEYSTORE_ALIAS")
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
debug {
applicationIdSuffix = ".debug"
isDebuggable = true
}
}
flavorDimensions += listOf("dependencies", "automotive")
productFlavors {
create("foss") {
dimension = "dependencies"
}
create("google") {
dimension = "dependencies"
versionNameSuffix = "-google"
}
create("normal") {
dimension = "automotive"
}
create("automotive") {
dimension = "automotive"
versionNameSuffix = "-automotive"
versionCode = defaultConfig.versionCode!! + 1
minSdk = 29
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs>().configureEach {
kotlinOptions {
jvmTarget = "1.8"
}
}
buildFeatures {
dataBinding = true
viewBinding = true
buildConfig = true
}
lint {
disable += listOf("NullSafeMutableLiveData")
warning += listOf("MissingTranslation")
}
androidResources {
generateLocaleConfig = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
namespace = "net.vonforst.evmap"
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all {
val goingelectricKey =
System.getenv("GOINGELECTRIC_API_KEY") ?: project.findProperty("GOINGELECTRIC_API_KEY")
?.toString()
if (goingelectricKey != null) {
resValue("string", "goingelectric_key", goingelectricKey)
}
var openchargemapKey =
System.getenv("OPENCHARGEMAP_API_KEY") ?: project.findProperty("OPENCHARGEMAP_API_KEY")
?.toString()
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
openchargemapKey = decode(
project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (openchargemapKey != null) {
resValue("string", "openchargemap_key", openchargemapKey)
}
val googleMapsKey =
System.getenv("GOOGLE_MAPS_API_KEY") ?: project.findProperty("GOOGLE_MAPS_API_KEY")
?.toString()
if (googleMapsKey != null && flavorName.startsWith("google")) {
resValue("string", "google_maps_key", googleMapsKey)
}
var mapboxKey =
System.getenv("MAPBOX_API_KEY") ?: project.findProperty("MAPBOX_API_KEY")?.toString()
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(
project.findProperty("MAPBOX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (mapboxKey != null) {
resValue("string", "mapbox_key", mapboxKey)
}
var chargepriceKey =
System.getenv("CHARGEPRICE_API_KEY") ?: project.findProperty("CHARGEPRICE_API_KEY")
?.toString()
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
chargepriceKey = decode(
project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (chargepriceKey != null) {
resValue("string", "chargeprice_key", chargepriceKey)
}
var fronyxKey =
System.getenv("FRONYX_API_KEY") ?: project.findProperty("FRONYX_API_KEY")?.toString()
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
fronyxKey = decode(
project.findProperty("FRONYX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (fronyxKey != null) {
resValue("string", "fronyx_key", fronyxKey)
}
var acraKey = System.getenv("ACRA_CRASHREPORT_CREDENTIALS")
?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")?.toString()
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
acraKey = decode(
project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (acraKey != null) {
resValue("string", "acra_credentials", acraKey)
}
}
packaging {
jniLibs {
pickFirsts.addAll(
listOf(
"lib/x86/libc++_shared.so",
"lib/arm64-v8a/libc++_shared.so",
"lib/x86_64/libc++_shared.so",
"lib/armeabi-v7a/libc++_shared.so"
)
)
}
}
}
configurations {
create("googleNormalImplementation") {}
create("googleAutomotiveImplementation") {}
}
aboutLibraries {
allowedLicenses = arrayOf(
"Apache-2.0", "mit", "BSD-2-Clause",
"asdkl", // Android SDK
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
"Google Maps Platform Terms of Service" // Google Maps SDK
)
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
}
dependencies {
val kotlinVersion: String by rootProject.extra
val aboutLibsVersion: String by rootProject.extra
val navVersion: String by rootProject.extra
val normalImplementation by configurations
val googleImplementation by configurations
val automotiveImplementation by configurations
val fossImplementation by configurations
val testGoogleImplementation by configurations
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.activity:activity-ktx:1.8.2")
implementation("androidx.fragment:fragment-ktx:1.6.2")
implementation("androidx.cardview:cardview:1.0.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.browser:browser:1.7.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.11.0")
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
implementation("com.markomilos.jsonapi:jsonapi-retrofit:1.1.0")
implementation("io.coil-kt:coil:2.4.0")
implementation("com.github.ev-map:StfalconImageViewer:5082ebd392")
implementation("com.mikepenz:aboutlibraries-core:$aboutLibsVersion")
implementation("com.mikepenz:aboutlibraries:$aboutLibsVersion")
implementation("com.airbnb.android:lottie:4.1.0")
implementation("io.michaelrocks.bimap:bimap:1.1.0")
implementation("com.google.guava:guava:29.0-android")
implementation("com.github.pengrad:mapscaleview:1.6.0")
implementation("com.github.romandanylyk:PageIndicatorView:b1bad589b5")
// Android Auto
val carAppVersion = "1.4.0-rc02"
implementation("androidx.car.app:app:$carAppVersion")
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
// AnyMaps
val anyMapsVersion = "4854581f72"
implementation("com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion")
googleImplementation("com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion")
googleImplementation("com.google.android.gms:play-services-maps:18.2.0")
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-accounts")
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-telemetry")
exclude(group = "com.google.android.gms", module = "play-services-location")
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-core")
}
// original version of mapbox-android-core
googleImplementation("com.mapbox.mapboxsdk:mapbox-android-core:2.0.1")
// patched version that removes build-time dependency on GMS (-> no Google location services)
fossImplementation("com.github.ev-map:mapbox-events-android:a21c324501")
implementation("com.mapbox.mapboxsdk:mapbox-android-sdk") {
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-accounts")
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-telemetry")
version {
strictly("9.1.0-SNAPSHOT")
}
}
// Google Places
googleImplementation("com.google.android.libraries.places:places:3.3.0")
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Mapbox Geocoding
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0")
// navigation library
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
// viewmodel library
val lifecycle_version = "2.6.2"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
// room library
val room_version = "2.6.1"
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.9") {
exclude(group = "com.github.dalgarins", module = "android-spatialite")
}
implementation("com.github.EV-map:android-spatialite:e5495c83ad") // version with minSdk increased to 21 & FORTIFY_SOURCE enabled
// billing library
val billing_version = "6.1.0"
googleImplementation("com.android.billingclient:billing:$billing_version")
googleImplementation("com.android.billingclient:billing-ktx:$billing_version")
// ACRA (crash reporting)
val acraVersion = "5.11.1"
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
debugImplementation("com.facebook.flipper:flipper:0.238.0")
debugImplementation("com.facebook.soloader:soloader:0.10.5")
debugImplementation("com.facebook.flipper:flipper-network-plugin:0.238.0")
debugImplementation("androidx.test.espresso:espresso-idling-resource:3.5.1")
// testing
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.11.0")
//noinspection GradleDependency
testImplementation("org.json:json:20080701")
testImplementation("org.robolectric:robolectric:4.11.1")
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.car.app:app-testing:$carAppVersion")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1")
androidTestImplementation("androidx.test:rules:1.5.0")
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
androidTestImplementation("tools.fastlane:screengrab:2.1.1")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}
fun decode(s: String, key: String): String {
return String(xorWithKey(Base64.getDecoder().decode(s), key.toByteArray()), Charsets.UTF_8)
}
fun xorWithKey(a: ByteArray, key: ByteArray): ByteArray {
val out = ByteArray(a.size)
for (i in a.indices) {
out[i] = (a[i].toInt() xor key[i % key.size].toInt()).toByte()
}
return out
}

6
app/lint.xml Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<issue id="MissingQuantity">
<ignore regexp=".*?Czech.*?many" />
</issue>
</lint>

View File

@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

View File

@@ -0,0 +1,112 @@
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.vonforst.evmap.screenshot
import android.view.View
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.FragmentActivity
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.IdlingResource
import androidx.test.ext.junit.rules.ActivityScenarioRule
import java.util.UUID
/**
* An espresso idling resource implementation that reports idle status for all data binding
* layouts. Data Binding uses a mechanism to post messages which Espresso doesn't track yet.
*
* Since this application only uses fragments, the resource only checks the fragments and their
* children instead of the whole view tree.
*
* Tracking bug: https://github.com/android/android-test/issues/317
*/
class DataBindingIdlingResource(
activityScenarioRule: ActivityScenarioRule<out FragmentActivity>
) : IdlingResource {
// list of registered callbacks
private val idlingCallbacks = mutableListOf<IdlingResource.ResourceCallback>()
// give it a unique id to workaround an espresso bug where you cannot register/unregister
// an idling resource w/ the same name.
private val id = UUID.randomUUID().toString()
// holds whether isIdle is called and the result was false. We track this to avoid calling
// onTransitionToIdle callbacks if Espresso never thought we were idle in the first place.
private var wasNotIdle = false
lateinit var activity: FragmentActivity
override fun getName() = "DataBinding $id"
init {
monitorActivity(activityScenarioRule.scenario)
}
override fun isIdleNow(): Boolean {
val idle = !getBindings().any { it.hasPendingBindings() }
@Suppress("LiftReturnOrAssignment")
if (idle) {
if (wasNotIdle) {
// notify observers to avoid espresso race detector
idlingCallbacks.forEach { it.onTransitionToIdle() }
}
wasNotIdle = false
} else {
wasNotIdle = true
// check next frame
activity.findViewById<View>(android.R.id.content).postDelayed({
isIdleNow
}, 16)
}
return idle
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
idlingCallbacks.add(callback)
}
/**
* Sets the activity from an [ActivityScenario] to be used from [DataBindingIdlingResource].
*/
private fun monitorActivity(
activityScenario: ActivityScenario<out FragmentActivity>
) {
activityScenario.onActivity {
this.activity = it
}
}
/**
* Find all binding classes in all currently available fragments.
*/
private fun getBindings(): List<ViewDataBinding> {
val fragments = (activity as? FragmentActivity)
?.supportFragmentManager
?.fragments
val bindings =
fragments?.mapNotNull {
it.view?.getBinding()
} ?: emptyList()
val childrenBindings = fragments?.flatMap { it.childFragmentManager.fragments }
?.mapNotNull { it.view?.getBinding() } ?: emptyList()
return bindings + childrenBindings
}
}
private fun View.getBinding(): ViewDataBinding? = DataBindingUtil.getBinding(this)

View File

@@ -0,0 +1,155 @@
package net.vonforst.evmap.screenshot
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.content.Intent
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.pressBack
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.contrib.NavigationViewActions
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import androidx.test.rule.GrantPermissionRule.grant
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.EXTRA_CHARGER_ID
import net.vonforst.evmap.EXTRA_LAT
import net.vonforst.evmap.EXTRA_LON
import net.vonforst.evmap.EspressoIdlingResource
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.GEReferenceData
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import tools.fastlane.screengrab.Screengrab
import tools.fastlane.screengrab.locale.LocaleTestRule
import java.time.Instant
@RunWith(AndroidJUnit4::class)
class ScreenshotTest {
companion object {
@JvmStatic
@BeforeClass
fun beforeAll() {
IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource)
Screengrab.setDefaultScreenshotStrategy { screenshotName, screenshotCallback ->
screenshotCallback.screenshotCaptured(
screenshotName,
androidx.test.core.app.takeScreenshot()
)
}
val context = InstrumentationRegistry.getInstrumentation().targetContext
val prefs = PreferenceDataSource(context)
prefs.dataSourceSet = true
prefs.welcomeDialogShown = true
prefs.privacyAccepted = true
prefs.opensourceDonationsDialogLastShown = Instant.now()
prefs.chargepriceMyVehicles = setOf("b58bc94d-d929-ad71-d95b-08b877bf76ba")
prefs.appStartCounter = 0
prefs.mapProvider = "google"
// insert favorites
val db = AppDatabase.getInstance(context)
val api = GoingElectricApiWrapper(
context.getString(R.string.goingelectric_key),
context = context
)
val ids = listOf(70774L to true, 40315L to true, 65330L to true, 62489L to false)
runBlocking {
val refData = api.getReferenceData().data as GEReferenceData
ids.forEachIndexed { i, (id, favorite) ->
val detail = api.getChargepointDetail(refData, id).data!!
db.chargeLocationsDao().insert(detail)
if (db.favoritesDao().findFavorite(id, "goingelectric") == null && favorite) {
db.favoritesDao().insert(
Favorite(
chargerId = id,
chargerDataSource = "goingelectric"
)
)
}
}
}
}
}
@get:Rule
val localeTestRule = LocaleTestRule()
@get:Rule
val activityRule: ActivityScenarioRule<MapsActivity> = ActivityScenarioRule(
Intent(
InstrumentationRegistry.getInstrumentation().targetContext,
MapsActivity::class.java
).apply {
putExtra(EXTRA_CHARGER_ID, 62489L)
putExtra(EXTRA_LAT, 53.099512)
putExtra(EXTRA_LON, 9.981884)
})
@get:Rule
val permissionRule: GrantPermissionRule = grant(ACCESS_FINE_LOCATION)
@Before
fun setUp() {
IdlingRegistry.getInstance().register(DataBindingIdlingResource(activityRule))
}
@Test
fun testTakeScreenshot() {
Thread.sleep(15000L)
Screengrab.screenshot("01_map_google")
onView(withId(R.id.topPart)).perform(click())
Thread.sleep(1000L)
Screengrab.screenshot("02_detail")
onView(withId(R.id.btnChargeprice)).perform(click())
Thread.sleep(5000L)
Screengrab.screenshot("03_prices")
onView(isRoot()).perform(pressBack())
Thread.sleep(500L)
onView(isRoot()).perform(pressBack())
Thread.sleep(2000L)
onView(withId(R.id.menu_filter)).perform(click())
Thread.sleep(1000L)
onView(withText(R.string.menu_edit_filters)).perform(click())
Thread.sleep(1000L)
Screengrab.screenshot("05_filters")
onView(isRoot()).perform(pressBack())
onView(withId(R.id.drawer_layout)).perform(DrawerActions.open())
onView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.favs))
Thread.sleep(10000L)
Screengrab.screenshot("04_favorites")
val context = InstrumentationRegistry.getInstrumentation().targetContext
PreferenceDataSource(context).mapProvider = "mapbox"
onView(isRoot()).perform(pressBack())
Thread.sleep(5000L)
Screengrab.screenshot("01_map_mapbox")
}
}

View File

@@ -1,4 +1,4 @@
package com.johan.evmap.storage
package net.vonforst.evmap.storage
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Povolit</string>
<string name="auto_location_permission_needed">Pro spuštění aplikace EVMap ve vašem autě musíte povolit přístup k vaší poloze.</string>
</resources>

View File

@@ -1,5 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions needed for fastlane screengrab -->
<!-- Allows unlocking your device and activating its screen so UI tests can succeed -->
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Allows for storing and retrieving screenshots -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Allows changing locales -->
<uses-permission
android:name="android.permission.CHANGE_CONFIGURATION"
tools:ignore="ProtectedPermissions" />
<!-- Allows changing SystemUI demo mode -->
<uses-permission
android:name="android.permission.DUMP"
tools:ignore="ProtectedPermissions" />
<application>
<activity

View File

@@ -2,6 +2,8 @@ package net.vonforst.evmap
import android.content.Context
import android.os.Build
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.idling.CountingIdlingResource
import com.facebook.flipper.android.AndroidFlipperClient
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin
import com.facebook.flipper.plugins.inspector.DescriptorMapping
@@ -34,9 +36,29 @@ fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder {
} catch (e: ClassNotFoundException) {
isRunningTest = false
}
if (!isRunningTest) {
this.addNetworkInterceptor(FlipperOkhttpInterceptor(networkFlipperPlugin))
}
return this
}
/**
* Contains a static reference to [IdlingResource], only available in the 'debug' build type.
*/
object EspressoIdlingResource {
private const val RESOURCE = "GLOBAL"
@JvmField
val countingIdlingResource = CountingIdlingResource(RESOURCE)
fun increment() {
countingIdlingResource.increment()
}
fun decrement() {
if (!countingIdlingResource.isIdleNow) {
countingIdlingResource.decrement()
}
}
}

View File

@@ -3,10 +3,12 @@ package net.vonforst.evmap
import android.app.Activity
import android.content.Context
@Suppress("UNUSED_PARAMETER")
fun init(context: Context) {
}
@Suppress("UNUSED_PARAMETER")
fun checkPlayServices(activity: Activity): Boolean {
return true
}

View File

@@ -12,9 +12,11 @@ import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.FragmentDonateBinding
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
class DonateFragment : Fragment() {
class DonateFragment : DonateFragmentBase() {
private lateinit var binding: FragmentDonateBinding
private lateinit var referrals: FragmentDonateReferralBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -28,6 +30,7 @@ class DonateFragment : Fragment() {
savedInstanceState: Bundle?
): View {
binding = FragmentDonateBinding.inflate(inflater, container, false)
referrals = binding.referrals
return binding.root
}
@@ -42,5 +45,7 @@ class DonateFragment : Fragment() {
binding.btnDonate.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
}
setupReferrals(referrals)
}
}

View File

@@ -1,16 +0,0 @@
package net.vonforst.evmap.fragment
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}

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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Pomohla vám EVMap? Podpořte její vývoj zasláním finančního daru vývojáři.</string>
<string name="donate_paypal">Přispět pomocí PayPalu</string>
<string name="data_sources_hint">Mapová data v aplikaci poskytuje služba OpenStreetMap (Mapbox).</string>
</resources>

View File

@@ -1,53 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<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" />
<uses-sdk tools:overrideLibrary="androidx.car.app,androidx.car.app.projected" />
<queries>
<package android:name="com.google.android.projection.gearhead" />
<package android:name="com.google.android.apps.automotive.templates.host" />
</queries>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="androidx.car.app.theme"
android:resource="@style/CarAppTheme" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
<service
android:name=".auto.CarAppService"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:exported="true">
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.POI" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -6,11 +6,10 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
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 +17,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() {
class DonateFragment : DonateFragmentBase() {
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 +44,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 +58,33 @@ 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()
})
}
setupReferrals(referrals)
// 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

@@ -1,69 +0,0 @@
package net.vonforst.evmap.fragment
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import net.vonforst.evmap.databinding.FragmentOnboardingAndroidAutoBinding
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 4
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> AndroidAutoFragment()
3 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}
class AndroidAutoFragment : OnboardingPageFragment() {
private lateinit var binding: FragmentOnboardingAndroidAutoBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentOnboardingAndroidAutoBinding.inflate(inflater, container, false)
binding.btnGetStarted.setOnClickListener {
parent.goToNext()
}
binding.imgAndroidAuto.alpha = 0f
return binding.root
}
@SuppressLint("Recycle")
override fun onResume() {
super.onResume()
val animators =
listOf(
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "translationY", -20f, 0f).apply {
interpolator = DecelerateInterpolator()
},
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "alpha", 0f, 1f).apply {
interpolator = DecelerateInterpolator()
}
)
AnimatorSet().apply {
playTogether(animators)
start()
}
}
override fun onPause() {
super.onPause()
binding.imgAndroidAuto.alpha = 0f
}
}

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

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Pomohla vám EVMap? Podpořte její vývoj posláním finančního daru vývojáři.
\n
\nGoogle si z každého daru strhne 15 %.</string>
<string name="data_sources_hint">V nastavení můžete také pro mapová data přepínat mezi službami Mapy Google a OpenStreetMap (Mapbox).</string>
</resources>

View File

@@ -1,39 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
<string name="auto_no_favorites_found">Keine Favoriten gefunden</string>
<string name="open_in_app">In App öffnen</string>
<string name="opened_on_phone">Auf dem Telefon geöffnet</string>
<string name="auto_location_permission_needed">Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
<string name="auto_vehicle_data_permission_needed">Für diese Funktion benötigt EVMap Zugriff auf Daten deines Fahrzeugs.</string>
<string name="grant_on_phone">Auf Telefon zulassen</string>
<string name="auto_chargers_closeby">In der Nähe</string>
<string name="auto_favorites">Favoriten</string>
<string name="auto_chargers_near_location">Nahe %s</string>
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
<string name="auto_prices">Preise</string>
<string name="auto_vehicle_data">Fahrzeugdaten</string>
<string name="auto_charging_level">Ladezustand</string>
<string name="auto_no_data">Nicht verfügbar</string>
<string name="auto_range">Reichweite</string>
<string name="auto_speed">Geschwindigkeit</string>
<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="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>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
<string name="auto_chargers_ahead">Nur Ladestationen in Fahrtrichtung</string>
<string name="settings_android_auto_chargeprice_range">Ladebereich für Preisvergleich</string>
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap (Mapbox) für die Kartendaten wechseln.</string>
<string name="selecting_all">alle Einträge ausgewählt</string>
<string name="selecting_none">alle Einträge abgewählt</string>
<string name="loading">Lade…</string>
<string name="auto_multipage_goto">Seite %d</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

@@ -3,38 +3,5 @@
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.
\n
\nGoogle prend 15% sur chaque don.</string>
<string name="auto_location_service">EVMap fonctionne sur Android Auto et utilise votre position.</string>
<string name="open_in_app">Ouvrir dans l\'application</string>
<string name="opened_on_phone">Ouvert sur le téléphone</string>
<string name="auto_location_permission_needed">Pour exécuter EVMap sur Android Auto, vous devez autoriser l\'accès à votre emplacement.</string>
<string name="grant_on_phone">Grant au téléphone</string>
<string name="auto_prices">Prix</string>
<string name="auto_vehicle_data">Données sur le véhicule</string>
<string name="auto_range">Autonomie</string>
<string name="auto_speed">Vitesse</string>
<string name="welcome_android_auto">Prise en charge dAndroid Auto</string>
<string name="sounds_cool">ça a l\'air cool</string>
<string name="auto_chargeprice_vehicle_unknown">Aucun des véhicules sélectionnés dans l\'application ne correspond à ce véhicule (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Plusieurs véhicules sélectionnés dans l\'application correspondent à ce véhicule (%1$s %2$s).</string>
<string name="selecting_all">tous les éléments sélectionnés</string>
<string name="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap (Mapbox) pour les données cartographiques.</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap n\'a pas pu déterminer le modèle de votre véhicule.</string>
<string name="auto_no_chargers_found">Aucun chargeur à proximité n\'a été trouvé</string>
<string name="auto_no_favorites_found">Pas de favoris trouvés</string>
<string name="auto_charging_level">Niveau de charge</string>
<string name="auto_chargers_closeby">Chargeurs à proximité</string>
<string name="auto_chargers_near_location">Près de %s</string>
<string name="auto_fault_report_date">⚠️ Rapport d\'anomalie (%s)</string>
<string name="auto_no_data">Indisponible</string>
<string name="auto_settings">Paramètres</string>
<string name="selecting_none">désélectionner tous les éléments</string>
<string name="auto_vehicle_data_permission_needed">Pour cette fonction, EVMap doit avoir accès aux données de votre véhicule.</string>
<string name="auto_heading">Direction</string>
<string name="auto_favorites">Favoris</string>
<string name="auto_no_refresh_possible">D\'autres mises à jour ne sont pas possibles. Veuillez revenir en arrière et redémarrer.</string>
<string name="settings_android_auto_chargeprice_range">Plage de charge pour la comparaison des prix</string>
<string name="welcome_android_auto_detail">Vous pouvez également utiliser EVMap à partir d\'Android Auto sur les voitures prises en charge. Il suffit de sélectionner l\'application EVMap dans le menu Android Auto.</string>
<string name="loading">Chargement…</string>
<string name="auto_multipage_goto">Page %d</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

@@ -3,39 +3,5 @@
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende penger til utvikleren.
\n
\nGoogle tar 15% av alle donasjoner.</string>
<string name="auto_favorites">Favoritter</string>
<string name="auto_charging_level">Ladingsnivå</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap kunne ikke fastsette kjøretøymodellen.</string>
<string name="selecting_none">fravalgte alle elementer</string>
<string name="grant_on_phone">Innvilg på mobilenheten</string>
<string name="auto_chargers_closeby">Ladere i nærheten</string>
<string name="auto_prices">Pris</string>
<string name="auto_no_chargers_found">Ingen ladere i nærheten</string>
<string name="auto_no_favorites_found">Fant ikke noen favoritter</string>
<string name="open_in_app">Åpne i programmet</string>
<string name="auto_location_service">EVMap kjører på Android Auto og bruker posisjonen din.</string>
<string name="auto_heading">Fartsretning</string>
<string name="opened_on_phone">Åpnet på mobilenheten</string>
<string name="auto_location_permission_needed">Innvilg posisjonstilgang for å bruke EVMap på Android Auto.</string>
<string name="auto_chargers_near_location">Nær %s</string>
<string name="auto_fault_report_date">⚠️ Feilrapport (%s)</string>
<string name="auto_vehicle_data">Kjøretøydata</string>
<string name="auto_no_data">Utilgjengelig</string>
<string name="auto_speed">Hastighet</string>
<string name="auto_settings">Innstillinger</string>
<string name="auto_chargeprice_vehicle_unknown">Ingen av kjøretøyene valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
<string name="welcome_android_auto">Android Auto-støtte</string>
<string name="auto_chargeprice_vehicle_ambiguous">Flere kjøretøy valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
<string name="auto_vehicle_data_permission_needed">EvMap trenger tilgang til kjøretøydata for å bruke denne funksjonen.</string>
<string name="auto_no_refresh_possible">Videre oppdateringer er ikke mulig. Gå tilbake og start på ny.</string>
<string name="auto_range">Rekkevidde</string>
<string name="welcome_android_auto_detail">Du kan også bruke EVMap inne i Android Auto på bilder som støtter dette ved å velge det i Android Auto-menyen.</string>
<string name="settings_android_auto_chargeprice_range">Prissammenligning for laderekkevidde fordelt på pris</string>
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap (Mapbox) for kartdata.</string>
<string name="selecting_all">valgte alle elementene</string>
<string name="sounds_cool">den er grei</string>
<string name="auto_chargers_ahead">Kun ladere i kjøreretningen</string>
<string name="loading">Laster inn …</string>
<string name="auto_multipage_goto">Side %d</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

@@ -1,41 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="auto_chargeprice_vehicle_ambiguous">Meerdere voertuigen geselecteerd in de app komen overeen met dit voertuig (%1$s %2$s).</string>
<string name="donations_info" formatted="false">Vind je EVMap nuttig\? Je kan de ontwikkeling steunen via een donatie aan de ontwikkelaar.
\n
\nGoogle houdt 15% in van elke donatie.</string>
<string name="auto_location_service">EVMap draait op Android Auto en gebruikt jouw locatie.</string>
<string name="auto_no_chargers_found">Geen laadpunten gevonden in de omgeving</string>
<string name="auto_no_favorites_found">Geen favorieten gevonden</string>
<string name="open_in_app">Open in de app</string>
<string name="opened_on_phone">Geopend op de telefoon</string>
<string name="auto_location_permission_needed">Om EVMap op Android Auto te gebruiken, moet je toegang geven tot je locatie.</string>
<string name="auto_vehicle_data_permission_needed">Voor deze functie heeft EVMap toegang nodig tot de gegevens van je voertuig.</string>
<string name="grant_on_phone">Geef toestemming op telefoon</string>
<string name="auto_chargers_closeby">Oplaadpunten in de buurt</string>
<string name="auto_favorites">Favorieten</string>
<string name="auto_chargers_near_location">Nabij %s</string>
<string name="auto_fault_report_date">⚠️ Foutrapport (%s)</string>
<string name="auto_no_refresh_possible">Verdere updates zijn niet mogelijk. Ga terug en herbegin.</string>
<string name="auto_prices">Prijzen</string>
<string name="auto_vehicle_data">Voertuiggegevens</string>
<string name="auto_charging_level">Laadniveau (SoC)</string>
<string name="auto_no_data">Niet beschikbaar</string>
<string name="auto_range">Reikwijdte</string>
<string name="auto_speed">Snelheid</string>
<string name="auto_heading">Richting</string>
<string name="auto_settings">Instellingen</string>
<string name="welcome_android_auto">Android Auto support</string>
<string name="welcome_android_auto_detail">Je kan EVMap ook gebruiken in Android Auto op ondersteunde voertuigen. Selecteer gewoon de EVMap app in het Android Auto menu.</string>
<string name="sounds_cool">klinkt cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap kon je voertuigtype niet bepalen.</string>
<string name="auto_chargers_ahead">Alleen laadpunten in rijrichting</string>
<string name="settings_android_auto_chargeprice_range">Laadbereik voor prijsvergelijking</string>
<string name="data_sources_hint">In de instellingen kan je ook wisselen tussen Google Maps en OpenStreetMap (Mapbox) voor de kaartgegevens.</string>
<string name="selecting_all">alle items geselecteerd</string>
<string name="selecting_none">alle items gedeselecteerd</string>
<string name="loading">Laden…</string>
<string name="auto_multipage_goto">Pagina %d</string>
<string name="auto_multipage">(%d/%d)</string>
<string name="auto_chargeprice_vehicle_unknown">Geen enkel voertuig geselecteerd in de app komt overeen met dit voertuig (%1$s %2$s).</string>
</resources>

View File

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

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="gauge_active">#00e676</color>
<color name="gauge_middle">#087f23</color>
<color name="gauge_inactive">#9e9e9e</color>
<color name="charger_100kw_dark">#FBC02D</color>
</resources>

View File

@@ -1,39 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
<string name="auto_no_chargers_found">No nearby chargers found</string>
<string name="auto_no_favorites_found">No favorites found</string>
<string name="open_in_app">Open in app</string>
<string name="opened_on_phone">Opened on phone</string>
<string name="auto_location_permission_needed">To run EVMap on Android Auto, you need to grant access to your location.</string>
<string name="auto_vehicle_data_permission_needed">For this feature, EVMap needs access to your vehicle data.</string>
<string name="grant_on_phone">Grant on phone</string>
<string name="auto_chargers_closeby">Nearby chargers</string>
<string name="auto_favorites">Favorites</string>
<string name="auto_chargers_near_location">Near %s</string>
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
<string name="auto_prices">Pricing</string>
<string name="auto_vehicle_data">Vehicle data</string>
<string name="auto_charging_level">Charging level</string>
<string name="auto_no_data">Unavailable</string>
<string name="auto_range">Range</string>
<string name="auto_speed">Speed</string>
<string name="auto_heading">Heading</string>
<string name="auto_settings">Settings</string>
<string name="welcome_android_auto">Android Auto support</string>
<string name="welcome_android_auto_detail">You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
<string name="sounds_cool">sounds cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap could not determine your vehicle model.</string>
<string name="auto_chargeprice_vehicle_unknown">None of the vehicles selected in the app matches this vehicle (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Multiple vehicles selected in the app match this vehicle (%1$s %2$s).</string>
<string name="auto_chargers_ahead">Only chargers along driving direction</string>
<string name="settings_android_auto_chargeprice_range">Charging range for price comparison</string>
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap (Mapbox) for the map data.</string>
<string name="selecting_all">selected all items</string>
<string name="selecting_none">deselected all items</string>
<string name="loading">Loading…</string>
<string name="auto_multipage_goto">Page %d</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="CarAppTheme">
<item name="carColorPrimary">@color/colorPrimary</item>
<item name="carColorPrimaryDark">@color/colorPrimaryDark</item>
<item name="carColorSecondary">@color/colorSecondary</item>
<item name="carColorSecondaryDark">@color/colorSecondaryDark</item>
</style>
</resources>

View File

@@ -1,7 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:fragment="net.vonforst.evmap.fragment.preference.AndroidAutoSettingsFragment"
android:title="@string/settings_android_auto"
android:icon="@drawable/ic_android_auto" />
<PreferenceScreen>
</PreferenceScreen>

View File

@@ -6,6 +6,11 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<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" />
<queries>
<intent>
@@ -19,6 +24,10 @@
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
<package android:name="com.google.android.projection.gearhead" />
<package android:name="com.google.android.apps.automotive.templates.host" />
<package android:name="com.google.android.apps.maps" />
</queries>
<application
@@ -32,8 +41,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:localeConfig="@xml/locales_config">
android:theme="@style/AppTheme">
<meta-data
android:name="com.mapbox.ACCESS_TOKEN"
@@ -265,6 +273,10 @@
android:host="openchargemap.org"
android:pathPattern="/site/poi/details/..*"
android:scheme="https" />
<data
android:host="map.openchargemap.io"
android:path="/"
android:scheme="https" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -282,6 +294,10 @@
android:resource="@xml/shortcuts" />
</activity>
<activity android:name=".auto.OAuthLoginActivity">
</activity>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
@@ -302,6 +318,39 @@
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- Configuration for Android Auto app -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="androidx.car.app.theme"
android:resource="@style/CarAppTheme" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
<service
android:name=".auto.CarAppService"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:exported="true"
android:foregroundServiceType="location">
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.POI" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -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 {
@@ -61,11 +72,9 @@ class EvMapApplication : Application(), Configuration.Provider {
}
}.build()).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.REPLACE, cleanupCacheRequest
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.UPDATE, cleanupCacheRequest
)
}
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder().build()
}
override val workManagerConfiguration = Configuration.Builder().build()
}

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap
import android.app.ActivityOptions
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent
@@ -107,6 +108,10 @@ class MapsActivity : AppCompatActivity(),
navGraph.setStartDestination(R.id.onboarding)
navController.graph = navGraph
return
} else if (!prefs.privacyAccepted) {
navGraph.setStartDestination(R.id.onboarding)
navController.graph = navGraph
return
} else {
navGraph.setStartDestination(R.id.map)
navController.setGraph(navGraph, MapFragmentArgs(appStart = true).toBundle())
@@ -151,8 +156,12 @@ class MapsActivity : AppCompatActivity(),
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
.createPendingIntent()
}
} else if (intent?.scheme == "https" && intent?.data?.host == "openchargemap.org") {
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
} else if (intent?.scheme == "https" && intent?.data?.host in listOf("openchargemap.org", "map.openchargemap.io")) {
val id = when (intent.data?.host) {
"openchargemap.org" -> intent.data?.pathSegments?.last()?.toLongOrNull()
"map.openchargemap.io" -> intent.data?.getQueryParameter("id")?.toLongOrNull()
else -> null
}
if (id != null) {
if (prefs.dataSource != "openchargemap") {
prefs.dataSource = "openchargemap"
@@ -198,18 +207,15 @@ class MapsActivity : AppCompatActivity(),
}
}
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
deepLink = navController.createDeepLink()
.setDestination(R.id.map)
.setArguments(
MapFragmentArgs(
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
latLng = LatLng(
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
).toBundle()
)
.createPendingIntent()
navController.navigate(
R.id.map, MapFragmentArgs(
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
latLng = LatLng(
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
).toBundle()
)
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
deepLink = navController.createDeepLink()
.setGraph(navGraph)
@@ -260,7 +266,7 @@ class MapsActivity : AppCompatActivity(),
}
}
fun openUrl(url: String) {
fun openUrl(url: String, preferBrowser: Boolean = true) {
val pkg = CustomTabsClient.getPackageName(this, null)
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
@@ -272,7 +278,7 @@ class MapsActivity : AppCompatActivity(),
pkg?.let {
// prefer to open URL in custom tab, even if native app
// available (such as EVMap itself)
intent.intent.setPackage(pkg)
if (preferBrowser) intent.intent.setPackage(pkg)
}
try {
intent.launchUrl(this, Uri.parse(url))

View File

@@ -1,15 +1,23 @@
package net.vonforst.evmap
import android.content.Context
import android.content.pm.ApplicationInfo
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.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.SpannedString
import android.text.TextUtils
import android.text.style.StyleSpan
import java.util.*
import net.vonforst.evmap.storage.PreferenceDataSource
import java.util.Locale
fun Bundle.optDouble(name: String): Double? {
if (!this.containsKey(name)) return null
@@ -88,9 +96,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 =
@@ -98,4 +122,21 @@ fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): Pa
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
}
}
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int = 0): ApplicationInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION") getApplicationInfo(packageName, flags)
}
fun PackageManager.isAppInstalled(packageName: String): Boolean {
return try {
getApplicationInfoCompat(packageName, 0).enabled
} catch (e: PackageManager.NameNotFoundException) {
false
}
}

View File

@@ -1,6 +1,7 @@
package net.vonforst.evmap.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
@@ -21,6 +22,7 @@ import net.vonforst.evmap.databinding.ItemChargepriceVehicleChipBinding
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.CheckableConstraintLayout
import java.time.Instant
interface Equatable {
override fun equals(other: Any?): Boolean
@@ -30,6 +32,7 @@ abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback(getKey)) {
var onClickListener: ((T) -> Unit)? = null
var onLongClickListener: ((T) -> Boolean)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> {
val layoutInflater = LayoutInflater.from(parent.context)
@@ -54,6 +57,12 @@ abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
listener(item)
}
}
if (onLongClickListener != null) {
holder.binding.root.setOnLongClickListener {
val listener = onLongClickListener ?: return@setOnLongClickListener false
return@setOnLongClickListener listener(item)
}
}
}
class DiffCallback<T : Equatable>(val getKey: ((T) -> Any)?) : DiffUtil.ItemCallback<T>() {
@@ -87,7 +96,19 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
override fun getItemViewType(position: Int): Int = R.layout.item_connector
}
class ChargepriceAdapter() :
class ConnectorDetailsAdapter : DataBindingAdapter<ConnectorDetailsAdapter.ConnectorDetails>() {
data class ConnectorDetails(
val status: ChargepointStatus?,
val evseId: String?,
val label: String?,
val lastChange: Instant?
) :
Equatable
override fun getItemViewType(position: Int): Int = R.layout.dialog_connector_details_item
}
class ChargepriceAdapter :
DataBindingAdapter<ChargePrice>() {
val viewPool = RecyclerView.RecycledViewPool()
@@ -209,8 +230,8 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
checkedItem = item
root.post {
notifyDataSetChanged()
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}

View File

@@ -7,7 +7,9 @@ import android.text.style.StyleSpan
import androidx.core.text.HtmlCompat
import androidx.core.text.buildSpannedString
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
import net.vonforst.evmap.api.availability.tesla.Pricing
import net.vonforst.evmap.api.availability.tesla.Rates
import net.vonforst.evmap.api.availability.tesla.TeslaChargingOwnershipGraphQlApi
import net.vonforst.evmap.bold
import net.vonforst.evmap.joinToSpannedString
import net.vonforst.evmap.model.ChargeCard
@@ -47,7 +49,7 @@ fun buildDetails(
loc: ChargeLocation?,
chargeCards: Map<Long, ChargeCard>?,
filteredChargeCards: Set<Long>?,
teslaPricing: TeslaGraphQlApi.Pricing?,
teslaPricing: Pricing?,
ctx: Context
): List<DetailsAdapter.Detail> {
if (loc == null) return emptyList()
@@ -139,7 +141,7 @@ fun buildDetails(
)
}
private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
fun formatTeslaParkingFee(teslaPricing: Pricing, ctx: Context) =
teslaPricing.memberRates?.activePricebook?.parking?.let { parkingFee ->
ctx.getString(
R.string.tesla_pricing_blocking_fee,
@@ -147,7 +149,7 @@ private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Co
)
}
private fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
fun formatTeslaPricing(teslaPricing: Pricing, ctx: Context) =
buildSpannedString {
teslaPricing.memberRates?.let { memberRates ->
append(
@@ -168,7 +170,7 @@ private fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Conte
}
}
private fun formatTeslaPricingRates(rates: TeslaGraphQlApi.Rates, ctx: Context) =
private fun formatTeslaPricingRates(rates: Rates, ctx: Context) =
buildSpannedString {
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
if (rates.activePricebook.charging.touRates.enabled) {

View File

@@ -1,17 +1,21 @@
package net.vonforst.evmap.api.availability
import android.content.Context
import android.os.Parcelable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.cartesianProduct
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.Cache
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -19,6 +23,7 @@ import retrofit2.HttpException
import java.io.IOException
import java.net.CookieManager
import java.net.CookiePolicy
import java.time.Instant
import java.util.concurrent.TimeUnit
interface AvailabilityDetector {
@@ -136,7 +141,9 @@ data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String,
val evseIds: Map<Chargepoint, List<String>>? = null,
val labels: Map<Chargepoint, List<String?>>? = null,
val congestionHistogram: List<Double>? = null,
val lastChange: Map<Chargepoint, List<Instant?>>? = null,
val extraData: Any? = null // API-specific data
) {
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
@@ -152,51 +159,73 @@ data class ChargeLocationStatus(
val totalChargepoints = status.map { it.key.count }.sum()
}
enum class ChargepointStatus {
@Parcelize
enum class ChargepointStatus : Parcelable {
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED
}
class AvailabilityDetectorException(message: String) : Exception(message)
class NotSignedInException : IOException("not signed in")
private val cookieManager = CookieManager().apply {
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
}
class AvailabilityRepository(context: Context) {
private val cacheSize = 5L * 1024 * 1024 // 5MB
private val okhttp = OkHttpClient.Builder()
.addInterceptor(RateLimitInterceptor())
.addDebugInterceptors()
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.cache(Cache(context.cacheDir, cacheSize))
.build()
private val teslaOwnerAvailabilityDetector =
TeslaOwnerAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context))
private val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
teslaOwnerAvailabilityDetector,
TeslaGuestAvailabilityDetector(okhttp),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
var result: ChargeLocationStatus? = null
var exception: Throwable? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
if (!ad.isChargerSupported(charger)) continue
try {
value = Resource.success(ad.getAvailability(charger))
result = ad.getAvailability(charger)
break
} catch (e: IOException) {
value = Resource.error(e.message, null)
exception = exception.takeIf { it is NotSignedInException } ?: e
e.printStackTrace()
} catch (e: HttpException) {
value = Resource.error(e.message, null)
exception = exception.takeIf { it is NotSignedInException } ?: e
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
value = Resource.error(e.message, null)
exception = exception.takeIf { it is NotSignedInException } ?: e
e.printStackTrace()
} catch (e: NotSignedInException) {
exception = e
e.printStackTrace()
}
}
}
return value ?: Resource.error(null, null)
result?.let {
return Resource.success(it)
}
return Resource.error(exception?.message, null)
}
fun isSupercharger(charger: ChargeLocation) =
teslaOwnerAvailabilityDetector.isChargerSupported(charger)
fun isTeslaSupported(charger: ChargeLocation) =
teslaOwnerAvailabilityDetector.isChargerSupported(charger) && teslaOwnerAvailabilityDetector.isSignedIn()
}

View File

@@ -1,6 +1,10 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import net.vonforst.evmap.api.availability.tesla.LocalTimeAdapter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
@@ -10,9 +14,11 @@ import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import java.time.Instant
import java.time.LocalTime
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
private const val maxDistance = 40 // max distance between reported positions in meters
private const val maxDistance = 60 // max distance between reported positions in meters
interface EnBwApi {
@GET("chargestations?grouping=false")
@@ -53,7 +59,8 @@ interface EnBwApi {
data class EnBwChargePoint(
val evseId: String?,
val status: String,
val connectors: List<EnBwConnector>
val connectors: List<EnBwConnector>,
val state: EnBwState?
)
@JsonClass(generateAdapter = true)
@@ -70,6 +77,11 @@ interface EnBwApi {
val upperRightLon: Double
)
@JsonClass(generateAdapter = true)
data class EnBwState(
val updatedAt: Instant?
)
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): EnBwApi {
val clientWithInterceptor = client.newBuilder()
@@ -85,7 +97,11 @@ interface EnBwApi {
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://enbw-emp.azure-api.net/emobility-public-api/api/v1/")
.addConverterFactory(MoshiConverterFactory.create())
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(InstantAdapter()).build()
)
)
.client(clientWithInterceptor)
.build()
return retrofit.create(EnBwApi::class.java)
@@ -93,6 +109,23 @@ interface EnBwApi {
}
}
internal class InstantAdapter {
@FromJson
fun fromJson(value: Long?): Instant? = value?.let {
Instant.ofEpochMilli(it)
}
@ToJson
fun toJson(value: Instant?): Long? = value?.toEpochMilli()
}
data class EnBwStatus(
val conn: EnBwApi.EnBwConnector,
val status: String,
val evseId: String?,
val lastChange: Instant?
)
class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
BaseAvailabilityDetector(client) {
val api = EnBwApi.create(client, baseUrl)
@@ -117,6 +150,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
listOf(it)
}
}
if (markers.any { it.grouped }) throw AvailabilityDetectorException("markers still grouped")
val nearest = markers.minByOrNull { marker ->
distanceBetween(marker.lat, marker.lon, lat, lng)
@@ -132,34 +166,39 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
throw AvailabilityDetectorException("no candidates found")
}
// combine related stations
markers = markers.filter { marker ->
distanceBetween(
marker.lat,
marker.lon,
nearest.lat,
nearest.lon
) < maxDistance
if (nearest.numberOfChargePoints < location.totalChargepoints) {
// combine related stations
markers = markers.filter { marker ->
distanceBetween(
marker.lat,
marker.lon,
nearest.lat,
nearest.lon
) < maxDistance
}.filter {
// only include stations from same operator
it.operator == nearest.operator && it.stationId != null
}
} else {
markers = listOf(nearest)
}
val details = markers.filter {
// only include stations from same operator
it.operator == nearest.operator && it.stationId != null
}.map {
val details = markers.mapNotNull { it.stationId }.map {
// load details
api.getLocation(it.stationId!!)
api.getLocation(it)
}
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
cp.connectors.map { connector ->
Triple(connector, cp.status, cp.evseId)
EnBwStatus(connector, cp.status, cp.evseId, cp.state?.updatedAt)
}
}
val enbwConnectors = mutableMapOf<Long, Pair<Double, String>>()
val enbwStatus = mutableMapOf<Long, ChargepointStatus>()
val enbwEvseId = mutableMapOf<Long, String>()
connectorStatus.forEachIndexed { index, (connector, statusStr, evseId) ->
val enbwLastChange = mutableMapOf<Long, Instant?>()
connectorStatus.forEachIndexed { index, (connector, statusStr, evseId, updatedAt) ->
val id = index.toLong()
val power = connector.maxPowerInKw ?: 0.0
val type = when (connector.plugTypeName) {
@@ -182,6 +221,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
}
enbwConnectors[id] = power to type
enbwStatus[id] = status
enbwLastChange[id] = updatedAt
evseId?.let { enbwEvseId[id] = it }
}
@@ -192,10 +232,13 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
val evseIds = if (enbwEvseId.size == enbwStatus.size) match.mapValues { entry ->
entry.value.map { enbwEvseId[it]!! }
} else null
val lastChange =
if (enbwLastChange.size == enbwStatus.size) match.mapValues { entry -> entry.value.map { enbwLastChange[it] } } else null
return ChargeLocationStatus(
chargepointStatus,
"EnBW",
evseIds
evseIds,
lastChange = lastChange
)
}

View File

@@ -1,6 +1,10 @@
package net.vonforst.evmap.api.availability
import androidx.car.app.model.DateTimeWithZone
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
@@ -9,10 +13,15 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeParseException
import java.util.*
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
private const val maxDistance = 40 // max distance between reported positions in meters
private const val maxDistance = 60 // max distance between reported positions in meters
interface NewMotionApi {
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}/{zoom}")
@@ -28,7 +37,7 @@ interface NewMotionApi {
suspend fun getLocation(@Path("id") id: Long): NMLocation
@JsonClass(generateAdapter = true)
data class NMMarker(val coordinates: NMCoordinates, val locationUid: Long)
data class NMMarker(val coordinates: NMCoordinates, val locationUid: Long, val evseCount: Int)
@JsonClass(generateAdapter = true)
data class NMCoordinates(val latitude: Double, val longitude: Double)
@@ -42,7 +51,12 @@ interface NewMotionApi {
)
@JsonClass(generateAdapter = true)
data class NMEvse(val evseId: String?, val status: String, val connectors: List<NMConnector>)
data class NMEvse(
val evseId: String?,
val status: String,
val connectors: List<NMConnector>,
val updated: ZonedDateTime?
)
@JsonClass(generateAdapter = true)
data class NMConnector(
@@ -78,7 +92,11 @@ interface NewMotionApi {
fun create(client: OkHttpClient, baseUrl: String? = null): NewMotionApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
.addConverterFactory(MoshiConverterFactory.create())
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(ZonedDateTimeAdapter()).build()
)
)
.client(client)
.build()
return retrofit.create(NewMotionApi::class.java)
@@ -86,6 +104,21 @@ interface NewMotionApi {
}
}
internal class ZonedDateTimeAdapter {
@FromJson
fun fromJson(value: String): ZonedDateTime? = ZonedDateTime.parse(value)
@ToJson
fun toJson(value: ZonedDateTime): String = value.toString()
}
data class NmStatus(
val conn: NewMotionApi.NMConnector,
val status: String,
val evseId: String?,
val updated: ZonedDateTime?
)
class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
BaseAvailabilityDetector(client) {
val api = NewMotionApi.create(client, baseUrl)
@@ -111,14 +144,18 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
throw AvailabilityDetectorException("no candidates found")
}
// combine related stations
markers = markers.filter { marker ->
distanceBetween(
marker.coordinates.latitude,
marker.coordinates.longitude,
nearest.coordinates.latitude,
nearest.coordinates.longitude
) < maxDistance
markers = if (nearest.evseCount < location.totalChargepoints) {
// combine related stations
markers.filter { marker ->
distanceBetween(
marker.coordinates.latitude,
marker.coordinates.longitude,
nearest.coordinates.latitude,
nearest.coordinates.longitude
) < maxDistance
}
} else {
listOf(nearest)
}
// load details
@@ -131,14 +168,15 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
}
val connectorStatus = details.flatMap { it.evses }.flatMap { evse ->
evse.connectors.map { connector ->
Triple(connector, evse.status, evse.evseId)
NmStatus(connector, evse.status, evse.evseId, evse.updated)
}
}
val nmConnectors = mutableMapOf<Long, Pair<Double, String>>()
val nmStatus = mutableMapOf<Long, ChargepointStatus>()
val nmEvseId = mutableMapOf<Long, String>()
connectorStatus.forEach { (connector, statusStr, evseId) ->
val nmUpdated = mutableMapOf<Long, ZonedDateTime>()
connectorStatus.forEach { (connector, statusStr, evseId, updated) ->
val id = connector.uid
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
@@ -164,6 +202,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
nmConnectors.put(id, power to type)
nmStatus.put(id, status)
evseId?.let { nmEvseId[id] = it }
updated?.let { nmUpdated[id] = it }
}
val match = matchChargepoints(nmConnectors, location.chargepointsMerged)
@@ -173,10 +212,12 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
val evseIds = if (nmEvseId.size == nmStatus.size) match.mapValues { entry ->
entry.value.map { nmEvseId[it]!! }
} else null
val updated = match.mapValues { entry -> entry.value.map { nmUpdated[it]?.toInstant() } }
return ChargeLocationStatus(
chargepointStatus,
"NewMotion",
evseIds
evseIds,
lastChange = updated
)
}

View File

@@ -0,0 +1,173 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.JsonDataException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import net.vonforst.evmap.api.availability.tesla.ChargerAvailability
import net.vonforst.evmap.api.availability.tesla.TeslaChargingGuestGraphQlApi
import net.vonforst.evmap.api.availability.tesla.TeslaCuaApi
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
import okhttp3.OkHttpClient
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
class TeslaGuestAvailabilityDetector(
client: OkHttpClient,
baseUrl: String? = null
) :
BaseAvailabilityDetector(client) {
private var cuaApi = TeslaCuaApi.create(client, baseUrl)
private var api = TeslaChargingGuestGraphQlApi.create(client, baseUrl)
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val results = cuaApi.getTeslaLocations()
val result =
results.minByOrNull {
if (it.latitude != null && it.longitude != null) {
distanceBetween(
it.latitude,
it.longitude,
location.coordinates.lat,
location.coordinates.lng
)
} else Double.POSITIVE_INFINITY
} ?: throw AvailabilityDetectorException("no candidates found.")
val resultDetails = try {
cuaApi.getTeslaLocation(result.locationId)
} catch (e: JsonDataException) {
// instead of a single location, this may also return an empty JSON list []. This is hard to fix with Moshi
if (e.message == "Expected BEGIN_OBJECT but was BEGIN_ARRAY at path \$") {
throw AvailabilityDetectorException("no candidates found.")
} else {
throw e
}
}
val trtId = resultDetails.trtId?.toLongOrNull()
?: throw AvailabilityDetectorException("charger data not available through guest API")
val (detailsA, guestPricing) = coroutineScope {
val details = async {
api.getChargingSiteDetails(
TeslaChargingGuestGraphQlApi.GetChargingSiteDetailsRequest(
TeslaChargingGuestGraphQlApi.GetChargingSiteInformationVariables(
TeslaChargingGuestGraphQlApi.Identifier(
TeslaChargingGuestGraphQlApi.ChargingSiteIdentifier(
trtId
)
),
TeslaChargingGuestGraphQlApi.Experience.ADHOC
)
)
).data.site ?: throw AvailabilityDetectorException("no candidates found.")
}
val guestPricing = async {
api.getChargingSiteDetails(
TeslaChargingGuestGraphQlApi.GetChargingSiteDetailsRequest(
TeslaChargingGuestGraphQlApi.GetChargingSiteInformationVariables(
TeslaChargingGuestGraphQlApi.Identifier(
TeslaChargingGuestGraphQlApi.ChargingSiteIdentifier(
trtId
)
),
TeslaChargingGuestGraphQlApi.Experience.GUEST
)
)
).data.site?.pricing
}
details to guestPricing
}
val details = detailsA.await()
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
val scV2CCSConnectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power <= 150
}
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
}
val scV3Connectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power > 150
}
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
"charger has unknown connectors"
)
val chargerDetails = details.chargersAvailable.chargerDetails
val chargers = details.chargers.associateBy { it.id }
var detailsSorted = chargerDetails
.sortedBy { chargers[it.id]?.labelLetter }
.sortedBy { chargers[it.id]?.labelNumber }
if (detailsSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
// apparently some connectors are missing in Tesla data
// If we have just one type of charger, we can still match
val numMissing =
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - detailsSorted.size
if ((scV2Connectors.isEmpty() || scV3Connectors.isEmpty()) && numMissing > 0) {
detailsSorted =
detailsSorted + List(numMissing) {
TeslaChargingGuestGraphQlApi.ChargerDetail(
ChargerAvailability.UNKNOWN,
""
)
}
} else {
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
}
}
val detailsMap =
mutableMapOf<Chargepoint, List<TeslaChargingGuestGraphQlApi.ChargerDetail>>()
var i = 0
for (connector in scV2Connectors) {
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
if (scV2CCSConnectors.isNotEmpty()) {
i = 0
for (connector in scV2CCSConnectors) {
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
}
for (connector in scV3Connectors) {
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
val labelsMap = detailsMap.mapValues { it.value.map { chargers[it.id]?.label } }
val pricing = details.pricing.copy(memberRates = guestPricing.await()?.userRates)
return ChargeLocationStatus(
statusMap,
"Tesla",
labels = labelsMap,
extraData = pricing
)
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
return when (charger.dataSource) {
"goingelectric" -> charger.network == "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
else -> false
}
}
}

View File

@@ -0,0 +1,200 @@
package net.vonforst.evmap.api.availability
import net.vonforst.evmap.api.availability.tesla.ChargerAvailability
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.TeslaChargingGuestGraphQlApi
import net.vonforst.evmap.api.availability.tesla.TeslaChargingOwnershipGraphQlApi
import net.vonforst.evmap.api.availability.tesla.asTeslaCoord
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.OkHttpClient
import java.time.Instant
import java.util.Collections
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
class TeslaOwnerAvailabilityDetector(
private val client: OkHttpClient,
private val tokenStore: TokenStore,
private val baseUrl: String? = null
) :
BaseAvailabilityDetector(client) {
private val authApi = TeslaAuthenticationApi.create(client, null)
private var api: TeslaChargingOwnershipGraphQlApi? = null
interface TokenStore {
var teslaRefreshToken: String?
var teslaAccessToken: String?
var teslaAccessTokenExpiry: Long
}
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val api = initApi()
val req = TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesRequest(
TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesVariables(
TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesArgs(
location.coordinates.asTeslaCoord(),
TeslaChargingOwnershipGraphQlApi.Coordinate(
location.coordinates.lat + coordRange,
location.coordinates.lng - coordRange
),
TeslaChargingOwnershipGraphQlApi.Coordinate(
location.coordinates.lat - coordRange,
location.coordinates.lng + coordRange
),
TeslaChargingOwnershipGraphQlApi.OpenToNonTeslasFilterValue(false)
)
)
)
val results = api.getNearbyChargingSites(
req,
req.operationName
).data.charging?.nearbySites?.sitesAndDistances
?: throw AvailabilityDetectorException("no candidates found.")
val result =
results.minByOrNull { it.haversineDistanceMiles.value }
?: throw AvailabilityDetectorException("no candidates found.")
val details = api.getChargingSiteInformation(
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationRequest(
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationVariables(
TeslaChargingOwnershipGraphQlApi.ChargingSiteIdentifier(result.id.text),
TeslaChargingOwnershipGraphQlApi.VehicleMakeType.NON_TESLA
)
)
).data.charging.site ?: throw AvailabilityDetectorException("no candidates found.")
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
val scV2CCSConnectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power <= 150
}
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
}
val scV3Connectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power > 150
}
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
"charger has unknown connectors"
)
val chargerDetails = details.siteDynamic.chargerDetails
val chargers = details.siteStatic.chargers.associateBy { it.id }
var detailsSorted = chargerDetails
.sortedBy { c -> c.charger.labelLetter ?: chargers[c.charger.id]?.labelLetter }
.sortedBy { c -> c.charger.labelNumber ?: chargers[c.charger.id]?.labelNumber }
if (detailsSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
// apparently some connectors are missing in Tesla data
// If we have just one type of charger, we can still match
val numMissing =
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - detailsSorted.size
if ((scV2Connectors.isEmpty() || scV3Connectors.isEmpty()) && numMissing > 0) {
detailsSorted =
detailsSorted + List(numMissing) {
TeslaChargingOwnershipGraphQlApi.ChargerDetail(
ChargerAvailability.UNKNOWN,
TeslaChargingOwnershipGraphQlApi.ChargerId(
TeslaChargingOwnershipGraphQlApi.Text(""),
null,
null
)
)
}
} else {
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
}
}
val detailsMap =
emptyMap<Chargepoint, List<TeslaChargingOwnershipGraphQlApi.ChargerDetail>>().toMutableMap()
var i = 0
for (connector in scV2Connectors) {
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
if (scV2CCSConnectors.isNotEmpty()) {
i = 0
for (connector in scV2CCSConnectors) {
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
}
for (connector in scV3Connectors) {
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
val congestionHistogram = details.congestionPriceHistogram?.let { cph ->
val indexOfMidnight = cph.dataAttributes.indexOfFirst { it.label == "12AM" }
indexOfMidnight.takeIf { it >= 0 }?.let { index ->
val data = cph.data.toMutableList()
Collections.rotate(data, -index)
data
}
}
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
val labelsMap = detailsMap.mapValues {
it.value.map {
it.charger.label?.value ?: chargers[it.charger.id]?.label?.value
}
}
return ChargeLocationStatus(
statusMap,
"Tesla",
labels = labelsMap,
congestionHistogram = congestionHistogram,
extraData = details.pricing
)
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
return when (charger.dataSource) {
"goingelectric" -> charger.network == "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
else -> false
}
}
private suspend fun initApi(): TeslaChargingOwnershipGraphQlApi {
return api ?: run {
val newApi = TeslaChargingOwnershipGraphQlApi.create(client, baseUrl) {
val now = Instant.now().epochSecond
val token =
tokenStore.teslaAccessToken.takeIf { tokenStore.teslaAccessTokenExpiry > now }
?: run {
val refreshToken = tokenStore.teslaRefreshToken
?: throw NotSignedInException()
val response =
authApi.getToken(
TeslaAuthenticationApi.RefreshTokenRequest(
refreshToken
)
)
tokenStore.teslaAccessToken = response.accessToken
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
response.accessToken
}
token
}
api = newApi
newApi
}
}
fun isSignedIn() = tokenStore.teslaRefreshToken != null
}

View File

@@ -0,0 +1,104 @@
package net.vonforst.evmap.api.availability.tesla
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.ToJson
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.model.Coordinate
import java.time.LocalTime
sealed class GraphQlRequest {
abstract val operationName: String
abstract val query: String
abstract val variables: Any?
}
fun Coordinate.asTeslaCoord() =
TeslaChargingOwnershipGraphQlApi.Coordinate(this.lat, this.lng)
@JsonClass(generateAdapter = true)
data class Outage(val message: String /* TODO: */)
enum class ChargerAvailability {
@Json(name = "CHARGER_AVAILABILITY_AVAILABLE")
AVAILABLE,
@Json(name = "CHARGER_AVAILABILITY_OCCUPIED")
OCCUPIED,
@Json(name = "CHARGER_AVAILABILITY_DOWN")
DOWN,
@Json(name = "CHARGER_AVAILABILITY_UNKNOWN")
UNKNOWN;
fun toStatus() = when (this) {
AVAILABLE -> ChargepointStatus.AVAILABLE
OCCUPIED -> ChargepointStatus.OCCUPIED
DOWN -> ChargepointStatus.FAULTED
UNKNOWN -> ChargepointStatus.UNKNOWN
}
}
@JsonClass(generateAdapter = true)
data class Pricing(
val canDisplayCombinedComparison: Boolean?,
val hasMSPPricing: Boolean?,
val hasMembershipPricing: Boolean?,
val memberRates: Rates?, // rates for Tesla drivers & non-Tesla drivers with subscription
val userRates: Rates? // rates without subscription
)
@JsonClass(generateAdapter = true)
data class Rates(
val activePricebook: Pricebook
)
@JsonClass(generateAdapter = true)
data class Pricebook(
val charging: PricebookDetails,
val parking: PricebookDetails,
val priceBookID: Long?
)
@JsonClass(generateAdapter = true)
data class PricebookDetails(
val bucketUom: String, // unit of measurement for buckets (typically "kw")
val buckets: List<Bucket>, // buckets of charging power (used for minute-based pricing)
val currencyCode: String,
val programType: String,
val rates: List<Double>,
val touRates: TouRates,
val uom: String, // unit of measurement ("kwh" or "min")
val vehicleMakeType: String
)
@JsonClass(generateAdapter = true)
data class Bucket(
val start: Int,
val end: Int
)
@JsonClass(generateAdapter = true)
data class TouRates(
val activeRatesByTime: List<ActiveRatesByTime>,
val enabled: Boolean
)
@JsonClass(generateAdapter = true)
data class ActiveRatesByTime(
val startTime: LocalTime,
val endTime: LocalTime,
val rates: List<Double>
)
internal class LocalTimeAdapter {
@FromJson
fun fromJson(value: String?): LocalTime? = value?.let {
if (it == "24:00") LocalTime.MAX else LocalTime.parse(it)
}
@ToJson
fun toJson(value: LocalTime?): String? = value?.toString()
}

View File

@@ -0,0 +1,169 @@
package net.vonforst.evmap.api.availability.tesla
import com.squareup.moshi.Json
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonClass
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okhttp3.CacheControl
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import java.lang.reflect.Type
import java.util.concurrent.TimeUnit
interface TeslaCuaApi {
@GET("tesla-locations")
suspend fun getTeslaLocations(
@Query("translate") translate: String = "en_US",
@Query("usetrt") usetrt: Boolean = true,
): List<TeslaLocation>
@GET("tesla-location")
suspend fun getTeslaLocation(
@Query("id") id: String,
@Query("translate") translate: String = "en_US",
@Query("usetrt") usetrt: Boolean = true
): TeslaLocation
@JsonClass(generateAdapter = true)
data class TeslaLocation(
val latitude: Double?,
val longitude: Double?,
@Json(name = "location_id") val locationId: String,
val title: String?,
@Json(name = "location_type") val locationType: List<String>,
val trtId: String?
)
companion object {
fun create(
client: OkHttpClient,
baseUrl: String? = null
): TeslaCuaApi {
val clientWithInterceptor = client.newBuilder()
.addInterceptor { chain ->
// increase cache duration to 24h (useful for the large getTeslaLocations request)
val request = chain.request().newBuilder()
.cacheControl(CacheControl.Builder().maxStale(24, TimeUnit.HOURS).build())
.build()
chain.proceed(request)
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://www.tesla.com/cua-api/")
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(LocalTimeAdapter()).build()
)
)
.client(clientWithInterceptor)
.build()
return retrofit.create(TeslaCuaApi::class.java)
}
}
}
interface TeslaChargingGuestGraphQlApi {
@POST("graphql")
suspend fun getChargingSiteDetails(
@Body request: GetChargingSiteDetailsRequest,
@Query("operationName") operationName: String = "getGuestChargingSiteDetails"
): GetChargingSiteDetailsResponse
@JsonClass(generateAdapter = true)
data class GetChargingSiteDetailsRequest(
override val variables: GetChargingSiteInformationVariables,
override val operationName: String = "getGuestChargingSiteDetails",
override val query: String =
"\n query getGuestChargingSiteDetails(\$identifier: ChargingSiteIdentifierInput!, \$deviceLocale: String!, \$experience: ChargingExperienceEnum!) {\n site(\n identifier: \$identifier\n deviceLocale: \$deviceLocale\n experience: \$experience\n ) {\n activeOutages\n address {\n countryCode\n }\n chargers {\n id\n label\n }\n chargersAvailable {\n chargerDetails {\n id\n availability\n }\n }\n holdAmount {\n holdAmount\n currencyCode\n }\n maxPowerKw\n name\n programType\n publicStallCount\n id\n pricing(experience: \$experience) {\n userRates {\n activePricebook {\n charging {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n }\n parking {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n }\n congestion {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n }\n }\n }\n }\n }\n}\n "
) : GraphQlRequest()
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationVariables(
val identifier: Identifier,
val experience: Experience,
val deviceLocale: String = "de-DE",
)
enum class Experience {
ADHOC, GUEST
}
@JsonClass(generateAdapter = true)
data class Identifier(
val siteId: ChargingSiteIdentifier
)
@JsonClass(generateAdapter = true)
data class ChargingSiteIdentifier(
val id: Long,
val siteType: SiteType = SiteType.SUPERCHARGER
)
enum class SiteType {
@Json(name = "SITE_TYPE_SUPERCHARGER")
SUPERCHARGER
}
@JsonClass(generateAdapter = true)
data class GetChargingSiteDetailsResponse(val data: GetChargingSiteDetailsResponseData)
@JsonClass(generateAdapter = true)
data class GetChargingSiteDetailsResponseData(val site: ChargingSiteInformation?)
@JsonClass(generateAdapter = true)
data class ChargingSiteInformation(
val activeOutages: List<Outage>?,
val chargers: List<ChargerId>,
val chargersAvailable: ChargersAvailable,
val id: Long,
val maxPowerKw: Int,
val name: String,
val pricing: Pricing,
val publicStallCount: Int
)
@JsonClass(generateAdapter = true)
data class ChargerId(
val id: String,
val label: String?,
) {
val labelNumber
get() = label?.replace(Regex("""\D"""), "")?.toInt()
val labelLetter
get() = label?.replace(Regex("""\d"""), "")
}
@JsonClass(generateAdapter = true)
data class ChargersAvailable(val chargerDetails: List<ChargerDetail>)
@JsonClass(generateAdapter = true)
data class ChargerDetail(
val availability: ChargerAvailability,
val id: String
)
companion object {
fun create(
client: OkHttpClient,
baseUrl: String? = null
): TeslaChargingGuestGraphQlApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://www.tesla.com/de_DE/charging/guest/api/")
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(LocalTimeAdapter()).build()
)
)
.client(client)
.build()
return retrofit.create(TeslaChargingGuestGraphQlApi::class.java)
}
}
}

View File

@@ -1,16 +1,12 @@
package net.vonforst.evmap.api.availability
package net.vonforst.evmap.api.availability.tesla
import android.net.Uri
import android.util.Base64
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.Coordinate
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
@@ -18,14 +14,9 @@ import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Instant
import java.time.LocalTime
import java.util.Collections
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
interface TeslaAuthenticationApi {
@POST("oauth2/v3/token")
@@ -102,6 +93,18 @@ interface TeslaAuthenticationApi {
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
}
fun buildSignInUri(codeChallenge: String): 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 resultUrlPrefix = "https://auth.tesla.com/void/callback"
}
}
@@ -144,7 +147,7 @@ interface TeslaOwnerApi {
}
}
interface TeslaGraphQlApi {
interface TeslaChargingOwnershipGraphQlApi {
@POST("/graphql")
suspend fun getNearbyChargingSites(
@Body request: GetNearbyChargingSitesRequest,
@@ -225,17 +228,11 @@ interface TeslaGraphQlApi {
TESLA, NON_TESLA
}
sealed class GraphQlRequest {
abstract val operationName: String
abstract val query: String
abstract val variables: Any?
}
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesResponse(val data: GetNearbyChargingSitesResponseData)
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesResponseData(val charging: GetNearbyChargingSitesResponseDataCharging)
data class GetNearbyChargingSitesResponseData(val charging: GetNearbyChargingSitesResponseDataCharging?)
@JsonClass(generateAdapter = true)
data class GetNearbyChargingSitesResponseDataCharging(val nearbySites: GetNearbyChargingSitesResponseDataChargingNearbySites)
@@ -258,15 +255,6 @@ interface TeslaGraphQlApi {
// TODO: siteType, accessType
)
@JsonClass(generateAdapter = true)
data class Outage(val message: String /* TODO: */)
@JsonClass(generateAdapter = true)
data class Value<T : Any>(val value: T)
@JsonClass(generateAdapter = true)
data class Text(val text: String)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationResponse(val data: GetChargingSiteInformationResponseData)
@@ -274,7 +262,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(
@@ -294,24 +282,24 @@ interface TeslaGraphQlApi {
val waitEstimateBucket: WaitEstimateBucket
)
@JsonClass(generateAdapter = true)
data class ChargerId(
val id: Text,
val label: Value<String>?,
val name: String?
) {
val labelNumber
get() = label?.value?.replace(Regex("""\D"""), "")?.toInt()
val labelLetter
get() = label?.value?.replace(Regex("""\d"""), "")
}
@JsonClass(generateAdapter = true)
data class ChargerDetail(
val availability: ChargerAvailability,
val charger: ChargerId
)
@JsonClass(generateAdapter = true)
data class ChargerId(
val id: Text,
val label: Value<String>,
val name: String?
) {
val labelNumber
get() = label.value.replace(Regex("""\D"""), "").toInt()
val labelLetter
get() = label.value.replace(Regex("""\d"""), "")
}
@JsonClass(generateAdapter = true)
data class SiteStatic(
val accessCode: Value<String>?,
@@ -329,58 +317,6 @@ interface TeslaGraphQlApi {
// TODO: siteType, accessType, address, amenities, timeZone
)
@JsonClass(generateAdapter = true)
data class Pricing(
val canDisplayCombinedComparison: Boolean,
val hasMSPPricing: Boolean,
val hasMembershipPricing: Boolean,
val memberRates: Rates?, // rates for Tesla drivers & non-Tesla drivers with subscription
val userRates: Rates? // rates without subscription
)
@JsonClass(generateAdapter = true)
data class Rates(
val activePricebook: Pricebook
)
@JsonClass(generateAdapter = true)
data class Pricebook(
val charging: PricebookDetails,
val parking: PricebookDetails,
val priceBookID: Long
)
@JsonClass(generateAdapter = true)
data class PricebookDetails(
val bucketUom: String, // unit of measurement for buckets (typically "kw")
val buckets: List<Bucket>, // buckets of charging power (used for minute-based pricing)
val currencyCode: String,
val programType: String,
val rates: List<Double>,
val touRates: TouRates,
val uom: String, // unit of measurement ("kwh" or "min")
val vehicleMakeType: String
)
@JsonClass(generateAdapter = true)
data class Bucket(
val start: Int,
val end: Int
)
@JsonClass(generateAdapter = true)
data class TouRates(
val activeRatesByTime: List<ActiveRatesByTime>,
val enabled: Boolean
)
@JsonClass(generateAdapter = true)
data class ActiveRatesByTime(
val startTime: LocalTime,
val endTime: LocalTime,
val rates: List<Double>
)
@JsonClass(generateAdapter = true)
data class CongestionPriceHistogram(
val data: List<Double>,
@@ -393,25 +329,11 @@ interface TeslaGraphQlApi {
val label: String // "1AM", "2AM", etc.
)
enum class ChargerAvailability {
@Json(name = "CHARGER_AVAILABILITY_AVAILABLE")
AVAILABLE,
@JsonClass(generateAdapter = true)
data class Value<T : Any>(val value: T)
@Json(name = "CHARGER_AVAILABILITY_OCCUPIED")
OCCUPIED,
@Json(name = "CHARGER_AVAILABILITY_DOWN")
DOWN,
@Json(name = "CHARGER_AVAILABILITY_UNKNOWN")
UNKNOWN;
fun toStatus() = when (this) {
AVAILABLE -> ChargepointStatus.AVAILABLE
OCCUPIED -> ChargepointStatus.OCCUPIED
DOWN -> ChargepointStatus.FAULTED
UNKNOWN -> ChargepointStatus.UNKNOWN
}
}
@JsonClass(generateAdapter = true)
data class Text(val text: String)
enum class WaitEstimateBucket {
@Json(name = "WAIT_ESTIMATE_BUCKET_NO_WAIT")
@@ -432,6 +354,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
}
@@ -441,7 +366,7 @@ interface TeslaGraphQlApi {
client: OkHttpClient,
baseUrl: String? = null,
token: suspend () -> String
): TeslaGraphQlApi {
): TeslaChargingOwnershipGraphQlApi {
val clientWithInterceptor = client.newBuilder()
.addInterceptor { chain ->
val t = runBlocking { token() }
@@ -463,182 +388,7 @@ interface TeslaGraphQlApi {
)
.client(clientWithInterceptor)
.build()
return retrofit.create(TeslaGraphQlApi::class.java)
return retrofit.create(TeslaChargingOwnershipGraphQlApi::class.java)
}
}
}
internal class LocalTimeAdapter {
@FromJson
fun fromJson(value: String?): LocalTime? = value?.let {
if (it == "24:00") LocalTime.MAX else LocalTime.parse(it)
}
@ToJson
fun toJson(value: LocalTime?): String? = value?.toString()
}
fun Coordinate.asTeslaCoord() =
TeslaGraphQlApi.Coordinate(this.lat, this.lng)
class TeslaAvailabilityDetector(
private val client: OkHttpClient,
private val tokenStore: TokenStore,
private val baseUrl: String? = null
) :
BaseAvailabilityDetector(client) {
private val authApi = TeslaAuthenticationApi.create(client, null)
private var api: TeslaGraphQlApi? = null
interface TokenStore {
var teslaRefreshToken: String?
var teslaAccessToken: String?
var teslaAccessTokenExpiry: Long
}
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val api = initApi()
val req = TeslaGraphQlApi.GetNearbyChargingSitesRequest(
TeslaGraphQlApi.GetNearbyChargingSitesVariables(
TeslaGraphQlApi.GetNearbyChargingSitesArgs(
location.coordinates.asTeslaCoord(),
TeslaGraphQlApi.Coordinate(
location.coordinates.lat + coordRange,
location.coordinates.lng - coordRange
),
TeslaGraphQlApi.Coordinate(
location.coordinates.lat - coordRange,
location.coordinates.lng + coordRange
),
TeslaGraphQlApi.OpenToNonTeslasFilterValue(false)
)
)
)
val results = api.getNearbyChargingSites(
req,
req.operationName
).data.charging.nearbySites.sitesAndDistances
val result =
results.minByOrNull { it.haversineDistanceMiles.value }
?: throw AvailabilityDetectorException("no candidates found.")
val details = api.getChargingSiteInformation(
TeslaGraphQlApi.GetChargingSiteInformationRequest(
TeslaGraphQlApi.GetChargingSiteInformationVariables(
TeslaGraphQlApi.ChargingSiteIdentifier(result.id.text),
TeslaGraphQlApi.VehicleMakeType.NON_TESLA
)
)
).data.charging.site
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
val scV2CCSConnectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power <= 150
}
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
}
val scV3Connectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power > 150
}
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
"charger has unknown connectors"
)
var statusSorted = details.siteDynamic.chargerDetails.sortedBy { it.charger.labelLetter }
.sortedBy { it.charger.labelNumber }.map { it.availability }
if (statusSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
// apparently some connectors are missing in Tesla data
// If we have just one type of charger, we can still match
val numMissing =
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - statusSorted.size
if (scV2Connectors.isEmpty() || scV3Connectors.isEmpty() && numMissing > 0) {
statusSorted =
statusSorted + List(numMissing) { TeslaGraphQlApi.ChargerAvailability.UNKNOWN }
} else {
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
}
}
val statusMap = emptyMap<Chargepoint, List<ChargepointStatus>>().toMutableMap()
var i = 0
for (connector in scV2Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
i += connector.count
}
if (scV2CCSConnectors.isNotEmpty()) {
i = 0
for (connector in scV2CCSConnectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
i += connector.count
}
}
for (connector in scV3Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
i += connector.count
}
val congestionHistogram = details.congestionPriceHistogram?.let { cph ->
val indexOfMidnight = cph.dataAttributes.indexOfFirst { it.label == "12AM" }
indexOfMidnight.takeIf { it >= 0 }?.let { index ->
val data = cph.data.toMutableList()
Collections.rotate(data, -index)
data
}
}
return ChargeLocationStatus(
statusMap,
"Tesla",
congestionHistogram = congestionHistogram,
extraData = details.pricing
)
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
return when (charger.dataSource) {
"goingelectric" -> charger.network == "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
else -> false
}
}
private suspend fun initApi(): TeslaGraphQlApi {
return api ?: run {
val newApi = TeslaGraphQlApi.create(client, baseUrl) {
val now = Instant.now().epochSecond
val token =
tokenStore.teslaAccessToken.takeIf { tokenStore.teslaAccessTokenExpiry > now }
?: run {
val refreshToken = tokenStore.teslaRefreshToken
?: throw IOException("not signed in")
val response =
authApi.getToken(
TeslaAuthenticationApi.RefreshTokenRequest(
refreshToken
)
)
tokenStore.teslaAccessToken = response.accessToken
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
response.accessToken
}
token
}
api = newApi
newApi
}
}
}

View File

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

@@ -12,16 +12,41 @@ import kotlinx.coroutines.withContext
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.*
import net.vonforst.evmap.model.*
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.powerSteps
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.Filter
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterValues
import net.vonforst.evmap.model.MultipleChoiceFilter
import net.vonforst.evmap.model.MultipleChoiceFilterValue
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.model.SliderFilter
import net.vonforst.evmap.model.getBooleanValue
import net.vonforst.evmap.model.getMultipleChoiceValue
import net.vonforst.evmap.model.getSliderValue
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getClusterDistance
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import java.io.IOException
import java.time.Duration
@@ -122,6 +147,8 @@ interface GoingElectricApi {
}
}
private const val STATUS_OK = "ok"
class GoingElectricApiWrapper(
val apikey: String,
baseurl: String = "https://api.goingelectric.de",
@@ -153,6 +180,10 @@ class GoingElectricApiWrapper(
// no connectors chosen
return Resource.success(ChargepointList.empty())
}
if (connectorsVal != null && connectorsVal.values.contains("CCS")) {
// see note about Tesla Supercharger CCS filter in getFilters below
connectorsVal.values.add("Tesla Supercharger CCS")
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
@@ -206,15 +237,17 @@ class GoingElectricApiWrapper(
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
if (!response.isSuccessful || response.body()!!.status != STATUS_OK) {
return Resource.error(response.message(), null)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
data.addAll(body.chargelocations!!)
startkey = body.startkey
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
} while (startkey != null && startkey < 10000)
@@ -247,6 +280,10 @@ class GoingElectricApiWrapper(
// no connectors chosen
return Resource.success(ChargepointList.empty())
}
if (connectorsVal != null && connectorsVal.values.contains("CCS")) {
// see note about Tesla Supercharger CCS filter in getFilters below
connectorsVal.values.add("Tesla Supercharger CCS")
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
@@ -297,15 +334,17 @@ class GoingElectricApiWrapper(
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
if (!response.isSuccessful || response.body()!!.status != STATUS_OK) {
return Resource.error(response.message(), null)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
data.addAll(body.chargelocations!!)
startkey = body.startkey
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
} while (startkey != null && startkey < 10000)
@@ -380,9 +419,9 @@ class GoingElectricApiWrapper(
): Resource<ChargeLocation> {
try {
val response = api.getChargepointDetail(id)
return if (response.isSuccessful && response.body()!!.status == "ok" && response.body()!!.chargelocations.size == 1) {
return if (response.isSuccessful && response.body()!!.status == STATUS_OK && response.body()!!.chargelocations!!.size == 1) {
Resource.success(
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
(response.body()!!.chargelocations!![0] as GEChargeLocation).convert(
apikey, true
)
)
@@ -391,6 +430,8 @@ class GoingElectricApiWrapper(
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
@@ -408,19 +449,24 @@ class GoingElectricApiWrapper(
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
if (responses.map { it.isSuccessful }.all { it }) {
if (responses.map { it.isSuccessful }.all { it }
&& plugsResponse.body()!!.status == STATUS_OK
&& chargeCardsResponse.body()!!.status == STATUS_OK
&& networksResponse.body()!!.status == STATUS_OK) {
Resource.success(
GEReferenceData(
plugsResponse.body()!!.result,
networksResponse.body()!!.result,
chargeCardsResponse.body()!!.result
plugsResponse.body()!!.result!!,
networksResponse.body()!!.result!!,
chargeCardsResponse.body()!!.result!!
)
)
} else {
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
Resource.error(responses.find { !it.isSuccessful }?.message(), null)
}
} catch (e: IOException) {
Resource.error(e.message, null)
} catch (e: HttpException) {
Resource.error(e.message, null)
}
}
}
@@ -434,9 +480,18 @@ class GoingElectricApiWrapper(
val networks = refData.networks
val chargeCards = refData.chargecards
val plugMap = plugs.associateWith { plug ->
nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
}
/*
"Tesla Supercharger CCS" is a bit peculiar - it is available as a filter, but the API
just returns "CCS" in the charging station details. So we cannot use it for filtering as
it won't work in the local database. So we join them into a single filter option.
If you want to find Tesla Superchargers with CCS, you can still do that using the network
filter.
*/
val plugMap = plugs
.filter { it != "Tesla Supercharger CCS" }
.associateWith { plug ->
nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
}
val networkMap = networks.associateWith { it }
val chargecardMap = chargeCards.associate { it.id.toString() to it.name }
val categoryMap = mapOf(
@@ -595,7 +650,7 @@ class GoingElectricApiWrapper(
val minConnectors = filters.getSliderValue("min_connectors")
if (minConnectors != null && minConnectors > 1) {
result.append(" GROUP BY ChargeLocation.id HAVING COUNT(1) >= ${minConnectors}")
result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= ${minConnectors}")
requiresChargepointQuery = true
}

View File

@@ -5,27 +5,42 @@ import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.Address
import net.vonforst.evmap.model.ChargeCard
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargeLocationCluster
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.ChargepriceData
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.Coordinate
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FaultReport
import net.vonforst.evmap.model.Hours
import net.vonforst.evmap.model.OpeningHours
import net.vonforst.evmap.model.OpeningHoursDays
import net.vonforst.evmap.model.ReferenceData
import java.time.Instant
import java.time.LocalTime
@JsonClass(generateAdapter = true)
data class GEChargepointList(
val status: String,
val chargelocations: List<GEChargepointListItem>,
val chargelocations: List<GEChargepointListItem>?,
@JsonObjectOrFalse val startkey: Int?
)
@JsonClass(generateAdapter = true)
data class GEStringList(
val status: String,
val result: List<String>
val result: List<String>?
)
@JsonClass(generateAdapter = true)
data class GEChargeCardList(
val status: String,
val result: List<GEChargeCard>
val result: List<GEChargeCard>?
)
sealed class GEChargepointListItem {
@@ -35,7 +50,7 @@ sealed class GEChargepointListItem {
@JsonClass(generateAdapter = true)
data class GEChargeLocation(
@Json(name = "ge_id") val id: Long,
val name: String,
val name: String?,
val coordinates: GECoordinate,
val address: GEAddress,
val chargepoints: List<GEChargepoint>,
@@ -57,7 +72,7 @@ data class GEChargeLocation(
override fun convert(apikey: String, isDetailed: Boolean) = ChargeLocation(
id,
"goingelectric",
name,
name ?: "Charging station",
coordinates.convert(),
address.convert(),
chargepoints.map { it.convert() },

View File

@@ -8,11 +8,30 @@ import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.*
import net.vonforst.evmap.model.*
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
import net.vonforst.evmap.api.powerSteps
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.Filter
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterValues
import net.vonforst.evmap.model.MultipleChoiceFilter
import net.vonforst.evmap.model.MultipleChoiceFilterValue
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.model.SliderFilter
import net.vonforst.evmap.model.getBooleanValue
import net.vonforst.evmap.model.getMultipleChoiceValue
import net.vonforst.evmap.model.getSliderValue
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
@@ -168,6 +187,8 @@ class OpenChargeMapApiWrapper(
return Resource.success(ChargepointList(result, data.size < maxResults))
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
@@ -259,6 +280,8 @@ class OpenChargeMapApiWrapper(
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
@@ -272,6 +295,8 @@ class OpenChargeMapApiWrapper(
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
@@ -375,7 +400,7 @@ class OpenChargeMapApiWrapper(
val minConnectors = filters.getSliderValue("min_connectors")
if (minConnectors != null && minConnectors > 1) {
result.append(" GROUP BY ChargeLocation.id HAVING COUNT(1) >= ${minConnectors}")
result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= ${minConnectors}")
requiresChargepointQuery = true
}
@@ -383,9 +408,7 @@ class OpenChargeMapApiWrapper(
}
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
val operators = filters.getMultipleChoiceValue("operators")
return (operators != null && !operators.all)
// TODO: it would be possible to implement this without requiring details if we extended the data structure to also save the operator ID in the DB
return false
}
}

View File

@@ -6,7 +6,15 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.max
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.Address
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargepriceData
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.Coordinate
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FaultReport
import net.vonforst.evmap.model.ReferenceData
import java.time.Instant
import java.time.ZonedDateTime
@@ -55,9 +63,9 @@ data class OCMChargepoint(
Coordinate(addressInfo.latitude, addressInfo.longitude),
addressInfo.toAddress(refData),
connections.map { it.convert(refData) },
operatorInfo?.title,
"https://openchargemap.org/site/poi/details/$id",
"https://openchargemap.org/site/poi/edit/$id",
operatorInfo?.title ?: refData.operators.find { it.id == operatorId }?.title,
"https://map.openchargemap.io/?id=$id",
"https://map.openchargemap.io/?id=$id",
convertFaultReport(),
recentlyVerified,
null,
@@ -151,7 +159,9 @@ data class OCMConnection(
fun convert(refData: OCMReferenceData) = Chargepoint(
convertConnectionTypeFromOCM(connectionTypeId, refData),
power,
quantity ?: 1
quantity ?: 1,
voltage?.toDouble(),
amps?.toDouble()
)
companion object {
@@ -165,7 +175,8 @@ data class OCMConnection(
17L -> Chargepoint.CEE_ROT
28L -> Chargepoint.SCHUKO
8L -> Chargepoint.TESLA_ROADSTER_HPC
27L -> Chargepoint.SUPERCHARGER
27L -> Chargepoint.SUPERCHARGER // Tesla North American plug (NACS)
30L -> Chargepoint.SUPERCHARGER // European Tesla Model S/X Supercharger plug (DC on Type 2)
25L -> Chargepoint.TYPE_2_SOCKET
1036L -> Chargepoint.TYPE_2_PLUG
1L -> Chargepoint.TYPE_1

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

@@ -5,6 +5,11 @@ package net.vonforst.evmap.auto
* and human-readable vehicle models as listed by Chargeprice in their vehicle database.
*/
private val brands = mapOf(
"Saic" to "MG", // Seen on MG 4
"Google" to "Hyundai" // useful for debugging on the DHU. Delete in case there's ever a Google car ;)
)
private val models = mapOf(
"Audi" to mapOf(
"516 (G4x)" to "e-tron"
@@ -19,4 +24,11 @@ fun getVehicleModel(manufacturer: String?, model: String?) =
models[manufacturer]?.get(model) ?: model
} else {
null
}
fun getVehicleBrand(manufacturer: String?) =
if (manufacturer != null) {
brands[manufacturer] ?: manufacturer
} else {
null
}

View File

@@ -28,6 +28,7 @@ import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.currency
import net.vonforst.evmap.ui.time
import retrofit2.HttpException
import java.io.IOException
import kotlin.math.roundToInt
@@ -207,7 +208,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
private fun loadPrices(model: Model?) {
val dataAdapter = ChargepriceApi.getDataAdapter(charger)
val manufacturer = model?.manufacturer?.value
val manufacturer = getVehicleBrand(model?.manufacturer?.value)
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
lifecycleScope.launch {
try {
@@ -303,6 +304,15 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
)
.show()
}
} catch (e: HttpException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.chargeprice_connection_error,
CarToast.LENGTH_LONG
)
.show()
}
} catch (e: NoVehicleSelectedException) {
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
invalidate()
@@ -345,7 +355,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
} else if (vehicles.size > 1) {
if (manufacturer != null) {
vehicles = vehicles.filter {
it.brand == manufacturer
it.brand.lowercase() == getVehicleBrand(manufacturer)?.lowercase()
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()

View File

@@ -1,7 +1,11 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.*
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.text.SpannableString
@@ -11,7 +15,17 @@ import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarIconSpan
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.model.Pane
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.ParkedOnlyOnClickListener
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.scale
import androidx.core.text.HtmlCompat
@@ -22,11 +36,22 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.EXTRA_CHARGER_ID
import net.vonforst.evmap.EXTRA_LAT
import net.vonforst.evmap.EXTRA_LON
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
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.tesla.Pricing
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
@@ -34,6 +59,7 @@ import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FaultReport
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.plus
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -45,6 +71,7 @@ import net.vonforst.evmap.viewmodel.awaitFinished
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -52,12 +79,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
@@ -118,7 +150,16 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
)
.setTitle(carContext.getString(R.string.auto_prices))
.setOnClickListener {
screenManager.push(ChargepriceScreen(carContext, charger))
if (prefs.chargepriceNativeIntegration) {
screenManager.push(ChargepriceScreen(carContext, charger))
} else {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(ChargepriceApi.getPoiUrl(charger))
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
carContext.startActivity(intent)
}
}
.build())
}
@@ -290,9 +331,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? 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 +509,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 +547,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)
}
@@ -430,7 +570,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val url = photo.getUrl(size = size)
val request = ImageRequest.Builder(carContext).data(url).build()
val img =
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
(carContext.imageLoader.execute(request).drawable as? BitmapDrawable)?.bitmap ?: return@let
// draw icon on top of image
val icon = iconGen.getBitmap(
@@ -463,12 +603,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_item,
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(
@@ -310,7 +344,7 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
R.string.deleted_item,
currentProfile.name
),
CarToast.LENGTH_SHORT

View File

@@ -4,6 +4,7 @@ import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
@@ -35,6 +36,7 @@ import net.vonforst.evmap.model.FilterWithValue
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.bearingBetween
@@ -43,6 +45,7 @@ import net.vonforst.evmap.utils.headingDiff
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.awaitFinished
import net.vonforst.evmap.viewmodel.filtersWithValue
import retrofit2.HttpException
import java.io.IOException
import java.time.Duration
import java.time.Instant
@@ -73,6 +76,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private var lastDistanceUpdateTime: Instant? = null
private var lastChargersUpdateTime: Instant? = null
private var chargers: List<ChargeLocation>? = null
private var isFavorite: List<Boolean>? = null
private var loadingError = false
private var locationError = false
private var prefs = PreferenceDataSource(ctx)
@@ -114,6 +118,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private var searchLocation: LatLng? = null
private val iconGen =
ChargerIconGenerator(carContext, null, height = 96)
init {
lifecycle.addObserver(this)
marker = MARKER
@@ -154,8 +161,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
val builder = ItemList.Builder()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger, showCity))
chargerList.forEachIndexed { i, charger ->
builder.addItem(formatCharger(charger, showCity, isFavorite?.get(i) ?: false))
}
builder.setNoItemsMessage(
carContext.getString(
@@ -231,10 +238,12 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
chargers = null
isFavorite = null
loadChargers()
}
} else {
chargers = null
isFavorite = null
loadChargers()
}
} else {
@@ -245,6 +254,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
) {
chargers = null
isFavorite = null
loadChargers()
}
session.mapScreen = null
@@ -277,13 +287,18 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}.build()
}
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
val markerTint = if ((charger.maxPower ?: 0.0) > 100) {
private fun formatCharger(
charger: ChargeLocation,
showCity: Boolean,
isFavorite: Boolean
): Row {
val markerTint = getMarkerTint(charger)
val backgroundTint = if ((charger.maxPower ?: 0.0) > 100) {
R.color.charger_100kw_dark // slightly darker color for better contrast
} else {
getMarkerTint(charger)
markerTint
}
val color = ContextCompat.getColor(carContext, markerTint)
val color = ContextCompat.getColor(carContext, backgroundTint)
val place =
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
.setMarker(
@@ -293,16 +308,32 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
.build()
val icon = iconGen.getBitmap(
markerTint,
fault = charger.faultReport != null,
multi = charger.isMulti(),
fav = isFavorite
)
val iconSpan =
CarIconSpan.create(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build())
return Row.Builder().apply {
// only show the city if not all chargers are in the same city (-> showCity == true)
// and the city is not already contained in the charger name
val title = SpannableStringBuilder().apply {
append(" ", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
append(" ")
append(charger.name)
}
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
setTitle(
CarText.Builder("${charger.name} · ${charger.address.city}")
.addVariant(charger.name)
.build())
val titleWithCity = SpannableStringBuilder().apply {
append("", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
append(" ")
append("${charger.name} · ${charger.address.city}")
}
setTitle(CarText.Builder(titleWithCity).addVariant(title).build())
} else {
setTitle(charger.name)
setTitle(title)
}
val text = SpannableStringBuilder()
@@ -318,7 +349,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
DistanceSpan.create(
roundValueToDistance(
distanceMeters,
energyLevel?.distanceDisplayUnit?.value
energyLevel?.distanceDisplayUnit?.value,
carContext
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
@@ -416,15 +448,19 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
val filters = repo.getFiltersAsync(carContext.stringProvider())
filtersWithValue = filtersWithValue(filters, filterValues)
val apiId = repo.api.value!!.id
// load chargers
if (filterStatus == FILTERS_FAVORITES) {
chargers =
val chargers =
db.favoritesDao().getAllFavoritesAsync().map { it.charger }.sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
this@MapScreen.chargers = chargers
isFavorite = List(chargers.size) { true }
} else {
// try multiple search radii until we have enough chargers
var chargers: List<ChargeLocation>? = null
@@ -453,7 +489,11 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
break
}
}
val isFavorite = chargers?.map {
db.favoritesDao().findFavorite(it.id, apiId) != null
}
this@MapScreen.chargers = chargers
this@MapScreen.isFavorite = isFavorite
}
updateCoroutine = null
@@ -463,6 +503,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
} catch (e: IOException) {
loadingError = true
invalidate()
} catch (e: HttpException) {
loadingError = true
invalidate()
}
}
}

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,12 @@ 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
import retrofit2.HttpException
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
SearchTemplate.SearchCallback {
@@ -22,9 +26,15 @@ 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) {
showLoadingError()
} catch (e: HttpException) {
showLoadingError()
}
}
}
@@ -62,6 +72,17 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
}.build()
}
private suspend fun showLoadingError() {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.generic_connection_error,
CarToast.LENGTH_LONG
).show()
screenManager.pop()
}
}
private fun filterList() {
currentList = fullList?.let {
it.sortedBy { getLabel(it).lowercase() }

View File

@@ -1,26 +1,57 @@
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
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.ParkedOnlyOnClickListener
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
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.BuildConfig
import net.vonforst.evmap.EXTRA_DONATE
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.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.getPackageInfoCompat
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 +154,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 +164,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 +215,122 @@ 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 codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
val args = OAuthLoginFragmentArgs(
uri.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!!, codeVerifier)
}
}, 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, codeVerifier: String) {
teslaLoggingIn = true
invalidate()
val code = url.getQueryParameter("code") ?: return
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
lifecycleScope.launch {
try {
val time = Instant.now().epochSecond
val response =
TeslaAuthenticationApi.create(okhttp).getToken(request)
val userResponse =
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
encryptedPrefs.teslaEmail = userResponse.response.email
encryptedPrefs.teslaAccessToken = response.accessToken
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
encryptedPrefs.teslaRefreshToken = response.refreshToken
} catch (e: IOException) {
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(
@@ -278,12 +425,21 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
setTitle(carContext.getString(R.string.settings_chargeprice))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_native_integration))
addText(carContext.getString(if (prefs.chargepriceNativeIntegration) R.string.pref_chargeprice_native_integration_on else R.string.pref_chargeprice_native_integration_off))
setToggle(Toggle.Builder {
prefs.chargepriceNativeIntegration = it
invalidate()
}.setChecked(prefs.chargepriceNativeIntegration).build())
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_vehicle))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectVehiclesScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_tariffs))
@@ -307,6 +463,7 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
)
}
)
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
@@ -324,6 +481,7 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
setOnClickListener {
screenManager.push(SelectChargingRangeScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_currency))
@@ -339,27 +497,31 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
setOnClickListener {
screenManager.push(SelectCurrencyScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_no_base_fee))
setToggle(Toggle.Builder {
prefs.chargepriceNoBaseFee = it
}.setChecked(prefs.chargepriceNoBaseFee).build())
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
setToggle(Toggle.Builder {
prefs.chargepriceShowProviderCustomerTariffs = it
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
if (maxRows > 6) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
setToggle(Toggle.Builder {
prefs.chargepriceShowProviderCustomerTariffs = it
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load))
addText(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load_summary))
setToggle(Toggle.Builder {
prefs.chargepriceAllowUnbalancedLoad = it
}.setChecked(prefs.chargepriceAllowUnbalancedLoad).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
}
}.build())
@@ -646,7 +808,7 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
.setOnClickListener(ParkedOnlyOnClickListener.create {
if (BuildConfig.FLAVOR_automotive == "automotive") {
// we can't open the donation page on the phone in this case
openUrl(carContext, carContext.getString(R.string.paypal_link))
openUrl(carContext, carContext.getString(R.string.donate_link))
} else {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -703,6 +865,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

@@ -84,7 +84,7 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
return GridTemplate.Builder().apply {
setTitle(
if (model != null && model.manufacturer.value != null && model.name.value != null) {
"${model.manufacturer.value} ${
"${getVehicleBrand(model.manufacturer.value)} ${
getVehicleModel(
model.manufacturer.value,
model.name.value
@@ -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
@@ -61,7 +62,7 @@ class ChargepriceFragment : Fragment() {
if (savedInstanceState == null) {
val prefs = PreferenceDataSource(requireContext())
prefs.chargepriceCounter += 1
if ((prefs.chargepriceCounter - 30).mod(50) == 0) {
if ((prefs.chargepriceCounter).mod(30) == 0) {
showDonationDialog()
}
}
@@ -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

@@ -0,0 +1,63 @@
package net.vonforst.evmap.fragment
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.ConnectorDetailsAdapter
import net.vonforst.evmap.adapter.SingleViewAdapter
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.databinding.DialogConnectorDetailsBinding
import net.vonforst.evmap.databinding.DialogConnectorDetailsHeaderBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
class ConnectorDetailsDialog(
val binding: DialogConnectorDetailsBinding,
context: Context,
onClose: () -> Unit
) {
private val headerBinding: DialogConnectorDetailsHeaderBinding
private val detailsAdapter = ConnectorDetailsAdapter()
init {
binding.list.apply {
itemAnimator = null
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
}
headerBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.dialog_connector_details_header, binding.list, false
)
binding.list.adapter = ConcatAdapter(
SingleViewAdapter(headerBinding.root),
detailsAdapter
)
binding.btnClose.setOnClickListener {
onClose()
}
}
fun setData(cp: Chargepoint, status: ChargeLocationStatus?) {
val cpStatus = status?.status?.get(cp)
val items = if (status != null) {
List(cp.count) { i ->
ConnectorDetailsAdapter.ConnectorDetails(
cpStatus?.get(i),
status.evseIds?.get(cp)?.get(i),
status.labels?.get(cp)?.get(i),
status.lastChange?.get(cp)?.get(i)
)
}.sortedBy { it.evseId ?: it.label }
} else emptyList()
detailsAdapter.submitList(items)
headerBinding.divider.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE
headerBinding.item = ConnectorAdapter.ChargepointWithAvailability(cp, cpStatus)
}
}

View File

@@ -0,0 +1,29 @@
package net.vonforst.evmap.fragment
import androidx.fragment.app.Fragment
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
abstract class DonateFragmentBase : Fragment() {
fun setupReferrals(referrals: FragmentDonateReferralBinding) {
referrals.referralTesla.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.tesla_referral_link))
}
referrals.referralJuicify.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.juicify_referral_link))
}
referrals.referralGeldfuereauto.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.geldfuereauto_referral_link))
}
referrals.referralMaingau.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.maingau_referral_link))
}
referrals.referralEwieeinfach.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.ewieeinfach_referral_link))
}
referrals.referralEprimo.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.eprimo_referral_link))
}
}
}

View File

@@ -28,7 +28,6 @@ import net.vonforst.evmap.databinding.FragmentFavoritesBinding
import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.model.FavoriteWithDetail
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.viewmodel.FavoritesViewModel
@@ -37,7 +36,6 @@ import net.vonforst.evmap.viewmodel.viewModelFactory
class FavoritesFragment : Fragment() {
private lateinit var binding: FragmentFavoritesBinding
private lateinit var locationEngine: LocationEngine
private var toDelete: Favorite? = null
private var deleteSnackbar: Snackbar? = null
private lateinit var adapter: FavoritesAdapter
@@ -113,6 +111,32 @@ class FavoritesFragment : Fragment() {
binding.swipeRefresh.isRefreshing = false
}
}
vm.deletedFavorite.observe(viewLifecycleOwner) { fav ->
if (fav == null) {
deleteSnackbar?.dismiss()
return@observe
}
val snackbar = Snackbar.make(
requireView(),
getString(
R.string.deleted_item,
fav.charger.name
),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
vm.undoDeletion()
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
// if undo was not clicked, actually delete
if (event == DISMISS_EVENT_TIMEOUT || event == DISMISS_EVENT_SWIPE) {
vm.deletedFavorite.value = null
}
}
})
deleteSnackbar = snackbar
snackbar.show()
}
}
override fun onStart() {
@@ -127,41 +151,7 @@ class FavoritesFragment : Fragment() {
}
fun delete(fav: FavoriteWithDetail) {
val position =
vm.listData.value?.indexOfFirst { it.fav.favorite.favoriteId == fav.favorite.favoriteId }
?: return
// if there is already a profile to delete, delete it now
actuallyDelete()
deleteSnackbar?.dismiss()
toDelete = fav.favorite
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fav.charger.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
toDelete = null
adapter.notifyItemChanged(position)
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
// if undo was not clicked, actually delete
if (event == DISMISS_EVENT_TIMEOUT || event == DISMISS_EVENT_SWIPE) {
actuallyDelete()
}
}
})
deleteSnackbar = snackbar
snackbar.show()
} ?: run {
actuallyDelete()
}
}
private fun actuallyDelete() {
toDelete?.let { vm.deleteFavorite(it) }
toDelete = null
vm.deleteFavoriteWithUndo(fav)
}
private fun createTouchHelper(): ItemTouchHelper {

View File

@@ -1,7 +1,12 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.*
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import androidx.databinding.DataBindingUtil
@@ -108,31 +113,19 @@ class FilterFragment : Fragment(), MenuProvider {
}
}
private fun saveProfile(error: Boolean = false) {
showEditTextDialog(requireContext()) { dialog, input ->
private fun saveProfile() {
showEditTextDialog(requireContext(), { dialog, input ->
vm.filterProfile.value?.let { profile ->
input.setText(profile.name)
}
if (error) {
input.error = getString(R.string.required)
}
dialog.setTitle(R.string.save_as_profile)
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { _, _ ->
if (input.text.isBlank()) {
saveProfile(true)
} else {
lifecycleScope.launch {
vm.saveAsProfile(input.text.toString())
findNavController().popBackStack()
}
}
}
.setNegativeButton(R.string.cancel) { _, _ ->
}
}
}, {
lifecycleScope.launch {
vm.saveAsProfile(it)
findNavController().popBackStack()
}
})
}
}

View File

@@ -183,20 +183,16 @@ class FilterProfilesFragment : Fragment() {
adapter = FilterProfilesAdapter(touchHelper, onDelete = { fp ->
delete(fp)
}, onRename = { fp ->
showEditTextDialog(requireContext()) { dialog, input ->
showEditTextDialog(requireContext(), { dialog, input ->
input.setText(fp.name)
dialog.setTitle(R.string.rename)
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { _, _ ->
lifecycleScope.launch {
vm.update(fp.copy(name = input.text.toString()))
}
}
.setNegativeButton(R.string.cancel) { _, _ ->
}
}
}, {
lifecycleScope.launch {
vm.update(fp.copy(name = it))
}
})
})
binding.filterProfilesList.apply {
this.adapter = this@FilterProfilesFragment.adapter
@@ -230,7 +226,7 @@ class FilterProfilesFragment : Fragment() {
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fp.name),
getString(R.string.deleted_item, fp.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
toDelete = null

View File

@@ -3,13 +3,22 @@ package net.vonforst.evmap.fragment
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.location.Geocoder
import android.os.Build
import android.os.Bundle
import android.text.method.KeyListener
import android.view.*
import android.view.ContextThemeWrapper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.AdapterView
import android.widget.ImageView
@@ -21,7 +30,13 @@ import androidx.annotation.RequiresPermission
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.view.*
import androidx.core.view.MenuCompat
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnLayout
import androidx.core.view.doOnNextLayout
import androidx.core.view.updateLayoutParams
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -29,7 +44,6 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@@ -51,10 +65,16 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.google.android.material.transition.MaterialContainerTransform.FADE_MODE_CROSS
import com.google.android.material.transition.MaterialFadeThrough
import com.google.android.material.transition.MaterialSharedAxis
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.*
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.from
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
import com.stfalcon.imageviewer.StfalconImageViewer
import io.michaelrocks.bimap.HashBiMap
@@ -69,23 +89,46 @@ import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.bold
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.fragment.preference.DataSettingsFragmentArgs
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargeLocationCluster
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
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.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.HideOnScrollFabBehavior
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.chargerZ
import net.vonforst.evmap.ui.clusterZ
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.ui.placeSearchZ
import net.vonforst.evmap.ui.setTouchModal
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.utils.checkFineLocationPermission
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.viewmodel.*
import net.vonforst.evmap.utils.formatDecimal
import net.vonforst.evmap.viewmodel.GalleryViewModel
import net.vonforst.evmap.viewmodel.MapPosition
import net.vonforst.evmap.viewmodel.MapViewModel
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import java.io.IOException
import java.time.Duration
import java.time.Instant
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.contains
@@ -103,6 +146,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private lateinit var detailsDialog: ConnectorDetailsDialog
private lateinit var prefs: PreferenceDataSource
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
@@ -111,6 +155,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var connectionErrorSnackbar: Snackbar? = null
private var previousChargepointIds: Set<Long>? = null
private var mapTopPadding: Int = 0
private var popupMenu: PopupMenu? = null
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
@@ -124,6 +169,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return
}
if (vm.selectedChargepoint.value != null) {
closeConnectorDetailsDialog()
vm.selectedChargepoint.value = null
return
}
if (binding.search.hasFocus()) {
removeSearchFocus()
}
@@ -194,6 +245,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
searchResultIcon = null
}
binding.detailAppBar.toolbar.popupTheme =
com.google.android.material.R.style.ThemeOverlay_AppCompat_DayNight
ViewCompat.setOnApplyWindowInsetsListener(
binding.root
) { _, insets ->
@@ -251,13 +305,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
favToggle = binding.detailAppBar.toolbar.menu.findItem(R.id.menu_fav)
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, vm.apiName)
vm.apiName.observe(viewLifecycleOwner) {
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, it)
}
binding.detailView.topPart.doOnNextLayout {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
vm.bottomSheetState.value?.let { bottomSheetBehavior.state = it }
}
bottomSheetBehavior.isCollapsible = bottomSheetCollapsible
binding.detailView.connectorDetails
setupObservers()
setupClickListeners()
@@ -269,9 +328,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
(requireActivity() as MapsActivity).appBarConfiguration
)
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
if (prefs.appStartCounter > 5 && Duration.between(
prefs.opensourceDonationsDialogLastShown,
Instant.now()
) > Duration.ofDays(30)
) {
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) {
@@ -280,7 +343,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
}
@@ -311,6 +374,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.appLogo.root.visibility = View.GONE
binding.search.visibility = View.VISIBLE
}
detailsDialog =
ConnectorDetailsDialog(binding.detailView.connectorDetails, requireContext()) {
closeConnectorDetailsDialog()
vm.selectedChargepoint.value = null
}
}
override fun onResume() {
@@ -367,22 +436,24 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.detailView.btnChargeprice.setOnClickListener {
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
)
if (prefs.chargepriceNativeIntegration) {
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().safeNavigate(
MapFragmentDirections.actionMapToChargepriceFragment(charger),
extras
)
} else {
(activity as? MapsActivity)?.openUrl(ChargepriceApi.getPoiUrl(charger), false)
}
}
binding.detailView.btnChargerWebsite.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
binding.detailView.btnLogin.setOnClickListener {
findNavController().navigate(
R.id.settings_data,
DataSettingsFragmentArgs(true).toBundle()
findNavController().safeNavigate(
MapFragmentDirections.actionMapToDataSettings(true)
)
}
binding.detailView.imgPredictionSource.setOnClickListener {
@@ -397,6 +468,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = STATE_ANCHOR_POINT
}
binding.detailView.topPart.setOnLongClickListener {
val charger = vm.charger.value?.data ?: return@setOnLongClickListener false
copyToClipboard(ClipData.newPlainText(getString(R.string.charger_name), charger.name))
return@setOnLongClickListener true
}
setupSearchAutocomplete()
binding.detailAppBar.toolbar.setNavigationOnClickListener {
if (bottomSheetCollapsible) {
@@ -433,6 +509,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
true
}
R.id.menu_reload -> {
vm.reloadChargerDetails()
true
}
else -> false
}
}
@@ -587,6 +669,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
) {
closeLayersMenu()
}
if (vm.selectedChargepoint.value != null && newState in listOf(
STATE_ANCHOR_POINT, STATE_COLLAPSED
)
) {
closeConnectorDetailsDialog()
vm.selectedChargepoint.value = null
}
}
})
vm.chargerSparse.observe(viewLifecycleOwner) {
@@ -640,6 +730,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
displaySearchResult(place, moveCamera = true)
}
vm.layersMenuOpen.observe(viewLifecycleOwner) { open ->
HideOnScrollFabBehavior.from(binding.fabLayers)?.hidden = open
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
binding.layersSheet.visibility = if (open) View.VISIBLE else View.INVISIBLE
updateBackPressedCallback()
@@ -650,6 +741,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.mapTrafficEnabled.observe(viewLifecycleOwner) {
map?.setTrafficEnabled(it)
}
vm.selectedChargepoint.observe(viewLifecycleOwner) {
binding.detailView.connectorDetailsCard.visibility =
if (it != null) View.VISIBLE else View.INVISIBLE
if (it != null) {
detailsDialog.setData(it, vm.availability.value?.data)
}
updateBackPressedCallback()
}
updateBackPressedCallback()
}
@@ -694,6 +793,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|| vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
|| binding.search.hasFocus()
|| vm.selectedChargepoint.value != null
}
private fun unhighlightAllMarkers() {
@@ -794,7 +894,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.detailView.connectors.apply {
adapter = ConnectorAdapter()
adapter = ConnectorAdapter().apply {
onClickListener = {
vm.selectedChargepoint.value = it.chargepoint
openConnectorDetailsDialog()
}
}
itemAnimator = null
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
@@ -812,15 +917,45 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
R.drawable.ic_fault_report -> {
(activity as? MapsActivity)?.openUrl(charger.url)
}
R.drawable.ic_payment -> {
showPaymentMethodsDialog(charger)
}
R.drawable.ic_network -> {
charger.networkUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
}
}
}
onLongClickListener = {
val charger = vm.chargerDetails.value?.data
if (charger != null) {
when (it.icon) {
R.drawable.ic_address -> {
if (charger.address != null) {
copyToClipboard(ClipData.newPlainText(
getString(R.string.address),
charger.address.toString()
))
true
} else {
false
}
}
R.drawable.ic_location -> {
copyToClipboard(ClipData.newPlainText(
getString(R.string.coordinates),
charger.coordinates.formatDecimal()
))
true
}
else -> false
}
} else {
false
}
}
}
itemAnimator = null
layoutManager =
@@ -834,6 +969,59 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
private fun copyToClipboard(clip: ClipData) {
val clipboardManager =
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Snackbar.make(
requireView(),
R.string.copied,
Toast.LENGTH_SHORT
)
.show()
}
}
private fun openConnectorDetailsDialog() {
val chargepoints = vm.chargerDetails.value?.data?.chargepointsMerged ?: return
val chargepoint = vm.selectedChargepoint.value ?: return
val index = chargepoints.indexOf(chargepoint).takeIf { it >= 0 } ?: return
val vh = binding.detailView.connectors.findViewHolderForAdapterPosition(index) ?: return
val materialTransform = MaterialContainerTransform().apply {
startView = vh.itemView
endView = binding.detailView.connectorDetailsCard
setPathMotion(MaterialArcMotion())
duration = 250
scrimColor = Color.TRANSPARENT
addTarget(binding.detailView.connectorDetailsCard)
isElevationShadowEnabled = false
fadeMode = FADE_MODE_CROSS
}
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
}
private fun closeConnectorDetailsDialog() {
val chargepoints = vm.chargerDetails.value?.data?.chargepointsMerged ?: return
val chargepoint = vm.selectedChargepoint.value ?: return
val index = chargepoints.indexOf(chargepoint).takeIf { it >= 0 } ?: return
val vh = binding.detailView.connectors.findViewHolderForAdapterPosition(index) ?: return
val materialTransform = MaterialContainerTransform().apply {
startView = binding.detailView.connectorDetailsCard
endView = vh.itemView
setPathMotion(MaterialArcMotion())
duration = 200
scrimColor = Color.TRANSPARENT
addTarget(vh.itemView)
isElevationShadowEnabled = false
fadeMode = FADE_MODE_CROSS
}
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
}
private fun showPaymentMethodsDialog(charger: ChargeLocation) {
val activity = activity ?: return
val chargecardData = vm.chargeCardMap.value ?: return
@@ -899,23 +1087,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) {
@@ -1035,7 +1219,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
}
@@ -1223,20 +1407,22 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
lifecycleScope.launch {
vm.copyFiltersToCustom()
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
findNavController().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
findNavController().safeNavigate(
MapFragmentDirections.actionMapToFilterProfilesFragment()
)
true
}
else -> {
val profileId = profilesMap.inverse[it]
if (profileId != null) {
@@ -1311,17 +1497,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
popup.setTouchModal(false)
popupMenu = popup
popup.show()
}
filterView?.setOnLongClickListener {
// enable/disable filters
vm.toggleFilters()
// haptic feedback
filterView.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS,
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
)
// show snackbar
Snackbar.make(
requireView(), if (vm.filterStatus.value != FILTERS_DISABLED) {
@@ -1335,8 +1517,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return false
override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) {
R.id.menu_reload -> {
vm.reloadChargepoints(true)
true
}
else -> false
}
override fun getRootView(): View {
@@ -1387,9 +1574,15 @@ 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() {
super.onDestroy()
/* if we don't dismiss the popup menu, it will be recreated in some cases
(split-screen mode) and then have references to a destroyed fragment. */
popupMenu?.dismiss()
}
}

View File

@@ -6,28 +6,36 @@ import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.AnimatedVectorDrawable
import android.os.Bundle
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.viewpager2.adapter.FragmentStateAdapter
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() {
private lateinit var binding: FragmentOnboardingBinding
private lateinit var adapter: OnboardingViewPagerAdapter
private lateinit var prefs: PreferenceDataSource
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
prefs = PreferenceDataSource(requireContext())
binding = FragmentOnboardingBinding.inflate(inflater)
adapter = OnboardingViewPagerAdapter(this)
@@ -67,15 +75,35 @@ class OnboardingFragment : Fragment() {
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (prefs.welcomeDialogShown) {
// skip to last page for selecting data source or accepting the privacy policy
binding.viewPager.currentItem = adapter.itemCount - 1
}
}
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)
}
}
}
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 4
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> AndroidAutoFragment()
3 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}
abstract class OnboardingPageFragment : Fragment() {
lateinit var parent: OnboardingFragment
@@ -182,7 +210,9 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
binding.rgDataSource.rbGoingElectric,
binding.rgDataSource.textView27,
binding.rgDataSource.rbOpenChargeMap,
binding.rgDataSource.textView28
binding.rgDataSource.textView28,
binding.dataSourceHint,
binding.cbAcceptPrivacy
)
override fun onCreateView(
@@ -196,6 +226,15 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.cbAcceptPrivacy.text =
HtmlCompat.fromHtml(
getString(
R.string.accept_privacy,
getString(R.string.privacy_link)
), HtmlCompat.FROM_HTML_MODE_LEGACY
)
binding.cbAcceptPrivacy.linksClickable = true
binding.cbAcceptPrivacy.movementMethod = LinkMovementMethod.getInstance()
binding.btnGetStarted.visibility = View.INVISIBLE
for (rb in listOf(
@@ -211,8 +250,24 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
}
}
}
if (prefs.dataSourceSet) {
when (prefs.dataSource) {
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
}
}
binding.btnGetStarted.setOnClickListener {
if (!binding.cbAcceptPrivacy.isChecked) {
binding.cbAcceptPrivacy.setTextColor(
ContextCompat.getColor(
requireContext(),
R.color.delete_red
)
)
return@setOnClickListener
}
val result = if (binding.rgDataSource.rbGoingElectric.isChecked) {
"goingelectric"
} else if (binding.rgDataSource.rbOpenChargeMap.isChecked) {
@@ -221,6 +276,7 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
return@setOnClickListener
}
prefs.dataSource = result
prefs.privacyAccepted = true
prefs.filterStatus = FILTERS_DISABLED
prefs.dataSourceSet = true
prefs.welcomeDialogShown = true
@@ -254,4 +310,47 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
super.onPause()
animatedItems.forEach { it.alpha = 0f }
}
}
class AndroidAutoFragment : OnboardingPageFragment() {
private lateinit var binding: FragmentOnboardingAndroidAutoBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentOnboardingAndroidAutoBinding.inflate(inflater, container, false)
binding.btnGetStarted.setOnClickListener {
parent.goToNext()
}
binding.imgAndroidAuto.alpha = 0f
return binding.root
}
@SuppressLint("Recycle")
override fun onResume() {
super.onResume()
val animators =
listOf(
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "translationY", -20f, 0f).apply {
interpolator = DecelerateInterpolator()
},
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "alpha", 0f, 1f).apply {
interpolator = DecelerateInterpolator()
}
)
AnimatorSet().apply {
playTogether(animators)
start()
}
}
override fun onPause() {
super.onPause()
binding.imgAndroidAuto.alpha = 0f
}
}

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
@@ -101,18 +102,17 @@ class AboutFragment : PreferenceFragmentCompat() {
.withAboutVersionShown(false)
.withAboutIconShown(false)
.withActivityTitle(getString(R.string.oss_licenses))
.withExcludedLibraries()
.start(requireActivity())
true
}
"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

@@ -7,6 +7,7 @@ import android.text.Spanned
import android.text.style.RelativeSizeSpan
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.CheckBoxPreference
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.MultiSelectDialogPreference
import net.vonforst.evmap.viewmodel.SettingsViewModel
@@ -28,9 +29,11 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
private lateinit var myVehiclePreference: MultiSelectDialogPreference
private lateinit var myTariffsPreference: MultiSelectDialogPreference
private lateinit var nativeIntegrationPreference: CheckBoxPreference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
nativeIntegrationPreference = findPreference("chargeprice_native_integration")!!
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
myVehiclePreference.isEnabled = false
@@ -48,7 +51,7 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
)
}
}.toTypedArray()
myVehiclePreference.isEnabled = true
myVehiclePreference.isEnabled = nativeIntegrationPreference.isChecked
updateMyVehiclesSummary()
}
}
@@ -65,10 +68,28 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
it.name
}
}.toTypedArray()
myTariffsPreference.isEnabled = true
myTariffsPreference.isEnabled = nativeIntegrationPreference.isChecked
updateMyTariffsSummary()
}
}
updateNativeIntegrationState()
}
private fun updateNativeIntegrationState() {
for (i in 0 until preferenceScreen.preferenceCount) {
val pref = preferenceScreen.getPreference(i)
if (pref == nativeIntegrationPreference) {
continue
} else if (pref == myTariffsPreference) {
pref.isEnabled =
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
} else if (pref == myVehiclePreference) {
pref.isEnabled =
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
} else {
pref.isEnabled = nativeIntegrationPreference.isChecked
}
}
}
private fun updateMyTariffsSummary() {
@@ -101,14 +122,19 @@ 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()
}
"chargeprice_native_integration" -> {
updateNativeIntegrationState()
}
}
}
}

View File

@@ -15,8 +15,8 @@ import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.TeslaOwnerApi
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.TeslaOwnerApi
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
@@ -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") {
@@ -141,18 +141,11 @@ 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 uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
val args = OAuthLoginFragmentArgs(
uri.toString(),
"https://auth.tesla.com/void/callback",
TeslaAuthenticationApi.resultUrlPrefix,
"#000000"
).toBundle()
@@ -184,7 +177,8 @@ class DataSettingsFragment : BaseSettingsFragment() {
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

@@ -6,9 +6,11 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import net.vonforst.evmap.R
import net.vonforst.evmap.isAppInstalled
import net.vonforst.evmap.ui.getAppLocale
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
@@ -16,6 +18,7 @@ import net.vonforst.evmap.ui.updateNightMode
class UiSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
lateinit var langPref: ListPreference
lateinit var immediateNavPref: CheckBoxPreference
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_ui, rootKey)
@@ -28,14 +31,21 @@ class UiSettingsFragment : BaseSettingsFragment() {
val appLinkPref = findPreference<Preference>("applink_associate")!!
appLinkPref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
immediateNavPref = findPreference("navigate_use_maps")!!
immediateNavPref.isVisible = isGoogleMapsInstalled()
}
private fun isGoogleMapsInstalled() =
requireContext().packageManager.isAppInstalled("com.google.android.apps.maps")
override fun onResume() {
super.onResume()
langPref.value = getAppLocale()
langPref.value = getAppLocale(requireContext())
immediateNavPref.isVisible = isGoogleMapsInstalled()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
"darkmode" -> {
updateNightMode(prefs)

View File

@@ -7,8 +7,10 @@ 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
import java.time.Instant
class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
private lateinit var binding: DialogOpensourceDonationsBinding
@@ -25,16 +27,16 @@ class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
override fun initView(view: View, savedInstanceState: Bundle?) {
val prefs = PreferenceDataSource(requireContext())
binding.btnOk.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
prefs.opensourceDonationsDialogLastShown = Instant.now()
dismiss()
}
binding.btnDonate.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().navigate(R.id.action_opensource_donations_to_donate)
prefs.opensourceDonationsDialogLastShown = Instant.now()
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToDonate())
}
binding.btnGithubSponsors.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().navigate(R.id.action_opensource_donations_to_github_sponsors)
prefs.opensourceDonationsDialogLastShown = Instant.now()
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToGithubSponsors())
}
}

View File

@@ -18,7 +18,7 @@ import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
import kotlin.math.abs
sealed class ChargepointListItem
@@ -127,10 +127,13 @@ data class ChargeLocation(
get() {
val variants = chargepoints.distinctBy { it.power to it.type }
return variants.map { variant ->
val count = chargepoints
val filtered = chargepoints
.filter { it.type == variant.type && it.power == variant.power }
.sumOf { it.count }
Chargepoint(variant.type, variant.power, count)
val count = filtered.sumOf { it.count }
Chargepoint(variant.type, variant.power, count,
filtered.map { it.voltage }.distinct().singleOrNull(),
filtered.map { it.current }.distinct().singleOrNull()
)
}
}
@@ -390,24 +393,29 @@ data class Address(
@Parcelize
@JsonClass(generateAdapter = true)
data class Chargepoint(
// The chargepoint type (use one of the constants in the companion object)
// The connector type (use one of the constants in the companion object if applicable)
val type: String,
// Power in kW (or null if unknown)
val power: Double?,
// How many instances of this plug/socket are available?
val count: Int,
// Max current in A (or null if unknown)
val current: Double? = null,
// Max voltage in V (or null if unknown).
// note that for DC chargers: current * voltage may be larger than power
// (each of the three can be separately limited)
val voltage: Double? = null
) : Equatable, Parcelable {
fun hasKnownPower(): Boolean = power != null
fun hasKnownVoltageAndCurrent(): Boolean = voltage != null && current != null
/**
* If chargepoint power is defined, format it into a string.
* Otherwise, return null.
*/
fun formatPower(): String? {
if (power == null) {
return null
}
val powerFmt = if (power - power.toInt() == 0.0) {
if (power == null) return null
val powerFmt = if (abs(power - power.toInt()) < 0.1) {
"%.0f".format(power)
} else {
"%.1f".format(power)
@@ -415,6 +423,12 @@ data class Chargepoint(
return "$powerFmt kW"
}
fun formatVoltageAndCurrent(): String? {
if (current == null || voltage == null) return null
return "%.0f V · %.0f A".format(voltage, current)
}
companion object {
const val TYPE_1 = "Type 1"
const val TYPE_2_UNKNOWN = "Type 2 (either plug or socket)"

View File

@@ -1,10 +1,12 @@
package net.vonforst.evmap.navigation
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.util.AttributeSet
import android.widget.Toast
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
@@ -52,7 +54,15 @@ class CustomNavigator(
.build()
)
.build()
intent.launchUrl(context, Uri.parse(url))
try {
intent.launchUrl(context, Uri.parse(url))
} catch (e: ActivityNotFoundException) {
Toast.makeText(
context,
R.string.no_browser_app_found,
Toast.LENGTH_SHORT
).show()
}
}
override fun popBackStack() = true // Managed by Chrome Custom Tabs

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