Compare commits

...

249 Commits

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

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

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

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

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

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

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

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

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

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

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

fixes #293
2023-09-02 17:39:37 +02:00
johan12345
92619ea95e ACRA: switch from email to HTTP sending for automotive flavor 2023-09-02 16:47:56 +02:00
johan12345
a7007284ff ACRA: introduce AAOS-friendly crash reporting screen 2023-09-02 16:38:02 +02:00
johan12345
7fce566052 add referral link to donate page 2023-08-27 19:53:32 +02:00
Johan von Forstner
0c44b4b074 Update FUNDING.yml 2023-08-27 19:52:48 +02:00
johan12345
a652d96f74 fix CarAppTest 2023-08-27 19:12:55 +02:00
johan12345
8a9b3ad948 Android Auto/Automotive: Make users explicitly accept the privacy policy 2023-08-27 19:05:04 +02:00
Johan von Forstner
c48f33e265 Release 1.6.7 2023-08-21 19:26:30 +02:00
johan12345
8ba4897026 Android Automotive: update donate link 2023-08-21 19:22:45 +02:00
Johan von Forstner
42916d71ca Onboarding: Do not select GoingElectric by default
instead make the user actively choose the data source
#291
2023-08-03 16:47:20 +02:00
Johan von Forstner
5ca7524e8b OpenChargeMap: Map "Tesla Model S/X" plug to Chargepoint.SUPERCHARGER
#6
2023-08-03 16:34:10 +02:00
Johan von Forstner
c37f72a26b reduce vertical padding around dialogs
#292
2023-08-02 20:46:16 +02:00
Johan von Forstner
6f0113c50d adjustments to data source selection screen for smaller screens 2023-08-02 19:58:05 +02:00
Johan von Forstner
f99ea7ca9e Fix formatting of Portuguese string 2023-08-02 16:21:02 +02:00
Johan von Forstner
788db0c10f fix popup menu theme in dark mode
see #287
2023-08-02 16:09:48 +02:00
Hosted Weblate
db2213a50f Translated using Weblate (Portuguese)
Currently translated at 100.0% (349 of 349 strings)

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

preparation for #281
2023-07-01 12:16:41 +02:00
johan12345
96df684b80 remove unneeded overrideLibrary in manifest 2023-07-01 12:16:41 +02:00
Johan von Forstner
7903c027c7 AvailabilityDetector: Further increase max merging distance
to 60m
2023-06-22 17:10:48 +02:00
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
290 changed files with 8114 additions and 1640 deletions

2
.github/FUNDING.yml vendored
View File

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

View File

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

View File

