Compare commits

...

178 Commits
1.4.2 ... 1.6.5

Author SHA1 Message Date
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
johan12345
06801c1898 Release 1.6.4 2023-06-17 17:59:30 +02:00
johan12345
c946b0fcd3 TeslaAvailabilityDetector: fix cases when number of chargepoints does not match 2023-06-17 17:38:33 +02:00
johan12345
dd4fcc7550 Run clustering on Dispatchers.Default, not Dispatchers.IO 2023-06-16 23:08:01 +02:00
johan12345
2ce82b961b Introduce clustering up to zoom level 15 in very crowded places (>500 chargers within view)
refs #285
2023-06-16 22:50:14 +02:00
johan12345
1be519b1ee Release 1.6.3 2023-06-14 22:02:07 +02:00
Hosted Weblate
01737f21d2 Translated using Weblate (Portuguese)
Currently translated at 100.0% (313 of 313 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-06-14 21:07:41 +02:00
johan12345
17ce9f420b Tesla: CongestionPriceHistogram is nullable 2023-06-13 22:57:10 +02:00
johan12345
6eb90498eb GoingElectric: fix SQL implementation of network/barrierFree/chargeCards filters 2023-06-13 20:37:30 +02:00
johan12345
074e0bf904 Release 1.6.2 2023-06-12 08:35:02 +02:00
johan12345
41ac223e97 properly escape strings in SQL queries 2023-06-12 08:33:33 +02:00
Hosted Weblate
f7196bcce0 Translated using Weblate (Portuguese)
Currently translated at 100.0% (313 of 313 strings)

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

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

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

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

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

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

Translated using Weblate (Portuguese)

Currently translated at 100.0% (283 of 283 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-05-01 13:06:23 +02:00
johan12345
b25c61fbea update Android Gradle plugin 2023-05-01 13:03:01 +02:00
johan12345
d472be1676 Release 1.4.10 2023-04-22 15:00:15 +02:00
Hosted Weblate
24fa85929e Added translation using Weblate (Romanian)
Added translation using Weblate (Romanian)

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

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

* Revert unused declaration.

---------

Co-authored-by: iboboc <Raluca2018>
2023-04-20 18:38:55 +02:00
Johan von Forstner
0bfa80bbe0 EnBW: expand list of countries 2023-04-14 21:04:30 +02:00
Johan von Forstner
d77f13682d enable portuguese locale 2023-04-07 23:01:15 +02:00
Johan von Forstner
0c19eb5833 minor fixes in Portuguese 2023-04-07 23:00:57 +02:00
Johan von Forstner
a5abedae55 Update translation files 2023-04-07 22:52:20 +02:00
johan12345
8405f4f4fa Release 1.4.9 2023-03-24 20:14:43 +01:00
johan12345
f435180c03 Chargeprice vehicle selection: add battery size and charging power to distinguish vehicle models
same as ee354d2cd1, but now also for Android Auto
2023-03-24 20:10:14 +01:00
Hosted Weblate
c2c3e96e97 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-google/
Translation: EVMap/Android (strings specific to Google Play variant)
2023-03-21 21:49:07 +01:00
Hosted Weblate
9100a6f442 Translated using Weblate (French)
Currently translated at 99.6% (282 of 283 strings)

Translated using Weblate (French)

Currently translated at 92.2% (261 of 283 strings)

Translated using Weblate (French)

Currently translated at 97.2% (35 of 36 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-google/fr/
Translate-URL: https://hosted.weblate.org/projects/evmap/android/fr/
Translation: EVMap/Android
Translation: EVMap/Android (strings specific to Google Play variant)
2023-03-21 21:49:06 +01:00
johan12345
5403549e0a gallery image loading improvements
refs #271
2023-03-21 21:48:08 +01:00
johan12345
c95f1e7c24 Coil: Allow hardware renderer
this didn't work previously as we used shared element transitions, but should be fine now that we switched to StfalconImageViewer in 0e1e3ba
2023-03-21 21:48:08 +01:00
johan12345
f8d5b78112 Remove SizeResolver(OriginalSize) for Coil
(determining the size automatically based on the actual ImageView size seems to work well now)
fixes #271
2023-03-21 21:48:08 +01:00
Hosted Weblate
246d456851 Translated using Weblate (German)
Currently translated at 100.0% (283 of 283 strings)

Co-authored-by: nautilusx <translate@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2023-03-05 21:19:01 +01:00
Johan von Forstner
3d303b6535 GoingElectric HoursAdapter: catch parsing exception 2023-03-05 13:31:54 +01:00
johan12345
135fce43c3 CarModels: add Renault BCB -> Megane E-Tech
see #269
2023-02-18 21:09:56 +01:00
johan12345
ee354d2cd1 Chargeprice vehicle selection: Add battery size and charging power to distinguish vehicle models
fixes #268
2023-02-18 21:05:45 +01:00
johan12345
350f18df8e Release 1.4.8 2023-02-14 19:34:46 +01:00
johan12345
dda151abf5 add @DoubleYouEl to contributors list 2023-02-14 19:30:41 +01:00
johan12345
a86f1397f4 fix unnecessary empty requests to fronyx API 2023-02-14 19:25:25 +01:00
johan12345
086cc51dd3 Release 1.4.7 2023-02-12 18:20:36 +01:00
johan12345
0de91bc107 update CustomBottomSheetBehavior 2023-02-12 18:17:38 +01:00
johan12345
3436bcd870 update CustomBottomSheetBehavior 2023-02-12 18:04:40 +01:00
johan12345
22c150d557 upgrade dependencies 2023-02-12 17:53:21 +01:00
johan12345
675abb5011 DonateViewModel: fix possible NPE when loading products
see also https://github.com/EventFahrplan/EventFahrplan/issues/71
2023-02-12 17:40:06 +01:00
johan12345
af2a2cfcae enable Dutch locale
fixes #267
2023-02-08 21:26:44 +01:00
Hosted Weblate
f74526fdd6 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-fdroid/
Translate-URL: https://hosted.weblate.org/projects/evmap/app-store-metadata/
Translation: EVMap/Android (strings specific to F-Droid variant)
Translation: EVMap/App Store metadata
2023-02-08 21:19:47 +01:00
Hosted Weblate
c5bbca0428 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-fdroid/
Translation: EVMap/Android (strings specific to F-Droid variant)
2023-02-08 21:19:26 +01:00
johan12345
6167079c0e update dependencies 2023-02-05 15:36:08 +01:00
Hosted Weblate
c3836a92ad Translated using Weblate (German)
Currently translated at 100.0% (283 of 283 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2023-02-05 15:31:48 +01:00
Hosted Weblate
dccce1a0a0 Translated using Weblate (Norwegian Bokmål)
Currently translated at 83.7% (237 of 283 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-02-05 15:31:47 +01:00
Hosted Weblate
74d79640a8 Translated using Weblate (English)
Currently translated at 100.0% (283 of 283 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/en/
Translation: EVMap/Android
2023-02-05 15:31:47 +01:00
Hosted Weblate
0eb6ece780 Translated using Weblate (French)
Currently translated at 92.2% (261 of 283 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/fr/
Translation: EVMap/Android
2023-02-05 15:31:47 +01:00
johan12345
ae15b13591 use patched mapbox-events-android version only for foss variant
to allow Google location services in google variant, even when Mapbox is selected
2023-02-04 19:40:16 +01:00
johan12345
4962eb7268 transfer project to ev-map GitHub org 2023-02-04 19:23:29 +01:00
johan12345
abe360d7c2 transfer JitPack dependencies to ev-map GitHub org 2023-02-04 19:18:42 +01:00
johan12345
2aa1fcf5bd Release 1.4.6 2023-02-01 18:56:15 +01:00
johan12345
221e5f49bc catch JsonDataExceptions from fronyx API 2023-02-01 18:54:35 +01:00
johan12345
df6f26ad56 fix import 2023-01-29 19:38:08 +01:00
johan12345
1210efd3b9 MapFragment: update map bottom padding when bottom sheet comes up 2023-01-29 18:54:02 +01:00
johan12345
097be8c92b get rid of some warnings 2023-01-28 22:33:49 +01:00
johan12345
16031884ac upgrade dependencies
Android Studio 2022.1.1
resourcesPlaceholders plugin broke - removed it for now
2023-01-28 22:00:52 +01:00
johan12345
c0b4c56eda Release 1.4.5 2023-01-20 20:10:57 +01:00
Hosted Weblate
9587ee948d Update translation files
Updated by "Squash Git commits" hook in Weblate.

Translated using Weblate (Norwegian Bokmål)

Currently translated at 83.3% (30 of 36 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android-google/
Translate-URL: https://hosted.weblate.org/projects/evmap/android-google/nb_NO/
Translation: EVMap/Android (strings specific to Google Play variant)
2023-01-20 19:13:16 +01:00
johan12345
890eec4419 FilterFragment: add button to reset current settings 2023-01-20 19:12:52 +01:00
johan12345
c972c871d4 add icons to filter popup menu 2023-01-20 18:38:06 +01:00
johan12345
e4da902430 GooglePlacesAutocompleteProvider: fix crash on network error 2023-01-19 22:28:13 +01:00
johan12345
7a5d4b4107 fix NPE 2023-01-08 12:48:23 +01:00
johan12345
80642b1731 fix infinite recursion in Utils.max 2023-01-08 12:45:31 +01:00
johan12345
6dab611c1b Release 1.4.4 2022-12-15 21:40:22 +01:00
johan12345
d9fc43af68 fix size of layers FAB
(fabSize=mini did not apply anymore since #135)
2022-12-11 19:01:24 +01:00
johan12345
2fd0fa7e22 update dependencies 2022-12-11 18:51:56 +01:00
johan12345
b04284fb16 AA/AAOS: fix crash when icon for plug type is not available 2022-12-11 17:58:06 +01:00
johan12345
7b3bd84d18 AA/AAOS: clear list of chargers on loading error 2022-12-11 17:49:04 +01:00
johan12345
773d052819 fix NPE in OpenChargeMapApi 2022-12-11 12:29:46 +01:00
johan12345
4e0ad98e17 AA/AAOS: implement app-driven refresh
(if supported by host)
2022-12-11 00:34:05 +01:00
johan12345
d8e572338a upgrade car app library to 1.3.0-rc01 2022-12-10 23:59:51 +01:00
johan12345
ff86eeff95 AA/AAOS: add some more useful info to developer options screen 2022-12-10 23:58:26 +01:00
johan12345
47f57992fb add extension function to abbreviate getContentLimit calls 2022-12-10 23:31:40 +01:00
johan12345
0ae59358ca AA/AAOS: implement a full "about" screen 2022-12-10 23:21:29 +01:00
johan12345
576e0b9c42 add @ExperimentalCarApi 2022-12-10 22:52:26 +01:00
johan12345
3878b27154 Revert "AAOS: CarSensorsWrapper: add experimental rotation sensor implementations"
This reverts commit e2cf332f34.
2022-12-10 22:46:31 +01:00
johan12345
2166ac076a Android Auto/Automotive: Fall back to GPS bearing if compass not available 2022-12-10 22:45:50 +01:00
johan12345
c489df2aaf Android Auto/Automotive: Fix crash when no or all prices match "my plans" 2022-12-09 21:43:43 +01:00
johan12345
56712ff1af Android Auto/Automotive: Fix crash when no prices are found 2022-12-09 21:34:45 +01:00
johan12345
e2cf332f34 AAOS: CarSensorsWrapper: add experimental rotation sensor implementations 2022-12-08 22:53:03 +01:00
johan12345
0b541d498d AA/AAOS: add developer options screen 2022-12-08 22:52:27 +01:00
johan12345
1bdc576300 AA/AAOS: enable search button while driving (#262)
Note that AA/AAOS will block access to keyboard while driving, but the search screen is still useful to access recent results. Also this enables the "clear search" button while driving.
2022-12-08 21:49:09 +01:00
johan12345
fb5da76834 fix changelogs 2022-11-30 19:59:48 +01:00
johan12345
ad922f0667 Release 1.4.3 2022-11-30 19:46:20 +01:00
johan12345
773b35d9ce Android Auto Place search: fix clickability when distance is not available 2022-11-30 19:26:34 +01:00
johan12345
a3347c9d62 ChargepriceScreen: use sectioned list instead of disabled state to separate own plans from others 2022-11-30 19:18:46 +01:00
johan12345
da671b8dd3 German string: fix informal form 2022-11-30 18:54:59 +01:00
johan12345
6d877e13e4 re-enable refresh button on AAOS
this is a workaround for https://issuetracker.google.com/issues/260112181
2022-11-30 18:45:23 +01:00
johan12345
8ab1d7170c update CustomBottomSheetBehavior
fixes #260
2022-11-26 21:15:44 +01:00
johan12345
1f75d722cd Implement multi-EVSEID request for fronyx API 2022-11-21 08:49:37 +01:00
johan12345
11bd4b2cec fix NPE in ChargepriceFragment 2022-11-20 20:30:23 +01:00
261 changed files with 6931 additions and 1380 deletions

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 11
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Decrypt keystore
@@ -76,3 +76,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-google-automotive-release.apk
asset_name: app-foss-automotive-release.apk
asset_content_type: application/vnd.android.package-archive

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
EVMap [![Build Status](https://github.com/johan12345/EVMap/actions/workflows/tests.yml/badge.svg)](https://github.com/johan12345/EVMap/actions)
EVMap [![Build Status](https://github.com/ev-map/EVMap/actions/workflows/tests.yml/badge.svg)](https://github.com/ev-map/EVMap/actions)
=====
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
<a href="https://ev-map.app" target="_blank">
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/></a>
Android app to find electric vehicle charging stations.
@@ -28,7 +29,7 @@ Features
Screenshots
-----------
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
Development setup
-----------------
@@ -42,13 +43,27 @@ EVMap uses and put them into the app in the form of a resource file called `apik
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,23 +1,25 @@
<svg id="Ebene_5" data-name="Ebene 5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>.cls-1,.cls-2{fill:none;}.cls-2{stroke:#000;stroke-miterlimit:10;stroke-width:2px;}
</style>
</defs>
<title>connector_supercharger</title>
<path class="cls-1" d="M12,12H36V36H12Z" />
<path class="cls-2"
d="M13.45,17.08a8.24,8.24,0,0,1-3.11.6,8.34,8.34,0,0,1-6-14.18H16.3a8.35,8.35,0,0,1,1.07,10.33" />
<circle cx="10.34" cy="9.34" r="1.67" />
<circle cx="15.35" cy="9.34" r="1.67" />
<circle cx="12.84" cy="13.51" r="1.67" />
<circle cx="7.84" cy="13.51" r="1.67" />
<circle cx="5.34" cy="9.34" r="1.67" />
<circle cx="7.84" cy="5.59" r="1" />
<circle cx="12.84" cy="5.59" r="1.04" />
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_5" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24"
style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{display:none;fill:none;}
.st1{fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:10;}
</style>
<path class="st0" d="M12,12h24v24H12V12z" />
<path class="st1"
d="M6.2,13.8C4.1,10.6,4.6,6.3,7.3,3.5h12c1.5,1.6,2.4,3.7,2.4,5.9c0,4.6-3.8,8.3-8.4,8.3c-1.1,0-2.1-0.2-3.1-0.6" />
<circle cx="13.3" cy="9.3" r="1.7" />
<circle cx="8.3" cy="9.3" r="1.7" />
<circle cx="10.8" cy="13.5" r="1.7" />
<circle cx="15.8" cy="13.5" r="1.7" />
<circle cx="18.3" cy="9.3" r="1.7" />
<circle cx="15.8" cy="5.6" r="1" />
<circle cx="10.8" cy="5.6" r="1" />
<g id="T">
<path id="path35"
d="M18.18,22.23l1-5.48c.93,0,1.22.1,1.27.52a2.15,2.15,0,0,0,.93-.7,6.91,6.91,0,0,0-2.46-.6l-.71.88h0L17.46,16a7,7,0,0,0-2.46.6,2.22,2.22,0,0,0,.94.7c0-.42.33-.52,1.26-.52l1,5.48" />
<path id="path37"
d="M18.18,15.72a7.9,7.9,0,0,1,3.28.66,2.65,2.65,0,0,0,.2-.4,9.24,9.24,0,0,0-7,0,2.61,2.61,0,0,0,.19.4,7.94,7.94,0,0,1,3.29-.66h0" />
</g>
</svg>
<path id="path35" d="M5.4,22.3l1-5.5c0.9,0,1.3,0.1,1.3,0.5c0.4-0.1,0.7-0.4,0.9-0.7C7.8,16.3,7,16,6.1,16l-0.8,0.8l0,0L4.7,16
c-0.8,0-1.7,0.3-2.5,0.6c0.2,0.3,0.6,0.6,0.9,0.7c0.1-0.4,0.3-0.5,1.3-0.5L5.4,22.3" />
<path id="path37" d="M5.5,15.7L5.5,15.7c1.1,0,2.3,0.2,3.3,0.7c0.1-0.1,0.1-0.3,0.2-0.4c-2.2-0.9-4.8-0.9-7,0
c0.1,0.1,0.1,0.3,0.2,0.4C3.2,15.9,4.3,15.7,5.5,15.7" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

After

Width:  |  Height:  |  Size: 872 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 KiB

After

Width:  |  Height:  |  Size: 886 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

After

Width:  |  Height:  |  Size: 872 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1005 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 KiB

After

Width:  |  Height:  |  Size: 848 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

After

Width:  |  Height:  |  Size: 884 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 KiB

After

Width:  |  Height:  |  Size: 848 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -8,9 +8,9 @@ apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlin-kapt'
apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
apply plugin: 'de.timfreiheit.resourceplaceholders'
apply plugin: 'pt.jcosta.resourceplaceholders'
def supportedLocales = "en,de,fr,nb-rNO"
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
android {
compileSdkVersion 33
@@ -21,11 +21,11 @@ android {
minSdkVersion 21
targetSdkVersion 33
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 142
versionName "1.4.2"
versionCode 190
versionName "1.6.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(",")
resConfigs supportedLocales.split(',')
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
}
@@ -73,13 +73,6 @@ android {
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
@@ -91,6 +84,12 @@ android {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs).configureEach {
kotlinOptions {
jvmTarget = "1.8"
}
}
buildFeatures {
dataBinding = true
viewBinding true
@@ -150,6 +149,12 @@ android {
}
}
packagingOptions {
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
}
}
configurations {
@@ -159,28 +164,30 @@ configurations {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.0-rc01'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.4"
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.7.0'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.browser:browser:1.4.0'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.browser:browser:1.5.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:3529a5a9f1'
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.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
implementation 'com.squareup.moshi:moshi-adapters:1.13.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.11.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
implementation 'com.squareup.moshi:moshi-adapters:1.15.0'
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.johan12345:StfalconImageViewer:5082ebd392'
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'
@@ -190,28 +197,30 @@ dependencies {
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
def carAppVersion = '1.3.0-beta01'
googleImplementation "androidx.car.app:app:$carAppVersion"
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
def carAppVersion = '1.3.0-rc01'
implementation "androidx.car.app:app:$carAppVersion"
normalImplementation "androidx.car.app:app-projected:$carAppVersion"
automotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
// AnyMaps
def anyMapsVersion = 'a9b3dd7d99'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
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.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
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'
}
// patched version of mapbox-android-core that removes build-time dependency on GMS
implementation 'com.github.johan12345:mapbox-events-android:a21c324501'
// 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:2.7.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.1'
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'
@@ -221,18 +230,19 @@ dependencies {
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.5.1"
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.4.3"
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 = "4.1.0"
def billing_version = "6.0.0"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
@@ -243,26 +253,31 @@ dependencies {
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
implementation 'com.facebook.stetho:stetho:1.6.0'
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
debugImplementation 'com.facebook.flipper:flipper:0.190.0'
debugImplementation 'com.facebook.soloader:soloader:0.10.5'
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.190.0'
// testing
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
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'
testGoogleImplementation 'org.robolectric:robolectric:4.9.2'
testGoogleImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.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.13.0"
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
}
private static String decode(String s, String key) {

View File

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

View File

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

View File

@@ -54,6 +54,13 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
<meta-data
android:name="distractionOptimized"

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Toestaan</string>
<string name="auto_location_permission_needed">Om EVmap te gebruiken in je wagen, moet je toegang geven tot je locatie.</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Vond je EVMap nuttig\? Je kan de ontwikkeling ondersteunen door een donatie te sturen naar de ontwikkelaar.</string>
<string name="donate_paypal">Doneer via PayPal</string>
<string name="data_sources_hint">De kaartgegevens zijn afkomstig van OpenStreetMap (Mapbox).</string>
</resources>

View File

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

View File

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

View File

@@ -2,5 +2,4 @@
<resources>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
</resources>

View File

@@ -1,45 +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" />
</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>
</service>
</application>
</manifest>

View File

@@ -7,6 +7,7 @@ import android.text.style.StyleSpan
import com.car2go.maps.google.adapter.AnyMapAdapter
import com.car2go.maps.util.SphericalUtil
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.gms.tasks.Tasks.await
@@ -19,6 +20,7 @@ import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRe
import com.google.android.libraries.places.api.net.PlacesStatusCodes
import kotlinx.coroutines.tasks.await
import net.vonforst.evmap.R
import java.io.IOException
import java.util.concurrent.ExecutionException
import kotlin.math.sqrt
@@ -36,10 +38,10 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
): List<AutocompletePlace> {
val request = FindAutocompletePredictionsRequest.builder().apply {
if (location != null) {
setLocationBias(calcLocationBias(location))
setOrigin(LatLng(location.latitude, location.longitude))
locationBias = calcLocationBias(location)
origin = LatLng(location.latitude, location.longitude)
}
setSessionToken(token)
sessionToken = token
setQuery(query)
}.build()
try {
@@ -58,6 +60,13 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
if (cause is ApiException) {
if (cause.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
throw ApiUnavailableException()
} else if (cause.statusCode in listOf(
CommonStatusCodes.NETWORK_ERROR,
CommonStatusCodes.TIMEOUT, CommonStatusCodes.RECONNECTION_TIMED_OUT,
CommonStatusCodes.RECONNECTION_TIMED_OUT_DURING_UPDATE
)
) {
throw IOException(cause)
}
}
throw e
@@ -83,10 +92,11 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
}
}
override fun getAttributionString(): Int = R.string.places_powered_by_google
override fun getAttributionString(): Int =
com.google.android.libraries.places.R.string.places_powered_by_google
override fun getAttributionImage(dark: Boolean): Int =
if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light
if (dark) com.google.android.libraries.places.R.drawable.places_powered_by_google_dark else com.google.android.libraries.places.R.drawable.places_powered_by_google_light
private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds {
val radius = 100e3 // meters

View File

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

@@ -5,6 +5,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import com.android.billingclient.api.*
import com.android.billingclient.api.BillingClient.ProductType
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.adapter.Equatable
@@ -14,6 +15,12 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
.setListener(this)
.enablePendingPurchases()
.build()
val products: MutableLiveData<Resource<List<DonationItem>>> by lazy {
MutableLiveData<Resource<List<DonationItem>>>().apply {
value = Resource.loading(null)
}
}
init {
billingClient.startConnection(object : BillingClientStateListener {
@@ -24,10 +31,15 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
loadProducts()
// consume pending purchases
val purchases = billingClient.queryPurchases(BillingClient.SkuType.INAPP)
purchases.purchasesList?.forEach {
if (!it.isAcknowledged) {
consumePurchase(it.purchaseToken, false)
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(ProductType.INAPP)
.build()
) { _, purchasesList ->
purchasesList.forEach {
if (!it.isAcknowledged) {
consumePurchase(it.purchaseToken, false)
}
}
}
}
@@ -36,26 +48,26 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
}
private fun loadProducts() {
val params = SkuDetailsParams.newBuilder()
.setType(BillingClient.SkuType.INAPP)
.setSkusList(
listOf(
"donate_1_eur", "donate_2_eur", "donate_5_eur", "donate_10_eur"
) +
if (BuildConfig.DEBUG) {
listOf(
"android.test.purchased", "android.test.canceled",
"android.test.item_unavailable"
)
} else {
emptyList()
}
val productIds = listOf(
"donate_1_eur", "donate_2_eur", "donate_5_eur", "donate_10_eur"
) + if (BuildConfig.DEBUG) {
listOf(
"android.test.purchased", "android.test.canceled",
"android.test.item_unavailable"
)
} else {
emptyList()
}
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productIds.map {
QueryProductDetailsParams.Product.newBuilder().setProductType(ProductType.INAPP)
.setProductId(it).build()
})
.build()
billingClient.querySkuDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK && details != null) {
billingClient.queryProductDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
products.postValue(Resource.success(details
.sortedBy { it.priceAmountMicros }
.sortedBy { it.oneTimePurchaseOfferDetails!!.priceAmountMicros }
.map { DonationItem(it) }
))
} else {
@@ -64,12 +76,6 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
}
}
val products: MutableLiveData<Resource<List<DonationItem>>> by lazy {
MutableLiveData<Resource<List<DonationItem>>>().apply {
value = Resource.loading(null)
}
}
val purchaseSuccessful = SingleLiveEvent<Nothing>()
val purchaseFailed = SingleLiveEvent<Nothing>()
@@ -97,7 +103,13 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
fun startPurchase(it: DonationItem, activity: Activity) {
val flowParams = BillingFlowParams.newBuilder()
.setSkuDetails(it.sku)
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(it.product)
.build()
)
)
.build()
val response = billingClient.launchBillingFlow(activity, flowParams)
if (response.responseCode != BillingClient.BillingResponseCode.OK) {
@@ -110,4 +122,4 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
}
}
data class DonationItem(val sku: SkuDetails) : Equatable
data class DonationItem(val product: ProductDetails) : Equatable

View File

@@ -28,7 +28,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@{item.sku.title}"
android:text="@{item.product.title}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView21"
@@ -41,7 +41,7 @@
android:id="@+id/textView21"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.sku.price}"
android:text="@{item.product.oneTimePurchaseOfferDetails.formattedPrice}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent"

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,36 +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>
</resources>

View File

@@ -3,37 +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>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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="data_sources_hint">In de instellingen kan je ook wisselen tussen Google Maps en OpenStreetMap (Mapbox) voor de kaartgegevens.</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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="data_sources_hint">Também pode mudar entre o Google Maps e OpenStreetMap (Mapbox) nas definições da app.</string>
</resources>

View File

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

@@ -1,10 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<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="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>
@@ -18,11 +23,18 @@
<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" />
</queries>
<application
android:name=".EvMapApplication"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/backup_rules_api31"
android:fullBackupOnly="true"
android:backupAgent=".storage.BackupAgent"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -264,6 +276,13 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
@@ -278,6 +297,50 @@
android:name="autoStoreLocales"
android:value="true" />
</service>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- Remove WorkManagerInitializer as we implement getWorkManagerConfiguration in application class -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- 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">
<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,7 +1,9 @@
package net.vonforst.evmap
import android.app.Application
import com.facebook.stetho.Stetho
import android.os.Build
import androidx.work.*
import net.vonforst.evmap.storage.CleanupCacheWorker
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
@@ -10,8 +12,9 @@ import org.acra.config.limiter
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import java.time.Duration
class EvMapApplication : Application() {
class EvMapApplication : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
val prefs = PreferenceDataSource(this)
@@ -24,8 +27,8 @@ class EvMapApplication : Application() {
prefs.language = null
}
Stetho.initializeWithDefaults(this);
init(applicationContext)
addDebugInterceptors(applicationContext)
if (!BuildConfig.DEBUG) {
initAcra {
@@ -49,5 +52,20 @@ class EvMapApplication : Application() {
}
}
}
val cleanupCacheRequest = PeriodicWorkRequestBuilder<CleanupCacheWorker>(Duration.ofDays(1))
.setConstraints(Constraints.Builder().apply {
setRequiresBatteryNotLow(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setRequiresDeviceIdle(true)
}
}.build()).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.REPLACE, cleanupCacheRequest
)
}
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder().build()
}
}

View File

@@ -40,6 +40,7 @@ const val EXTRA_CHARGER_ID = "chargerId"
const val EXTRA_LAT = "lat"
const val EXTRA_LON = "lon"
const val EXTRA_FAVORITES = "favorites"
const val EXTRA_DONATE = "donate"
class MapsActivity : AppCompatActivity(),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
@@ -54,8 +55,8 @@ class MapsActivity : AppCompatActivity(),
private lateinit var prefs: PreferenceDataSource
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_maps)
@@ -75,7 +76,7 @@ class MapsActivity : AppCompatActivity(),
val navView = findViewById<NavigationView>(R.id.nav_view)
navView.setupWithNavController(navController)
ViewCompat.setOnApplyWindowInsetsListener(navView) { v, insets ->
ViewCompat.setOnApplyWindowInsetsListener(navView) { _, insets ->
val header = navView.getHeaderView(0)
header.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
insets
@@ -106,6 +107,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())
@@ -123,7 +128,7 @@ class MapsActivity : AppCompatActivity(),
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
.createPendingIntent()
} else if (query != null && query.isNotEmpty()) {
} else if (!query.isNullOrEmpty()) {
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
@@ -170,6 +175,32 @@ class MapsActivity : AppCompatActivity(),
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
.createPendingIntent()
}
} else if (intent.scheme == "net.vonforst.evmap") {
intent.data?.let {
if (it.host == "find_charger") {
val lat = it.getQueryParameter("latitude")?.toDouble()
val lon = it.getQueryParameter("longitude")?.toDouble()
val name = it.getQueryParameter("name")
if (lat != null && lon != null) {
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(
MapFragmentArgs(
latLng = LatLng(lat, lon),
locationName = name
).toBundle()
)
.createPendingIntent()
} else if (name != null) {
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(locationName = name).toBundle())
.createPendingIntent()
}
}
}
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
deepLink = navController.createDeepLink()
.setDestination(R.id.map)
@@ -188,6 +219,11 @@ class MapsActivity : AppCompatActivity(),
.setGraph(navGraph)
.setDestination(R.id.favs)
.createPendingIntent()
} else if (intent.hasExtra(EXTRA_DONATE)) {
deepLink = navController.createDeepLink()
.setGraph(navGraph)
.setDestination(R.id.donate)
.createPendingIntent()
}
deepLink?.send()
@@ -201,7 +237,7 @@ class MapsActivity : AppCompatActivity(),
intent.data = Uri.parse("google.navigation:q=${coord.lat},${coord.lng}")
intent.`package` = "com.google.android.apps.maps"
if (prefs.navigateUseMaps && intent.resolveActivity(packageManager) != null) {
startActivity(intent);
startActivity(intent)
} else {
// fallback: generic geo intent
showLocation(charger)
@@ -217,7 +253,7 @@ class MapsActivity : AppCompatActivity(),
})"
)
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent);
startActivity(intent)
} else {
val cb = fragmentCallback ?: return
Snackbar.make(
@@ -256,7 +292,7 @@ class MapsActivity : AppCompatActivity(),
fun shareUrl(url: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
setType("text/plain")
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(intent)

View File

@@ -1,8 +1,11 @@
package net.vonforst.evmap
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Typeface
import android.os.Build
import android.os.Bundle
import android.text.*
import android.text.style.StyleSpan
@@ -72,7 +75,7 @@ fun max(a: Int?, b: Int?): Int? {
* otherwise the non-null value or null
*/
return if (a != null && b != null) {
max(a, b)
kotlin.math.max(a, b)
} else {
a ?: b
}
@@ -88,4 +91,11 @@ const val meterPerFt = 0.3048
fun shouldUseImperialUnits(): Boolean {
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
}
}
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
}

View File

@@ -1,7 +1,6 @@
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
@@ -91,7 +90,7 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
class ChargepriceAdapter() :
DataBindingAdapter<ChargePrice>() {
val viewPool = RecyclerView.RecycledViewPool();
val viewPool = RecyclerView.RecycledViewPool()
var meta: ChargepriceChargepointMeta? = null
set(value) {
field = value
@@ -166,7 +165,7 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
root.setOnCheckedChangeListener { _, checked: Boolean ->
if (checked) {
checkedItem = holder.bindingAdapterPosition.takeIf { it != -1 }
root.post {
@@ -205,7 +204,7 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
root.setOnCheckedChangeListener { _, checked: Boolean ->
if (checked && item != checkedItem) {
checkedItem = item
root.post {

View File

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

View File

@@ -25,7 +25,7 @@ class FilterProfilesAdapter(
super.bind(holder, item)
val binding = holder.binding as ItemFilterProfileBinding
binding.handle.setOnTouchListener { v, event ->
binding.handle.setOnTouchListener { _, event ->
if (event?.action == MotionEvent.ACTION_DOWN) {
dragHelper.startDrag(holder)
}

View File

@@ -5,14 +5,14 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver.OnGlobalLayoutListener
import android.widget.ImageView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.memory.MemoryCache
import coil.size.OriginalSize
import coil.size.SizeResolver
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.model.ChargerPhoto
@@ -37,27 +37,43 @@ class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener?
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val id = getItem(position).id
val url = getItem(position).getUrl(height = holder.view.height)
val item = getItem(position)
holder.view.load(
url
) {
size(SizeResolver(OriginalSize))
allowHardware(false)
listener(
onSuccess = { _, metadata ->
memoryKeys[id] = metadata.memoryCacheKey
if (holder.view.height == 0) {
holder.view.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
holder.view.viewTreeObserver.removeOnGlobalLayoutListener(this)
loadImage(item, holder)
}
)
})
} else {
loadImage(item, holder)
}
if (itemClickListener != null) {
holder.view.setOnClickListener {
itemClickListener.onItemClick(holder.view, position, memoryKeys[id])
itemClickListener.onItemClick(holder.view, position, memoryKeys[item.id])
}
}
}
private fun loadImage(
item: ChargerPhoto,
holder: ViewHolder
) {
val url = item.getUrl(height = holder.view.height)
holder.view.load(
url
) {
listener(
onSuccess = { _, metadata ->
memoryKeys[item.id] = metadata.memoryCacheKey
}
)
allowHardware(!BuildConfig.DEBUG)
}
}
}
class ChargerPhotoDiffCallback : DiffUtil.ItemCallback<ChargerPhoto>() {

View File

@@ -8,23 +8,36 @@ import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.model.*
import net.vonforst.evmap.viewmodel.Resource
import java.time.Duration
import java.time.Instant
interface ChargepointApi<out T : ReferenceData> {
/**
* Query for chargepoints within certain geographic bounds
*/
suspend fun getChargepoints(
referenceData: ReferenceData,
bounds: LatLngBounds,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?
): Resource<List<ChargepointListItem>>
): Resource<ChargepointList>
/**
* Query for chargepoints within a given radius in kilometers
*/
suspend fun getChargepointsRadius(
referenceData: ReferenceData,
location: LatLng,
radius: Int,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?
): Resource<List<ChargepointListItem>>
): Resource<ChargepointList>
/**
* Fetches detailed data for a specific charging site
*/
suspend fun getChargepointDetail(
referenceData: ReferenceData,
id: Long
@@ -34,8 +47,17 @@ interface ChargepointApi<out T : ReferenceData> {
fun getFilters(referenceData: ReferenceData, sp: StringProvider): List<Filter<FilterValue>>
fun convertFiltersToSQL(filters: FilterValues, referenceData: ReferenceData): FiltersSQLQuery
fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean
val name: String
val id: String
/**
* Duration we are limited to if there is a required API local cache time limit.
*/
val cacheLimit: Duration
}
interface StringProvider {
@@ -66,4 +88,16 @@ fun createApi(type: String, ctx: Context): ChargepointApi<ReferenceData> {
}
else -> throw IllegalArgumentException()
}
}
data class FiltersSQLQuery(
val query: String,
val requiresChargepointQuery: Boolean,
val requiresChargeCardQuery: Boolean
)
data class ChargepointList(val items: List<ChargepointListItem>, val isComplete: Boolean) {
companion object {
fun empty() = ChargepointList(emptyList(), true)
}
}

View File

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

View File

@@ -1,14 +1,16 @@
package net.vonforst.evmap.api.availability
import com.facebook.stetho.okhttp3.StethoInterceptor
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.cartesianProduct
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
@@ -71,19 +73,19 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
connectors: Map<Long, Pair<Double, String>>,
chargepoints: List<Chargepoint>
): Map<Chargepoint, Set<Long>> {
var chargepoints = chargepoints
var cpts = chargepoints
// iterate over each connector type
val types = connectors.map { it.value.second }.distinct().toSet()
val equivalentTypes = types.map { equivalentPlugTypes(it).plus(it) }.cartesianProduct()
var geTypes = chargepoints.map { it.type }.distinct().toSet()
var geTypes = cpts.map { it.type }.distinct().toSet()
if (!equivalentTypes.any { it == geTypes } && geTypes.size > 1 && geTypes.contains(
Chargepoint.SCHUKO
)) {
// If charger has household plugs and other plugs, try removing the household plugs
// (common e.g. in Hamburg -> 2x Type 2 + 2x Schuko, but NM only lists Type 2)
geTypes = geTypes.filter { it != Chargepoint.SCHUKO }.toSet()
chargepoints = chargepoints.filter { it.type != Chargepoint.SCHUKO }
cpts = cpts.filter { it.type != Chargepoint.SCHUKO }
}
if (!equivalentTypes.any { it == geTypes }) throw AvailabilityDetectorException("chargepoints do not match")
return types.flatMap { type ->
@@ -93,14 +95,14 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
val powers = connsOfType.map { it.value.first }.distinct().sorted()
// find corresponding powers in GE data
val gePowers =
chargepoints.filter { equivalentPlugTypes(it.type).any { it == type } }
cpts.filter { equivalentPlugTypes(it.type).any { it == type } }
.mapNotNull { it.power }.distinct().sorted()
// if the distinct number of powers is the same, try to match.
if (powers.size == gePowers.size) {
gePowers.zip(powers).map { (gePower, power) ->
val chargepoint =
chargepoints.find { equivalentPlugTypes(it.type).any { it == type } && it.power == gePower }!!
cpts.find { equivalentPlugTypes(it.type).any { it == type } && it.power == gePower }!!
val ids = connsOfType.filter { it.value.first == power }.keys
if (chargepoint.count != ids.size) {
throw AvailabilityDetectorException("chargepoints do not match")
@@ -108,7 +110,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
chargepoint to ids
}
} else if (powers.size == 1 && gePowers.size == 2
&& chargepoints.sumOf { it.count } == connsOfType.size
&& cpts.sumOf { it.count } == connsOfType.size
) {
// special case: dual charger(s) with load balancing
// GoingElectric shows 2 different powers, NewMotion just one
@@ -116,7 +118,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
var i = 0
gePowers.map { gePower ->
val chargepoint =
chargepoints.find { it.type in equivalentPlugTypes(type) && it.power == gePower }!!
cpts.find { it.type in equivalentPlugTypes(type) && it.power == gePower }!!
val ids = allIds.subList(i, i + chargepoint.count).toSet()
i += chargepoint.count
chargepoint to ids
@@ -133,7 +135,9 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String,
val evseIds: Map<Chargepoint, List<String>>? = null
val evseIds: Map<Chargepoint, List<String>>? = null,
val congestionHistogram: List<Double>? = null,
val extraData: Any? = null // API-specific data
) {
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
val statusFiltered = status.filterKeys {
@@ -158,38 +162,41 @@ private val cookieManager = CookieManager().apply {
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
}
private val okhttp = OkHttpClient.Builder()
.addInterceptor(RateLimitInterceptor())
.addNetworkInterceptor(StethoInterceptor())
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
class AvailabilityRepository(context: Context) {
private val okhttp = OkHttpClient.Builder()
.addInterceptor(RateLimitInterceptor())
.addDebugInterceptors()
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
private val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
if (!ad.isChargerSupported(charger)) continue
try {
value = Resource.success(ad.getAvailability(charger))
break
} catch (e: IOException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: HttpException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
value = Resource.error(e.message, null)
e.printStackTrace()
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
if (!ad.isChargerSupported(charger)) continue
try {
value = Resource.success(ad.getAvailability(charger))
break
} catch (e: IOException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: HttpException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
value = Resource.error(e.message, null)
e.printStackTrace()
}
}
}
return value ?: Resource.error(null, null)
}
return value ?: Resource.error(null, null)
}

View File

@@ -51,7 +51,7 @@ interface ChargecloudApi {
)
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): ChargecloudApi {
fun create(client: OkHttpClient, baseUrl: String): ChargecloudApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(MoshiConverterFactory.create())

View File

@@ -12,7 +12,7 @@ import retrofit2.http.Path
import retrofit2.http.Query
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")
@@ -132,20 +132,24 @@ 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)
}
var details = markers.filter {
// only include stations from same operator
it.operator == nearest.operator && it.stationId != null
}.map {
val details = markers.map {
// load details
api.getLocation(it.stationId!!)
}
@@ -203,30 +207,46 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
val country = charger.chargepriceData?.country
?: charger.address?.country ?: return false
return when (charger.dataSource) {
// list of countries as of 2021/06/30, according to
// https://www.electrive.net/2021/06/30/enbw-expandiert-mit-ladenetz-in-drei-weitere-laender/
// list of countries as of 2023/04/14, according to
// https://www.enbw.com/elektromobilitaet/produkte/ladetarife
"goingelectric" -> country in listOf(
"Deutschland",
"Österreich",
"Schweiz",
"Frankreich",
"Belgien",
"Niederlande",
"Luxemburg",
"Liechtenstein",
"Dänemark",
"Frankreich",
"Italien",
)
"Kroatien",
"Liechtenstein",
"Luxemburg",
"Niederlande",
"Polen",
"Schweden",
"Slowakei",
"Slowenien",
"Spanien",
"Tschechien"
) && charger.network != "Tesla Supercharger"
"openchargemap" -> country in listOf(
"DE",
"AT",
"CH",
"FR",
"BE",
"NE",
"LU",
"DK",
"FR",
"IT",
"HR",
"LI",
"IT"
)
"LU",
"NE",
"PL",
"SE",
"SK",
"SI",
"ES",
"CZ"
) && charger.chargepriceData?.network !in listOf("23", "3534")
else -> false
}
}

View File

@@ -12,22 +12,23 @@ import retrofit2.http.Path
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}")
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}/{zoom}")
suspend fun getMarkers(
@Path("lngMin") lngMin: Double,
@Path("lngMax") lngMax: Double,
@Path("latMin") latMin: Double,
@Path("latMax") latMax: Double
@Path("latMax") latMax: Double,
@Path("zoom") zoom: Int = 22
): List<NMMarker>
@GET("locations/{id}")
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)
@@ -76,7 +77,7 @@ interface NewMotionApi {
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): NewMotionApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://my.newmotion.com/api/map/v2/")
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
.addConverterFactory(MoshiConverterFactory.create())
.client(client)
.build()
@@ -110,14 +111,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
if (nearest.evseCount < location.totalChargepoints) {
// combine related stations
markers = markers.filter { marker ->
distanceBetween(
marker.coordinates.latitude,
marker.coordinates.longitude,
nearest.coordinates.latitude,
nearest.coordinates.longitude
) < maxDistance
}
} else {
markers = listOf(nearest)
}
// load details
@@ -140,7 +145,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
connectorStatus.forEach { (connector, statusStr, evseId) ->
val id = connector.uid
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType.toLowerCase(Locale.ROOT)) {
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
"type3" -> Chargepoint.TYPE_3
"type2" -> Chargepoint.TYPE_2_UNKNOWN
"type1" -> Chargepoint.TYPE_1
@@ -181,7 +186,11 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
override fun isChargerSupported(charger: ChargeLocation): Boolean {
// NewMotion is our fallback
return true
return when (charger.dataSource) {
"goingelectric" -> charger.network != "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network !in listOf("23", "3534")
else -> false
}
}
}

View File

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

@@ -1,13 +1,13 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import jsonapi.Document
import jsonapi.JsonApiFactory
import jsonapi.retrofit.DocumentConverterFactory
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.model.ChargeLocation
import okhttp3.Cache
import okhttp3.OkHttpClient
@@ -77,10 +77,10 @@ interface ChargepriceApi {
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
@@ -127,14 +127,16 @@ interface ChargepriceApi {
charger.chargepriceData?.country?.let { isCountrySupported(it, charger.dataSource) }
?: false
val networkSupported = charger.chargepriceData?.network?.let {
if (charger.dataSource == "openchargemap") {
it !in listOf(
when (charger.dataSource) {
"openchargemap" -> it !in listOf(
"1", // unknown operator
"44", // private residence/individual
"45" // business owner at location
"45", // business owner at location
"23", "3534" // Tesla
)
} else {
true
"goingelectric" -> it != "Tesla Supercharger"
else -> true
}
} ?: false
val powerAvailable = charger.chargepoints.all { it.hasKnownPower() }
@@ -163,7 +165,7 @@ interface ChargepriceApi {
"Spanien",
"Großbritannien",
"Irland",
// additional countries found 2022/09/17, https://github.com/johan12345/EVMap/issues/234
// additional countries found 2022/09/17, https://github.com/ev-map/EVMap/issues/234
"Finnland",
"Lettland",
"Litauen",
@@ -202,7 +204,7 @@ interface ChargepriceApi {
"ES",
"GB",
"IE",
// additional countries found 2022/09/17, https://github.com/johan12345/EVMap/issues/234
// additional countries found 2022/09/17, https://github.com/ev-map/EVMap/issues/234
"FI",
"LV",
"LT",

View File

@@ -114,8 +114,26 @@ data class ChargepriceCar(
val brand: String,
@Json(name = "dc_charge_ports")
val dcChargePorts: List<String>
val dcChargePorts: List<String>,
@Json(name = "usable_battery_size")
val usableBatterySize: Float,
@Json(name = "ac_max_power")
val acMaxPower: Float,
@Json(name = "dc_max_power")
val dcMaxPower: Float?
) : Equatable, Parcelable {
fun formatSpecs(): String = buildString {
append("%.0f kWh".format(usableBatterySize))
append(" | ")
append("AC %.0f kW".format(acMaxPower))
dcMaxPower?.let {
append(" | ")
append("DC %.0f kW".format(it))
}
}
companion object {
private val acConnectors = listOf(
@@ -139,9 +157,9 @@ data class ChargepriceCar(
get() = id_!!
val compatibleEvmapConnectors: List<String>
get() = dcChargePorts.map {
get() = dcChargePorts.mapNotNull {
plugMapping[it]
}.filterNotNull().plus(acConnectors)
}.plus(acConnectors)
}
@JsonClass(generateAdapter = true)
@@ -210,7 +228,7 @@ internal object RelationshipsParceler : Parceler<Relationships?> {
if (parcel.readInt() == 0) return null
val nMembers = parcel.readInt()
val members = (0 until nMembers).map { _ ->
val members = (0 until nMembers).associate { _ ->
val key = parcel.readString()!!
val value = if (parcel.readInt() == 0) {
val type = parcel.readString()
@@ -229,7 +247,7 @@ internal object RelationshipsParceler : Parceler<Relationships?> {
Relationship.ToMany(ris)
}
key to value
}.toMap()
}
return Relationships(members)
}
@@ -281,12 +299,12 @@ data class ChargepointPrice(
}
fun time(value: Int): String {
val h = floor(value.toDouble() / 60).toInt();
val min = ceil(value.toDouble() % 60).toInt();
if (h == 0 && min > 0) return "${min}min";
val h = floor(value.toDouble() / 60).toInt()
val min = ceil(value.toDouble() % 60).toInt()
return if (h == 0 && min > 0) "${min}min";
// be slightly sloppy (3:01 is shown as 3h) to save space
else if (h > 0 && (min == 0 || min == 1)) return "${h}h";
else return "%d:%02dh".format(h, min);
else if (h > 0 && (min == 0 || min == 1)) "${h}h";
else "%d:%02dh".format(h, min)
}
// based on https://github.com/chargeprice/chargeprice-client/blob/d420bb2f216d9ad91a210a36dd0859a368a8229a/src/views/priceList.js

View File

@@ -1,9 +1,9 @@
package net.vonforst.evmap.api.fronyx
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.Cache
@@ -14,13 +14,18 @@ import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface FronyxApi {
private interface FronyxApiRetrofit {
@GET("predictions/evse-id/{evseId}")
suspend fun getPredictionsForEvseId(
@Path("evseId") evseId: String,
@Query("timeframe") timeframe: Int? = null
): FronyxEvseIdResponse
@GET("predictions/evses")
suspend fun getPredictionsForEvseIds(
@Query("evseIds", encoded = true) evseIds: String // comma-separated
): List<FronyxEvseIdResponse>
companion object {
private val cacheSize = 1L * 1024 * 1024 // 1MB
@@ -32,7 +37,7 @@ interface FronyxApi {
apikey: String,
baseurl: String = "https://api.fronyx.io/api/",
context: Context? = null
): FronyxApi {
): FronyxApiRetrofit {
val client = OkHttpClient.Builder().apply {
addInterceptor { chain ->
// add API key to every request
@@ -44,10 +49,10 @@ interface FronyxApi {
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
@@ -56,9 +61,28 @@ interface FronyxApi {
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
return retrofit.create(FronyxApi::class.java)
return retrofit.create(FronyxApiRetrofit::class.java)
}
}
}
class FronyxApi(
apikey: String,
baseurl: String = "https://api.fronyx.io/api/",
context: Context? = null
) {
private val api = FronyxApiRetrofit.create(apikey, baseurl, context)
suspend fun getPredictionsForEvseId(
evseId: String,
timeframe: Int? = null
): FronyxEvseIdResponse = api.getPredictionsForEvseId(evseId, timeframe)
suspend fun getPredictionsForEvseIds(
evseIds: List<String>
): List<FronyxEvseIdResponse> = api.getPredictionsForEvseIds(evseIds.joinToString(","))
companion object {
/**
* Checks if a chargepoint is supported by Fronyx.
*

View File

@@ -6,7 +6,8 @@ import java.time.ZonedDateTime
@JsonClass(generateAdapter = true)
data class FronyxEvseIdResponse(
val evseId: String,
val predictions: List<FronyxPrediction>
val predictions: List<FronyxPrediction>,
val locationId: String?
)
@JsonClass(generateAdapter = true)

View File

@@ -5,6 +5,7 @@ import com.squareup.moshi.*
import java.lang.reflect.Type
import java.time.Instant
import java.time.LocalTime
import java.time.format.DateTimeParseException
internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
@@ -13,12 +14,12 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
annotations: MutableSet<out Annotation>,
moshi: Moshi
): JsonAdapter<*>? {
if (Types.getRawType(type) == GEChargepointListItem::class.java) {
return ChargepointListItemJsonAdapter(
return if (Types.getRawType(type) == GEChargepointListItem::class.java) {
ChargepointListItemJsonAdapter(
moshi
)
} else {
return null
null
}
}
@@ -71,10 +72,10 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
private val clazz: Class<*>
) : JsonAdapter<T>() {
class Factory() : JsonAdapter.Factory {
class Factory : JsonAdapter.Factory {
override fun create(
type: Type,
annotations: Set<Annotation>?,
annotations: Set<Annotation>,
moshi: Moshi
): JsonAdapter<Any>? {
val clazz = Types.getRawType(type)
@@ -95,7 +96,7 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
false -> null // Response was false
else -> {
if (this.clazz == GEFaultReport::class.java) {
GEFaultReport(null, null) as T
GEFaultReport(null, "") as T
} else {
throw IllegalStateException("Non-false boolean for @JsonObjectOrFalse field")
}
@@ -138,7 +139,12 @@ internal class HoursAdapter {
val end = if (match.groupValues[2] == "24:00") {
LocalTime.MAX
} else {
LocalTime.parse(match.groupValues[2])
try {
LocalTime.parse(match.groupValues[2])
} catch (e: DateTimeParseException) {
// got a rare bug report where the value is 24:0000
LocalTime.MIN
}
}
return GEHours(start, end)
} else {

View File

@@ -1,9 +1,9 @@
package net.vonforst.evmap.api.goingelectric
import android.content.Context
import android.database.DatabaseUtils
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -11,9 +11,9 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.*
import net.vonforst.evmap.model.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getClusterDistance
import okhttp3.Cache
@@ -23,6 +23,7 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import java.io.IOException
import java.time.Duration
interface GoingElectricApi {
@FormUrlEncoded
@@ -104,10 +105,10 @@ interface GoingElectricApi {
chain.proceed(original)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
@@ -126,18 +127,19 @@ class GoingElectricApiWrapper(
baseurl: String = "https://api.goingelectric.de",
context: Context? = null
) : ChargepointApi<GEReferenceData> {
private val clusterThreshold = 11f
val api = GoingElectricApi.create(apikey, baseurl, context)
override val name = "GoingElectric.de"
override val id = "going_electric"
override val id = "goingelectric"
override val cacheLimit = Duration.ofDays(1)
override suspend fun getChargepoints(
referenceData: ReferenceData,
bounds: LatLngBounds,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?
): Resource<List<ChargepointListItem>> {
): Resource<ChargepointList> {
val freecharging = filters?.getBooleanValue("freecharging")
val freeparking = filters?.getBooleanValue("freeparking")
val open247 = filters?.getBooleanValue("open_247")
@@ -146,36 +148,35 @@ class GoingElectricApiWrapper(
val minPower = filters?.getSliderValue("min_power")
val minConnectors = filters?.getSliderValue("min_connectors")
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val chargeCards = formatMultipleChoice(chargeCardsVal)
val networksVal = filters?.getMultipleChoiceValue("networks")
if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = filters?.getMultipleChoiceValue("categories")
if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < clusterThreshold
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
@@ -217,9 +218,9 @@ class GoingElectricApiWrapper(
}
} while (startkey != null && startkey < 10000)
var result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
val result = postprocessResult(data, filters)
return Resource.success(result)
return Resource.success(ChargepointList(result, startkey == null))
}
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
@@ -230,8 +231,9 @@ class GoingElectricApiWrapper(
location: LatLng,
radius: Int,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?
): Resource<List<ChargepointListItem>> {
): Resource<ChargepointList> {
val freecharging = filters?.getBooleanValue("freecharging")
val freeparking = filters?.getBooleanValue("freeparking")
val open247 = filters?.getBooleanValue("open_247")
@@ -240,36 +242,35 @@ class GoingElectricApiWrapper(
val minPower = filters?.getSliderValue("min_power")
val minConnectors = filters?.getSliderValue("min_connectors")
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val chargeCards = formatMultipleChoice(chargeCardsVal)
val networksVal = filters?.getMultipleChoiceValue("networks")
if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = filters?.getMultipleChoiceValue("categories")
if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < clusterThreshold
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
@@ -308,19 +309,26 @@ class GoingElectricApiWrapper(
}
} while (startkey != null && startkey < 10000)
val result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
return Resource.success(result)
val result = postprocessResult(data, filters)
return Resource.success(ChargepointList(result, startkey == null))
}
private fun postprocessResult(
chargers: List<GEChargepointListItem>,
minPower: Int?,
connectorsVal: MultipleChoiceFilterValue?,
minConnectors: Int?,
zoom: Float
filters: FilterValues?
): List<ChargepointListItem> {
// apply filters which GoingElectric does not support natively
var result = chargers.filter { it ->
val minPower = filters?.getSliderValue("min_power")
val minConnectors = filters?.getSliderValue("min_connectors")
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
val freecharging = filters?.getBooleanValue("freecharging")
val freeparking = filters?.getBooleanValue("freeparking")
val open247 = filters?.getBooleanValue("open_247")
val barrierfree = filters?.getBooleanValue("barrierfree")
val networks = filters?.getMultipleChoiceValue("networks")
val chargecards = filters?.getMultipleChoiceValue("chargecards")
return chargers.filter { it ->
// apply filters which GoingElectric does not support natively
if (it is GEChargeLocation) {
it.chargepoints
.filter { it.power >= (minPower ?: 0) }
@@ -329,19 +337,41 @@ class GoingElectricApiWrapper(
} else {
true
}
}.map { it.convert(apikey, false) }
// apply clustering
val useClustering = zoom < clusterThreshold
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
if (!geClusteringAvailable && useClustering) {
// apply local clustering if server side clustering is not available
Dispatchers.IO.run {
result = cluster(result, zoom, clusterDistance!!)
}.map {
// infer some properties based on applied filters
if (it is GEChargeLocation) {
var inferred = it
if (freecharging == true) {
inferred = inferred.copy(
cost = inferred.cost?.copy(freecharging = true)
?: GECost(freecharging = true)
)
}
if (freeparking == true) {
inferred = inferred.copy(
cost = inferred.cost?.copy(freeparking = true) ?: GECost(freeparking = true)
)
}
if (open247 == true) {
inferred = inferred.copy(
openinghours = inferred.openinghours?.copy(twentyfourSeven = true)
?: GEOpeningHours(twentyfourSeven = true)
)
}
if (barrierfree == true
&& (networks == null || networks.all || it.network !in networks.values)
&& (chargecards == null || chargecards.all)
) {
/* barrierfree, networks and chargecards are combined with OR - so we can only
* be sure that the charger is barrierFree if the other filters are not active
* or the charger does not match the other filters */
inferred = inferred.copy(barrierFree = true)
}
inferred
} else {
it
}
}
return result
}.map { it.convert(apikey, false) }
}
override suspend fun getChargepointDetail(
@@ -399,16 +429,16 @@ class GoingElectricApiWrapper(
referenceData: ReferenceData,
sp: StringProvider
): List<Filter<FilterValue>> {
val referenceData = referenceData as GEReferenceData
val plugs = referenceData.plugs
val networks = referenceData.networks
val chargeCards = referenceData.chargecards
val refData = referenceData as GEReferenceData
val plugs = refData.plugs
val networks = refData.networks
val chargeCards = refData.chargecards
val plugMap = plugs.map { plug ->
plug to nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
}.toMap()
val networkMap = networks.map { it to it }.toMap()
val chargecardMap = chargeCards.map { it.id.toString() to it.name }.toMap()
val plugMap = plugs.associateWith { plug ->
nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
}
val networkMap = networks.associateWith { it }
val chargecardMap = chargeCards.associate { it.id.toString() to it.name }
val categoryMap = mapOf(
"Autohaus" to sp.getString(R.string.category_car_dealership),
"Autobahnraststätte" to sp.getString(R.string.category_service_on_motorway),
@@ -481,5 +511,104 @@ class GoingElectricApiWrapper(
)
)
}
override fun convertFiltersToSQL(
filters: FilterValues,
referenceData: ReferenceData
): FiltersSQLQuery {
if (filters.isEmpty()) return FiltersSQLQuery("", false, false)
var requiresChargepointQuery = false
var requiresChargeCardQuery = false
val result = StringBuilder()
if (filters.getBooleanValue("freecharging") == true) {
result.append(" AND freecharging IS 1")
}
if (filters.getBooleanValue("freeparking") == true) {
result.append(" AND freeparking IS 1")
}
if (filters.getBooleanValue("open_247") == true) {
result.append(" AND twentyfourSeven IS 1")
}
if (filters.getBooleanValue("exclude_faults") == true) {
result.append(" AND fault_report_description IS NULL AND fault_report_created IS NULL")
}
val minPower = filters.getSliderValue("min_power")
if (minPower != null && minPower > 0) {
result.append(" AND json_extract(cp.value, '$.power') >= ${minPower}")
requiresChargepointQuery = true
}
val connectors = filters.getMultipleChoiceValue("connectors")
if (connectors != null && !connectors.all) {
val connectorsList = if (connectors.values.size == 0) {
""
} else {
connectors.values.joinToString(",") {
DatabaseUtils.sqlEscapeString(
GEChargepoint.convertTypeFromGE(
it
)
)
}
}
result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})")
requiresChargepointQuery = true
}
// networks, chargecards and barrierFree filters are combined with OR in the GE API
val networks = filters.getMultipleChoiceValue("networks")
val chargecards = filters.getMultipleChoiceValue("chargecards")
val barrierFree = filters.getBooleanValue("barrierfree")
if ((networks != null && !networks.all) || barrierFree == true || (chargecards != null && !chargecards.all)) {
val queries = mutableListOf<String>()
if (networks != null && !networks.all) {
val networksList = if (networks.values.size == 0) {
""
} else {
networks.values.joinToString(",") { DatabaseUtils.sqlEscapeString(it) }
}
queries.add("network IN (${networksList})")
}
if (barrierFree == true) {
queries.add("barrierFree IS 1")
}
if (chargecards != null && !chargecards.all) {
val chargecardsList = if (chargecards.values.size == 0) {
""
} else {
chargecards.values.joinToString(",")
}
queries.add("json_extract(cc.value, '$.id') IN (${chargecardsList})")
requiresChargeCardQuery = true
}
result.append(" AND (${queries.joinToString(" OR ")})")
}
val categories = filters.getMultipleChoiceValue("categories")
if (categories != null && !categories.all) {
throw NotImplementedError() // category cannot be determined in SQL
}
val minConnectors = filters.getSliderValue("min_connectors")
if (minConnectors != null && minConnectors > 1) {
result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= ${minConnectors}")
requiresChargepointQuery = true
}
return FiltersSQLQuery(result.toString(), requiresChargepointQuery, requiresChargeCardQuery)
}
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
val chargecards = filters.getMultipleChoiceValue("chargecards")
return filters.getBooleanValue("freecharging") == true
|| filters.getBooleanValue("freeparking") == true
|| filters.getBooleanValue("open_247") == true
|| filters.getBooleanValue("barrierfree") == true
|| (chargecards != null && !chargecards.all)
}
}

View File

@@ -77,6 +77,8 @@ data class GEChargeLocation(
cost?.convert(),
null,
ChargepriceData(address.country, network, chargepoints.map { it.type }),
null,
null,
Instant.now(),
isDetailed
)
@@ -84,10 +86,10 @@ data class GEChargeLocation(
@JsonClass(generateAdapter = true)
data class GECost(
val freecharging: Boolean,
val freeparking: Boolean,
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
val freecharging: Boolean = false,
val freeparking: Boolean = false,
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String? = null,
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String? = null
) {
fun convert() = Cost(
// In GE, freecharging = false can either mean "paid charging" or "no information
@@ -102,8 +104,8 @@ data class GECost(
@JsonClass(generateAdapter = true)
data class GEOpeningHours(
@Json(name = "24/7") val twentyfourSeven: Boolean,
@JsonObjectOrFalse val description: String?,
val days: GEOpeningHoursDays?
@JsonObjectOrFalse val description: String? = null,
val days: GEOpeningHoursDays? = null
) {
fun convert() = OpeningHours(twentyfourSeven, description, days?.convert())
}
@@ -147,7 +149,7 @@ data class GEChargerPhoto(val id: String) {
@JsonClass(generateAdapter = true)
class GEChargerPhotoAdapter(override val id: String, val apikey: String) :
ChargerPhoto(id) {
override fun getUrl(height: Int?, width: Int?, size: Int?): String {
override fun getUrl(height: Int?, width: Int?, size: Int?, allowOriginal: Boolean): String {
return "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}&id=$id" +
when {
size != null -> "&size=$size"
@@ -209,6 +211,7 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) {
"Typ1" -> Chargepoint.TYPE_1
"Typ2" -> Chargepoint.TYPE_2_UNKNOWN
"Typ3" -> Chargepoint.TYPE_3
"Tesla Supercharger CCS" -> Chargepoint.CCS_UNKNOWN
"CCS" -> Chargepoint.CCS_UNKNOWN
"Schuko" -> Chargepoint.SCHUKO
"CHAdeMO" -> Chargepoint.CHADEMO

View File

@@ -1,18 +1,16 @@
package net.vonforst.evmap.api.openchargemap
import android.content.Context
import android.database.DatabaseUtils
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.*
import net.vonforst.evmap.model.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getClusterDistance
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Response
@@ -21,6 +19,9 @@ import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
import java.io.IOException
import java.time.Duration
private const val maxResults = 3000
interface OpenChargeMapApi {
@GET("poi/")
@@ -30,7 +31,7 @@ interface OpenChargeMapApi {
@Query("minpowerkw") minPower: Double? = null,
@Query("operatorid") operators: String? = null,
@Query("statustypeid") statusType: String? = null,
@Query("maxresults") maxresults: Int = 500,
@Query("maxresults") maxresults: Int = maxResults,
@Query("compact") compact: Boolean = true,
@Query("verbose") verbose: Boolean = false
): Response<List<OCMChargepoint>>
@@ -45,7 +46,7 @@ interface OpenChargeMapApi {
@Query("minpowerkw") minPower: Double? = null,
@Query("operatorid") operators: String? = null,
@Query("statustypeid") statusType: String? = null,
@Query("maxresults") maxresults: Int = 500,
@Query("maxresults") maxresults: Int = maxResults,
@Query("compact") compact: Boolean = true,
@Query("verbose") verbose: Boolean = false
): Response<List<OCMChargepoint>>
@@ -83,7 +84,7 @@ interface OpenChargeMapApi {
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.cacheDir, cacheSize))
@@ -105,11 +106,11 @@ class OpenChargeMapApiWrapper(
baseurl: String = "https://api.openchargemap.io/v3/",
context: Context? = null
) : ChargepointApi<OCMReferenceData> {
private val clusterThreshold = 11
override val cacheLimit = Duration.ofDays(300L)
val api = OpenChargeMapApi.create(apikey, baseurl, context)
override val name = "OpenChargeMap.org"
override val id = "open_charge_map"
override val id = "openchargemap"
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
if (value == null || value.all) null else value.values.joinToString(",")
@@ -118,9 +119,10 @@ class OpenChargeMapApiWrapper(
referenceData: ReferenceData,
bounds: LatLngBounds,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?,
): Resource<List<ChargepointListItem>> {
val referenceData = referenceData as OCMReferenceData
): Resource<ChargepointList> {
val refData = referenceData as OCMReferenceData
val minPower = filters?.getSliderValue("min_power")?.toDouble()
val minConnectors = filters?.getSliderValue("min_connectors")
@@ -129,14 +131,14 @@ class OpenChargeMapApiWrapper(
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val connectors = formatMultipleChoice(connectorsVal)
val operatorsVal = filters?.getMultipleChoiceValue("operators")!!
val operatorsVal = filters?.getMultipleChoiceValue("operators")
if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) {
// no operators chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val operators = formatMultipleChoice(operatorsVal)
@@ -148,22 +150,22 @@ class OpenChargeMapApiWrapper(
),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
operators = operators
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
var result = postprocessResult(
response.body()!!,
val data = response.body()!!
val result = postprocessResult(
data,
minPower,
connectorsVal,
minConnectors,
referenceData,
zoom
excludeFaults,
refData
)
return Resource.success(result)
return Resource.success(ChargepointList(result, data.size < maxResults))
} catch (e: IOException) {
return Resource.error(e.message, null)
}
@@ -174,9 +176,10 @@ class OpenChargeMapApiWrapper(
location: LatLng,
radius: Int,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?
): Resource<List<ChargepointListItem>> {
val referenceData = referenceData as OCMReferenceData
): Resource<ChargepointList> {
val refData = referenceData as OCMReferenceData
val minPower = filters?.getSliderValue("min_power")?.toDouble()
val minConnectors = filters?.getSliderValue("min_connectors")
@@ -185,14 +188,14 @@ class OpenChargeMapApiWrapper(
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val connectors = formatMultipleChoice(connectorsVal)
val operatorsVal = filters?.getMultipleChoiceValue("operators")
if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) {
// no operators chosen
return Resource.success(emptyList())
return Resource.success(ChargepointList.empty())
}
val operators = formatMultipleChoice(operatorsVal)
@@ -202,22 +205,22 @@ class OpenChargeMapApiWrapper(
radius.toDouble(),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
operators = operators
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
val data = response.body()!!
val result = postprocessResult(
response.body()!!,
data,
minPower,
connectorsVal,
minConnectors,
referenceData,
zoom
excludeFaults,
refData
)
return Resource.success(result)
return Resource.success(ChargepointList(result, data.size < 499))
} catch (e: IOException) {
return Resource.error(e.message, null)
}
@@ -228,37 +231,29 @@ class OpenChargeMapApiWrapper(
minPower: Double?,
connectorsVal: MultipleChoiceFilterValue?,
minConnectors: Int?,
referenceData: OCMReferenceData,
zoom: Float
excludeFaults: Boolean?,
referenceData: OCMReferenceData
): List<ChargepointListItem> {
// apply filters which OCM does not support natively
var result = chargers.filter { it ->
return chargers.filter { it ->
it.connections
.filter { it.power == null || it.power >= (minPower ?: 0.0) }
.filter { if (connectorsVal != null && !connectorsVal.all) it.connectionTypeId in connectorsVal.values.map { it.toLong() } else true }
.sumOf { it.quantity ?: 1 } >= (minConnectors ?: 0)
}.map { it.convert(referenceData, false) }.distinct() as List<ChargepointListItem>
// apply clustering
val useClustering = zoom < clusterThreshold
if (useClustering) {
val clusterDistance = getClusterDistance(zoom)
Dispatchers.IO.run {
result = cluster(result, zoom, clusterDistance!!)
}
}
return result
}.filter {
it.statusTypeId == null || (it.statusTypeId !in removedStatuses && if (excludeFaults == true) it.statusTypeId !in faultStatuses else true)
}.map { it.convert(referenceData, false) }.distinct()
}
override suspend fun getChargepointDetail(
referenceData: ReferenceData,
id: Long
): Resource<ChargeLocation> {
val referenceData = referenceData as OCMReferenceData
val refData = referenceData as OCMReferenceData
try {
val response = api.getChargepointDetail(id)
if (response.isSuccessful && response.body()?.size == 1) {
return Resource.success(response.body()!![0].convert(referenceData, true))
return Resource.success(response.body()!![0].convert(refData, true))
} else {
return Resource.error(response.message(), null)
}
@@ -284,10 +279,10 @@ class OpenChargeMapApiWrapper(
referenceData: ReferenceData,
sp: StringProvider
): List<Filter<FilterValue>> {
val referenceData = referenceData as OCMReferenceData
val refData = referenceData as OCMReferenceData
val operatorsMap = referenceData.operators.map { it.id.toString() to it.title }.toMap()
val plugMap = referenceData.connectionTypes.map { it.id.toString() to it.title }.toMap()
val operatorsMap = refData.operators.associate { it.id.toString() to it.title }
val plugMap = refData.connectionTypes.associate { it.id.toString() to it.title }
return listOf(
// supported by OCM API
@@ -327,4 +322,70 @@ class OpenChargeMapApiWrapper(
)
}
override fun convertFiltersToSQL(
filters: FilterValues,
referenceData: ReferenceData
): FiltersSQLQuery {
if (filters.isEmpty()) return FiltersSQLQuery("", false, false)
val refData = referenceData as OCMReferenceData
var requiresChargepointQuery = false
val result = StringBuilder()
if (filters.getBooleanValue("exclude_faults") == true) {
result.append(" AND fault_report_description IS NULL AND fault_report_created IS NULL")
}
val minPower = filters.getSliderValue("min_power")
if (minPower != null && minPower > 0) {
result.append(" AND json_extract(cp.value, '$.power') >= ${minPower}")
requiresChargepointQuery = true
}
val connectors = filters.getMultipleChoiceValue("connectors")
if (connectors != null && !connectors.all) {
val connectorsList = if (connectors.values.size == 0) {
""
} else {
connectors.values.joinToString(",") {
DatabaseUtils.sqlEscapeString(
OCMConnection.convertConnectionTypeFromOCM(
it.toLong(),
refData
)
)
}
}
result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})")
requiresChargepointQuery = true
}
val operators = filters.getMultipleChoiceValue("operators")
if (operators != null && !operators.all) {
val networksList = if (operators.values.size == 0) {
""
} else {
operators.values.joinToString(",") { opId ->
DatabaseUtils.sqlEscapeString(refData.operators.find { it.id == opId.toLong() }?.title.orEmpty())
}
}
result.append(" AND network IN (${networksList})")
}
val minConnectors = filters.getSliderValue("min_connectors")
if (minConnectors != null && minConnectors > 1) {
result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= ${minConnectors}")
requiresChargepointQuery = true
}
return FiltersSQLQuery(result.toString(), requiresChargepointQuery, false)
}
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
val operators = filters.getMultipleChoiceValue("operators")
return (operators != null && !operators.all)
// TODO: it would be possible to implement this without requiring details if we extended the data structure to also save the operator ID in the DB
}
}

View File

@@ -11,12 +11,15 @@ import java.time.Instant
import java.time.ZonedDateTime
// Unknown, Currently Available, Currently In Use, Operational
val noFaultStatuses = listOf(0, 10, 20, 50)
val noFaultStatuses = listOf(0L, 10L, 20L, 50L)
// Temporarily Unavailable, Partly Operational, Not Operational, Planned For Future Date, Removed (Decommissioned)
val faultStatuses = listOf(30L, 75L, 100L, 150L, 200L)
// Temporarily Unavailable, Partly Operational, Not Operational, Planned For Future Date
val faultStatuses = listOf(30L, 75L, 100L, 150L)
val faultReportCommentType = 1000L
// Removed (Decommissioned), Removed (Duplicate Listing)
val removedStatuses = listOf(200L, 210L)
data class OCMBoundingBox(
val sw_lat: Double, val sw_lng: Double,
val ne_lat: Double, val ne_lng: Double
@@ -71,10 +74,16 @@ data class OCMChargepoint(
addressInfo.countryISOCode(refData),
operatorId?.toString(),
connections.map { "${it.connectionTypeId},${it.currentTypeId}" }),
operatorInfo?.websiteUrl,
if (operatorInfo?.websiteUrl?.withoutTrailingSlash() != addressInfo.relatedUrl?.withoutTrailingSlash()) addressInfo.relatedUrl else null,
Instant.now(),
isDetailed
)
private fun String.withoutTrailingSlash(): String {
return this.replace(Regex("/$"), "")
}
private fun convertFaultReport(): FaultReport? {
if (statusTypeId in faultStatuses || connections.any { it.statusTypeId in faultStatuses }) {
if (userComments != null) {
@@ -92,7 +101,7 @@ data class OCMChargepoint(
connections.first { it.statusType != null && it.statusTypeId in faultStatuses }.statusType!!.title
)
}
return FaultReport(null, null)
return FaultReport(null, "")
} else {
return null
}
@@ -251,14 +260,13 @@ class OCMChargerPhotoAdapter(
val largeUrl: String,
val thumbUrl: String
) : ChargerPhoto(id) {
override fun getUrl(height: Int?, width: Int?, size: Int?): String {
override fun getUrl(height: Int?, width: Int?, size: Int?, allowOriginal: Boolean): String {
val maxSize = size ?: max(height, width)
val mediumUrl = thumbUrl.replace(".thmb.", ".medi.")
return when (maxSize) {
0 -> mediumUrl
in 1..100 -> thumbUrl
in 0..100 -> thumbUrl
in 101..400 -> mediumUrl
else -> largeUrl
else -> if (allowOriginal) largeUrl else mediumUrl
}
}
}

View File

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

View File

@@ -27,6 +27,7 @@ import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.car2go.maps.model.LatLng
import net.vonforst.evmap.R
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
@@ -121,6 +122,8 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
override fun onCreateScreen(intent: Intent): Screen {
handleActionsIntent(intent)
val mapScreen = MapScreen(carContext, this)
val screens = mutableListOf<Screen>(mapScreen)
@@ -157,6 +160,30 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
return screens.last()
}
private fun handleActionsIntent(intent: Intent): Boolean {
intent.data?.let {
if (it.host == "find_charger") {
val lat = it.getQueryParameter("latitude")?.toDouble()
val lon = it.getQueryParameter("longitude")?.toDouble()
val name = it.getQueryParameter("name")
if (lat != null && lon != null) {
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
return true
} else if (name != null) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
screenManager.push(PlaceSearchScreen(carContext, this, name))
return true
}
}
}
return false
}
override fun onNewIntent(intent: Intent) {
handleActionsIntent(intent)
}
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
private fun updateLocation(location: Location?) {

View File

@@ -5,9 +5,17 @@ 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"
),
"Renault" to mapOf(
"BCB" to "Megane E-Tech"
)
)
@@ -16,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

@@ -1,10 +1,5 @@
package net.vonforst.evmap.auto
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
@@ -22,7 +17,6 @@ import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.api.equivalentPlugTypes
@@ -49,9 +43,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
private var prices: List<ChargePrice>? = null
private var meta: ChargepriceChargepointMeta? = null
private var chargepoint: Chargepoint? = null
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private var errorMessage: String? = null
private val batteryRange = prefs.chargepriceBatteryRangeAndroidAuto
@@ -77,34 +69,54 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
carContext.stringProvider(),
chargepoint.type
)
} ${chargepoint.formatPower()} " + carContext.getString(
R.string.chargeprice_stats,
meta.energy,
time(meta.duration.roundToInt()),
meta.energy / meta.duration * 60
)
} ${chargepoint.formatPower()} ${
carContext.getString(
R.string.chargeprice_stats,
meta.energy,
time(meta.duration.roundToInt()),
meta.energy / meta.duration * 60
)
}"
}
}
val myTariffs = prefs.chargepriceMyTariffs
val myTariffsAll = prefs.chargepriceMyTariffsAll
val list = ItemList.Builder().apply {
setNoItemsMessage(
errorMessage ?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.take(maxRows)?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
if (carContext.carAppApiLevel >= 5) {
setEnabled(myTariffsAll || myTariffs != null && price.tariffId in myTariffs)
}
}.build())
val prices = prices?.take(maxRows)
if (prices != null && prices.isNotEmpty() && !myTariffsAll && myTariffs != null) {
val (myPrices, otherPrices) = prices.partition { price -> price.tariffId in myTariffs }
val myPricesList = buildPricesList(myPrices)
val otherPricesList = buildPricesList(otherPrices)
if (myPricesList.items.isNotEmpty() && otherPricesList.items.isNotEmpty()) {
addSectionedList(
SectionedItemList.create(
myPricesList,
(header?.let { it + "\n" } ?: "") +
carContext.getString(R.string.chargeprice_header_my_tariffs)
)
)
addSectionedList(
SectionedItemList.create(
otherPricesList,
carContext.getString(R.string.chargeprice_header_other_tariffs)
)
)
} else {
val list =
if (myPricesList.items.isNotEmpty()) myPricesList else otherPricesList
if (header != null) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
}.build()
if (header != null && list.items.isNotEmpty()) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
val list = buildPricesList(prices)
if (header != null && list.items.isNotEmpty()) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
}
setActionStrip(
@@ -117,44 +129,28 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
)
).build()
).setOnClickListener {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
R.color.colorPrimary
)
)
.build()
)
.build().intent
intent.data =
Uri.parse(ChargepriceApi.getPoiUrl(charger))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
if (BuildConfig.FLAVOR_automotive != "automotive") {
// only show the toast "opened on phone" if we're running on a phone
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
}
} catch (e: ActivityNotFoundException) {
CarToast.makeText(
carContext,
R.string.no_browser_app_found,
CarToast.LENGTH_LONG
).show()
}
openUrl(carContext, ChargepriceApi.getPoiUrl(charger))
}.build()
).build()
)
}.build()
}
private fun buildPricesList(prices: List<ChargePrice>?): ItemList {
return ItemList.Builder().apply {
setNoItemsMessage(
errorMessage
?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
}.build())
}
}.build()
}
private fun formatProvider(price: ChargePrice): String {
if (!price.tariffName.startsWith(price.provider)) {
return price.provider + " " + price.tariffName
@@ -210,8 +206,8 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
private fun loadPrices(model: Model?) {
val dataAdapter = ChargepriceApi.getDataAdapter(charger) ?: return
val manufacturer = model?.manufacturer?.value
val dataAdapter = ChargepriceApi.getDataAdapter(charger)
val manufacturer = getVehicleBrand(model?.manufacturer?.value)
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
lifecycleScope.launch {
try {
@@ -279,7 +275,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.maxByOrNull { it.power }
prices = result.data!!.map { cp ->
prices = result.data!!.mapNotNull { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
@@ -291,7 +287,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
chargepointPrices = filteredPrices
)
}
}.filterNotNull()
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
@@ -349,14 +345,14 @@ 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()
} else if (vehicles.size > 1) {
if (modelName != null) {
vehicles = vehicles.filter {
it.name.startsWith(modelName)
it.name.lowercase().startsWith(modelName.lowercase())
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()

View File

@@ -23,8 +23,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.iconForPlugType
@@ -57,6 +57,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val imageSize = 128 // images should be 128dp according to docs
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
@@ -64,9 +65,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val iconGen =
ChargerIconGenerator(carContext, null, height = imageSize)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
} else 2
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
private val largeImageSupported =
ctx.carAppApiLevel >= 4 // since API 4, Row.setImage is supported
@@ -350,23 +349,31 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder {
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
if (i > 0) chargepointsText.append(" · ")
chargepointsText.append(
"${cp.count}× "
).append(
nameForPlugType(carContext.stringProvider(), cp.type),
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
iconForPlugType(cp.type)
)
).setTint(
CarColor.createCustom(Color.WHITE, Color.BLACK)
).build()
),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
).append(" ").append(cp.formatPower())
chargepointsText.apply {
if (i > 0) append(" · ")
append("${cp.count}× ")
val plugIcon = iconForPlugType(cp.type)
if (plugIcon != 0) {
append(
nameForPlugType(carContext.stringProvider(), cp.type),
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
plugIcon
)
).setTint(
CarColor.createCustom(Color.WHITE, Color.BLACK)
).build()
),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
} else {
append(nameForPlugType(carContext.stringProvider(), cp.type))
}
append(" ")
append(cp.formatPower())
}
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
@@ -459,7 +466,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
invalidate()
availability = getAvailability(charger).data
availability = availabilityRepo.getAvailability(charger).data
invalidate()
} else {

View File

@@ -31,10 +31,8 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
db.filterProfileDao().getProfiles(prefs.dataSource)
}
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
private var page = 0
init {
@@ -129,10 +127,14 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
)
setOnClickListener {
page -= 1
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())
@@ -222,10 +224,14 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
)
setOnClickListener {
page += 1
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())
@@ -243,9 +249,8 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
private val vm = FilterViewModel(carContext.applicationContext as Application)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
private var page = 0
private var paginatedFilters = vm.filtersWithValue.map {
@@ -372,10 +377,14 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
)
setOnClickListener {
page -= 1
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())
@@ -454,10 +463,14 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
)
setOnClickListener {
page += 1
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())

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
@@ -24,8 +25,8 @@ import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
@@ -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
@@ -71,24 +73,26 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private var location: Location? = null
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)
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val searchRadius = 5 // kilometers
private val distanceUpdateThreshold = Duration.ofSeconds(15)
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private val chargersUpdateThresholdDistance = 500 // meters
private val chargersUpdateThresholdTime = Duration.ofSeconds(30)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
HashMap()
private val maxRows = if (ctx.carAppApiLevel >= 2) {
min(
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST),
25
)
} else 6
private val maxRows =
min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
private var filterStatus = prefs.filterStatus
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
@@ -113,6 +117,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
@@ -153,8 +160,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(
@@ -205,7 +212,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
).setTint(CarColor.DEFAULT).build()
)
.setOnClickListener {
screenManager.push(SettingsScreen(carContext))
screenManager.push(SettingsScreen(carContext, session))
session.mapScreen = null
}
.build())
@@ -223,12 +230,19 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
setOnClickListener {
if (prefs.placeSearchResultAndroidAuto != null) {
prefs.placeSearchResultAndroidAutoName = null
prefs.placeSearchResultAndroidAuto = null
screenManager.pushForResult(DummyReturnScreen(carContext)) {
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
chargers = null
isFavorite = null
loadChargers()
}
} else {
chargers = null
isFavorite = null
loadChargers()
}
} else {
@@ -239,11 +253,12 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
) {
chargers = null
isFavorite = null
loadChargers()
}
session.mapScreen = null
}
})
}
}.build())
.addAction(
Action.Builder()
@@ -263,19 +278,26 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
.build())
.build())
if (carContext.carAppApiLevel >= 5) {
if (carContext.carAppApiLevel >= 5 ||
(BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
) {
setOnContentRefreshListener(this@MapScreen)
}
}.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(
@@ -285,16 +307,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 +356,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
// power
val power = charger.maxPower;
val power = charger.maxPower
if (power != null) {
if (text.isNotEmpty()) text.append(" · ")
text.append("${power.roundToInt()} kW")
@@ -328,7 +366,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
availabilities[charger.id]?.second?.let { av ->
val status = av.status.values.flatten()
val available = availabilityText(status)
val total = charger.chargepoints.sumBy { it.count }
val total = charger.chargepoints.sumOf { it.count }
if (text.isNotEmpty()) text.append(" · ")
text.append(
@@ -362,6 +400,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
this.location = location
if (previousLocation == null) {
loadChargers()
return
}
val now = Instant.now()
@@ -372,6 +411,23 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// update displayed distances
invalidate()
}
// if chargers are searched around current location, consider app-driven refresh
val searchLocation =
if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null
val distance = searchLocation?.let {
distanceBetween(
it.latitude, it.longitude, location.latitude, location.longitude
)
} ?: 0.0
if (supportsRefresh && (lastChargersUpdateTime == null ||
Duration.between(
lastChargersUpdateTime,
now
) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance)
) {
onContentRefreshRequested()
}
}
private fun loadChargers() {
@@ -390,42 +446,56 @@ 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
for (radius in listOf(searchRadius, searchRadius * 10, searchRadius * 50)) {
val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50)
for (radius in radiusValues) {
val response = repo.getChargepointsRadius(
searchLocation,
radius,
zoom = 16f,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR) {
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
loadingError = true
this@MapScreen.chargers = null
invalidate()
return@launch
}
chargers = headingFilter(
response.data?.filterIsInstance(ChargeLocation::class.java),
searchLocation
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
if (prefs.placeSearchResultAndroidAutoName == null) {
chargers = headingFilter(
chargers,
searchLocation
)
}
if (chargers == null || chargers.size >= maxRows) {
break
}
}
val isFavorite = chargers?.map {
db.favoritesDao().findFavorite(it.id, apiId) != null
}
this@MapScreen.chargers = chargers
this@MapScreen.isFavorite = isFavorite
}
updateCoroutine = null
lastChargersUpdateTime = Instant.now()
lastDistanceUpdateTime = Instant.now()
invalidate()
} catch (e: IOException) {
@@ -441,8 +511,12 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private fun headingFilter(
chargers: List<ChargeLocation>?,
searchLocation: LatLng
): List<ChargeLocation>? =
heading?.orientations?.value?.get(0)?.let { heading ->
): List<ChargeLocation>? {
// use compass heading if available, otherwise fall back to GPS
val location = location
val heading = heading?.orientations?.value?.get(0)
?: if (location?.hasBearing() == true) location.bearing else null
return heading?.let {
if (!prefs.showChargersAheadAndroidAuto) return@let chargers
chargers?.filter {
@@ -456,6 +530,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
abs(diff) < 30
}
} ?: chargers
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
val isUpdate = this.energyLevel == null
@@ -542,7 +617,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
// when the list is scrolled, load corresponding availabilities
if (startIndex == visibleStart && endIndex == visibleEnd && !availabilities.isEmpty()) return
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
if (startIndex == -1 || endIndex == -1) return
if (availabilityUpdateCoroutine != null) return
@@ -571,7 +646,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
lifecycleScope.async {
val availability = getAvailability(it).data
val availability = availabilityRepo.getAvailability(it).data
val date = ZonedDateTime.now()
availabilities[it.id] = date to availability
}

View File

@@ -46,7 +46,7 @@ class PermissionScreen(
}
private fun requestPermissions() {
carContext.requestPermissions(permissions) { granted, rejected ->
carContext.requestPermissions(permissions) { granted, _ ->
if (granted.containsAll(permissions)) {
screenManager.pop()
} else {

View File

@@ -31,7 +31,11 @@ import java.io.IOException
import java.time.Instant
@ExperimentalCarApi
class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
class PlaceSearchScreen(
ctx: CarContext,
val session: EVMapSession,
val initialSearch: String = ""
) : Screen(ctx),
SearchTemplate.SearchCallback, LocationAwareScreen,
DefaultLifecycleObserver {
private val hardwareMan: CarHardwareManager by lazy {
@@ -64,13 +68,15 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
init {
lifecycle.addObserver(this)
update("")
update(initialSearch)
}
override fun onGetTemplate(): Template {
return SearchTemplate.Builder(this).apply {
setHeaderAction(Action.BACK)
setSearchHint(carContext.getString(R.string.search))
setInitialSearchText(initialSearch)
setShowKeyboardByDefault(initialSearch == "")
resultList?.let {
setItemList(buildItemList(it))
} ?: setLoading(true)
@@ -105,15 +111,15 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
addText(text)
}
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
screenManager.popTo(MapScreen.MARKER)
}
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
screenManager.popTo(MapScreen.MARKER)
}
}
}.build())
@@ -148,6 +154,7 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
}
private suspend fun loadNewList(query: String) {
val location = location?.let { LatLng.fromLocation(it) }
for (provider in providers) {
try {
recentResults.clear()
@@ -161,7 +168,7 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
}
recentResults.addAll(recentPlaces)
resultList =
recentPlaces.map { it.asAutocompletePlace(LatLng.fromLocation(location)) }
recentPlaces.map { it.asAutocompletePlace(location) }
invalidate()
// if we already have enough results or the query is short, stop here
@@ -170,7 +177,7 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
// then search online
val recentIds = recentPlaces.map { it.id }
resultList = withContext(Dispatchers.IO) {
(resultList!! + provider.autocomplete(query, LatLng.fromLocation(location))
(resultList!! + provider.autocomplete(query, location)
.filter { !recentIds.contains(it.id) }).take(maxItems)
}
invalidate()

View File

@@ -15,9 +15,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
protected var fullList: List<T>? = null
private var currentList: List<T> = emptyList()
private var query: String = ""
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
protected abstract val isMultiSelect: Boolean
protected abstract val shouldShowSelectAll: Boolean
@@ -88,6 +86,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
addItem(
Row.Builder()
.setTitle(getLabel(item))
.apply { getDetails(item)?.let { addText(it) } }
.setImage(if (isSelected(item)) checkedIcon else uncheckedIcon)
.setOnClickListener {
toggleSelected(item)
@@ -132,5 +131,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
abstract fun getLabel(it: T): String
open fun getDetails(it: T): String? = null
abstract suspend fun loadData(): List<T>
}

View File

@@ -1,15 +1,21 @@
package net.vonforst.evmap.auto
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
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.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.*
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
@@ -18,7 +24,8 @@ import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.max
import kotlin.math.min
class SettingsScreen(ctx: CarContext) : Screen(ctx) {
@ExperimentalCarApi
class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
override fun onGetTemplate(): Template {
@@ -71,7 +78,7 @@ class SettingsScreen(ctx: CarContext) : Screen(ctx) {
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(VehicleDataScreen(carContext))
screenManager.push(VehicleDataScreen(carContext, session))
}
.build()
)
@@ -81,9 +88,34 @@ class SettingsScreen(ctx: CarContext) : Screen(ctx) {
.setToggle(Toggle.Builder {
prefs.showChargersAheadAndroidAuto = it
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).setTint(CarColor.DEFAULT).build()
)
.build()
)
}
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.about))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_about
)
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(AboutScreen(carContext))
}
.build()
)
}.build())
}.build()
}
@@ -239,9 +271,7 @@ class ChooseDataSourceScreen(
class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
@@ -360,6 +390,8 @@ class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<Chargepric
override fun getLabel(it: ChargepriceCar) = "${it.brand} ${it.name}"
override fun getDetails(it: ChargepriceCar) = it.formatSpecs()
override suspend fun loadData(): List<ChargepriceCar> {
return api.getVehicles()
}
@@ -556,4 +588,165 @@ class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
)
}.build()
}
}
class AboutScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
var developerOptionsCounter = 0
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.about))
setHeaderAction(Action.BACK)
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.version))
.addText(BuildConfig.VERSION_NAME)
.addText(
carContext.getString(R.string.copyright) + " " + carContext.getString(
R.string.copyright_summary
)
)
.setBrowsable(prefs.developerModeEnabled)
.setOnClickListener {
if (!prefs.developerModeEnabled) {
developerOptionsCounter += 1
if (developerOptionsCounter >= 7) {
prefs.developerModeEnabled = true
invalidate()
CarToast.makeText(
carContext,
carContext.getString(R.string.developer_mode_enabled),
CarToast.LENGTH_SHORT
).show()
}
} else {
screenManager.pushForResult(DeveloperOptionsScreen(carContext)) {
developerOptionsCounter = 0
invalidate()
}
}
}.build()
)
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.faq))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.faq_link))
}).build()
)
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.donate))
.addText(carContext.getString(R.string.donate_desc))
.setBrowsable(true)
.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))
} else {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_DONATE, true)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
}
}).build()
)
}.build(), carContext.getString(R.string.about)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.twitter))
.addText(carContext.getString(R.string.twitter_handle))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.twitter_url))
}).build()
)
if (maxRows > 6) {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.goingelectric_forum))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(
carContext,
carContext.getString(R.string.goingelectric_forum_url)
)
}).build()
)
}
}.build(), carContext.getString(R.string.contact)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.github_link_title))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.github_link))
}).build()
)
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.privacy))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.privacy_link))
}).build()
)
}.build(), carContext.getString(R.string.other)))
}.build()
}
}
class DeveloperOptionsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.developer_options))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(
Row.Builder().apply {
setTitle("Car app API Level: ${carContext.carAppApiLevel}")
val hostPackage = carContext.hostInfo?.packageName
val hostVersion = hostPackage?.let {
try {
carContext.packageManager.getPackageInfoCompat(it).versionName
} catch (e: NameNotFoundException) {
null
}
}
addText("$hostPackage $hostVersion")
if (BuildConfig.FLAVOR_automotive == "automotive") {
addText(
"Sensor list: ${
(carContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager).getSensorList(
Sensor.TYPE_ALL
).map { it.type }.joinToString(",")
}"
)
}
}.build()
)
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.disable_developer_mode))
setOnClickListener {
prefs.developerModeEnabled = false
CarToast.makeText(
carContext,
carContext.getString(R.string.developer_mode_disabled),
CarToast.LENGTH_SHORT
).show()
screenManager.pop()
}
}.build())
}.build())
}.build()
}
}

View File

@@ -1,16 +1,25 @@
package net.vonforst.evmap.auto
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
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.hardware.common.CarUnit
import androidx.car.app.model.*
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.content.ContextCompat
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.getPackageInfoCompat
import java.util.*
import kotlin.math.roundToInt
@@ -33,6 +42,23 @@ fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val CarContext.constraintManager
get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
fun CarContext.getContentLimit(id: Int) = if (carAppApiLevel >= 2) {
constraintManager.getContentLimit(id)
} else {
when (id) {
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 6
ConstraintManager.CONTENT_LIMIT_TYPE_LIST -> 6
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST -> 6
ConstraintManager.CONTENT_LIMIT_TYPE_ROUTE_LIST -> 3
else -> throw IllegalArgumentException("unknown limit ID")
}
}
val CarContext.isAppDrivenRefreshSupported
@androidx.car.app.annotations.ExperimentalCarApi
get() = if (carAppApiLevel >= 6) constraintManager.isAppDrivenRefreshEnabled else false
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
val emptyCarIcon: CarIcon by lazy {
@@ -171,7 +197,7 @@ fun <T> List<T>.paginate(nSingle: Int, nFirst: Int, nOther: Int, nLast: Int): Li
}
fun getAndroidAutoVersion(ctx: Context): List<String> {
val info = ctx.packageManager.getPackageInfo("com.google.android.projection.gearhead", 0)
val info = ctx.packageManager.getPackageInfoCompat("com.google.android.projection.gearhead", 0)
return info.versionName.split(".")
}
@@ -190,6 +216,40 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
return true
}
fun openUrl(carContext: CarContext, url: String) {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
R.color.colorPrimary
)
)
.build()
)
.build().intent
intent.data = Uri.parse(url)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
if (BuildConfig.FLAVOR_automotive != "automotive") {
// only show the toast "opened on phone" if we're running on a phone
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
}
} catch (e: ActivityNotFoundException) {
CarToast.makeText(
carContext,
R.string.no_browser_app_found,
CarToast.LENGTH_LONG
).show()
}
}
class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
/*
Dummy screen to get around template refresh limitations.

View File

@@ -1,6 +1,7 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
@@ -16,10 +17,13 @@ import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.CompassNeedle
import net.vonforst.evmap.ui.Gauge
import net.vonforst.evmap.utils.formatDecimal
import kotlin.math.min
import kotlin.math.roundToInt
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver {
@androidx.car.app.annotations.ExperimentalCarApi
class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
LocationAwareScreen, DefaultLifecycleObserver {
private val carInfo =
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
private val carSensors = carContext.patchedCarSensors
@@ -27,6 +31,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
private var energyLevel: EnergyLevel? = null
private var speed: Speed? = null
private var heading: Compass? = null
private var location: Location? = null
private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
private var compass =
CompassNeedle((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
@@ -70,12 +75,16 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
val energyLevel = energyLevel
val model = model
val speed = speed
val heading = heading
val location = location
val compassHeading = heading?.orientations?.value?.get(0)
val gpsHeading = if (location?.hasBearing() == true) location.bearing else null
val heading = compassHeading ?: gpsHeading
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
@@ -192,17 +201,30 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
if (heading == null) {
setLoading(true)
} else {
val heading = heading.orientations.value
if (heading != null) {
setText(
"${heading[0].roundToInt()}°"
val headingSource =
if (compassHeading != null) carContext.getString(R.string.compass) else carContext.getString(
R.string.gps
)
} else {
setText(carContext.getString(R.string.auto_no_data))
}
setText("${heading.roundToInt()}° ($headingSource)")
setImage(
compass.draw(heading?.get(0)).asCarIcon()
compass.draw(heading).asCarIcon()
)
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.coordinates))
if (location == null) {
setLoading(true)
} else {
val dms = location.formatDecimal(4)
setText(dms)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_location
)
).setTint(CarColor.DEFAULT).build()
)
}
}.build())
@@ -229,6 +251,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
override fun onResume(owner: LifecycleOwner) {
setupListeners()
session.mapScreen = this
}
private fun setupListeners() {
@@ -253,6 +276,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
override fun onPause(owner: LifecycleOwner) {
removeListeners()
session.mapScreen = null
}
private fun removeListeners() {
@@ -269,4 +293,8 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
it
) == PackageManager.PERMISSION_GRANTED
}
override fun updateLocation(location: Location) {
this.location = location
}
}

View File

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

View File

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

View File

@@ -123,12 +123,12 @@ class ChargepriceFragment : Fragment() {
val charger = fragmentArgs.charger
vm.charger.value = charger
if (vm.chargepoint.value == null) {
vm.chargepoint.value = charger.chargepointsMerged.get(0)
vm.chargepoint.value = charger.chargepointsMerged[0]
}
val vehicleAdapter = CheckableChargepriceCarAdapter()
headerBinding.vehicleSelection.adapter = vehicleAdapter
val vehicleObserver: Observer<ChargepriceCar> = Observer {
val vehicleObserver: Observer<ChargepriceCar?> = Observer {
vehicleAdapter.setCheckedItem(it)
}
vm.vehicle.observe(viewLifecycleOwner, vehicleObserver)
@@ -167,12 +167,12 @@ class ChargepriceFragment : Fragment() {
chargepriceAdapter.myTariffsAll = it
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
chargepriceAdapter.submitList(it.data)
it?.data?.let { chargepriceAdapter.submitList(it) }
}
val connectorsAdapter = CheckableConnectorAdapter()
val observer: Observer<Chargepoint> = Observer {
val observer: Observer<Chargepoint?> = Observer {
connectorsAdapter.setCheckedItem(it)
}
vm.chargepoint.observe(viewLifecycleOwner, observer)

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