@@ -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
@@ -32,6 +32,7 @@ jobs:
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
@@ -76,3 +77,12 @@ jobs:
asset_path: app/build/outputs/apk/googleAutomotive/release/app-google-automotive-release.apk
asset_name: app-google-automotive-release.apk
asset_content_type: application/vnd.android.package-archive
- name: upload Foss Automotive artifact
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fossAutomotive/release/app-foss-automotive-release.apk
asset_name: app-foss-automotive-release.apk
asset_content_type: application/vnd.android.package-archive

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,24 +8,22 @@ 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
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "net.vonforst.evmap"
compileSdk 34
minSdkVersion 21
targetSdkVersion 33
targetSdkVersion 34
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 142
versionName "1.4.2"
versionCode 204
versionName "1.7.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(",")
resConfigs supportedLocales.split(',')
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
}
@@ -73,13 +71,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 +82,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
@@ -148,8 +145,28 @@ android {
if (fronyxKey != null) {
variant.resValue "string", "fronyx_key", fronyxKey
}
def acraKey = env.ACRA_CRASHREPORT_CREDENTIALS ?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
acraKey = decode(project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (acraKey != null) {
variant.resValue "string", "acra_credentials", acraKey
}
def teslaKey = env.TESLA_CREDENTIALS ?: project.findProperty("TESLA_CREDENTIALS")
if (teslaKey == null && project.hasProperty("TESLA_CREDENTIALS_ENCRYPTED")) {
teslaKey = decode(project.findProperty("TESLA_CREDENTIALS_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (teslaKey != null) {
variant.resValue "string", "tesla_credentials", teslaKey
}
}
packagingOptions {
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 +176,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.12.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation "androidx.activity:activity-ktx:1.7.2"
implementation "androidx.fragment:fragment-ktx:1.6.1"
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 'androidx.preference:preference-ktx:1.2.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.browser:browser:1.4.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.browser:browser:1.6.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 'io.coil-kt:coil:2.4.0'
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:4.1.0'
@@ -190,28 +209,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.4.0-beta02'
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.2.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.1'
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
@@ -221,48 +242,53 @@ dependencies {
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.5.1"
def lifecycle_version = "2.6.2"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.4.3"
def room_version = "2.6.0-beta01"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation 'com.github.anboralabs:spatia-room:0.2.7'
// billing library
def billing_version = "4.1.0"
def billing_version = "6.0.1"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
// ACRA (crash reporting)
def acraVersion = "5.8.4"
implementation("ch.acra:acra-mail:$acraVersion")
def acraVersion = "5.11.1"
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// 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.10.3'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'org.robolectric:robolectric:4.9'
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

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">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

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

View File

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

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

View File

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

View File

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

View File

@@ -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,16 @@
<?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="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
<queries>
<intent>
@@ -18,11 +24,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,12 +277,23 @@
<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"
android:resource="@xml/shortcuts" />
</activity>
<activity android:name=".auto.OAuthLoginActivity">
</activity>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
@@ -278,6 +302,51 @@
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"
android:foregroundServiceType="location">
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.POI" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -1,17 +1,22 @@
package net.vonforst.evmap
import android.app.Activity
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
import org.acra.config.dialog
import org.acra.config.httpSender
import org.acra.config.limiter
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import java.time.Duration
class EvMapApplication : Application() {
class EvMapApplication : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
val prefs = PreferenceDataSource(this)
@@ -24,16 +29,21 @@ class EvMapApplication : Application() {
prefs.language = null
}
Stetho.initializeWithDefaults(this);
init(applicationContext)
addDebugInterceptors(applicationContext)
if (!BuildConfig.DEBUG) {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST
mailSender {
mailTo = "evmap+crashreport@vonforst.net"
// Vehicles often don't have an email app, so use HTTP to send instead
reportFormat = StringFormat.JSON
httpSender {
uri = getString(R.string.acra_backend_url)
val creds = getString(R.string.acra_credentials).split(":")
basicAuthLogin = creds[0]
basicAuthPassword = creds[1]
httpMethod = HttpSender.Method.POST
}
dialog {
@@ -42,6 +52,10 @@ class EvMapApplication : Application() {
commentPrompt = getString(R.string.crash_report_comment_prompt)
resIcon = R.drawable.ic_launcher_foreground
resTheme = R.style.AppTheme
if (BuildConfig.FLAVOR_automotive == "automotive") {
reportDialogClass =
Class.forName("androidx.car.app.activity.CarAppActivity") as Class<out Activity>?
}
}
limiter {
@@ -49,5 +63,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,11 +1,17 @@
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.icu.util.LocaleData
import android.icu.util.ULocale
import android.os.Build
import android.os.Bundle
import android.text.*
import android.text.style.StyleSpan
import net.vonforst.evmap.storage.PreferenceDataSource
import java.util.*
fun Bundle.optDouble(name: String): Double? {
@@ -72,7 +78,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
}
@@ -85,7 +91,30 @@ fun Context.isDarkMode() =
const val kmPerMile = 1.609344
const val meterPerFt = 0.3048
const val ftPerMile = 5280
const val ydPerMile = 1760
fun shouldUseImperialUnits(): Boolean {
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
}
fun shouldUseImperialUnits(ctx: Context): Boolean {
val prefs = PreferenceDataSource(ctx)
return when (prefs.units) {
"metric" -> false
"imperial" -> true
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
when (LocaleData.getMeasurementSystem(ULocale.getDefault())) {
LocaleData.MeasurementSystem.US, LocaleData.MeasurementSystem.UK -> true
LocaleData.MeasurementSystem.SI -> false
else -> false
}
} else {
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
}
}
}
fun 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,13 +204,13 @@ 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 {
notifyDataSetChanged()
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}

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(
)
}
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)
)
}
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,17 @@
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.R
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 +74,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 +96,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 +111,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 +119,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 +136,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 +163,56 @@ 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 teslaAvailabilityDetector = run {
val (clientId, clientSecret) = context.getString(R.string.tesla_credentials).split(":")
TeslaAvailabilityDetector(
okhttp,
EncryptedPreferenceDataStore(context),
clientId,
clientSecret
)
}
private val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
teslaAvailabilityDetector,
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)
fun isSupercharger(charger: ChargeLocation) =
teslaAvailabilityDetector.isChargerSupported(charger)
fun isTeslaSupported(charger: ChargeLocation) =
teslaAvailabilityDetector.isChargerSupported(charger) && teslaAvailabilityDetector.isSignedIn()
}

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")
@@ -117,6 +117,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
listOf(it)
}
}
if (markers.any { it.grouped }) throw AvailabilityDetectorException("markers still grouped")
val nearest = markers.minByOrNull { marker ->
distanceBetween(marker.lat, marker.lon, lat, lng)
@@ -132,22 +133,26 @@ 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.mapNotNull { it.stationId }.map {
// load details
api.getLocation(it.stationId!!)
api.getLocation(it)
}
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
@@ -203,30 +208,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,648 @@
package net.vonforst.evmap.api.availability
import android.net.Uri
import android.util.Base64
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.Coordinate
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
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 = "redirect_uri") val redirectUri: String = "https://ev-map.app/void/callback",
scope: String = "openid offline_access vehicle_device_data",
@Json(name = "client_id") clientId: String,
@Json(name = "client_secret") clientSecret: String
) : OAuth2Request(scope, clientId, clientSecret)
@JsonClass(generateAdapter = true)
class RefreshTokenRequest(
@Json(name = "refresh_token") val refreshToken: String,
scope: String = "openid offline_access vehicle_device_data",
@Json(name = "client_id") clientId: String,
@Json(name = "client_secret") clientSecret: String,
) : OAuth2Request(scope, clientId, clientSecret)
sealed class OAuth2Request(
val scope: String,
val clientId: String,
val clientSecret: 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 buildSignInUri(clientId: String): Uri =
Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", "https://ev-map.app/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid offline_access vehicle_device_data")
.appendQueryParameter("state", "123").build()
val resultUrlPrefix = "https://ev-map.app/void/callback"
}
}
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_GREATER_THAN_25_MINUTES")
GREATER_THAN_25_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 clientId: String,
private val clientSecret: String,
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
?: throw AvailabilityDetectorException("no candidates found.")
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 ?: throw AvailabilityDetectorException("no candidates found.")
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
val scV2CCSConnectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power <= 150
}
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
}
val scV3Connectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power > 150
}
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
"charger has unknown connectors"
)
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,
clientId = clientId,
clientSecret = clientSecret
)
)
tokenStore.teslaAccessToken = response.accessToken
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
response.accessToken
}
token
}
api = newApi
newApi
}
}
fun isSignedIn() = tokenStore.teslaRefreshToken != null
}

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

@@ -0,0 +1,188 @@
package net.vonforst.evmap.api.fronyx
import android.content.Context
import com.squareup.moshi.JsonDataException
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.Resource
import retrofit2.HttpException
import java.io.IOException
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
data class PredictionData(
val predictionGraph: Map<ZonedDateTime, Double>?,
val maxValue: Double,
val predictedChargepoints: List<Chargepoint>,
val isPercentage: Boolean,
val description: String?
)
class PredictionRepository(private val context: Context) {
private val predictionApi = FronyxApi(context.getString(R.string.fronyx_key))
private val prefs = PreferenceDataSource(context)
suspend fun getPredictionData(
charger: ChargeLocation,
availability: ChargeLocationStatus?,
filteredConnectors: Set<String>? = null
): PredictionData {
val fronyxPrediction = availability?.evseIds?.let { evseIds ->
getFronyxPrediction(charger, evseIds, filteredConnectors)
}?.data
val graph = buildPredictionGraph(availability, fronyxPrediction)
val predictedChargepoints = getPredictedChargepoints(charger, filteredConnectors)
val maxValue = getPredictionMaxValue(availability, fronyxPrediction, predictedChargepoints)
val isPercentage = predictionIsPercentage(availability, fronyxPrediction)
val description = getDescription(charger, predictedChargepoints)
return PredictionData(
graph, maxValue, predictedChargepoints, isPercentage, description
)
}
private suspend fun getFronyxPrediction(
charger: ChargeLocation,
evseIds: Map<Chargepoint, List<String>>,
filteredConnectors: Set<String>?
): Resource<List<FronyxEvseIdResponse>> {
if (!prefs.predictionEnabled) return Resource.success(null)
val allEvseIds =
evseIds.filterKeys {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors?.let { filtered ->
equivalentPlugTypes(
it.type
).any { filtered.contains(it) }
} ?: true
}.flatMap { it.value }
if (allEvseIds.isEmpty()) {
return Resource.success(emptyList())
}
try {
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
if (result.size == allEvseIds.size) {
return Resource.success(result)
} else {
return Resource.error("not all EVSEIDs found", null)
}
} catch (e: IOException) {
e.printStackTrace()
return Resource.error(e.message, null)
} catch (e: HttpException) {
e.printStackTrace()
return Resource.error(e.message, null)
} catch (e: AvailabilityDetectorException) {
e.printStackTrace()
return Resource.error(e.message, null)
} catch (e: JsonDataException) {
// malformed JSON response from fronyx API
e.printStackTrace()
return Resource.error(e.message, null)
}
}
private fun buildPredictionGraph(
availability: ChargeLocationStatus?,
prediction: List<FronyxEvseIdResponse>?
): Map<ZonedDateTime, Double>? {
val congestionHistogram = availability?.congestionHistogram
return if (congestionHistogram != null && prediction == null) {
congestionHistogram.mapIndexed { i, value ->
LocalTime.of(i, 0).atDate(LocalDate.now())
.atZone(ZoneId.systemDefault()) to value
}.toMap()
} else {
prediction?.let { responses ->
if (responses.isEmpty()) {
null
} else {
val evseIds = responses.map { it.evseId }
val groupByTimestamp = responses.flatMap { response ->
response.predictions.map {
Triple(
it.timestamp,
response.evseId,
it.status
)
}
}
.groupBy { it.first } // group by timestamp
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
groupByTimestamp.mapValues {
it.value.count {
it.second == FronyxStatus.UNAVAILABLE
}.toDouble()
}.ifEmpty { null }
}
}
}
}
private fun getPredictedChargepoints(
charger: ChargeLocation,
filteredConnectors: Set<String>?
) =
charger.chargepoints.filter {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors?.let { filtered ->
equivalentPlugTypes(it.type).any {
filtered.contains(
it
)
}
} ?: true
}
private fun getPredictionMaxValue(
availability: ChargeLocationStatus?,
prediction: List<FronyxEvseIdResponse>?,
predictedChargepoints: List<Chargepoint>
): Double = if (availability?.congestionHistogram != null && prediction == null) {
1.0
} else {
predictedChargepoints.sumOf { it.count }.toDouble()
}
private fun predictionIsPercentage(
availability: ChargeLocationStatus?,
prediction: List<FronyxEvseIdResponse>?
) =
availability?.congestionHistogram != null && prediction == null
private fun getDescription(
charger: ChargeLocation,
predictedChargepoints: List<Chargepoint>
): String? {
val allChargepoints = charger.chargepoints
val predictedChargepointTypes = predictedChargepoints.map { it.type }.distinct()
return if (allChargepoints == predictedChargepoints) {
null
} else if (predictedChargepointTypes.size == 1) {
context.getString(
R.string.prediction_only,
nameForPlugType(context.stringProvider(), predictedChargepointTypes[0])
)
} else {
context.getString(
R.string.prediction_only,
context.getString(R.string.prediction_dc_plugs_only)
)
}
}
}

View File

@@ -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,39 @@ 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())
}
if (connectorsVal != null && connectorsVal.values.contains("CCS")) {
// see note about Tesla Supercharger CCS filter in getFilters below
connectorsVal.values.add("Tesla Supercharger CCS")
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
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 +222,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 +235,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 +246,39 @@ 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())
}
if (connectorsVal != null && connectorsVal.values.contains("CCS")) {
// see note about Tesla Supercharger CCS filter in getFilters below
connectorsVal.values.add("Tesla Supercharger CCS")
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
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 +317,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 +345,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 +437,25 @@ 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()
/*
"Tesla Supercharger CCS" is a bit peculiar - it is available as a filter, but the API
just returns "CCS" in the charging station details. So we cannot use it for filtering as
it won't work in the local database. So we join them into a single filter option.
If you want to find Tesla Superchargers with CCS, you can still do that using the network
filter.
*/
val plugMap = plugs
.filter { it != "Tesla Supercharger CCS" }
.associateWith { plug ->
nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
}
val networkMap = networks.associateWith { it }
val chargecardMap = chargeCards.associate { it.id.toString() to it.name }
val categoryMap = mapOf(
"Autohaus" to sp.getString(R.string.category_car_dealership),
"Autobahnraststätte" to sp.getString(R.string.category_service_on_motorway),
@@ -481,5 +528,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

@@ -5,7 +5,22 @@ import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.Address
import net.vonforst.evmap.model.ChargeCard
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargeLocationCluster
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.ChargepriceData
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.Coordinate
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FaultReport
import net.vonforst.evmap.model.Hours
import net.vonforst.evmap.model.OpeningHours
import net.vonforst.evmap.model.OpeningHoursDays
import net.vonforst.evmap.model.ReferenceData
import java.time.Instant
import java.time.LocalTime
@@ -35,7 +50,7 @@ sealed class GEChargepointListItem {
@JsonClass(generateAdapter = true)
data class GEChargeLocation(
@Json(name = "ge_id") val id: Long,
val name: String,
val name: String?,
val coordinates: GECoordinate,
val address: GEAddress,
val chargepoints: List<GEChargepoint>,
@@ -57,7 +72,7 @@ data class GEChargeLocation(
override fun convert(apikey: String, isDetailed: Boolean) = ChargeLocation(
id,
"goingelectric",
name,
name ?: "Charging station",
coordinates.convert(),
address.convert(),
chargepoints.map { it.convert() },
@@ -77,6 +92,8 @@ data class GEChargeLocation(
cost?.convert(),
null,
ChargepriceData(address.country, network, chargepoints.map { it.type }),
null,
null,
Instant.now(),
isDetailed
)
@@ -84,10 +101,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 +119,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 +164,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 +226,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

@@ -6,17 +6,28 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.max
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.Address
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargepriceData
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.Coordinate
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FaultReport
import net.vonforst.evmap.model.ReferenceData
import java.time.Instant
import java.time.ZonedDateTime
// 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
@@ -53,8 +64,8 @@ data class OCMChargepoint(
addressInfo.toAddress(refData),
connections.map { it.convert(refData) },
operatorInfo?.title,
"https://openchargemap.org/site/poi/details/$id",
"https://openchargemap.org/site/poi/edit/$id",
"https://map.openchargemap.io/?id=$id",
"https://map.openchargemap.io/?id=$id",
convertFaultReport(),
recentlyVerified,
null,
@@ -71,10 +82,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 +109,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
}
@@ -156,7 +173,8 @@ data class OCMConnection(
17L -> Chargepoint.CEE_ROT
28L -> Chargepoint.SCHUKO
8L -> Chargepoint.TESLA_ROADSTER_HPC
27L -> Chargepoint.SUPERCHARGER
27L -> Chargepoint.SUPERCHARGER // Tesla North American plug (NACS)
30L -> Chargepoint.SUPERCHARGER // European Tesla Model S/X Supercharger plug (DC on Type 2)
25L -> Chargepoint.TYPE_2_SOCKET
1036L -> Chargepoint.TYPE_2_PLUG
1L -> Chargepoint.TYPE_1
@@ -251,14 +269,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,12 +27,14 @@ 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
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.checkFineLocationPermission
import org.acra.interaction.DialogInteraction
interface LocationAwareScreen {
@@ -43,13 +45,15 @@ interface LocationAwareScreen {
class CarAppService : androidx.car.app.CarAppService() {
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
private var foregroundStarted = false
override fun onCreate() {
super.onCreate()
fun ensureForegroundService() {
// we want to run as a foreground service to make sure we can use location
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
if (!foregroundStarted) {
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
foregroundStarted = true
}
}
private fun createNotificationChannel() {
@@ -121,9 +125,13 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
override fun onCreateScreen(intent: Intent): Screen {
val mapScreen = MapScreen(carContext, this)
val screens = mutableListOf<Screen>(mapScreen)
handleActionsIntent(intent)?.let {
screens.add(it)
}
if (!prefs.dataSourceSet) {
screens.add(
ChooseDataSourceScreen(
@@ -146,6 +154,14 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
)
)
}
if (!prefs.privacyAccepted) {
screens.add(
AcceptPrivacyScreen(carContext)
)
}
handleACRAIntent(intent)?.let {
screens.add(it)
}
if (screens.size > 1) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
@@ -157,6 +173,35 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
return screens.last()
}
private fun handleACRAIntent(intent: Intent): Screen? {
return if (intent.hasExtra(DialogInteraction.EXTRA_REPORT_CONFIG)) {
CrashReportScreen(carContext, intent)
} else null
}
private fun handleActionsIntent(intent: Intent): Screen? {
intent.data?.let {
if (it.host == "find_charger") {
val lat = it.getQueryParameter("latitude")?.toDouble()
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 null
} else if (name != null) {
val screen = PlaceSearchScreen(carContext, this, name)
return screen
}
}
}
return null
}
override fun onNewIntent(intent: Intent) {
handleActionsIntent(intent)
}
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
private fun updateLocation(location: Location?) {
@@ -179,6 +224,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
@SuppressLint("MissingPermission")
fun requestLocationUpdates() {
if (!locationPermissionGranted()) return
cas.ensureForegroundService()
Log.i(TAG, "Requesting location updates")
requestCarHardwareLocationUpdates()
requestPhoneLocationUpdates()

View File

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

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

View File

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

View File

@@ -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 {
@@ -43,7 +41,9 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
page = 0
} else {
page = paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
val index =
paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
page = index.takeUnless { it == -1 } ?: 0
}
invalidate()
}
@@ -129,10 +129,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())
@@ -202,6 +206,37 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
Row.IMAGE_TYPE_ICON
)
setOnClickListener { onItemClick(it.id) }
if (carContext.carAppApiLevel >= 6) {
// Delete action
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_delete
)
).build()
)
setOnClickListener {
lifecycleScope.launch {
db.filterProfileDao().delete(it)
if (prefs.filterStatus == it.id) {
prefs.filterStatus = FILTERS_DISABLED
}
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
it.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
}
}
}.build())
}
}.build())
}
if (page < paginatedProfiles.size - 1) {
@@ -222,10 +257,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 +282,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 {
@@ -288,7 +326,8 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
setActionStrip(ActionStrip.Builder().apply {
val currentProfile = vm.filterProfile.value
if (currentProfile != null) {
if (currentProfile != null && carContext.carAppApiLevel < 6) {
// Delete action (when row actions are not available)
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
@@ -372,10 +411,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 +497,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()
@@ -310,7 +348,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
DistanceSpan.create(
roundValueToDistance(
distanceMeters,
energyLevel?.distanceDisplayUnit?.value
energyLevel?.distanceDisplayUnit?.value,
carContext
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
@@ -318,7 +357,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 +367,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 +401,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
this.location = location
if (previousLocation == null) {
loadChargers()
return
}
val now = Instant.now()
@@ -372,6 +412,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 +447,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 +512,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 +531,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
abs(diff) < 30
}
} ?: chargers
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
val isUpdate = this.energyLevel == null
@@ -542,7 +618,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 +647,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
}

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