Compare commits

...

228 Commits

Author SHA1 Message Date
johan12345
b99e2ea2c8 WIP: replace CustomBottomSheetBehavior 2025-07-12 18:06:51 +02:00
johan12345
d2ae3733d1 Big toolchain update
- Gradle + AGP
- Java 17
- compile/targetSdk 35
- Room & Moshi use KSP, not KAPT
2025-07-12 18:06:27 +02:00
johan12345
72845da4b5 Release 1.9.18 2025-06-14 17:40:09 +02:00
johan12345
51b57433a8 TeslaAvailabilityDetector: Fix nullability bug 2025-06-14 17:36:47 +02:00
johan12345
3202f821d1 always show current location on start, even if we were not following the location before 2025-06-14 17:32:24 +02:00
johan12345
b7e1ff09db FilterScreen: remove unnecessary invalidate() calls
we are already observing filterProfiles
2025-06-13 22:13:05 +02:00
Licaon_Kter
feabf49b8d Remove some non-determinism 2025-05-30 12:03:49 +02:00
johan12345
dcbe4c6325 Release 1.9.17 2025-05-29 00:41:28 +02:00
johan12345
dcff74c125 update TeslaOwnerApi 2025-05-28 23:58:01 +02:00
johan12345
d8f7d77a36 Release 1.9.16 2025-05-17 19:41:36 +02:00
johan12345
d03cf70499 capture (but ignore) clicks on searchResultMarker 2025-05-17 19:36:22 +02:00
johan12345
7a6bebd143 RTL and Arabic locale fixes 2025-05-17 19:24:39 +02:00
johan12345
66d68ca68e disable extendBounds if map is zoomed out far 2025-05-17 18:04:50 +02:00
johan12345
772885a8eb add "zoom in to see all charging stations" snackbar if response is not complete 2025-05-17 17:53:15 +02:00
johan12345
6b07ce012a Rework showLocation function
avoid opening within EVMap itself
2025-05-15 23:15:51 +02:00
johan12345
29dbc202d8 Rework openUrl function
- use preferBrowser only when needed (when opening charger URLs, which might otherwise open in EVMap itself)
- make preferBrowser work even if the default browser does not support Custom Tabs

#313

Cherry-picked from 17efe71
2025-05-15 22:53:19 +02:00
johan12345
cf8371d095 allow Unicode license 2025-05-07 23:38:47 +02:00
johan12345
01cb551cbc Use CarAppService for startActivity instead of CarContext
fixes #375 for startActivity and openUrl

https://issuetracker.google.com/issues/372055514
Warning: You must update to androidx.car.app:1.7.0-alpha01 or later for the permissions dialog to show up on the phone screen when your app is used on a device running Android 14 or higher.
2025-05-07 23:28:54 +02:00
johan12345
45fe297616 Update car app library
fixes #375 for permission request

https://developer.android.com/training/cars/apps#permissions

Warning: You must update to androidx.car.app:1.7.0-alpha01 or later for the permissions dialog to show up on the phone screen when your app is used on a device running Android 14 or higher.
2025-05-07 22:35:02 +02:00
johan12345
32cabefe7d Fix touch targets for privacy policy link on API < 34
https://github.com/material-components/material-components-android/issues/2100#issuecomment-2234437889

fixes #374
2025-04-26 22:35:38 +02:00
johan12345
9ff8329171 Release 1.9.15 2025-03-29 12:15:44 +01:00
johan12345
e9b70a2f00 fix bug in extendBounds
introduced in 890af2ddef
fixes #373
2025-03-29 12:15:33 +01:00
johan12345
c4c3aba7c7 Release 1.9.14 2025-03-15 15:48:53 +01:00
johan12345
890af2ddef try to better handle situations where map bounds cross the Antimeridian 2025-03-12 22:04:12 +01:00
johan12345
ba0b36b3ec update AnyMaps 2025-03-12 21:11:32 +01:00
johan12345
161b48789f Chargeprice: reset to default charging range when tapping title 2025-03-09 23:07:37 +01:00
johan12345
042b983aa3 CI: run checksec on release APKs 2025-03-04 22:11:32 +01:00
johan12345
1c21da7be0 CI: move apikeys-ci.xml to _ci folder 2025-03-04 21:24:19 +01:00
johan12345
405baed0f7 upgrade android-spatialite 2025-03-04 21:24:05 +01:00
johan12345
19c0d57f2b upgrade maplibre 2025-03-04 20:21:19 +01:00
johan12345
42c2a2f72a improve setLinkify BindingAdapter
fixes #371
2025-02-26 21:21:12 +01:00
johan12345
36ee3ff231 CarAppService: ignore if starting foreground service fails
this happens on AAOS API 34+ due to https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start. However, the app still works even without the foreground service.
2025-02-26 20:08:23 +01:00
johan12345
883735ef05 FusionEngine: change log level 2025-02-26 20:00:22 +01:00
johan12345
4c68356ae9 Release 1.9.13 2025-02-18 21:56:07 +01:00
johan12345
7fde5b50aa fix disappearing markers
fixes #368
2025-02-18 21:49:55 +01:00
johan12345
7c4136c66d Release 1.9.12 2025-02-07 22:08:41 +01:00
johan12345
6e56f5c3ff update social links 2025-02-07 22:05:23 +01:00
johan12345
017be6f31a increase heap space 2025-02-07 22:02:11 +01:00
johan12345
b398a5dc81 embed referral links as webpage instead of native Android buttons 2025-02-07 19:13:58 +01:00
johan12345
3fb0dec868 Release 1.9.11 (only for Harman Ignite) 2025-01-28 20:58:23 +01:00
johan12345
8c4de115ec remove setting for fronyx predictions in AAOS app
see also e7c9432191
2025-01-28 20:51:05 +01:00
johan12345
334b68cf5e SearchSelectScreen: fix displaying selectAll buttons 2025-01-27 23:40:37 +01:00
johan12345
788c68c9dd Onboarding Android Auto page: fix icon disappearing 2025-01-18 23:43:14 +01:00
johan12345
7842a15529 fix more possible memory leak issues 2025-01-07 20:42:22 +01:00
johan12345
e7c9432191 Disable fronyx predictions
API has been broken for >4 months now
2024-12-03 22:18:47 +01:00
johan12345
76b6abd3ca Release 1.9.10 (only for Faurecia Aptoide) 2024-11-24 18:32:51 +01:00
johan12345
752c184146 fix crash on first start 2024-11-24 18:13:39 +01:00
johan12345
5471ac5073 Release 1.9.9 (only for Faurecia Aptoide) 2024-11-13 20:31:33 +01:00
johan12345
69ae13a199 fix crash on first start 2024-11-13 20:30:58 +01:00
johan12345
8a2e2d9a25 release 1.9.8 (only for Faurecia Aptoide) 2024-11-09 13:56:06 +01:00
johan12345
fe69a78b94 fix possible memory leak issues 2024-11-08 20:50:06 +01:00
johan12345
2663bd7964 update MapLibre & AnyMaps 2024-10-26 22:30:25 +02:00
johan12345
3b54b2799f Databinding: use viewLifecycleOwner instead of Fragment as lifecycle owner 2024-10-26 22:25:08 +02:00
johan12345
3a24711626 translations: move nb-rNO to nb 2024-10-25 22:03:52 +02:00
johan12345
c158744bc2 fix unnecessary recreation of MapFragment 2024-10-23 22:51:15 +02:00
johan12345
c01033a036 upgrade AnyMaps 2024-10-23 22:07:33 +02:00
Hosted Weblate
16474c3864 Translated using Weblate (Portuguese)
Currently translated at 100.0% (358 of 358 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2024-10-17 18:55:26 +02:00
Hosted Weblate
7ce2f8d452 Translated using Weblate (Czech)
Currently translated at 100.0% (358 of 358 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (356 of 356 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (355 of 355 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2024-10-17 18:55:26 +02:00
Hosted Weblate
28df158d94 Translated using Weblate (German)
Currently translated at 100.0% (358 of 358 strings)

Co-authored-by: mcliquid <info@mcliquid.de>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2024-10-17 18:43:40 +02:00
johan12345
90b3645a0b fix crash when trying to rename a filter profile to a name that already exists 2024-10-15 20:20:27 +02:00
johan12345
de901aa825 Release 1.9.7 (only for Faurecia Aptoide) 2024-08-16 19:13:30 +02:00
johan12345
2ce61f2f6b AAOS: more navigateToCharger fixes 2024-08-16 19:12:34 +02:00
Johan von Forstner
398f159e27 Release 1.9.6 2024-07-31 16:37:23 +02:00
Johan von Forstner
6ab3ba2ed2 Mapbox Autocomplete: catch HttpExceptions 2024-07-31 16:10:36 +02:00
Johan von Forstner
f59fd9b3aa OpenChargeMap API: fix nullability 2024-07-31 16:04:14 +02:00
Johan von Forstner
9e18c62d9d remove unused method 2024-07-31 16:03:07 +02:00
Johan von Forstner
3626c9a72f update spatia-room 2024-07-31 15:56:48 +02:00
Johan von Forstner
36805d8224 AAOS: fallback to regular navigation intent if car app intent fails 2024-07-30 21:21:41 +02:00
johan12345
b1dee90068 docs improvements 2024-07-13 14:11:51 +02:00
johan12345
dfc7de75ad Release 1.9.5 2024-06-30 17:37:53 +02:00
johan12345
32c7774a3a update MapLibre
may help with #351
2024-06-30 16:34:03 +02:00
johan12345
02ef25b961 rework navigation handling to avoid changing start destination
fixes the following issue:
- start app for the first time, go through onboarding
- go to settings, change to dark mode
- try to go back to the map using the drawer
-> stuck, only back button helps
2024-06-30 16:22:35 +02:00
johan12345
e535e77b7a update build tools 2024-06-30 15:08:30 +02:00
johan12345
5b0b4e4337 remove "noinspection JCenterRepositoryObsolete" 2024-06-30 15:07:50 +02:00
johan12345
a6bbf635c5 Update AnyMaps & Google Maps
uses new Google Maps dark mode
2024-06-30 14:08:24 +02:00
johan12345
591f99dea4 update to released locale-config-x version 2024-06-22 11:53:52 +02:00
johan12345
0c5bd69205 Release 1.9.4 2024-06-21 00:25:13 +02:00
johan12345
72e98cf611 fix NoSuchElementExceptions in intent handling 2024-06-21 00:23:37 +02:00
johan12345
0fefffda2f update MapLibre
may help with #351
2024-06-21 00:19:38 +02:00
johan12345
49e555ef04 switch to fork of locale-config-x
fixes crash #352 until https://github.com/erfansn/locale-config-x/pull/2 is merged
2024-06-21 00:00:42 +02:00
johan12345
d6d1e915ee updated TeslaGuestApi 2024-06-19 00:19:36 +02:00
johan12345
546d7a11ce maybe fix rare NPE in GoingElectricAPI 2024-06-16 17:54:59 +02:00
johan12345
4849944c23 Release 1.9.3 2024-06-05 20:32:10 +02:00
johan12345
77b38661dd fix b9354e77: use correct exception type 2024-06-05 19:05:57 +02:00
johan12345
3723ee161b update AnyMaps
fixes issue where satellite map would not load correctly under some conditions
2024-06-04 21:41:26 +02:00
johan12345
1d3efe5295 update AnyMaps
fixes #346 through a5b09b5fda
2024-06-04 21:17:53 +02:00
johan12345
f011944135 fix lint error 2024-05-30 17:28:38 +02:00
johan12345
1d81bb5d37 Simplify currency handling
removes the need to translate all the currency names
2024-05-30 17:15:06 +02:00
johan12345
e8adb759a6 Simplify locale handling
using Locale Config X library
https://github.com/erfansn/locale-config-x
2024-05-30 16:52:02 +02:00
johan12345
f4384b4b60 update Android Gradle plugin 2024-05-30 13:10:20 +02:00
johan12345
1d63e37467 Release 1.9.2 2024-05-29 21:25:04 +02:00
johan12345
b5b0254bdd Coil: disable hardware acceleration for gallery images
https://github.com/coil-kt/coil/issues/159
2024-05-29 21:19:19 +02:00
johan12345
6514197920 upgrade dependencies 2024-05-29 21:06:30 +02:00
johan12345
4c5388350f upgrade car app library to stable 1.4.0 2024-05-29 20:33:32 +02:00
johan12345
20e9e43f0d upgrade coil library 2024-05-29 20:20:55 +02:00
johan12345
541646dda9 fix missing catch HttpException 2024-05-29 20:16:53 +02:00
johan12345
b9354e77a9 AAOS: fix crash when no navigation app is installed 2024-05-29 20:09:31 +02:00
johan12345
65fa54ef36 CarInfoWrapper: fix NPE 2024-05-29 19:57:29 +02:00
johan12345
6e419849b1 update sponsors 2024-05-25 16:38:33 +02:00
johan12345
c4c6f09a05 update sponsors 2024-05-25 16:23:24 +02:00
johan12345
3b602c03c4 update sponsors 2024-05-25 16:21:39 +02:00
Hosted Weblate
012e5d7362 Translated using Weblate (Portuguese)
Currently translated at 100.0% (367 of 367 strings)

Co-authored-by: Asmodeus <colligare1Asmodeum@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2024-05-25 13:17:28 +02:00
johan12345
4f5007ca0d fix czech string CDATA 2024-05-25 13:17:24 +02:00
Hosted Weblate
b40a74aa37 Translated using Weblate (Czech)
Currently translated at 100.0% (367 of 367 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (366 of 366 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2024-05-25 13:16:40 +02:00
johan12345
ce172bd4b0 Release 1.9.1 2024-05-25 13:07:22 +02:00
johan12345
b3bbca576f revert to MapLibre 10.x
fixes #345
2024-05-25 12:59:34 +02:00
johan12345
72ccd99c1f re-release 1.9.0 for Play Store 2024-05-24 23:09:37 +02:00
johan12345
dd5c8659df fix declaration of car app category in manifest 2024-05-24 23:08:13 +02:00
johan12345
8f7f9e5a09 re-release 1.9.0 for Play Store
Car App Library alpha version is not accepted
2024-05-21 23:38:33 +02:00
johan12345
8256981b8d workaround for car app library crash with invalid speed units 2024-05-21 23:38:33 +02:00
johan12345
5649ef202f fix Android Auto version check 2024-05-21 23:38:33 +02:00
johan12345
ed9c729684 Revert "update car app library to 1.7.0-alpha02"
This reverts commit d540faa179.
2024-05-21 22:14:55 +02:00
johan12345
add66811ae Export licenses to CSV
needed for Faurecia Aptoide store
2024-05-20 23:10:26 +02:00
johan12345
2df5710910 Release 1.9.0 2024-05-20 22:04:09 +02:00
johan12345
a513a8d6d4 fix source sets for releaseAutomotivePackageName build type 2024-05-20 22:04:09 +02:00
johan12345
a55e4df62d hide Tesla referral link
referral program has ended
2024-05-20 21:08:18 +02:00
johan12345
d540faa179 update car app library to 1.7.0-alpha02 2024-05-20 20:39:29 +02:00
johan12345
3d69d3e50c update AGP 2024-05-20 00:18:39 +02:00
johan12345
fce27f0c19 add SVG for Play Store icon 2024-05-20 00:18:07 +02:00
johan12345
da3b9643bc add build type with different package name for fossAutomotive
needed for Faurecia Aptoide
2024-05-20 00:16:21 +02:00
johan12345
b690d9744d update Play Store icon according to new specs
https://developer.android.com/distribute/google-play/resources/icon-design-specifications
2024-05-20 00:16:21 +02:00
johan12345
70387ec350 fix docs in README 2024-05-19 17:55:47 +02:00
johan12345
5a331df232 add Jawg Maps sponsor logo 2024-05-19 17:53:20 +02:00
johan12345
5dc5e1f43f Migrate Mapbox -> MapLibre
Use Jawg Maps for basemap, ArcGIS for satellite maps

fixes #141
refs #169, #197

hide traffic checkbox if traffic is not supported by map
2024-05-19 17:53:20 +02:00
Hosted Weblate
360e7767bd Translated using Weblate (Portuguese)
Currently translated at 100.0% (366 of 366 strings)

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

* fix getAppLocale

---------

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

* keep the layers icon gray

---------

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

Translated using Weblate (Czech)

Currently translated at 100.0% (366 of 366 strings)

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

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

Translated using Weblate (Czech)

Currently translated at 100.0% (363 of 363 strings)

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

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

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

Translated using Weblate (Czech)

Currently translated at 100.0% (363 of 363 strings)

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

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

Update translation files

Updated by "Squash Git commits" hook in Weblate.

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

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

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

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

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

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

* fix version code

* fix API keys

* fix kotlinVersion <-> kotlin_version

* fix supportedLocales

* Migrate to build KTS

* upgrade to Gradle 8.4

* updates

* variable renaming

* fix packagingOptions

---------

Co-authored-by: Johan von Forstner <johan.forstner@gmail.com>
2023-10-18 12:54:24 +02:00
johan12345
bc91c0571b Chargeprice: Fix switching between vehicles 2023-10-14 19:28:54 +02:00
johan12345
a83102a97e Tesla API: fix nullability 2023-10-14 19:14:07 +02:00
johan12345
f52a98540c Tesla API: add missing waiting estimate bucket 2023-10-14 19:11:42 +02:00
johan12345
e0d97e7219 fix NPE in PlaceSearchScreen 2023-10-14 19:10:16 +02:00
johan12345
3bbd20a57e possibly fix IllegalStateException 2023-10-14 19:05:39 +02:00
Hosted Weblate
3279c5eceb Translated using Weblate (Portuguese)
Currently translated at 100.0% (355 of 355 strings)

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

#236
2023-10-07 19:36:19 +02:00
johan12345
bdc96fcd57 Android Auto: use simpler navigation intent URI without POI name
Workaround for TomTom GO, which seems to not handle intents with description correctly
2023-09-27 22:00:49 +02:00
johan12345
0d54e17eb4 update version code to retry Play Store release 2023-09-26 22:27:56 +02:00
johan12345
b1d0081fb7 re-enable CarAppTest
see https://github.com/robolectric/robolectric/issues/8404#issuecomment-1733309468
2023-09-26 17:47:03 +02:00
206 changed files with 6011 additions and 2234 deletions

2
.github/FUNDING.yml vendored
View File

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

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Java environment
uses: actions/setup-java@v2
uses: actions/setup-java@v4
with:
java-version: 17
distribution: 'zulu'
@@ -22,7 +22,7 @@ jobs:
- name: Decrypt keystore
run: openssl aes-256-cbc -K ${{ secrets.encrypted_53968681344a_key }} -iv ${{ secrets.encrypted_53968681344a_iv }} -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
- name: Extract version code
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s\+[0-9]*" app/build.gradle | awk '{ print $2 }' | tr -d \''"\\')" >> $GITHUB_ENV
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s*=\s*[0-9]\+" app/build.gradle.kts | awk '{ print $3 }' | tr -d \''"\\')" >> $GITHUB_ENV
- name: Build app release
env:
@@ -30,6 +30,8 @@ jobs:
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
CHARGEPRICE_API_KEY: ${{ secrets.CHARGEPRICE_API_KEY }}
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
JAWG_API_KEY: ${{ secrets.JAWG_API_KEY }}
ARCGIS_API_KEY: ${{ secrets.ARCGIS_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 }}

View File

@@ -16,17 +16,17 @@ jobs:
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
steps:
- name: Check out code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Java environment
uses: actions/setup-java@v2
uses: actions/setup-java@v4
with:
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Copy apikeys.xml
run: cp .github/workflows/apikeys-ci.xml app/src/main/res/values/apikeys.xml
run: cp _ci/apikeys-ci.xml app/src/main/res/values/apikeys.xml
- name: Build app
run: ./gradlew assemble${{ matrix.buildvariant }}Debug --no-daemon
@@ -34,3 +34,55 @@ jobs:
run: ./gradlew test${{ matrix.buildvariant }}DebugUnitTest --no-daemon
- name: Run Android Lint
run: ./gradlew lint${{ matrix.buildvariant }}Debug --no-daemon
- name: Check licenses
run: ./gradlew exportLibraryDefinitions --no-daemon
apk_check:
name: Release APK checks (${{ matrix.buildvariant }})
runs-on: ubuntu-latest
strategy:
matrix:
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
steps:
- name: Install checksec
run: sudo apt install -y checksec
- name: Check out code
uses: actions/checkout@v4
- name: Set up Java environment
uses: actions/setup-java@v4
with:
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Copy apikeys.xml
run: cp _ci/apikeys-ci.xml app/src/main/res/values/apikeys.xml
- name: Build app
run: ./gradlew assemble${{ matrix.buildvariant }}Release --no-daemon
- name: Unpack native libraries from APK
run: |
VARIANT_FILENAME=$(echo ${{ matrix.buildvariant }} | sed -E 's/([a-z])([A-Z])/\1-\2/g' | tr 'A-Z' 'a-z')
VARIANT_FOLDER=$(echo ${{ matrix.buildvariant }} | sed -E 's/^([A-Z])/\L\1/')
APK_FILE="app/build/outputs/apk/$VARIANT_FOLDER/release/app-$VARIANT_FILENAME-release-unsigned.apk"
unzip $APK_FILE "lib/*"
- name: Run checksec on native libraries
run: |
checksec --output=json --dir=lib > checksec_output.json
jq --argjson exceptions '[
"lib/armeabi-v7a/libc++_shared.so",
"lib/x86/libc++_shared.so"
]' '
to_entries
| map(select(.value.fortify_source == "no" and (.key as $lib | $exceptions | index($lib) | not)))
| if length > 0 then
error("The following libraries do not have fortify enabled (and are not in the exception list): " + (map(.key) | join(", ")))
else
"All libraries have fortify enabled or are in the exception list."
end
' checksec_output.json

3
.gitignore vendored
View File

@@ -12,4 +12,5 @@ apikeys.xml
/app/**/*.apk
/_img/connectors/*.ai
api-7125266970515251116-798419-8e2dda660c80.json
output-metadata.json
output-metadata.json
licenses_*.csv

View File

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

View File

@@ -24,7 +24,8 @@ Features
- Android Auto & Android Automotive OS integration
- No ads, fully open source
- Compatible with Android 5.0 and above
- Can use Google Maps or Mapbox (OpenStreetMap) as map backends - the version available on F-Droid only uses Mapbox.
- Can use Google Maps or OpenStreetMap as map backends - the version available on F-Droid only uses
OSM.
Screenshots
-----------
@@ -37,17 +38,18 @@ Development setup
The App is developed using Android Studio and should pretty much work out-of-the-box when you clone
the Git repository and open the project with Android Studio.
The only exception is that you need to obtain some free API keys for the different data sources that
The only exception is that you need to obtain some API keys for the different data sources that
EVMap uses and put them into the app in the form of a resource file called `apikeys.xml` under
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
features and how they can be obtained in our [documentation page](doc/api_keys.md).
There are three different build flavors, `googleNormal`, `fossNormal` and `googleAutomotive`.
- The `foss` variants only use Mapbox data and should run on most Android devices, even without
Google Play Services.
There are four different build flavors, `googleNormal`, `fossNormal`, `googleAutomotive`, and
`fossAutomotive`.
- The `foss` variants only use OSM data for the base map and place search. They should run on most Android devices, even those 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).
Auto app for use on the car display (however Android Auto may not work if the app is not
installed from Google Play, see https://github.com/ev-map/EVMap/issues/319).
- `fossAutomotive` can be installed directly on
[Android Automotive OS (AAOS)](https://source.android.com/docs/automotive/start/what_automotive)
headunits without Google services.
@@ -75,5 +77,19 @@ You can use our [Weblate page](https://hosted.weblate.org/projects/evmap/) to he
into new languages.
<a href="https://hosted.weblate.org/engage/evmap/">
<img src="https://hosted.weblate.org/widgets/evmap/-/open-graph.png" width="500" alt="Translation status" />
<img src="https://hosted.weblate.org/widgets/evmap/-/open-graph.png" width="400" alt="Translation status" />
</a>
Sponsors
--------
Many users currently support the development EVMap with their donations. You can find more
information on the [Donate page](https://ev-map.app/donate/) on the EVMap website.
<a href="https://www.jawg.io"><img src="https://www.jawg.io/static/Blue@10x-9cdc4596e4e59acbd9ead55e9c28613e.png" alt="JawgMaps" height="58"/></a><br>
Since May 2024, **JawgMaps** provides their OpenStreetMap vector map tiles service to EVMap for
free, i.e. the background map displayed in the app if OpenStreetMap is selected as the data source.
<a href="https://chargeprice.app"><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/powered_by_chargeprice.svg" alt="Powered by Chargeprice" height="58"/></a><br>
Since April 2021, **Chargeprice.app** provide their price comparison API at a greatly reduced
price for EVMap. This data is used in EVMap's price comparison feature.

View File

@@ -1,6 +1,8 @@
<resources>
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">ci</string>
<string name="mapbox_key" translatable="false">ci</string>
<string name="jawg_key" translatable="false">ci</string>
<string name="arcgis_key" translatable="false">ci</string>
<string name="goingelectric_key" translatable="false">ci</string>
<string name="chargeprice_key" translatable="false">ci</string>
<string name="openchargemap_key" translatable="false">ci</string>

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: #00e676;
}
.cls-1, .cls-2, .cls-3, .cls-4, .cls-5, .cls-6, .cls-7, .cls-8 {
stroke-width: 0px;
}
.cls-2 {
fill: rgba(255, 255, 255, .2);
}
.cls-3 {
fill: #ffb300;
}
.cls-4 {
fill: #000;
isolation: isolate;
opacity: .45;
}
.cls-5 {
fill: #fff;
}
.cls-6 {
fill: #90a4ae;
}
.cls-7 {
fill: #546e7a;
}
.cls-8 {
fill: rgba(62, 39, 35, .2);
}
</style>
</defs>
<g id="Layer_1" data-name="Layer 1">
<g>
<rect class="cls-5" width="512" height="512" />
<g>
<g>
<path class="cls-3"
d="M159.42,338.98l-6.43-56.15-9.81,1.01,6.43,56.15,9.81-1.01ZM194.26,334.92l-6.43-56.15-9.81,1.01,6.43,56.15,9.81-1.01Z" />
<path class="cls-6"
d="M212.53,411.37c-3.04,3.72-5.41,6.09-5.75,6.43-8.79,7.1-15.9,9.13-21.65,6.43-10.15-5.07-9.47-24.02-9.13-26.05l7.1.34c-.34,5.41.68,16.91,5.41,19.28,2.71,1.35,7.44-.34,13.53-5.41h0s19.62-19.62,15.56-35.18c-4.74-18.6,16.91-45.33,24.02-54.46l1.01-1.01,5.75,4.4-1.01,1.35c-21.99,27.06-24.35,40.93-22.66,48.03,3.38,13.53-5.75,28.08-12.18,35.85Z" />
<path class="cls-6"
d="M137.78,338.3l2.71,23,21.31,14.21,28.75-3.04,17.59-18.6-2.71-23-67.65,7.44Z" />
<path class="cls-7"
d="M190.21,372.47l-28.75,3.04,6.09,25.37,22.66-2.71v-25.71h0ZM210.84,311.58l2.37,20.97-82.53,9.47-2.37-20.97,82.53-9.47Z" />
</g>
<g>
<g>
<path class="cls-1"
d="M275.45,80.22c-59.19,0-107.23,48.03-107.23,107.23,0,80.84,90.31,123.12,101.14,238.47.34,3.38,3.04,5.75,6.43,5.75s6.09-2.37,6.43-5.75c10.82-115.34,101.14-157.63,101.14-238.47-.68-59.53-48.71-107.23-107.9-107.23Z" />
<path class="cls-2"
d="M275.45,82.58c58.86,0,106.55,47.36,107.23,105.87v-1.01c0-59.19-48.03-107.23-107.23-107.23s-107.23,47.69-107.23,107.23v1.01c.68-58.52,48.37-105.87,107.23-105.87h0Z" />
<path class="cls-8"
d="M281.87,423.21c-.34,3.38-3.04,5.75-6.43,5.75s-6.09-2.37-6.43-5.75c-10.49-115.01-100.12-157.29-100.8-237.12v1.69c0,80.84,90.31,123.12,101.14,238.47.34,3.38,3.04,5.75,6.43,5.75s6.09-2.37,6.43-5.75c10.82-115.34,101.14-157.63,101.14-238.47v-1.69c-1.35,79.83-90.99,122.11-101.48,237.12h0Z" />
</g>
<path class="cls-4"
d="M250.75,135.01v64.94h17.59v53.11l41.27-71.03h-23.68l23.68-47.36c.34.34-58.86.34-58.86.34Z" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,23 @@
import subprocess
import json
build_types = ["fossNormalRelease", "fossAutomotiveRelease"]
for build_type in build_types:
result = subprocess.run(["gradlew.bat", f"generateLibraryDefinitions{build_type.capitalize()}"],
capture_output=True)
data = json.load(
open(f"app/build/generated/aboutLibraries/{build_type}/res/raw/aboutlibraries.json"))
with open(f"licenses_{build_type}.csv", "w") as f:
f.write("component_name;license_title;license_url;public_repository;copyrights\n")
for lib in data["libraries"]:
license = data["licenses"][lib["licenses"][0]] if len(lib["licenses"]) > 0 else None
license_name = license["name"] if license is not None else " "
license_url = license["url"] if license is not None else " "
copyrights = ", ".join([dev["name"] for dev in lib["developers"] if "name" in dev])
if copyrights == "":
copyrights = " "
repo_url = lib['scm']['url'] if 'scm' in lib else ''
f.write(f"{lib['name']};{license_name};{license_url};\"{copyrights}\";{repo_url}\n")

View File

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

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

@@ -0,0 +1,412 @@
import java.util.Base64
plugins {
id("com.adarshr.test-logger") version "3.1.0"
id("com.android.application")
id("kotlin-android")
id("kotlin-parcelize")
id("kotlin-kapt")
id("com.google.devtools.ksp").version("2.0.21-1.0.28")
id("androidx.navigation.safeargs.kotlin")
id("com.mikepenz.aboutlibraries.plugin")
}
android {
useLibrary("android.car")
defaultConfig {
applicationId = "net.vonforst.evmap"
compileSdk = 35
minSdk = 21
targetSdk = 35
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode = 256
versionName = "1.9.18"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
val isRunningOnCI = System.getenv("CI") == "true"
val isCIKeystoreAvailable = System.getenv("KEYSTORE_PASSWORD") != null
signingConfigs {
create("release") {
if (isRunningOnCI && isCIKeystoreAvailable) {
// configure keystore
storeFile = file("../_ci/keystore.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEYSTORE_ALIAS")
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = if (isRunningOnCI && !isCIKeystoreAvailable) {
null
} else {
signingConfigs.getByName("release")
}
}
create("releaseAutomotivePackageName") {
// Faurecia Aptoide requires the automotive variant to use a separate package name
initWith(getByName("release"))
applicationIdSuffix = ".automotive"
}
debug {
applicationIdSuffix = ".debug"
isDebuggable = true
}
}
sourceSets {
getByName("releaseAutomotivePackageName").setRoot("src/release")
}
flavorDimensions += listOf("dependencies", "automotive")
productFlavors {
create("foss") {
dimension = "dependencies"
}
create("google") {
dimension = "dependencies"
versionNameSuffix = "-google"
}
create("normal") {
dimension = "automotive"
}
create("automotive") {
dimension = "automotive"
versionNameSuffix = "-automotive"
versionCode = defaultConfig.versionCode!! + 1
minSdk = 29
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
buildFeatures {
dataBinding = true
viewBinding = true
buildConfig = true
}
lint {
disable += listOf("NullSafeMutableLiveData")
warning += listOf("MissingTranslation")
}
androidResources {
generateLocaleConfig = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
namespace = "net.vonforst.evmap"
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all {
val goingelectricKey =
System.getenv("GOINGELECTRIC_API_KEY") ?: project.findProperty("GOINGELECTRIC_API_KEY")
?.toString()
if (goingelectricKey != null) {
resValue("string", "goingelectric_key", goingelectricKey)
}
var openchargemapKey =
System.getenv("OPENCHARGEMAP_API_KEY") ?: project.findProperty("OPENCHARGEMAP_API_KEY")
?.toString()
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
openchargemapKey = decode(
project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (openchargemapKey != null) {
resValue("string", "openchargemap_key", openchargemapKey)
}
val googleMapsKey =
System.getenv("GOOGLE_MAPS_API_KEY") ?: project.findProperty("GOOGLE_MAPS_API_KEY")
?.toString()
if (googleMapsKey != null && flavorName.startsWith("google")) {
resValue("string", "google_maps_key", googleMapsKey)
}
var mapboxKey =
System.getenv("MAPBOX_API_KEY") ?: project.findProperty("MAPBOX_API_KEY")?.toString()
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(
project.findProperty("MAPBOX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (mapboxKey != null) {
resValue("string", "mapbox_key", mapboxKey)
}
var jawgKey =
System.getenv("JAWG_API_KEY") ?: project.findProperty("JAWG_API_KEY")?.toString()
if (jawgKey == null && project.hasProperty("JAWG_API_KEY_ENCRYPTED")) {
jawgKey = decode(
project.findProperty("JAWG_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (jawgKey != null) {
resValue("string", "jawg_key", jawgKey)
}
var arcgisKey =
System.getenv("ARCGIS_API_KEY") ?: project.findProperty("ARCGIS_API_KEY")?.toString()
if (arcgisKey == null && project.hasProperty("ARCGIS_API_KEY_ENCRYPTED")) {
arcgisKey = decode(
project.findProperty("ARCGIS_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (arcgisKey != null) {
resValue("string", "arcgis_key", jawgKey)
}
var chargepriceKey =
System.getenv("CHARGEPRICE_API_KEY") ?: project.findProperty("CHARGEPRICE_API_KEY")
?.toString()
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
chargepriceKey = decode(
project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (chargepriceKey != null) {
resValue("string", "chargeprice_key", chargepriceKey)
}
var fronyxKey =
System.getenv("FRONYX_API_KEY") ?: project.findProperty("FRONYX_API_KEY")?.toString()
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
fronyxKey = decode(
project.findProperty("FRONYX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (fronyxKey != null) {
resValue("string", "fronyx_key", fronyxKey)
}
var acraKey = System.getenv("ACRA_CRASHREPORT_CREDENTIALS")
?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")?.toString()
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
acraKey = decode(
project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (acraKey != null) {
resValue("string", "acra_credentials", acraKey)
}
}
packaging {
jniLibs {
pickFirsts.addAll(
listOf(
"lib/x86/libc++_shared.so",
"lib/arm64-v8a/libc++_shared.so",
"lib/x86_64/libc++_shared.so",
"lib/armeabi-v7a/libc++_shared.so"
)
)
}
}
}
androidComponents {
beforeVariants { variantBuilder ->
if (variantBuilder.buildType == "releaseAutomotivePackageName"
&& !variantBuilder.productFlavors.containsAll(
listOf(
"automotive" to "automotive",
"dependencies" to "foss"
)
)
) {
// releaseAutomotivePackageName type is only needed for fossAutomotive
variantBuilder.enable = false
}
}
}
configurations {
create("googleNormalImplementation") {}
create("googleAutomotiveImplementation") {}
}
aboutLibraries {
allowedLicenses = arrayOf(
"Apache-2.0", "mit", "BSD-2-Clause", "BSD-3-Clause", "EPL-1.0",
"asdkl", // Android SDK
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
"Google Maps Platform Terms of Service", // Google Maps SDK
"provided without support or warranty", // org.json
"Unicode/ICU License", "Unicode-3.0", // icu4j
"Bouncy Castle Licence", // bcprov
"CDDL + GPLv2 with classpath exception", // javax.annotation-api
)
excludeFields = arrayOf("generated")
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
}
dependencies {
val kotlinVersion: String by rootProject.extra
val aboutLibsVersion: String by rootProject.extra
val navVersion: String by rootProject.extra
val normalImplementation by configurations
val googleImplementation by configurations
val automotiveImplementation by configurations
val fossImplementation by configurations
val testGoogleImplementation by configurations
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.activity:activity-ktx:1.9.0")
implementation("androidx.fragment:fragment-ktx:1.7.1")
implementation("androidx.cardview:cardview:1.0.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.viewpager2:viewpager2:1.1.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.12.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
implementation("com.squareup.moshi:moshi-adapters:1.15.2")
implementation("com.markomilos.jsonapi:jsonapi-retrofit:1.1.0")
implementation("io.coil-kt:coil:2.6.0")
implementation("com.github.ev-map:StfalconImageViewer:5082ebd392")
implementation("com.mikepenz:aboutlibraries-core:$aboutLibsVersion")
implementation("com.mikepenz:aboutlibraries:$aboutLibsVersion")
implementation("com.airbnb.android:lottie:4.1.0")
implementation("io.michaelrocks.bimap:bimap:1.1.0")
implementation("com.google.guava:guava:29.0-android")
implementation("com.github.pengrad:mapscaleview:1.6.0")
implementation("com.github.romandanylyk:PageIndicatorView:b1bad589b5")
implementation("com.github.erfansn:locale-config-x:1.0.1")
// Android Auto
val carAppVersion = "1.7.0-rc01"
implementation("androidx.car.app:app:$carAppVersion")
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
// AnyMaps
val anyMapsVersion = "a3290b148d"
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:19.0.0")
implementation("com.github.ev-map.AnyMaps:anymaps-maplibre:$anyMapsVersion") {
// duplicates classes from mapbox-sdk-services
exclude("org.maplibre.gl", "android-sdk-geojson")
}
implementation("org.maplibre.gl:android-sdk:10.3.4") {
exclude("org.maplibre.gl", "android-sdk-geojson")
}
// Google Places
googleImplementation("com.google.android.libraries.places:places:3.5.0")
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Mapbox Geocoding
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0")
// navigation library
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
// viewmodel library
val lifecycle_version = "2.8.1"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
// room library
val room_version = "2.7.1"
implementation("androidx.room:room-runtime:$room_version")
ksp("androidx.room:room-compiler:$room_version")
implementation("androidx.room:room-ktx:$room_version")
implementation("com.github.anboralabs:spatia-room:0.3.0") {
exclude("com.github.dalgarins", "android-spatialite")
}
// forked version with upgraded sqlite & libxml
// https://github.com/dalgarins/android-spatialite/pull/10
implementation("com.github.ev-map:android-spatialite:31495dcd81")
// billing library
val billing_version = "7.0.0"
googleImplementation("com.android.billingclient:billing:$billing_version")
googleImplementation("com.android.billingclient:billing-ktx:$billing_version")
// ACRA (crash reporting)
val acraVersion = "5.11.1"
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
debugImplementation("com.facebook.flipper:flipper:0.238.0")
debugImplementation("com.facebook.soloader:soloader:0.10.5")
debugImplementation("com.facebook.flipper:flipper-network-plugin:0.238.0")
debugImplementation("com.jakewharton.timber:timber:5.0.1")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
// testing
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
//noinspection GradleDependency
testImplementation("org.json:json:20080701")
testImplementation("org.robolectric:robolectric:4.11.1")
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.car.app:app-testing:$carAppVersion")
testImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}
fun decode(s: String, key: String): String {
return String(xorWithKey(Base64.getDecoder().decode(s), key.toByteArray()), Charsets.UTF_8)
}
fun xorWithKey(a: ByteArray, key: ByteArray): ByteArray {
val out = ByteArray(a.size)
for (i in a.indices) {
out[i] = (a[i].toInt() xor key[i % key.size].toInt()).toByte()
}
return out
}

6
app/lint.xml Normal file
View File

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

View File

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

View File

@@ -0,0 +1,904 @@
{
"formatVersion": 1,
"database": {
"version": 22,
"identityHash": "5dbaaa5adf8cb9b6e8a8314bb7766447",
"entities": [
{
"tableName": "ChargeLocation",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coordinates",
"columnName": "coordinates",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "chargepoints",
"columnName": "chargepoints",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "network",
"columnName": "network",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "editUrl",
"columnName": "editUrl",
"affinity": "TEXT"
},
{
"fieldPath": "verified",
"columnName": "verified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "barrierFree",
"columnName": "barrierFree",
"affinity": "INTEGER"
},
{
"fieldPath": "operator",
"columnName": "operator",
"affinity": "TEXT"
},
{
"fieldPath": "generalInformation",
"columnName": "generalInformation",
"affinity": "TEXT"
},
{
"fieldPath": "amenities",
"columnName": "amenities",
"affinity": "TEXT"
},
{
"fieldPath": "locationDescription",
"columnName": "locationDescription",
"affinity": "TEXT"
},
{
"fieldPath": "photos",
"columnName": "photos",
"affinity": "TEXT"
},
{
"fieldPath": "chargecards",
"columnName": "chargecards",
"affinity": "TEXT"
},
{
"fieldPath": "license",
"columnName": "license",
"affinity": "TEXT"
},
{
"fieldPath": "networkUrl",
"columnName": "networkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "chargerUrl",
"columnName": "chargerUrl",
"affinity": "TEXT"
},
{
"fieldPath": "timeRetrieved",
"columnName": "timeRetrieved",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDetailed",
"columnName": "isDetailed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "address.city",
"columnName": "city",
"affinity": "TEXT"
},
{
"fieldPath": "address.country",
"columnName": "country",
"affinity": "TEXT"
},
{
"fieldPath": "address.postcode",
"columnName": "postcode",
"affinity": "TEXT"
},
{
"fieldPath": "address.street",
"columnName": "street",
"affinity": "TEXT"
},
{
"fieldPath": "faultReport.created",
"columnName": "fault_report_created",
"affinity": "INTEGER"
},
{
"fieldPath": "faultReport.description",
"columnName": "fault_report_description",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.twentyfourSeven",
"columnName": "twentyfourSeven",
"affinity": "INTEGER"
},
{
"fieldPath": "openinghours.description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.start",
"columnName": "mostart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.monday.end",
"columnName": "moend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.start",
"columnName": "tustart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.tuesday.end",
"columnName": "tuend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.start",
"columnName": "westart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.wednesday.end",
"columnName": "weend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.start",
"columnName": "thstart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.thursday.end",
"columnName": "thend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.start",
"columnName": "frstart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.friday.end",
"columnName": "frend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.start",
"columnName": "sastart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.saturday.end",
"columnName": "saend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.start",
"columnName": "sustart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.sunday.end",
"columnName": "suend",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.start",
"columnName": "hostart",
"affinity": "TEXT"
},
{
"fieldPath": "openinghours.days.holiday.end",
"columnName": "hoend",
"affinity": "TEXT"
},
{
"fieldPath": "cost.freecharging",
"columnName": "freecharging",
"affinity": "INTEGER"
},
{
"fieldPath": "cost.freeparking",
"columnName": "freeparking",
"affinity": "INTEGER"
},
{
"fieldPath": "cost.descriptionShort",
"columnName": "descriptionShort",
"affinity": "TEXT"
},
{
"fieldPath": "cost.descriptionLong",
"columnName": "descriptionLong",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.country",
"columnName": "chargepricecountry",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.network",
"columnName": "chargepricenetwork",
"affinity": "TEXT"
},
{
"fieldPath": "chargepriceData.plugTypes",
"columnName": "chargepriceplugTypes",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"dataSource"
]
}
},
{
"tableName": "Favorite",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "favoriteId",
"columnName": "favoriteId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chargerId",
"columnName": "chargerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chargerDataSource",
"columnName": "chargerDataSource",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"favoriteId"
]
},
"indices": [
{
"name": "index_Favorite_chargerId_chargerDataSource",
"unique": false,
"columnNames": [
"chargerId",
"chargerDataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Favorite_chargerId_chargerDataSource` ON `${TABLE_NAME}` (`chargerId`, `chargerDataSource`)"
}
],
"foreignKeys": [
{
"table": "ChargeLocation",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"chargerId",
"chargerDataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "BooleanFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_BooleanFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_BooleanFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "MultipleChoiceFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "values",
"columnName": "values",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "all",
"columnName": "all",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_MultipleChoiceFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_MultipleChoiceFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "SliderFilterValue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profile",
"columnName": "profile",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key",
"profile",
"dataSource"
]
},
"indices": [
{
"name": "index_SliderFilterValue_profile_dataSource",
"unique": false,
"columnNames": [
"profile",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SliderFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
}
],
"foreignKeys": [
{
"table": "FilterProfile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profile",
"dataSource"
],
"referencedColumns": [
"id",
"dataSource"
]
}
]
},
{
"tableName": "FilterProfile",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `id` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`dataSource`, `id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"dataSource",
"id"
]
},
"indices": [
{
"name": "index_FilterProfile_dataSource_name",
"unique": true,
"columnNames": [
"dataSource",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_FilterProfile_dataSource_name` ON `${TABLE_NAME}` (`dataSource`, `name`)"
}
]
},
{
"tableName": "RecentAutocompletePlace",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "primaryText",
"columnName": "primaryText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "secondaryText",
"columnName": "secondaryText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "latLng",
"columnName": "latLng",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "viewport",
"columnName": "viewport",
"affinity": "TEXT"
},
{
"fieldPath": "types",
"columnName": "types",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"dataSource"
]
}
},
{
"tableName": "GEPlug",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "GENetwork",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
}
},
{
"tableName": "GEChargeCard",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMConnectionType",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "formalName",
"columnName": "formalName",
"affinity": "TEXT"
},
{
"fieldPath": "discontinued",
"columnName": "discontinued",
"affinity": "INTEGER"
},
{
"fieldPath": "obsolete",
"columnName": "obsolete",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMCountry",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isoCode",
"columnName": "isoCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "continentCode",
"columnName": "continentCode",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "OCMOperator",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "websiteUrl",
"columnName": "websiteUrl",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contactEmail",
"columnName": "contactEmail",
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone1",
"columnName": "contactTelephone1",
"affinity": "TEXT"
},
{
"fieldPath": "contactTelephone2",
"columnName": "contactTelephone2",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "SavedRegion",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "region",
"columnName": "region",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "dataSource",
"columnName": "dataSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timeRetrieved",
"columnName": "timeRetrieved",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filters",
"columnName": "filters",
"affinity": "TEXT"
},
{
"fieldPath": "isDetailed",
"columnName": "isDetailed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_SavedRegion_filters_dataSource",
"unique": false,
"columnNames": [
"filters",
"dataSource"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `${TABLE_NAME}` (`filters`, `dataSource`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5dbaaa5adf8cb9b6e8a8314bb7766447')"
]
}
}

View File

@@ -0,0 +1,171 @@
import android.car.Car
import android.car.VehiclePropertyIds
import android.car.VehicleUnit
import android.car.hardware.CarPropertyValue
import android.car.hardware.property.CarPropertyManager
import android.car.hardware.property.CarPropertyManager.CarPropertyEventCallback
import androidx.annotation.OptIn
import androidx.car.app.CarContext
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.common.OnCarDataAvailableListener
import androidx.car.app.hardware.info.CarInfo
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.hardware.info.EnergyProfile
import androidx.car.app.hardware.info.EvStatus
import androidx.car.app.hardware.info.Mileage
import androidx.car.app.hardware.info.Model
import androidx.car.app.hardware.info.Speed
import androidx.car.app.hardware.info.TollCard
import java.util.concurrent.Executor
val CarContext.patchedCarInfo: CarInfo
get() = CarInfoWrapper(this)
class CarInfoWrapper(ctx: CarContext) : CarInfo {
private val wrapped =
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
private val carPropertyManager = try {
val car = Car.createCar(ctx)
car.getCarManager(Car.PROPERTY_SERVICE) as CarPropertyManager
} catch (e: NoClassDefFoundError) {
null
}
private val callbacks = mutableMapOf<OnCarDataAvailableListener<*>, CarPropertyEventCallback>()
override fun fetchModel(executor: Executor, listener: OnCarDataAvailableListener<Model>) =
wrapped.fetchModel(executor, listener)
override fun fetchEnergyProfile(
executor: Executor,
listener: OnCarDataAvailableListener<EnergyProfile>
) = wrapped.fetchEnergyProfile(executor, listener)
override fun addTollListener(
executor: Executor,
listener: OnCarDataAvailableListener<TollCard>
) = wrapped.addTollListener(executor, listener)
override fun removeTollListener(listener: OnCarDataAvailableListener<TollCard>) =
wrapped.removeTollListener(listener)
override fun addEnergyLevelListener(
executor: Executor,
listener: OnCarDataAvailableListener<EnergyLevel>
) = wrapped.addEnergyLevelListener(executor, listener)
override fun removeEnergyLevelListener(listener: OnCarDataAvailableListener<EnergyLevel>) =
wrapped.removeEnergyLevelListener(listener)
override fun addSpeedListener(executor: Executor, listener: OnCarDataAvailableListener<Speed>) {
// TODO: This is a emporary workaround until Car App Library 1.7.0 is released - previous versions would crash if the car reported an invalid speed display unit
carPropertyManager ?: return
val callback = object : CarPropertyEventCallback {
private var speedRaw: CarPropertyValue<Float>? = null
private var speedDisplay: CarPropertyValue<Float>? = null
private var speedUnit: CarPropertyValue<Int>? = null
override fun onChangeEvent(value: CarPropertyValue<*>?) {
when (value?.propertyId) {
VehiclePropertyIds.PERF_VEHICLE_SPEED -> speedRaw =
value as CarPropertyValue<Float>?
VehiclePropertyIds.PERF_VEHICLE_SPEED_DISPLAY -> speedDisplay =
value as CarPropertyValue<Float>?
VehiclePropertyIds.VEHICLE_SPEED_DISPLAY_UNITS -> speedUnit =
value as CarPropertyValue<Int>?
}
executor.execute {
listener.onCarDataAvailable(Speed.Builder().apply {
speedRaw?.let {
setRawSpeedMetersPerSecond(
CarValue(
it.value,
it.timestamp,
if (it.value != null) CarValue.STATUS_SUCCESS else CarValue.STATUS_UNKNOWN
)
)
}
speedDisplay?.let {
setDisplaySpeedMetersPerSecond(
CarValue(
it.value,
it.timestamp,
if (it.value != null) CarValue.STATUS_SUCCESS else CarValue.STATUS_UNKNOWN
)
)
}
speedUnit?.let {
val unit = when (it.value) {
VehicleUnit.METER_PER_SEC -> CarUnit.METERS_PER_SEC
VehicleUnit.MILES_PER_HOUR -> CarUnit.MILES_PER_HOUR
VehicleUnit.KILOMETERS_PER_HOUR -> CarUnit.KILOMETERS_PER_HOUR
else -> null
}
setSpeedDisplayUnit(
CarValue(
unit,
it.timestamp,
if (unit != null) CarValue.STATUS_SUCCESS else CarValue.STATUS_UNKNOWN
)
)
}
}.build())
}
}
override fun onErrorEvent(propertyId: Int, areaId: Int) {
listener.onCarDataAvailable(
Speed.Builder()
.setRawSpeedMetersPerSecond(CarValue(null, 0, CarValue.STATUS_UNKNOWN))
.setDisplaySpeedMetersPerSecond(CarValue(null, 0, CarValue.STATUS_UNKNOWN))
.setSpeedDisplayUnit(CarValue(null, 0, CarValue.STATUS_UNKNOWN))
.build()
)
}
}
carPropertyManager.registerCallback(
callback,
VehiclePropertyIds.PERF_VEHICLE_SPEED,
CarPropertyManager.SENSOR_RATE_NORMAL
)
carPropertyManager.registerCallback(
callback,
VehiclePropertyIds.PERF_VEHICLE_SPEED_DISPLAY,
CarPropertyManager.SENSOR_RATE_NORMAL
)
carPropertyManager.registerCallback(
callback,
VehiclePropertyIds.VEHICLE_SPEED_DISPLAY_UNITS,
CarPropertyManager.SENSOR_RATE_NORMAL
)
}
override fun removeSpeedListener(listener: OnCarDataAvailableListener<Speed>) {
val callback = callbacks[listener] ?: return
carPropertyManager?.unregisterCallback(callback)
}
override fun addMileageListener(
executor: Executor,
listener: OnCarDataAvailableListener<Mileage>
) = wrapped.addMileageListener(executor, listener)
override fun removeMileageListener(listener: OnCarDataAvailableListener<Mileage>) =
wrapped.removeMileageListener(listener)
@OptIn(ExperimentalCarApi::class)
override fun addEvStatusListener(
executor: Executor,
listener: OnCarDataAvailableListener<EvStatus>
) = wrapped.addEvStatusListener(executor, listener)
@OptIn(ExperimentalCarApi::class)
override fun removeEvStatusListener(listener: OnCarDataAvailableListener<EvStatus>) =
wrapped.removeEvStatusListener(listener)
}

View File

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

View File

@@ -11,6 +11,7 @@ import com.facebook.flipper.plugins.network.NetworkFlipperPlugin
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
import com.facebook.soloader.SoLoader
import okhttp3.OkHttpClient
import timber.log.Timber
private val networkFlipperPlugin = NetworkFlipperPlugin()
@@ -24,6 +25,8 @@ fun addDebugInterceptors(context: Context) {
client.addPlugin(DatabasesFlipperPlugin(context))
client.addPlugin(SharedPreferencesFlipperPlugin(context))
client.start()
Timber.plant(Timber.DebugTree())
}
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder {

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EVMap (debug)</string>
<string name="app_name">EVMap</string>
</resources>

View File

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

View File

@@ -5,16 +5,17 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.FragmentDonateBinding
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
class DonateFragment : Fragment() {
class DonateFragment : DonateFragmentBase() {
private lateinit var binding: FragmentDonateBinding
private lateinit var referrals: FragmentDonateReferralBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -28,6 +29,7 @@ class DonateFragment : Fragment() {
savedInstanceState: Bundle?
): View {
binding = FragmentDonateBinding.inflate(inflater, container, false)
referrals = binding.referrals
return binding.root
}
@@ -40,11 +42,9 @@ class DonateFragment : Fragment() {
)
binding.btnDonate.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link), binding.root)
}
binding.referrals.referralTesla.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.tesla_referral_link))
}
setupReferrals(referrals)
}
}

View File

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

View File

@@ -2,5 +2,5 @@
<resources>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.</string>
<string name="donate_paypal">Mit PayPal spenden</string>
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap (Mapbox).</string>
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap.</string>
</resources>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.</string>
<string name="data_sources_hint">Les données cartographiques de l\'application sont fournies par OpenStreetMap (Mapbox).</string>
<string name="data_sources_hint">Les données cartographiques de l\'application sont fournies par OpenStreetMap.</string>
<string name="donate_paypal">Faire un don avec PayPal</string>
</resources>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donate_paypal">Doner med PayPal</string>
<string name="data_sources_hint">Kartdata i programmet tilbys av OpenStreetMap (Mapbox).</string>
<string name="data_sources_hint">Kartdata i programmet tilbys av OpenStreetMap.</string>
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende en slant til utvikleren.</string>
</resources>

View File

@@ -2,5 +2,5 @@
<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>
<string name="data_sources_hint">De kaartgegevens zijn afkomstig van OpenStreetMap.</string>
</resources>

View File

@@ -1,6 +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="data_sources_hint">Os dados do mapa são fornecidos pelo OpenStreetMap.</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

@@ -2,5 +2,5 @@
<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>
<string name="data_sources_hint">Hartile din aplicatie sunt furnizate de OpenStreetMap.</string>
</resources>

View File

@@ -2,5 +2,5 @@
<resources>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
<string name="donate_paypal">Donate with PayPal</string>
<string name="data_sources_hint">Map data in the app is provided by OpenStreetMap (Mapbox).</string>
<string name="data_sources_hint">Map data in the app is provided by OpenStreetMap.</string>
</resources>

View File

@@ -6,9 +6,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.ConcatAdapter
@@ -25,7 +23,7 @@ import net.vonforst.evmap.databinding.FragmentDonateHeaderBinding
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
import net.vonforst.evmap.viewmodel.DonateViewModel
class DonateFragment : Fragment() {
class DonateFragment : DonateFragmentBase() {
private lateinit var binding: FragmentDonateBinding
private val vm: DonateViewModel by viewModels()
private lateinit var header: FragmentDonateHeaderBinding
@@ -86,9 +84,7 @@ class DonateFragment : Fragment() {
Snackbar.make(view, R.string.donation_failed, Snackbar.LENGTH_LONG).show()
}
referrals.referralTesla.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.tesla_referral_link))
}
setupReferrals(referrals)
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))

View File

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

View File

@@ -1,5 +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="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap (Mapbox) für die Kartendaten wechseln.</string>
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap für die Kartendaten wechseln.</string>
</resources>

View File

@@ -3,5 +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="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="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap pour les données cartographiques.</string>
</resources>

View File

@@ -3,5 +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="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap (Mapbox) for kartdata.</string>
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap for kartdata.</string>
</resources>

View File

@@ -3,5 +3,5 @@
<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>
<string name="data_sources_hint">In de instellingen kan je ook wisselen tussen Google Maps en OpenStreetMap voor de kaartgegevens.</string>
</resources>

View File

@@ -3,5 +3,5 @@
<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>
<string name="data_sources_hint">Também pode mudar entre o Google Maps e OpenStreetMap nas definições da app.</string>
</resources>

View File

@@ -2,7 +2,7 @@
<resources>
<string-array name="pref_map_provider_names">
<item>@string/pref_provider_google_maps</item>
<item>@string/pref_provider_osm_mapbox</item>
<item>@string/pref_provider_osm</item>
</string-array>
<string-array name="pref_map_provider_values" translatable="false">
<item>google</item>

View File

@@ -1,5 +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="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap (Mapbox) for the map data.</string>
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap for the map data.</string>
</resources>

View File

@@ -24,9 +24,14 @@
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<package android:name="com.google.android.projection.gearhead" />
<package android:name="com.google.android.apps.automotive.templates.host" />
<package android:name="com.google.android.apps.maps" />
</queries>
<application
@@ -41,12 +46,20 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:localeConfig="@xml/locales_config">
android:enableOnBackInvokedCallback="true">
<meta-data
android:name="com.mapbox.ACCESS_TOKEN"
android:value="@string/mapbox_key" />
<meta-data
android:name="io.jawg.ACCESS_TOKEN"
android:value="@string/jawg_key" />
<meta-data
android:name="com.arcgis.ACCESS_TOKEN"
android:value="@string/arcgis_key" />
<activity
android:name=".MapsActivity"
android:label="@string/app_name"
@@ -273,6 +286,10 @@
android:host="openchargemap.org"
android:pathPattern="/site/poi/details/..*"
android:scheme="https" />
<data
android:host="map.openchargemap.io"
android:path="/"
android:scheme="https" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -335,9 +352,8 @@
android:exported="true"
android:foregroundServiceType="location">
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.POI" />
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.POI" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View File

@@ -3,7 +3,11 @@ package net.vonforst.evmap
import android.app.Activity
import android.app.Application
import android.os.Build
import androidx.work.*
import androidx.work.Configuration
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import net.vonforst.evmap.storage.CleanupCacheWorker
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateAppLocale
@@ -24,7 +28,7 @@ class EvMapApplication : Application(), Configuration.Provider {
// Convert to new AppCompat storage for app language
val lang = prefs.language
if (lang != null && lang !in listOf("", "default")) {
if (lang != null) {
updateAppLocale(lang)
prefs.language = null
}
@@ -72,11 +76,9 @@ class EvMapApplication : Application(), Configuration.Provider {
}
}.build()).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.REPLACE, cleanupCacheRequest
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.UPDATE, cleanupCacheRequest
)
}
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder().build()
}
override val workManagerConfiguration = Configuration.Builder().build()
}

View File

@@ -3,6 +3,8 @@ package net.vonforst.evmap
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -11,7 +13,6 @@ import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen
@@ -44,14 +45,11 @@ const val EXTRA_DONATE = "donate"
class MapsActivity : AppCompatActivity(),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
interface FragmentCallback {
fun getRootView(): View
}
private var reenterState: Bundle? = null
private lateinit var navController: NavController
private lateinit var navHostFragment: NavHostFragment
lateinit var appBarConfiguration: AppBarConfiguration
var fragmentCallback: FragmentCallback? = null
private lateinit var prefs: PreferenceDataSource
override fun onCreate(savedInstanceState: Bundle?) {
@@ -60,6 +58,7 @@ class MapsActivity : AppCompatActivity(),
setContentView(R.layout.activity_maps)
val drawerLayout = findViewById<DrawerLayout>(R.id.drawer_layout)
appBarConfiguration = AppBarConfiguration(
setOf(
R.id.map,
@@ -67,9 +66,9 @@ class MapsActivity : AppCompatActivity(),
R.id.about,
R.id.settings
),
findViewById<DrawerLayout>(R.id.drawer_layout)
drawerLayout
)
val navHostFragment =
navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navController = navHostFragment.navController
val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)
@@ -87,6 +86,17 @@ class MapsActivity : AppCompatActivity(),
checkPlayServices(this)
navController.setGraph(navGraph, MapFragmentArgs(appStart = true).toBundle())
var deepLink: PendingIntent? = null
navController.addOnDestinationChangedListener { _, destination, _ ->
if (destination.id == R.id.onboarding) {
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
} else {
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
}
}
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// wait for splash screen animation to finish on first start
@@ -104,133 +114,128 @@ 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())
var deepLink: PendingIntent? = null
} else if (intent?.scheme == "geo") {
val query = intent.data?.query?.split("=")?.get(1)
val coords = getLocationFromIntent(intent)
if (intent?.scheme == "geo") {
val query = intent.data?.query?.split("=")?.get(1)
val coords = getLocationFromIntent(intent)
if (coords != null) {
val lat = coords[0]
val lon = coords[1]
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
.createPendingIntent()
} else if (!query.isNullOrEmpty()) {
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(locationName = query).toBundle())
.createPendingIntent()
}
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
if (id != null) {
if (prefs.dataSource != "goingelectric") {
prefs.dataSource = "goingelectric"
Toast.makeText(
this,
getString(
R.string.data_source_switched_to,
getString(R.string.data_source_goingelectric)
),
Toast.LENGTH_LONG
).show()
}
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
.createPendingIntent()
}
} else if (intent?.scheme == "https" && intent?.data?.host == "openchargemap.org") {
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
if (id != null) {
if (prefs.dataSource != "openchargemap") {
prefs.dataSource = "openchargemap"
Toast.makeText(
this,
getString(
R.string.data_source_switched_to,
getString(R.string.data_source_openchargemap)
),
Toast.LENGTH_LONG
).show()
}
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.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)) {
if (coords != null) {
val lat = coords[0]
val lon = coords[1]
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(
MapFragmentArgs(
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
latLng = LatLng(
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
).toBundle()
)
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
.createPendingIntent()
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
} else if (!query.isNullOrEmpty()) {
deepLink = navController.createDeepLink()
.setGraph(navGraph)
.setDestination(R.id.favs)
.createPendingIntent()
} else if (intent.hasExtra(EXTRA_DONATE)) {
deepLink = navController.createDeepLink()
.setGraph(navGraph)
.setDestination(R.id.donate)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(locationName = query).toBundle())
.createPendingIntent()
}
deepLink?.send()
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
val id = intent.data?.pathSegments?.lastOrNull()?.toLongOrNull()
if (id != null) {
if (prefs.dataSource != "goingelectric") {
prefs.dataSource = "goingelectric"
Toast.makeText(
this,
getString(
R.string.data_source_switched_to,
getString(R.string.data_source_goingelectric)
),
Toast.LENGTH_LONG
).show()
}
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
.createPendingIntent()
}
} else if (intent?.scheme == "https" && intent?.data?.host in listOf(
"openchargemap.org",
"map.openchargemap.io"
)
) {
val id = when (intent.data?.host) {
"openchargemap.org" -> intent.data?.pathSegments?.lastOrNull()?.toLongOrNull()
"map.openchargemap.io" -> intent.data?.getQueryParameter("id")?.toLongOrNull()
else -> null
}
if (id != null) {
if (prefs.dataSource != "openchargemap") {
prefs.dataSource = "openchargemap"
Toast.makeText(
this,
getString(
R.string.data_source_switched_to,
getString(R.string.data_source_openchargemap)
),
Toast.LENGTH_LONG
).show()
}
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.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)
.setArguments(
MapFragmentArgs(
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
latLng = LatLng(
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
).toBundle()
)
.createPendingIntent()
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
deepLink = navController.createDeepLink()
.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()
}
fun navigateTo(charger: ChargeLocation) {
fun navigateTo(charger: ChargeLocation, rootView: View) {
// google maps navigation
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
@@ -240,11 +245,11 @@ class MapsActivity : AppCompatActivity(),
startActivity(intent)
} else {
// fallback: generic geo intent
showLocation(charger)
showLocation(charger, rootView)
}
}
fun showLocation(charger: ChargeLocation) {
fun showLocation(charger: ChargeLocation, rootView: View) {
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(
@@ -252,20 +257,33 @@ class MapsActivity : AppCompatActivity(),
Uri.encode(charger.name)
})"
)
if (intent.resolveActivity(packageManager) != null) {
val resolveInfo =
packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
val pkg =
resolveInfo?.activityInfo?.packageName.takeIf { it != "android" && it != packageName }
if (pkg == null) {
// There is no default maps app or EVMap itself is the current default, fall back to app chooser
val chooserIntent = Intent.createChooser(intent, null).apply {
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(componentName))
}
startActivity(chooserIntent)
return
}
intent.setPackage(pkg)
try {
startActivity(intent)
} else {
val cb = fragmentCallback ?: return
} catch (e: ActivityNotFoundException) {
Snackbar.make(
cb.getRootView(),
rootView,
R.string.no_maps_app_found,
Snackbar.LENGTH_SHORT
).show()
}
}
fun openUrl(url: String) {
val pkg = CustomTabsClient.getPackageName(this, null)
fun openUrl(url: String, rootView: View, preferBrowser: Boolean = true) {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
@@ -273,17 +291,49 @@ class MapsActivity : AppCompatActivity(),
.build()
)
.build()
pkg?.let {
// prefer to open URL in custom tab, even if native app
// available (such as EVMap itself)
val uri = Uri.parse(url)
val viewIntent = Intent(Intent.ACTION_VIEW, uri)
if (preferBrowser) {
// EVMap may be set as default app for this link, but we want to open it in a browser
// try to find default web browser
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://"))
val resolveInfo =
packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
val pkg = resolveInfo?.activityInfo?.packageName.takeIf { it != "android" }
if (pkg == null) {
// There is no default browser, fall back to app chooser
val chooserIntent = Intent.createChooser(viewIntent, null).apply {
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(componentName))
}
val targets: List<ResolveInfo> = packageManager.queryIntentActivities(
viewIntent,
PackageManager.MATCH_DEFAULT_ONLY
)
// add missing browsers (if EVMap is already set as default, Android might not find other browsers with the specific intent)
val browsers = packageManager.queryIntentActivities(
browserIntent,
PackageManager.MATCH_DEFAULT_ONLY
)
val extraIntents = browsers.filter { browser ->
targets.find { it.activityInfo.packageName == browser.activityInfo.packageName } == null
}.map { browser ->
Intent(Intent.ACTION_VIEW, uri).apply {
setPackage(browser.activityInfo.packageName)
}
}
chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents.toTypedArray())
startActivity(chooserIntent)
return
}
intent.intent.setPackage(pkg)
}
try {
intent.launchUrl(this, Uri.parse(url))
intent.launchUrl(this, uri)
} catch (e: ActivityNotFoundException) {
val cb = fragmentCallback ?: return
Snackbar.make(
cb.getRootView(),
rootView,
R.string.no_browser_app_found,
Snackbar.LENGTH_SHORT
).show()

View File

@@ -1,6 +1,7 @@
package net.vonforst.evmap
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
@@ -9,10 +10,17 @@ import android.icu.util.LocaleData
import android.icu.util.ULocale
import android.os.Build
import android.os.Bundle
import android.text.*
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.SpannedString
import android.text.TextUtils
import android.text.style.StyleSpan
import android.view.View
import android.view.ViewTreeObserver
import net.vonforst.evmap.storage.PreferenceDataSource
import java.util.*
import java.util.Currency
import java.util.Locale
fun Bundle.optDouble(name: String): Double? {
if (!this.containsKey(name)) return null
@@ -117,4 +125,31 @@ fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): Pa
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
}
}
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int = 0): ApplicationInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION") getApplicationInfo(packageName, flags)
}
fun PackageManager.isAppInstalled(packageName: String): Boolean {
return try {
getApplicationInfoCompat(packageName, 0).enabled
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
fun currencyDisplayName(code: String) = "${Currency.getInstance(code).displayName} ($code)"
inline fun View.waitForLayout(crossinline f: () -> Unit) =
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
viewTreeObserver.removeOnGlobalLayoutListener(this)
f()
}
})

View File

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

View File

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

View File

@@ -5,16 +5,15 @@ 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 net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.waitForLayout
class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener? = null) :
@@ -40,12 +39,9 @@ class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener?
val item = getItem(position)
if (holder.view.height == 0) {
holder.view.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
holder.view.viewTreeObserver.removeOnGlobalLayoutListener(this)
loadImage(item, holder)
}
})
holder.view.waitForLayout {
loadImage(item, holder)
}
} else {
loadImage(item, holder)
}
@@ -71,7 +67,7 @@ class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener?
memoryKeys[item.id] = metadata.memoryCacheKey
}
)
allowHardware(!BuildConfig.DEBUG)
allowHardware(false)
}
}
}

View File

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

View File

@@ -1,6 +1,10 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import net.vonforst.evmap.api.availability.tesla.LocalTimeAdapter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
@@ -10,6 +14,8 @@ import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import java.time.Instant
import java.time.LocalTime
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
private const val maxDistance = 60 // max distance between reported positions in meters
@@ -53,7 +59,8 @@ interface EnBwApi {
data class EnBwChargePoint(
val evseId: String?,
val status: String,
val connectors: List<EnBwConnector>
val connectors: List<EnBwConnector>,
val state: EnBwState?
)
@JsonClass(generateAdapter = true)
@@ -70,6 +77,11 @@ interface EnBwApi {
val upperRightLon: Double
)
@JsonClass(generateAdapter = true)
data class EnBwState(
val updatedAt: Instant?
)
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): EnBwApi {
val clientWithInterceptor = client.newBuilder()
@@ -85,7 +97,11 @@ interface EnBwApi {
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://enbw-emp.azure-api.net/emobility-public-api/api/v1/")
.addConverterFactory(MoshiConverterFactory.create())
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(InstantAdapter()).build()
)
)
.client(clientWithInterceptor)
.build()
return retrofit.create(EnBwApi::class.java)
@@ -93,6 +109,23 @@ interface EnBwApi {
}
}
internal class InstantAdapter {
@FromJson
fun fromJson(value: Long?): Instant? = value?.let {
Instant.ofEpochMilli(it)
}
@ToJson
fun toJson(value: Instant?): Long? = value?.toEpochMilli()
}
data class EnBwStatus(
val conn: EnBwApi.EnBwConnector,
val status: String,
val evseId: String?,
val lastChange: Instant?
)
class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
BaseAvailabilityDetector(client) {
val api = EnBwApi.create(client, baseUrl)
@@ -157,14 +190,15 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
cp.connectors.map { connector ->
Triple(connector, cp.status, cp.evseId)
EnBwStatus(connector, cp.status, cp.evseId, cp.state?.updatedAt)
}
}
val enbwConnectors = mutableMapOf<Long, Pair<Double, String>>()
val enbwStatus = mutableMapOf<Long, ChargepointStatus>()
val enbwEvseId = mutableMapOf<Long, String>()
connectorStatus.forEachIndexed { index, (connector, statusStr, evseId) ->
val enbwLastChange = mutableMapOf<Long, Instant?>()
connectorStatus.forEachIndexed { index, (connector, statusStr, evseId, updatedAt) ->
val id = index.toLong()
val power = connector.maxPowerInKw ?: 0.0
val type = when (connector.plugTypeName) {
@@ -187,6 +221,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
}
enbwConnectors[id] = power to type
enbwStatus[id] = status
enbwLastChange[id] = updatedAt
evseId?.let { enbwEvseId[id] = it }
}
@@ -197,10 +232,13 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
val evseIds = if (enbwEvseId.size == enbwStatus.size) match.mapValues { entry ->
entry.value.map { enbwEvseId[it]!! }
} else null
val lastChange =
if (enbwLastChange.size == enbwStatus.size) match.mapValues { entry -> entry.value.map { enbwLastChange[it] } } else null
return ChargeLocationStatus(
chargepointStatus,
"EnBW",
evseIds
evseIds,
lastChange = lastChange
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,12 @@
package net.vonforst.evmap.api.availability
package net.vonforst.evmap.api.availability.tesla
import android.net.Uri
import android.util.Base64
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.Coordinate
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
@@ -19,14 +14,8 @@ 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")
@@ -141,8 +130,8 @@ interface TeslaOwnerApi {
// 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("User-Agent", "okhttp/4.11.0")
.header("x-tesla-user-agent", "TeslaApp/4.44.5-3304/3a5d531cc3/android/27")
.header("Accept", "*/*")
.build()
chain.proceed(request)
@@ -157,7 +146,7 @@ interface TeslaOwnerApi {
}
}
interface TeslaGraphQlApi {
interface TeslaChargingOwnershipGraphQlApi {
@POST("/graphql")
suspend fun getNearbyChargingSites(
@Body request: GetNearbyChargingSitesRequest,
@@ -183,7 +172,7 @@ interface TeslaGraphQlApi {
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 "
"\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 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 locationGUID\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 teslaExclusive\n amenities\n chargingAccessibility\n ownerType\n isThirdPartySite\n usabilityArchetype\n accessHours {\n shouldDisplay\n openNow\n hour\n }\n isMagicDockSupportedSite\n hasParkingBenefit\n hasTou\n}\n \n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n"
) : GraphQlRequest()
@JsonClass(generateAdapter = true)
@@ -212,7 +201,7 @@ interface TeslaGraphQlApi {
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"
"\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 upsellingBanner(vehicleMakeType: \$vehicleMakeType) {\n header\n caption\n backgroundImageUrl\n routeName\n }\n nacsOnlyAssets {\n banner {\n header\n caption\n link\n }\n disclaimer {\n text\n sheetTitle\n sheetContent\n }\n }\n enableChargingSiteReportIssue\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 locationGUID\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 isThirdPartySite\n isMagicDockSupportedSite\n trtId {\n value\n }\n siteDisclaimer\n chargingAccessibility\n accessHours {\n shouldDisplay\n openNow\n hour\n }\n isCanvasSite\n ownerDisclaimer\n chargingFeesDisclaimer {\n title\n description\n }\n idleFeesDisclaimer {\n title\n description\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 chargersAvailable {\n value\n }\n chargerDetails {\n charger {\n id {\n text\n }\n label {\n value\n }\n name\n }\n availability\n stateOfCharge\n chargerDisabled\n }\n waitEstimateBucket\n currentCongestion\n usabilityArchetype\n}\n \n\n fragment ChargingActiveRateFragment on ChargingActiveRateType {\n activePricebook {\n charging {\n dynamicRates {\n enabled\n }\n ...ChargingUserRateFragment\n }\n parking {\n ...ChargingUserRateFragment\n }\n congestion {\n ...ChargingUserRateFragment\n }\n service {\n ...ChargingUserRateFragment\n }\n electricity {\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 stateOfCharge\n congestionGracePeriodSecs\n congestionPercent\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)
@@ -227,23 +216,17 @@ interface TeslaGraphQlApi {
@JsonClass(generateAdapter = true)
data class ChargingSiteIdentifier(
val id: String,
val type: ChargingSiteIdentifierType = ChargingSiteIdentifierType.SITE_ID
val type: ChargingSiteIdentifierType = ChargingSiteIdentifierType.LOCATION_GUID
)
enum class ChargingSiteIdentifierType {
SITE_ID
SITE_ID, LOCATION_GUID
}
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)
@@ -258,7 +241,6 @@ interface TeslaGraphQlApi {
@JsonClass(generateAdapter = true)
data class ChargingSite(
val activeOutages: List<Outage>,
val availableStalls: Value<Int>?,
val centroid: Coordinate,
val drivingDistanceMiles: Value<Double>?,
@@ -267,19 +249,11 @@ interface TeslaGraphQlApi {
val id: Text,
val localizedSiteName: Value<String>,
val maxPowerKw: Value<Int>,
val totalStalls: Value<Int>
val totalStalls: Value<Int>,
val locationGUID: String
// 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)
@@ -287,7 +261,7 @@ interface TeslaGraphQlApi {
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation?)
@JsonClass(generateAdapter = true)
data class ChargingSiteInformation(
@@ -299,7 +273,6 @@ interface TeslaGraphQlApi {
@JsonClass(generateAdapter = true)
data class SiteDynamic(
val activeOutages: List<Outage>,
val chargerDetails: List<ChargerDetail>,
val chargersAvailable: Value<Int>?,
val currentCongestion: Double,
@@ -307,24 +280,24 @@ interface TeslaGraphQlApi {
val waitEstimateBucket: WaitEstimateBucket
)
@JsonClass(generateAdapter = true)
data class ChargerId(
val id: Text,
val label: Value<String>?,
val name: String?
) {
val labelNumber
get() = label?.value?.replace(Regex("""\D"""), "")?.toInt()
val labelLetter
get() = label?.value?.replace(Regex("""\d"""), "")
}
@JsonClass(generateAdapter = true)
data class ChargerDetail(
val availability: ChargerAvailability,
val charger: ChargerId
)
@JsonClass(generateAdapter = true)
data class ChargerId(
val id: Text,
val label: Value<String>,
val name: String?
) {
val labelNumber
get() = label.value.replace(Regex("""\D"""), "").toInt()
val labelLetter
get() = label.value.replace(Regex("""\d"""), "")
}
@JsonClass(generateAdapter = true)
data class SiteStatic(
val accessCode: Value<String>?,
@@ -342,58 +315,6 @@ interface TeslaGraphQlApi {
// TODO: siteType, accessType, address, amenities, timeZone
)
@JsonClass(generateAdapter = true)
data class Pricing(
val canDisplayCombinedComparison: Boolean,
val hasMSPPricing: Boolean,
val hasMembershipPricing: Boolean,
val memberRates: Rates?, // rates for Tesla drivers & non-Tesla drivers with subscription
val userRates: Rates? // rates without subscription
)
@JsonClass(generateAdapter = true)
data class Rates(
val activePricebook: Pricebook
)
@JsonClass(generateAdapter = true)
data class Pricebook(
val charging: PricebookDetails,
val parking: PricebookDetails,
val priceBookID: Long
)
@JsonClass(generateAdapter = true)
data class PricebookDetails(
val bucketUom: String, // unit of measurement for buckets (typically "kw")
val buckets: List<Bucket>, // buckets of charging power (used for minute-based pricing)
val currencyCode: String,
val programType: String,
val rates: List<Double>,
val touRates: TouRates,
val uom: String, // unit of measurement ("kwh" or "min")
val vehicleMakeType: String
)
@JsonClass(generateAdapter = true)
data class Bucket(
val start: Int,
val end: Int
)
@JsonClass(generateAdapter = true)
data class TouRates(
val activeRatesByTime: List<ActiveRatesByTime>,
val enabled: Boolean
)
@JsonClass(generateAdapter = true)
data class ActiveRatesByTime(
val startTime: LocalTime,
val endTime: LocalTime,
val rates: List<Double>
)
@JsonClass(generateAdapter = true)
data class CongestionPriceHistogram(
val data: List<Double>,
@@ -406,25 +327,11 @@ interface TeslaGraphQlApi {
val label: String // "1AM", "2AM", etc.
)
enum class ChargerAvailability {
@Json(name = "CHARGER_AVAILABILITY_AVAILABLE")
AVAILABLE,
@JsonClass(generateAdapter = true)
data class Value<T : Any>(val value: T)
@Json(name = "CHARGER_AVAILABILITY_OCCUPIED")
OCCUPIED,
@Json(name = "CHARGER_AVAILABILITY_DOWN")
DOWN,
@Json(name = "CHARGER_AVAILABILITY_UNKNOWN")
UNKNOWN;
fun toStatus() = when (this) {
AVAILABLE -> ChargepointStatus.AVAILABLE
OCCUPIED -> ChargepointStatus.OCCUPIED
DOWN -> ChargepointStatus.FAULTED
UNKNOWN -> ChargepointStatus.UNKNOWN
}
}
@JsonClass(generateAdapter = true)
data class Text(val text: String)
enum class WaitEstimateBucket {
@Json(name = "WAIT_ESTIMATE_BUCKET_NO_WAIT")
@@ -445,6 +352,9 @@ interface TeslaGraphQlApi {
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
APPROXIMATELY_20_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_GREATER_THAN_25_MINUTES")
GREATER_THAN_25_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
UNKNOWN
}
@@ -454,15 +364,15 @@ interface TeslaGraphQlApi {
client: OkHttpClient,
baseUrl: String? = null,
token: suspend () -> String
): TeslaGraphQlApi {
): TeslaChargingOwnershipGraphQlApi {
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("User-Agent", "okhttp/4.11.0")
.header("x-tesla-user-agent", "TeslaApp/4.44.5-3304/3a5d531cc3/android/27")
.header("Accept", "*/*")
.build()
chain.proceed(request)
@@ -476,185 +386,7 @@ interface TeslaGraphQlApi {
)
.client(clientWithInterceptor)
.build()
return retrofit.create(TeslaGraphQlApi::class.java)
return retrofit.create(TeslaChargingOwnershipGraphQlApi::class.java)
}
}
}
internal class LocalTimeAdapter {
@FromJson
fun fromJson(value: String?): LocalTime? = value?.let {
if (it == "24:00") LocalTime.MAX else LocalTime.parse(it)
}
@ToJson
fun toJson(value: LocalTime?): String? = value?.toString()
}
fun Coordinate.asTeslaCoord() =
TeslaGraphQlApi.Coordinate(this.lat, this.lng)
class TeslaAvailabilityDetector(
private val client: OkHttpClient,
private val tokenStore: TokenStore,
private val baseUrl: String? = null
) :
BaseAvailabilityDetector(client) {
private val authApi = TeslaAuthenticationApi.create(client, null)
private var api: TeslaGraphQlApi? = null
interface TokenStore {
var teslaRefreshToken: String?
var teslaAccessToken: String?
var teslaAccessTokenExpiry: Long
}
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val api = initApi()
val req = TeslaGraphQlApi.GetNearbyChargingSitesRequest(
TeslaGraphQlApi.GetNearbyChargingSitesVariables(
TeslaGraphQlApi.GetNearbyChargingSitesArgs(
location.coordinates.asTeslaCoord(),
TeslaGraphQlApi.Coordinate(
location.coordinates.lat + coordRange,
location.coordinates.lng - coordRange
),
TeslaGraphQlApi.Coordinate(
location.coordinates.lat - coordRange,
location.coordinates.lng + coordRange
),
TeslaGraphQlApi.OpenToNonTeslasFilterValue(false)
)
)
)
val results = api.getNearbyChargingSites(
req,
req.operationName
).data.charging?.nearbySites?.sitesAndDistances
?: 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
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
val scV2CCSConnectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power <= 150
}
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
}
val scV3Connectors = location.chargepoints.filter {
it.type in listOf(
Chargepoint.CCS_TYPE_2,
Chargepoint.CCS_UNKNOWN
) && it.power != null && it.power > 150
}
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
"charger has unknown connectors"
)
var statusSorted = details.siteDynamic.chargerDetails.sortedBy { it.charger.labelLetter }
.sortedBy { it.charger.labelNumber }.map { it.availability }
if (statusSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
// apparently some connectors are missing in Tesla data
// If we have just one type of charger, we can still match
val numMissing =
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - statusSorted.size
if (scV2Connectors.isEmpty() || scV3Connectors.isEmpty() && numMissing > 0) {
statusSorted =
statusSorted + List(numMissing) { TeslaGraphQlApi.ChargerAvailability.UNKNOWN }
} else {
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
}
}
val statusMap = emptyMap<Chargepoint, List<ChargepointStatus>>().toMutableMap()
var i = 0
for (connector in scV2Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
i += connector.count
}
if (scV2CCSConnectors.isNotEmpty()) {
i = 0
for (connector in scV2CCSConnectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
i += connector.count
}
}
for (connector in scV3Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
i += connector.count
}
val congestionHistogram = details.congestionPriceHistogram?.let { cph ->
val indexOfMidnight = cph.dataAttributes.indexOfFirst { it.label == "12AM" }
indexOfMidnight.takeIf { it >= 0 }?.let { index ->
val data = cph.data.toMutableList()
Collections.rotate(data, -index)
data
}
}
return ChargeLocationStatus(
statusMap,
"Tesla",
congestionHistogram = congestionHistogram,
extraData = details.pricing
)
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
return when (charger.dataSource) {
"goingelectric" -> charger.network == "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
else -> false
}
}
private suspend fun initApi(): TeslaGraphQlApi {
return api ?: run {
val newApi = TeslaGraphQlApi.create(client, baseUrl) {
val now = Instant.now().epochSecond
val token =
tokenStore.teslaAccessToken.takeIf { tokenStore.teslaAccessTokenExpiry > now }
?: run {
val refreshToken = tokenStore.teslaRefreshToken
?: throw IOException("not signed in")
val response =
authApi.getToken(
TeslaAuthenticationApi.RefreshTokenRequest(
refreshToken
)
)
tokenStore.teslaAccessToken = response.accessToken
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
response.accessToken
}
token
}
api = newApi
newApi
}
}
fun isSignedIn() = tokenStore.teslaRefreshToken != null
}

View File

@@ -12,16 +12,41 @@ import kotlinx.coroutines.withContext
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.*
import net.vonforst.evmap.model.*
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.powerSteps
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.Filter
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterValues
import net.vonforst.evmap.model.MultipleChoiceFilter
import net.vonforst.evmap.model.MultipleChoiceFilterValue
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.model.SliderFilter
import net.vonforst.evmap.model.getBooleanValue
import net.vonforst.evmap.model.getMultipleChoiceValue
import net.vonforst.evmap.model.getSliderValue
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getClusterDistance
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import java.io.IOException
import java.time.Duration
@@ -122,6 +147,8 @@ interface GoingElectricApi {
}
}
private const val STATUS_OK = "ok"
class GoingElectricApiWrapper(
val apikey: String,
baseurl: String = "https://api.goingelectric.de",
@@ -210,15 +237,17 @@ class GoingElectricApiWrapper(
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
if (!response.isSuccessful || response.body()!!.status != STATUS_OK) {
return Resource.error(response.message(), null)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
data.addAll(body.chargelocations!!)
startkey = body.startkey
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
} while (startkey != null && startkey < 10000)
@@ -305,15 +334,17 @@ class GoingElectricApiWrapper(
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
if (!response.isSuccessful || response.body()!!.status != STATUS_OK) {
return Resource.error(response.message(), null)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
data.addAll(body.chargelocations!!)
startkey = body.startkey
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
} while (startkey != null && startkey < 10000)
@@ -388,9 +419,9 @@ class GoingElectricApiWrapper(
): Resource<ChargeLocation> {
try {
val response = api.getChargepointDetail(id)
return if (response.isSuccessful && response.body()!!.status == "ok" && response.body()!!.chargelocations.size == 1) {
return if (response.isSuccessful && response.body()!!.status == STATUS_OK && response.body()!!.chargelocations!!.size == 1) {
Resource.success(
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
(response.body()!!.chargelocations!![0] as GEChargeLocation).convert(
apikey, true
)
)
@@ -399,6 +430,8 @@ class GoingElectricApiWrapper(
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
@@ -416,19 +449,27 @@ class GoingElectricApiWrapper(
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
if (responses.map { it.isSuccessful }.all { it }) {
if (responses.map { it.isSuccessful }.all { it }
&& plugsResponse.body()!!.status == STATUS_OK
&& chargeCardsResponse.body()!!.status == STATUS_OK
&& networksResponse.body()!!.status == STATUS_OK
&& plugsResponse.body()!!.result != null
&& chargeCardsResponse.body()!!.result != null
&& networksResponse.body()!!.result != null) {
Resource.success(
GEReferenceData(
plugsResponse.body()!!.result,
networksResponse.body()!!.result,
chargeCardsResponse.body()!!.result
plugsResponse.body()!!.result!!,
networksResponse.body()!!.result!!,
chargeCardsResponse.body()!!.result!!
)
)
} else {
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
Resource.error(responses.find { !it.isSuccessful }?.message(), null)
}
} catch (e: IOException) {
Resource.error(e.message, null)
} catch (e: HttpException) {
Resource.error(e.message, null)
}
}
}

View File

@@ -27,20 +27,20 @@ import java.time.LocalTime
@JsonClass(generateAdapter = true)
data class GEChargepointList(
val status: String,
val chargelocations: List<GEChargepointListItem>,
val chargelocations: List<GEChargepointListItem>?,
@JsonObjectOrFalse val startkey: Int?
)
@JsonClass(generateAdapter = true)
data class GEStringList(
val status: String,
val result: List<String>
val result: List<String>?
)
@JsonClass(generateAdapter = true)
data class GEChargeCardList(
val status: String,
val result: List<GEChargeCard>
val result: List<GEChargeCard>?
)
sealed class GEChargepointListItem {

View File

@@ -8,11 +8,30 @@ import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.*
import net.vonforst.evmap.model.*
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
import net.vonforst.evmap.api.powerSteps
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.Filter
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterValues
import net.vonforst.evmap.model.MultipleChoiceFilter
import net.vonforst.evmap.model.MultipleChoiceFilterValue
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.model.SliderFilter
import net.vonforst.evmap.model.getBooleanValue
import net.vonforst.evmap.model.getMultipleChoiceValue
import net.vonforst.evmap.model.getSliderValue
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
@@ -168,6 +187,8 @@ class OpenChargeMapApiWrapper(
return Resource.success(ChargepointList(result, data.size < maxResults))
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
@@ -223,6 +244,8 @@ class OpenChargeMapApiWrapper(
return Resource.success(ChargepointList(result, data.size < 499))
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
@@ -259,6 +282,8 @@ class OpenChargeMapApiWrapper(
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
@@ -272,6 +297,8 @@ class OpenChargeMapApiWrapper(
}
} catch (e: IOException) {
return Resource.error(e.message, null)
} catch (e: HttpException) {
return Resource.error(e.message, null)
}
}
@@ -383,9 +410,7 @@ class OpenChargeMapApiWrapper(
}
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
val operators = filters.getMultipleChoiceValue("operators")
return (operators != null && !operators.all)
// TODO: it would be possible to implement this without requiring details if we extended the data structure to also save the operator ID in the DB
return false
}
}

View File

@@ -63,9 +63,9 @@ data class OCMChargepoint(
Coordinate(addressInfo.latitude, addressInfo.longitude),
addressInfo.toAddress(refData),
connections.map { it.convert(refData) },
operatorInfo?.title,
"https://openchargemap.org/site/poi/details/$id",
"https://openchargemap.org/site/poi/edit/$id",
operatorInfo?.title ?: refData.operators.find { it.id == operatorId }?.title,
"https://map.openchargemap.io/?id=$id",
"https://map.openchargemap.io/?id=$id",
convertFaultReport(),
recentlyVerified,
null,
@@ -159,7 +159,9 @@ data class OCMConnection(
fun convert(refData: OCMReferenceData) = Chargepoint(
convertConnectionTypeFromOCM(connectionTypeId, refData),
power,
quantity ?: 1
quantity ?: 1,
voltage?.toDouble(),
amps?.toDouble()
)
companion object {
@@ -252,7 +254,7 @@ data class OCMUserComment(
@Json(name = "ID") val id: Long,
@Json(name = "CommentTypeID") val commentTypeId: Long,
@Json(name = "Comment") val comment: String?,
@Json(name = "UserName") val userName: String,
@Json(name = "UserName") val userName: String?,
@Json(name = "DateCreated") val dateCreated: ZonedDateTime
)

View File

@@ -45,14 +45,20 @@ interface LocationAwareScreen {
class CarAppService : androidx.car.app.CarAppService() {
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
private val TAG = "CarAppService"
private var foregroundStarted = false
fun ensureForegroundService() {
// we want to run as a foreground service to make sure we can use location
if (!foregroundStarted) {
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
foregroundStarted = true
try {
if (!foregroundStarted) {
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
foregroundStarted = true
Log.i(TAG, "Started foreground service")
}
} catch (e: SecurityException) {
Log.w(TAG, "Failed to start foreground service: ", e)
}
}
@@ -156,7 +162,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
if (!prefs.privacyAccepted) {
screens.add(
AcceptPrivacyScreen(carContext)
AcceptPrivacyScreen(carContext, this)
)
}
handleACRAIntent(intent)?.let {

View File

@@ -3,13 +3,22 @@ package net.vonforst.evmap.auto
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.Model
import androidx.car.app.model.*
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarIcon
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
@@ -18,7 +27,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceInclude
import net.vonforst.evmap.api.chargeprice.ChargepriceMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceOptions
import net.vonforst.evmap.api.chargeprice.ChargepriceRequest
import net.vonforst.evmap.api.chargeprice.ChargepriceRequestTariffMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceStation
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
@@ -28,10 +46,13 @@ import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.currency
import net.vonforst.evmap.ui.time
import retrofit2.HttpException
import java.io.IOException
import kotlin.math.roundToInt
class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(ctx) {
@ExperimentalCarApi
class ChargepriceScreen(ctx: CarContext, val session: EVMapSession, val charger: ChargeLocation) :
Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
@@ -69,7 +90,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
carContext.stringProvider(),
chargepoint.type
)
} ${chargepoint.formatPower()} ${
} ${chargepoint.formatPower(carContext.currentOrDefaultLocale)} ${
carContext.getString(
R.string.chargeprice_stats,
meta.energy,
@@ -129,7 +150,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
)
).build()
).setOnClickListener {
openUrl(carContext, ChargepriceApi.getPoiUrl(charger))
openUrl(carContext, session.cas, ChargepriceApi.getPoiUrl(charger))
}.build()
).build()
)
@@ -303,6 +324,15 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
)
.show()
}
} catch (e: HttpException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.chargeprice_connection_error,
CarToast.LENGTH_LONG
)
.show()
}
} catch (e: NoVehicleSelectedException) {
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
invalidate()

View File

@@ -1,39 +1,59 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.*
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.HostException
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarIconSpan
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.model.Pane
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.ParkedOnlyOnClickListener
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.scale
import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import coil.imageLoader
import coil.request.ImageRequest
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.EXTRA_CHARGER_ID
import net.vonforst.evmap.EXTRA_LAT
import net.vonforst.evmap.EXTRA_LON
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.formatTeslaParkingFee
import net.vonforst.evmap.adapter.formatTeslaPricing
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
import net.vonforst.evmap.api.availability.tesla.Pricing
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.fronyx.FronyxApi
import net.vonforst.evmap.api.fronyx.PredictionData
import net.vonforst.evmap.api.fronyx.PredictionRepository
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
@@ -41,6 +61,7 @@ import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FaultReport
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.plus
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -52,12 +73,17 @@ 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
private const val TAG = "ChargerDetailScreen"
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
@ExperimentalCarApi
class ChargerDetailScreen(
ctx: CarContext,
val chargerSparse: ChargeLocation,
val session: EVMapSession
) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
@@ -70,7 +96,8 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val predictionRepo = PredictionRepository(ctx)
//private val predictionRepo = PredictionRepository(ctx)
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val imageSize = 128 // images should be 128dp according to docs
@@ -132,7 +159,22 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
)
.setTitle(carContext.getString(R.string.auto_prices))
.setOnClickListener {
screenManager.push(ChargepriceScreen(carContext, charger))
if (prefs.chargepriceNativeIntegration) {
screenManager.push(
ChargepriceScreen(
carContext,
session,
charger
)
)
} else {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(ChargepriceApi.getPoiUrl(charger))
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
session.cas.startActivity(intent)
}
}
.build())
}
@@ -150,12 +192,12 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
val intent = Intent(session.cas, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, chargerSparse.id)
.putExtra(EXTRA_LAT, chargerSparse.coordinates.lat)
.putExtra(EXTRA_LON, chargerSparse.coordinates.lng)
carContext.startActivity(intent)
session.cas.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
@@ -330,14 +372,14 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}.build())
}
if (rows.count() < maxRows && teslaSupported) {
val teslaPricing = availability?.extraData as? TeslaGraphQlApi.Pricing
val teslaPricing = availability?.extraData as? Pricing
rows.add(3, Row.Builder().apply {
setTitle(carContext.getString(R.string.cost))
teslaPricing?.let {
var text = formatTeslaPricing(teslaPricing, carContext) as CharSequence
formatTeslaParkingFee(teslaPricing, carContext)?.let { text += "\n\n" + it }
addText(text)
} ?: {
} ?: run {
addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
}
}.build())
@@ -482,7 +524,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
} else {
append(nameForPlugType(carContext.stringProvider(), cp.type))
}
cp.formatPower()?.let {
cp.formatPower(carContext.currentOrDefaultLocale)?.let {
append(" ")
append(it)
}
@@ -516,13 +558,55 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}
private fun navigateToCharger(charger: ChargeLocation) {
var success = navigateCarApp(charger)
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
// on AAOS, some OEMs' navigation apps might not support
success = navigateRegularApp(charger)
}
if (!success) {
CarToast.makeText(carContext, R.string.no_maps_app_found, CarToast.LENGTH_SHORT).show()
}
}
private fun navigateCarApp(charger: ChargeLocation): Boolean {
val coord = charger.coordinates
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)
try {
carContext.startCarApp(intent)
return true
} catch (e: HostException) {
Log.w(TAG, "Could not start navigation using car app intent")
Log.w(TAG, intent.toString())
e.printStackTrace()
} catch (e: SecurityException) {
Log.w(TAG, "Could not start navigation using car app intent")
Log.w(TAG, intent.toString())
e.printStackTrace()
}
return false
}
private fun navigateRegularApp(charger: ChargeLocation): Boolean {
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(
"geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
Uri.encode(charger.name)
})"
)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(carContext.packageManager) != null) {
carContext.startActivity(intent)
return true
} else {
Log.w(TAG, "Could not start navigation using regular intent")
Log.w(TAG, intent.toString())
}
return false
}
private fun loadCharger() {
@@ -543,7 +627,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val url = photo.getUrl(size = size)
val request = ImageRequest.Builder(carContext).data(url).build()
val img =
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
(carContext.imageLoader.execute(request).drawable as? BitmapDrawable)?.bitmap ?: return@let
// draw icon on top of image
val icon = iconGen.getBitmap(
@@ -576,12 +660,12 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
)
this@ChargerDetailScreen.photo = outImg
}
fronyxSupported = charger.chargepoints.any {
fronyxSupported = false /*charger.chargepoints.any {
FronyxApi.isChargepointSupported(
charger,
it
)
} && !availabilityRepo.isSupercharger(charger)
} && !availabilityRepo.isSupercharger(charger)*/
teslaSupported = availabilityRepo.isTeslaSupported(charger)
invalidate()
@@ -590,7 +674,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
invalidate()
prediction = predictionRepo.getPredictionData(charger, availability)
//prediction = predictionRepo.getPredictionData(charger, availability)
invalidate()
} else {

View File

@@ -9,14 +9,36 @@ import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Pane
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.ParkedOnlyOnClickListener
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.map
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.BooleanFilterValue
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.model.FilterValues
import net.vonforst.evmap.model.MultipleChoiceFilter
import net.vonforst.evmap.model.MultipleChoiceFilterValue
import net.vonforst.evmap.model.SliderFilter
import net.vonforst.evmap.model.SliderFilterValue
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -227,12 +249,11 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
R.string.deleted_item,
it.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
}
}
}.build())
@@ -344,12 +365,11 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
R.string.deleted_item,
currentProfile.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
screenManager.pop()
}
}
@@ -381,7 +401,6 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
}
if (!saveSuccess) return@pushForResult
}
invalidate()
}
.build()
)

View File

@@ -15,14 +15,34 @@ import androidx.car.app.hardware.info.CarInfo
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.*
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarIconSpan
import androidx.car.app.model.CarLocation
import androidx.car.app.model.CarText
import androidx.car.app.model.DistanceSpan
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.model.ItemList
import androidx.car.app.model.Metadata
import androidx.car.app.model.OnContentRefreshListener
import androidx.car.app.model.Place
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.model.PlaceMarker
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityRepository
@@ -45,6 +65,7 @@ import net.vonforst.evmap.utils.headingDiff
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.awaitFinished
import net.vonforst.evmap.viewmodel.filtersWithValue
import retrofit2.HttpException
import java.io.IOException
import java.time.Duration
import java.time.Instant
@@ -385,7 +406,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
setOnClickListener {
screenManager.push(ChargerDetailScreen(carContext, charger))
screenManager.push(ChargerDetailScreen(carContext, charger, session))
session.mapScreen = null
}
}.build()
@@ -471,13 +492,13 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
zoom = 16f,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data?.items.isNullOrEmpty() else response.data == null) {
loadingError = true
this@MapScreen.chargers = null
invalidate()
return@launch
}
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers = response.data?.items?.filterIsInstance<ChargeLocation>()
if (prefs.placeSearchResultAndroidAutoName == null) {
chargers = headingFilter(
chargers,
@@ -502,6 +523,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
} catch (e: IOException) {
loadingError = true
invalidate()
} catch (e: HttpException) {
loadingError = true
invalidate()
}
}
}

View File

@@ -116,7 +116,7 @@ class PlaceSearchScreen(
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
val placeDetails = getDetails(place.id) ?: return@launch
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
@@ -226,9 +226,9 @@ class PlaceSearchScreen(
}
}
suspend fun getDetails(id: String): PlaceWithBounds {
suspend fun getDetails(id: String): PlaceWithBounds? {
val provider = currentProvider!!
val result = resultList!!.find { it.id == id }!!
val result = resultList?.find { it.id == id } ?: return null
val recentPlace = recentResults.find { it.id == id }
if (recentPlace != null) return recentPlace.asPlaceWithBounds()

View File

@@ -4,7 +4,14 @@ import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.ItemList
import androidx.car.app.model.Row
import androidx.car.app.model.SearchTemplate
import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
@@ -12,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import okio.IOException
import retrofit2.HttpException
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
SearchTemplate.SearchCallback {
@@ -30,14 +38,9 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
filterList()
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.generic_connection_error,
CarToast.LENGTH_LONG
).show()
screenManager.pop()
}
showLoadingError()
} catch (e: HttpException) {
showLoadingError()
}
}
}
@@ -49,7 +52,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
} ?: run {
setLoading(true)
}
if (isMultiSelect) {
if (isMultiSelect && shouldShowSelectAll) {
setActionStrip(ActionStrip.Builder().apply {
addAction(
Action.Builder().setIcon(
@@ -76,6 +79,17 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
}.build()
}
private suspend fun showLoadingError() {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.generic_connection_error,
CarToast.LENGTH_LONG
).show()
screenManager.pop()
}
}
private fun filterList() {
currentList = fullList?.let {
it.sortedBy { getLabel(it).lowercase() }

View File

@@ -8,31 +8,45 @@ import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
import android.net.Uri
import android.os.Bundle
import android.os.IInterface
import android.text.Html
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.ParkedOnlyOnClickListener
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.TeslaOwnerApi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.EXTRA_DONATE
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.TeslaOwnerApi
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.currencyDisplayName
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.getPackageInfoCompat
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -65,7 +79,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
)
setBrowsable(true)
setOnClickListener {
screenManager.push(DataSettingsScreen(carContext))
screenManager.push(DataSettingsScreen(carContext, session))
}
}.build())
addItem(Row.Builder().apply {
@@ -130,7 +144,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(AboutScreen(carContext))
screenManager.push(AboutScreen(carContext, session))
}
.build()
)
@@ -139,7 +153,8 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
}
}
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
@ExperimentalCarApi
class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
val encryptedPrefs = EncryptedPreferenceDataStore(ctx)
val db = AppDatabase.getInstance(ctx)
@@ -202,7 +217,7 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
}
}
}.build())
addItem(
/*addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.pref_prediction_enabled))
.addText(carContext.getString(R.string.pref_prediction_enabled_summary))
@@ -210,7 +225,7 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
prefs.predictionEnabled = it
}.setChecked(prefs.predictionEnabled).build())
.build()
)
)*/
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_tesla_account))
addText(
@@ -265,7 +280,7 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
carContext.startActivity(intent)
session.cas.startActivity(intent)
if (BuildConfig.FLAVOR_automotive != "automotive") {
CarToast.makeText(
@@ -412,12 +427,21 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
setTitle(carContext.getString(R.string.settings_chargeprice))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_native_integration))
addText(carContext.getString(if (prefs.chargepriceNativeIntegration) R.string.pref_chargeprice_native_integration_on else R.string.pref_chargeprice_native_integration_off))
setToggle(Toggle.Builder {
prefs.chargepriceNativeIntegration = it
invalidate()
}.setChecked(prefs.chargepriceNativeIntegration).build())
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_vehicle))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectVehiclesScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_tariffs))
@@ -441,6 +465,7 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
)
}
)
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
@@ -458,14 +483,14 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
setOnClickListener {
screenManager.push(SelectChargingRangeScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_currency))
val names =
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
val values =
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
carContext.resources.getStringArray(R.array.pref_chargeprice_currencies)
val names = values.map(::currencyDisplayName)
val index = values.indexOf(prefs.chargepriceCurrency)
addText(if (index >= 0) names[index] else "")
@@ -473,27 +498,31 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
setOnClickListener {
screenManager.push(SelectCurrencyScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_no_base_fee))
setToggle(Toggle.Builder {
prefs.chargepriceNoBaseFee = it
}.setChecked(prefs.chargepriceNoBaseFee).build())
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
setToggle(Toggle.Builder {
prefs.chargepriceShowProviderCustomerTariffs = it
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
if (maxRows > 6) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
setToggle(Toggle.Builder {
prefs.chargepriceShowProviderCustomerTariffs = it
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load))
addText(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load_summary))
setToggle(Toggle.Builder {
prefs.chargepriceAllowUnbalancedLoad = it
}.setChecked(prefs.chargepriceAllowUnbalancedLoad).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
}
}.build())
@@ -601,8 +630,8 @@ class SelectCurrencyScreen(ctx: CarContext) : MultiSelectSearchScreen<Pair<Strin
override fun getLabel(it: Pair<String, String>): String = it.first
override suspend fun loadData(): List<Pair<String, String>> {
val names = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
val values = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
val values = carContext.resources.getStringArray(R.array.pref_chargeprice_currencies)
val names = values.map(::currencyDisplayName)
return names.zip(values)
}
}
@@ -724,7 +753,8 @@ class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
}
}
class AboutScreen(ctx: CarContext) : Screen(ctx) {
@ExperimentalCarApi
class AboutScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
var developerOptionsCounter = 0
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
@@ -769,7 +799,11 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
.setTitle(carContext.getString(R.string.faq))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.faq_link))
openUrl(
carContext,
session.cas,
carContext.getString(R.string.faq_link)
)
}).build()
)
addItem(
@@ -780,12 +814,16 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
.setOnClickListener(ParkedOnlyOnClickListener.create {
if (BuildConfig.FLAVOR_automotive == "automotive") {
// we can't open the donation page on the phone in this case
openUrl(carContext, carContext.getString(R.string.donate_link))
openUrl(
carContext,
session.cas,
carContext.getString(R.string.donate_link)
)
} else {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_DONATE, true)
carContext.startActivity(intent)
session.cas.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
@@ -797,39 +835,75 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
}.build(), carContext.getString(R.string.about)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.twitter))
.addText(carContext.getString(R.string.twitter_handle))
.setTitle(carContext.getString(R.string.mastodon))
.addText(carContext.getString(R.string.mastodon_handle))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.twitter_url))
openUrl(
carContext,
session.cas,
carContext.getString(R.string.mastodon_url)
)
}).build()
)
if (maxRows > 8) {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.twitter))
.addText(carContext.getString(R.string.twitter_handle))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(
carContext,
session.cas,
carContext.getString(R.string.twitter_url)
)
}).build()
)
}
if (maxRows > 6) {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.goingelectric_forum))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(
carContext,
carContext, session.cas,
carContext.getString(R.string.goingelectric_forum_url)
)
}).build()
)
}
if (maxRows > 7) {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.tff_forum))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(
carContext, session.cas,
carContext.getString(R.string.tff_forum_url)
)
}).build()
)
}
}.build(), carContext.getString(R.string.contact)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.github_link_title))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.github_link))
openUrl(carContext, session.cas, carContext.getString(R.string.github_link))
}).build()
)
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.privacy))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.privacy_link))
openUrl(
carContext,
session.cas,
carContext.getString(R.string.privacy_link)
)
}).build()
)
}.build(), carContext.getString(R.string.other)))
@@ -837,7 +911,8 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
}
}
class AcceptPrivacyScreen(ctx: CarContext) : Screen(ctx) {
@ExperimentalCarApi
class AcceptPrivacyScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
override fun onGetTemplate(): Template {
val textWithoutLink = HtmlCompat.fromHtml(
@@ -858,7 +933,7 @@ class AcceptPrivacyScreen(ctx: CarContext) : Screen(ctx) {
addAction(Action.Builder()
.setTitle(carContext.getString(R.string.privacy))
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.privacy_link))
openUrl(carContext, session.cas, carContext.getString(R.string.privacy_link))
}).build()
)
}.build()

View File

@@ -12,9 +12,14 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.model.*
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Template
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
@@ -26,7 +31,7 @@ import net.vonforst.evmap.getPackageInfoCompat
import net.vonforst.evmap.kmPerMile
import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.ydPerMile
import java.util.*
import java.util.Locale
import kotlin.math.roundToInt
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
@@ -197,7 +202,7 @@ fun <T> List<T>.paginate(nSingle: Int, nFirst: Int, nOther: Int, nLast: Int): Li
fun getAndroidAutoVersion(ctx: Context): List<String> {
val info = ctx.packageManager.getPackageInfoCompat("com.google.android.projection.gearhead", 0)
return info.versionName.split(".")
return info.versionName!!.split(".")
}
fun supportsCarApiLevel3(ctx: CarContext): Boolean {
@@ -207,7 +212,9 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
val version = getAndroidAutoVersion(ctx)
// Android Auto 6.7 is required. 6.6 reports supporting API Level 3,
// but crashes when using it. See: https://issuetracker.google.com/issues/199509584
if (version[0] < "6" || version[0] == "6" && version[1] < "7") {
val major = version[0].toIntOrNull() ?: return false
val minor = version[1].toIntOrNull() ?: return false
if (major < 6 || major < 6 && minor < 7) {
return false
}
}
@@ -215,13 +222,14 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
return true
}
fun openUrl(carContext: CarContext, url: String) {
@ExperimentalCarApi
fun openUrl(carContext: CarContext, cas: CarAppService, url: String) {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
cas,
R.color.colorPrimary
)
)
@@ -231,7 +239,7 @@ fun openUrl(carContext: CarContext, url: String) {
intent.data = Uri.parse(url)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
cas.startActivity(intent)
if (BuildConfig.FLAVOR_automotive != "automotive") {
// only show the toast "opened on phone" if we're running on a phone
CarToast.makeText(

View File

@@ -6,9 +6,18 @@ import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.*
import androidx.car.app.model.*
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.hardware.info.Model
import androidx.car.app.hardware.info.Speed
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.ItemList
import androidx.car.app.model.Template
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
@@ -18,14 +27,14 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.ui.CompassNeedle
import net.vonforst.evmap.ui.Gauge
import net.vonforst.evmap.utils.formatDecimal
import patchedCarInfo
import kotlin.math.min
import kotlin.math.roundToInt
@androidx.car.app.annotations.ExperimentalCarApi
class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
LocationAwareScreen, DefaultLifecycleObserver {
private val carInfo =
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
private val carInfo = carContext.patchedCarInfo
private val carSensors = carContext.patchedCarSensors
private var model: Model? = null
private var energyLevel: EnergyLevel? = null

View File

@@ -16,6 +16,7 @@ import com.mapbox.api.geocoding.v5.models.CarmenFeature
import com.mapbox.geojson.BoundingBox
import com.mapbox.geojson.Point
import net.vonforst.evmap.R
import retrofit2.HttpException
import java.io.IOException
class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
@@ -25,7 +26,7 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
override val id = "mapbox"
override fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace> {
val result = MapboxGeocoding.builder().apply {
val request = MapboxGeocoding.builder().apply {
location?.let {
proximity(Point.fromLngLat(location.longitude, location.latitude))
}
@@ -33,7 +34,12 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
accessToken(context.getString(R.string.mapbox_key))
autocomplete(true)
this.query(query)
}.build().executeCall()
}
val result = try {
request.build().executeCall()
} catch (e: HttpException) {
throw IOException(e)
}
if (!result.isSuccessful) {
throw IOException(result.message())
}
@@ -113,8 +119,7 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
override fun getAttributionString(): Int = R.string.powered_by_mapbox
override fun getAttributionImage(dark: Boolean): Int =
if (dark) com.mapbox.mapboxsdk.R.drawable.mapbox_logo_icon else R.drawable.mapbox_logo
override fun getAttributionImage(dark: Boolean): Int = R.drawable.mapbox_logo
}
private fun BoundingBox.toLatLngBounds(): LatLngBounds {

View File

@@ -62,7 +62,7 @@ class ChargepriceFragment : Fragment() {
if (savedInstanceState == null) {
val prefs = PreferenceDataSource(requireContext())
prefs.chargepriceCounter += 1
if ((prefs.chargepriceCounter - 30).mod(50) == 0) {
if ((prefs.chargepriceCounter).mod(30) == 0) {
showDonationDialog()
}
}
@@ -100,9 +100,9 @@ class ChargepriceFragment : Fragment() {
inflater,
R.layout.fragment_chargeprice_header, container, false
)
binding.lifecycleOwner = this
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
headerBinding.lifecycleOwner = this
headerBinding.lifecycleOwner = viewLifecycleOwner
headerBinding.vm = vm
binding.toolbar.inflateMenu(R.menu.chargeprice)
@@ -141,7 +141,7 @@ class ChargepriceFragment : Fragment() {
val chargepriceAdapter = ChargepriceAdapter().apply {
onClickListener = {
(requireActivity() as MapsActivity).openUrl(it.url)
(requireActivity() as MapsActivity).openUrl(it.url, binding.root)
}
}
val joinedAdapter = ConcatAdapter(
@@ -168,7 +168,7 @@ class ChargepriceFragment : Fragment() {
chargepriceAdapter.myTariffsAll = it
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
it?.data?.let { chargepriceAdapter.submitList(it) }
chargepriceAdapter.submitList(it?.data ?: emptyList())
}
val connectorsAdapter = CheckableConnectorAdapter()
@@ -194,7 +194,10 @@ class ChargepriceFragment : Fragment() {
}
binding.imgChargepriceLogo.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(ChargepriceApi.getPoiUrl(charger))
(requireActivity() as MapsActivity).openUrl(
ChargepriceApi.getPoiUrl(charger),
binding.root
)
}
binding.btnSettings.setOnClickListener {
@@ -213,11 +216,19 @@ class ChargepriceFragment : Fragment() {
}
false
}
headerBinding.tvChargeFromTo.setOnClickListener {
it.postDelayed({
vm.resetBatteryRangeToDefault()
}, 250)
}
binding.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_help -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.chargeprice_faq_link))
(activity as? MapsActivity)?.openUrl(
getString(R.string.chargeprice_faq_link),
binding.root
)
true
}
else -> false

View File

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

View File

@@ -0,0 +1,26 @@
package net.vonforst.evmap.fragment
import android.content.Intent
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.fragment.app.Fragment
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
abstract class DonateFragmentBase : Fragment() {
fun setupReferrals(referrals: FragmentDonateReferralBinding) {
referrals.referralWebView.loadUrl(getString(R.string.referral_link))
referrals.referralWebView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
Intent(Intent.ACTION_VIEW, request.url).apply {
startActivity(this)
}
return true
}
}
}
}

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ class FilterProfilesFragment : Fragment() {
savedInstanceState: Bundle?
): View {
binding = FragmentFilterProfilesBinding.inflate(inflater, container, false)
binding.lifecycleOwner = this
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
return binding.root
@@ -183,20 +183,24 @@ class FilterProfilesFragment : Fragment() {
adapter = FilterProfilesAdapter(touchHelper, onDelete = { fp ->
delete(fp)
}, onRename = { fp ->
showEditTextDialog(requireContext()) { dialog, input ->
showEditTextDialog(requireContext(), { dialog, input ->
input.setText(fp.name)
dialog.setTitle(R.string.rename)
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { _, _ ->
lifecycleScope.launch {
vm.update(fp.copy(name = input.text.toString()))
}
}, { newName ->
lifecycleScope.launch {
if (vm.filterProfiles.value?.find { it.name == newName } != null) {
Snackbar.make(
view,
R.string.filterprofile_name_not_unique,
Snackbar.LENGTH_LONG
).show()
} else {
vm.update(fp.copy(name = newName))
}
.setNegativeButton(R.string.cancel) { _, _ ->
}
}
}
})
})
binding.filterProfilesList.apply {
this.adapter = this@FilterProfilesFragment.adapter
@@ -230,7 +234,7 @@ class FilterProfilesFragment : Fragment() {
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fp.name),
getString(R.string.deleted_item, fp.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
toDelete = null

View File

@@ -3,24 +3,40 @@ package net.vonforst.evmap.fragment
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.text.method.KeyListener
import android.view.*
import android.view.ContextThemeWrapper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.AdapterView
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresPermission
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.view.*
import androidx.core.view.MenuCompat
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnLayout
import androidx.core.view.updateLayoutParams
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -28,7 +44,6 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@@ -40,21 +55,25 @@ import androidx.transition.TransitionManager
import coil.load
import coil.memory.MemoryCache
import com.car2go.maps.AnyMap
import com.car2go.maps.MapFactory
import com.car2go.maps.MapFragment
import com.car2go.maps.OnMapReadyCallback
import com.car2go.maps.model.BitmapDescriptor
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.Marker
import com.car2go.maps.model.MarkerOptions
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_SETTLING
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.google.android.material.transition.MaterialContainerTransform.FADE_MODE_CROSS
import com.google.android.material.transition.MaterialFadeThrough
import com.google.android.material.transition.MaterialSharedAxis
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.*
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
import com.stfalcon.imageviewer.StfalconImageViewer
import io.michaelrocks.bimap.HashBiMap
import io.michaelrocks.bimap.MutableBiMap
@@ -68,25 +87,47 @@ import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.bold
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.fragment.preference.DataSettingsFragmentArgs
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargeLocationCluster
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.*
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.HideOnScrollFabBehavior
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.chargerZ
import net.vonforst.evmap.ui.clusterZ
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.ui.placeSearchZ
import net.vonforst.evmap.ui.setTouchModal
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.utils.checkFineLocationPermission
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.viewmodel.*
import net.vonforst.evmap.utils.formatDecimal
import net.vonforst.evmap.viewmodel.GalleryViewModel
import net.vonforst.evmap.viewmodel.MapPosition
import net.vonforst.evmap.viewmodel.MapViewModel
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import java.io.IOException
import java.time.Duration
import java.time.Instant
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.contains
@@ -94,29 +135,61 @@ import kotlin.collections.set
import kotlin.math.min
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback, MenuProvider {
private lateinit var binding: FragmentMapBinding
class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
private var _binding: FragmentMapBinding? = null
private val binding get() = _binding!!
private val vm: MapViewModel by viewModels()
private val galleryVm: GalleryViewModel by activityViewModels()
private var mapFragment: MapFragment? = null
private var map: AnyMap? = null
private lateinit var locationEngine: LocationEngine
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var detailsDialog: ConnectorDetailsDialog
private lateinit var prefs: PreferenceDataSource
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
private var searchResultMarker: Marker? = null
private var searchResultIcon: BitmapDescriptor? = null
private var connectionErrorSnackbar: Snackbar? = null
private var zoomInSnackbar: Snackbar? = null
private var previousChargepointIds: Set<Long>? = null
private var mapTopPadding: Int = 0
private var popupMenu: PopupMenu? = null
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
private lateinit var animator: MarkerAnimator
private lateinit var favToggle: MenuItem
private val bottomSheetBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val state = bottomSheetBehavior.state
when (state) {
STATE_COLLAPSED -> vm.chargerSparse.value = null
STATE_HIDDEN -> return
else -> if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
}
bottomSheetBehavior.cancelBackProgress()
}
override fun handleOnBackStarted(backEvent: BackEventCompat) {
bottomSheetBehavior.startBackProgress(backEvent)
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
bottomSheetBehavior.updateBackProgress(backEvent)
}
override fun handleOnBackCancelled() {
bottomSheetBehavior.cancelBackProgress()
}
}
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val value = vm.layersMenuOpen.value
@@ -125,20 +198,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return
}
if (binding.search.hasFocus()) {
removeSearchFocus()
if (vm.selectedChargepoint.value != null) {
closeConnectorDetailsDialog()
vm.selectedChargepoint.value = null
return
}
val state = bottomSheetBehavior.state
when (state) {
STATE_COLLAPSED -> vm.chargerSparse.value = null
STATE_HIDDEN -> vm.searchResult.value = null
else -> if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
if (binding.search.hasFocus()) {
removeSearchFocus()
return
}
vm.searchResult.value = null
}
}
@@ -161,9 +232,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
_binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
println(binding.detailView.sourceButton)
binding.lifecycleOwner = this
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
val provider = prefs.mapProvider
@@ -171,16 +242,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
mapFragment =
childFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
}
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
if (mapFragment == null || mapFragment!!.priority[0] != getMapProvider(provider)) {
mapFragment = MapFragment()
mapFragment!!.priority = arrayOf(
when (provider) {
"mapbox" -> MapFragment.MAPBOX
"google" -> MapFragment.GOOGLE
else -> null
},
MapFragment.GOOGLE,
MapFragment.MAPBOX
getMapProvider(provider),
MapFactory.GOOGLE,
MapFactory.MAPLIBRE
)
childFragmentManager
.beginTransaction()
@@ -195,7 +262,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
searchResultIcon = null
}
binding.detailAppBar.toolbar.popupTheme =
binding.detailView.toolbar.popupTheme =
com.google.android.material.R.style.ThemeOverlay_AppCompat_DayNight
ViewCompat.setOnApplyWindowInsetsListener(
@@ -204,9 +271,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
ViewCompat.onApplyWindowInsets(binding.root, insets)
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
/*binding.detailView.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemWindowInsetTop
}
}*/
val insetsBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom + insetsBottom
// margin of layers button: status bar height + toolbar height + margin
val density = resources.displayMetrics.density
@@ -225,7 +295,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// set map padding so that compass is not obstructed by toolbar
mapTopPadding = systemWindowInsetTop + (48 * density).toInt() + (16 * density).toInt()
// if we actually use map.setPadding here, Mapbox will re-trigger onApplyWindowInsets
// if we actually use map.setPadding here, MapLibre will re-trigger onApplyWindowInsets
// and cause an infinite loop. So we rely on onMapReady being called later than
// onApplyWindowInsets.
@@ -239,32 +309,46 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
viewLifecycleOwner,
backPressedCallback
)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
bottomSheetBackPressedCallback
)
return binding.root
}
private fun getMapProvider(provider: String) = when (provider) {
"mapbox" -> MapFactory.MAPLIBRE
"google" -> MapFactory.GOOGLE
else -> null
}
val bottomSheetCollapsible
get() = resources.getBoolean(R.bool.bottom_sheet_collapsible)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet || !prefs.privacyAccepted) {
findNavController().navigate(R.id.onboarding)
}
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
mapFragment!!.getMapAsync(this)
bottomSheetBehavior = from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
bottomSheetBehavior = BottomSheetBehavior.from(binding.detailView.root)
//detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
favToggle = binding.detailAppBar.toolbar.menu.findItem(R.id.menu_fav)
binding.detailView.toolbar.inflateMenu(R.menu.detail)
favToggle = binding.detailView.toolbar.menu.findItem(R.id.menu_fav)
vm.apiName.observe(viewLifecycleOwner) {
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
binding.detailView.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, it)
}
binding.detailView.topPart.doOnNextLayout {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
}
bottomSheetBehavior.isCollapsible = bottomSheetCollapsible
vm.bottomSheetState.value?.let { bottomSheetBehavior.state = it }
bottomSheetBehavior.skipCollapsed = !bottomSheetCollapsible
bottomSheetBehavior.state = STATE_HIDDEN
binding.detailView.connectorDetails
setupObservers()
setupClickListeners()
@@ -276,7 +360,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
(requireActivity() as MapsActivity).appBarConfiguration
)
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
if (prefs.appStartCounter > 5 && Duration.between(
prefs.opensourceDonationsDialogLastShown,
Instant.now()
) > Duration.ofDays(30)
) {
try {
findNavController().safeNavigate(MapFragmentDirections.actionMapToOpensourceDonations())
} catch (ignored: IllegalArgumentException) {
@@ -302,9 +390,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.appLogo.root.animate().alpha(1f)
.withEndAction {
if (_binding == null) return@withEndAction
binding.appLogo.root.animate().alpha(0f).apply {
startDelay = 1000
}.withEndAction {
if (_binding == null) return@withEndAction
binding.appLogo.root.visibility = View.GONE
binding.search.visibility = View.VISIBLE
binding.search.alpha = 0f
@@ -318,13 +408,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.appLogo.root.visibility = View.GONE
binding.search.visibility = View.VISIBLE
}
detailsDialog =
ConnectorDetailsDialog(binding.detailView.connectorDetails, requireContext()) {
closeConnectorDetailsDialog()
vm.selectedChargepoint.value = null
}
}
override fun onResume() {
super.onResume()
val hostActivity = activity as? MapsActivity ?: return
hostActivity.fragmentCallback = this
vm.reloadPrefs()
if (requestingLocationUpdates && requireContext().checkAnyLocationPermission()
) {
@@ -356,7 +449,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val charger = vm.charger.value?.data
if (charger != null) {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
(requireActivity() as MapsActivity).navigateTo(charger)
(requireActivity() as MapsActivity).navigateTo(charger, binding.root)
}
}
}
@@ -369,21 +462,28 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailView.sourceButton.setOnClickListener {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.openUrl(charger.url)
(activity as? MapsActivity)?.openUrl(charger.url, binding.root, true)
}
}
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().safeNavigate(
MapFragmentDirections.actionMapToChargepriceFragment(charger),
extras
)
if (prefs.chargepriceNativeIntegration) {
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().safeNavigate(
MapFragmentDirections.actionMapToChargepriceFragment(charger),
extras
)
} else {
(activity as? MapsActivity)?.openUrl(
ChargepriceApi.getPoiUrl(charger),
binding.root
)
}
}
binding.detailView.btnChargerWebsite.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it, binding.root) }
}
binding.detailView.btnLogin.setOnClickListener {
findNavController().safeNavigate(
@@ -391,7 +491,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
)
}
binding.detailView.imgPredictionSource.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url))
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url), binding.root)
}
binding.detailView.btnPredictionHelp.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
@@ -400,17 +500,22 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.show()
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = STATE_ANCHOR_POINT
bottomSheetBehavior.state = STATE_HALF_EXPANDED
}
binding.detailView.topPart.setOnLongClickListener {
val charger = vm.charger.value?.data ?: return@setOnLongClickListener false
copyToClipboard(ClipData.newPlainText(getString(R.string.charger_name), charger.name))
return@setOnLongClickListener true
}
setupSearchAutocomplete()
binding.detailAppBar.toolbar.setNavigationOnClickListener {
binding.detailView.toolbar.setNavigationOnClickListener {
if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
}
binding.detailAppBar.toolbar.setOnMenuItemClickListener {
binding.detailView.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_fav -> {
toggleFavorite()
@@ -426,7 +531,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
R.id.menu_edit -> {
val charger = vm.charger.value?.data
if (charger?.editUrl != null) {
(activity as? MapsActivity)?.openUrl(charger.editUrl)
(activity as? MapsActivity)?.openUrl(charger.editUrl, binding.root, true)
if (vm.apiId.value == "goingelectric") {
// instructions specific to GoingElectric
Toast.makeText(
@@ -571,7 +676,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun setupObservers() {
bottomSheetBehavior.addBottomSheetCallback(object :
BottomSheetCallback() {
BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (bottomSheetBehavior.state == STATE_HIDDEN) {
map?.setPadding(0, mapTopPadding, 0, 0)
@@ -588,7 +693,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onStateChanged(bottomSheet: View, newState: Int) {
vm.bottomSheetState.value = newState
updateBackPressedCallback()
bottomSheetBackPressedCallback.isEnabled = newState != STATE_HIDDEN
if (vm.layersMenuOpen.value!! && newState !in listOf(
STATE_SETTLING,
@@ -598,17 +703,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
) {
closeLayersMenu()
}
if (vm.selectedChargepoint.value != null && newState in listOf(
STATE_HALF_EXPANDED, STATE_COLLAPSED
)
) {
closeConnectorDetailsDialog()
vm.selectedChargepoint.value = null
}
}
})
vm.chargerSparse.observe(viewLifecycleOwner) {
if (it != null) {
if (vm.bottomSheetState.value != STATE_ANCHOR_POINT) {
if (vm.bottomSheetState.value != STATE_HALF_EXPANDED) {
bottomSheetBehavior.state =
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_ANCHOR_POINT
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_HALF_EXPANDED
}
removeSearchFocus()
binding.fabDirections.show()
detailAppBarBehavior.setToolbarTitle(it.name)
//detailAppBarBehavior.setToolbarTitle(it.name)
updateFavoriteToggle()
highlightMarker(it)
} else {
@@ -619,12 +732,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
val chargepoints = res.data
if (chargepoints != null) {
updateMap(chargepoints)
updateMap(chargepoints.items)
}
val view = view ?: return@Observer
when (res.status) {
Status.ERROR -> {
val view = view ?: return@Observer
zoomInSnackbar?.dismiss()
connectionErrorSnackbar?.dismiss()
connectionErrorSnackbar = Snackbar
.make(view, R.string.connection_error, Snackbar.LENGTH_INDEFINITE)
@@ -636,13 +749,20 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
Status.SUCCESS -> {
connectionErrorSnackbar?.dismiss()
if (res.data != null && !res.data.isComplete) {
zoomInSnackbar?.dismiss()
zoomInSnackbar = Snackbar
.make(view, R.string.zoom_in_to_see_more, Snackbar.LENGTH_INDEFINITE)
zoomInSnackbar!!.show()
}
}
Status.LOADING -> {
zoomInSnackbar?.dismiss()
}
}
})
vm.useMiniMarkers.observe(viewLifecycleOwner) {
vm.chargepoints.value?.data?.let { updateMap(it) }
vm.chargepoints.value?.data?.let { updateMap(it.items) }
}
vm.favorites.observe(viewLifecycleOwner) {
updateFavoriteToggle()
@@ -651,6 +771,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
displaySearchResult(place, moveCamera = true)
}
vm.layersMenuOpen.observe(viewLifecycleOwner) { open ->
HideOnScrollFabBehavior.from(binding.fabLayers)?.hidden = open
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
binding.layersSheet.visibility = if (open) View.VISIBLE else View.INVISIBLE
updateBackPressedCallback()
@@ -661,6 +782,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.mapTrafficEnabled.observe(viewLifecycleOwner) {
map?.setTrafficEnabled(it)
}
vm.selectedChargepoint.observe(viewLifecycleOwner) {
binding.detailView.connectorDetailsCard.visibility =
if (it != null) View.VISIBLE else View.INVISIBLE
if (it != null) {
detailsDialog.setData(it, vm.availability.value?.data)
}
}
updateBackPressedCallback()
}
@@ -700,9 +828,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
private fun updateBackPressedCallback() {
backPressedCallback.isEnabled =
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|| vm.searchResult.value != null
backPressedCallback.isEnabled = vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
|| binding.search.hasFocus()
}
@@ -775,6 +901,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (photo == photos[position] && imageCacheKey != null) {
placeholderMemoryCacheKey(imageCacheKey)
}
allowHardware(false)
}
}
.withTransitionFrom(view as ImageView)
@@ -805,7 +932,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.detailView.connectors.apply {
adapter = ConnectorAdapter()
adapter = ConnectorAdapter().apply {
onClickListener = {
vm.selectedChargepoint.value = it.chargepoint
openConnectorDetailsDialog()
}
}
itemAnimator = null
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
@@ -818,20 +950,59 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (charger != null) {
when (it.icon) {
R.drawable.ic_location, R.drawable.ic_address -> {
(activity as? MapsActivity)?.showLocation(charger)
(activity as? MapsActivity)?.showLocation(charger, binding.root)
}
R.drawable.ic_fault_report -> {
(activity as? MapsActivity)?.openUrl(charger.url)
(activity as? MapsActivity)?.openUrl(
charger.url,
binding.root,
true
)
}
R.drawable.ic_payment -> {
showPaymentMethodsDialog(charger)
}
R.drawable.ic_network -> {
charger.networkUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
charger.networkUrl?.let {
(activity as? MapsActivity)?.openUrl(
it,
binding.root
)
}
}
}
}
}
onLongClickListener = {
val charger = vm.chargerDetails.value?.data
if (charger != null) {
when (it.icon) {
R.drawable.ic_address -> {
if (charger.address != null) {
copyToClipboard(ClipData.newPlainText(
getString(R.string.address),
charger.address.toString()
))
true
} else {
false
}
}
R.drawable.ic_location -> {
copyToClipboard(ClipData.newPlainText(
getString(R.string.coordinates),
charger.coordinates.formatDecimal()
))
true
}
else -> false
}
} else {
false
}
}
}
itemAnimator = null
layoutManager =
@@ -845,6 +1016,59 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
private fun copyToClipboard(clip: ClipData) {
val clipboardManager =
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Snackbar.make(
requireView(),
R.string.copied,
Toast.LENGTH_SHORT
)
.show()
}
}
private fun openConnectorDetailsDialog() {
val chargepoints = vm.chargerDetails.value?.data?.chargepointsMerged ?: return
val chargepoint = vm.selectedChargepoint.value ?: return
val index = chargepoints.indexOf(chargepoint).takeIf { it >= 0 } ?: return
val vh = binding.detailView.connectors.findViewHolderForAdapterPosition(index) ?: return
val materialTransform = MaterialContainerTransform().apply {
startView = vh.itemView
endView = binding.detailView.connectorDetailsCard
setPathMotion(MaterialArcMotion())
duration = 250
scrimColor = Color.TRANSPARENT
addTarget(binding.detailView.connectorDetailsCard)
isElevationShadowEnabled = false
fadeMode = FADE_MODE_CROSS
}
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
}
private fun closeConnectorDetailsDialog() {
val chargepoints = vm.chargerDetails.value?.data?.chargepointsMerged ?: return
val chargepoint = vm.selectedChargepoint.value ?: return
val index = chargepoints.indexOf(chargepoint).takeIf { it >= 0 } ?: return
val vh = binding.detailView.connectors.findViewHolderForAdapterPosition(index) ?: return
val materialTransform = MaterialContainerTransform().apply {
startView = binding.detailView.connectorDetailsCard
endView = vh.itemView
setPathMotion(MaterialArcMotion())
duration = 200
scrimColor = Color.TRANSPARENT
addTarget(vh.itemView)
isElevationShadowEnabled = false
fadeMode = FADE_MODE_CROSS
}
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
}
private fun showPaymentMethodsDialog(charger: ChargeLocation) {
val activity = activity ?: return
val chargecardData = vm.chargeCardMap.value ?: return
@@ -865,17 +1089,21 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.setTitle(R.string.charge_cards)
.setItems(names.toTypedArray()) { _, i ->
val card = data[i]
(activity as? MapsActivity)?.openUrl("https:${card.url}")
(activity as? MapsActivity)?.openUrl("https:${card.url}", binding.root)
}.show()
}
override fun onMapReady(map: AnyMap) {
this.map = map
vm.mapProjection = map.projection
val context = this.context ?: return
view ?: return
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
vm.mapTrafficSupported.value =
mapFragment?.let { AnyMap.Feature.TRAFFIC_LAYER in it.supportedFeatures } ?: false
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFactory.GOOGLE) {
// Google Maps: icons can be generated in background thread
lifecycleScope.launch {
withContext(Dispatchers.Default) {
@@ -883,7 +1111,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
} else {
// Mapbox: needs to be run on main thread
// MapLibre: needs to be run on main thread
chargerIconGenerator.preloadCache()
}
@@ -896,14 +1124,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.uiSettings.setIndoorLevelPickerEnabled(false)
map.setOnCameraIdleListener {
vm.mapProjection = map.projection
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
vm.reloadChargepoints()
}
map.setOnCameraMoveListener {
vm.mapProjection = map.projection
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
@@ -926,7 +1152,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
vm.mapPosition.observe(viewLifecycleOwner) {
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
val target = map.cameraPosition.target ?: return@observe
binding.scaleView.update(map.cameraPosition.zoom, target.latitude)
}
map.setOnCameraMoveStartedListener { reason ->
@@ -943,6 +1170,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
map.setOnMarkerClickListener { marker ->
val map = this@MapFragment.map ?: return@setOnMarkerClickListener false
when (marker) {
in markers -> {
vm.chargerSparse.value = markers[marker]
@@ -958,6 +1186,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
)
true
}
searchResultMarker -> true
else -> false
}
@@ -965,6 +1194,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.setOnMapClickListener {
if (backPressedCallback.isEnabled) {
backPressedCallback.handleOnBackPressed()
} else if (bottomSheetBackPressedCallback.isEnabled) {
bottomSheetBackPressedCallback.handleOnBackPressed()
}
}
map.setMapType(vm.mapType.value)
@@ -1019,10 +1250,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// show charger detail after chargers were loaded
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(value: Resource<List<ChargepointListItem>>) {
object : Observer<Resource<ChargepointList>> {
override fun onChanged(value: Resource<ChargepointList>) {
if (value.data == null) return
for (item in value.data) {
for (item in value.data.items) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
@@ -1042,9 +1273,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.search.requestFocus()
binding.search.setSelection(locationName.length)
}
if (context.checkAnyLocationPermission() && prefs.currentMapMyLocationEnabled) {
enableLocation(!positionSet, false)
positionSet = true
if (context.checkAnyLocationPermission()) {
if (prefs.currentMapMyLocationEnabled && !positionSet) {
enableLocation(true, false)
positionSet = true
} else {
enableLocation(false, false)
}
}
if (!positionSet) {
// use position saved in preferences, fall back to default (Europe)
@@ -1230,20 +1465,22 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
lifecycleScope.launch {
vm.copyFiltersToCustom()
requireView().findNavController().safeNavigate(
findNavController().safeNavigate(
MapFragmentDirections.actionMapToFilterFragment()
)
}
true
}
R.id.menu_manage_filter_profiles -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
requireView().findNavController().safeNavigate(
findNavController().safeNavigate(
MapFragmentDirections.actionMapToFilterProfilesFragment()
)
true
}
else -> {
val profileId = profilesMap.inverse[it]
if (profileId != null) {
@@ -1318,17 +1555,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
popup.setTouchModal(false)
popupMenu = popup
popup.show()
}
filterView?.setOnLongClickListener {
// enable/disable filters
vm.toggleFilters()
// haptic feedback
filterView.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS,
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
)
// show snackbar
Snackbar.make(
requireView(), if (vm.filterStatus.value != FILTERS_DISABLED) {
@@ -1351,10 +1584,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
else -> false
}
override fun getRootView(): View {
return binding.root
}
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
private fun requestLocationUpdates() {
locationEngine.requestLocationUpdates(
@@ -1404,7 +1633,19 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
override fun onDestroy() {
super.onDestroy()
override fun onDestroyView() {
super.onDestroyView()
detailsDialog.onDestroy()
map = null
mapFragment = null
_binding = null
markers.clear()
clusterMarkers = emptyList()
searchResultMarker = null
searchResultIcon = null
/* if we don't dismiss the popup menu, it will be recreated in some cases
(split-screen mode) and then have references to a destroyed fragment. */
popupMenu?.dismiss()
}
}

View File

@@ -6,23 +6,28 @@ import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.AnimatedVectorDrawable
import android.os.Bundle
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.*
import net.vonforst.evmap.databinding.FragmentOnboardingAndroidAutoBinding
import net.vonforst.evmap.databinding.FragmentOnboardingBinding
import net.vonforst.evmap.databinding.FragmentOnboardingDataSourceBinding
import net.vonforst.evmap.databinding.FragmentOnboardingIconsBinding
import net.vonforst.evmap.databinding.FragmentOnboardingWelcomeBinding
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.waitForLayout
class OnboardingFragment : Fragment() {
private lateinit var binding: FragmentOnboardingBinding
@@ -58,7 +63,6 @@ class OnboardingFragment : Fragment() {
}
override fun onPageSelected(position: Int) {
binding.pageIndicatorView.selection = position
binding.forward?.visibility =
if (position == adapter.itemCount - 1) View.INVISIBLE else View.VISIBLE
binding.backward?.visibility = if (position == 0) View.INVISIBLE else View.VISIBLE
@@ -75,9 +79,13 @@ class OnboardingFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (prefs.welcomeDialogShown) {
// skip to last page for selecting data source or accepting the privacy policy
binding.viewPager.currentItem = adapter.itemCount - 1
binding.root.waitForLayout {
binding.viewPager.currentItem = if (prefs.welcomeDialogShown) {
// skip to last page for selecting data source or accepting the privacy policy
adapter.itemCount - 1
} else {
0
}
}
}
@@ -226,9 +234,14 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.cbAcceptPrivacy.text =
Html.fromHtml(getString(R.string.accept_privacy, getString(R.string.privacy_link)))
HtmlCompat.fromHtml(
getString(
R.string.accept_privacy,
getString(R.string.privacy_link)
), HtmlCompat.FROM_HTML_MODE_LEGACY
)
binding.cbAcceptPrivacy.linksClickable = true
binding.cbAcceptPrivacy.movementMethod = LinkMovementMethod.getInstance()
binding.cbAcceptPrivacy.movementMethod = LinkMovementMethodCompat.getInstance()
binding.btnGetStarted.visibility = View.INVISIBLE
for (rb in listOf(

View File

@@ -78,22 +78,25 @@ class AboutFragment : PreferenceFragmentCompat() {
}
"website" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.website_url))
(activity as? MapsActivity)?.openUrl(getString(R.string.website_url), requireView())
true
}
"github_link" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link))
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link), requireView())
true
}
"privacy" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.privacy_link))
(activity as? MapsActivity)?.openUrl(
getString(R.string.privacy_link),
requireView()
)
true
}
"faq" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link))
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link), requireView())
true
}
"oss_licenses" -> {
@@ -102,7 +105,6 @@ class AboutFragment : PreferenceFragmentCompat() {
.withAboutVersionShown(false)
.withAboutIconShown(false)
.withActivityTitle(getString(R.string.oss_licenses))
.withExcludedLibraries()
.start(requireActivity())
true
}
@@ -116,12 +118,29 @@ class AboutFragment : PreferenceFragmentCompat() {
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToGithubSponsors())
true
}
"mastodon" -> {
(activity as? MapsActivity)?.openUrl(
getString(R.string.mastodon_url),
requireView()
)
true
}
"twitter" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.twitter_url))
(activity as? MapsActivity)?.openUrl(getString(R.string.twitter_url), requireView())
true
}
"goingelectric" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.goingelectric_forum_url))
(activity as? MapsActivity)?.openUrl(
getString(R.string.goingelectric_forum_url),
requireView()
)
true
}
"tffforum" -> {
(activity as? MapsActivity)?.openUrl(
getString(R.string.tff_forum_url),
requireView()
)
true
}
else -> super.onPreferenceTreeClick(preference)

View File

@@ -7,7 +7,10 @@ import android.text.Spanned
import android.text.style.RelativeSizeSpan
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import net.vonforst.evmap.R
import net.vonforst.evmap.currencyDisplayName
import net.vonforst.evmap.ui.MultiSelectDialogPreference
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
@@ -28,9 +31,11 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
private lateinit var myVehiclePreference: MultiSelectDialogPreference
private lateinit var myTariffsPreference: MultiSelectDialogPreference
private lateinit var nativeIntegrationPreference: CheckBoxPreference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
nativeIntegrationPreference = findPreference("chargeprice_native_integration")!!
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
myVehiclePreference.isEnabled = false
@@ -48,7 +53,7 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
)
}
}.toTypedArray()
myVehiclePreference.isEnabled = true
myVehiclePreference.isEnabled = nativeIntegrationPreference.isChecked
updateMyVehiclesSummary()
}
}
@@ -65,10 +70,33 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
it.name
}
}.toTypedArray()
myTariffsPreference.isEnabled = true
myTariffsPreference.isEnabled = nativeIntegrationPreference.isChecked
updateMyTariffsSummary()
}
}
updateNativeIntegrationState()
val currencyPreference = findPreference<ListPreference>("chargeprice_currency")!!
currencyPreference.entries = currencyPreference.entryValues.map {
currencyDisplayName(it.toString()).replaceFirstChar { it.uppercase() }
}.toTypedArray()
}
private fun updateNativeIntegrationState() {
for (i in 0 until preferenceScreen.preferenceCount) {
val pref = preferenceScreen.getPreference(i)
if (pref == nativeIntegrationPreference) {
continue
} else if (pref == myTariffsPreference) {
pref.isEnabled =
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
} else if (pref == myVehiclePreference) {
pref.isEnabled =
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
} else {
pref.isEnabled = nativeIntegrationPreference.isChecked
}
}
}
private fun updateMyTariffsSummary() {
@@ -110,6 +138,10 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
"chargeprice_my_tariffs" -> {
updateMyTariffsSummary()
}
"chargeprice_native_integration" -> {
updateNativeIntegrationState()
}
}
}
}

View File

@@ -15,8 +15,8 @@ import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.TeslaOwnerApi
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.TeslaOwnerApi
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory

View File

@@ -6,33 +6,66 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import com.github.erfansn.localeconfigx.configuredLocales
import net.vonforst.evmap.R
import net.vonforst.evmap.isAppInstalled
import net.vonforst.evmap.ui.getAppLocale
import net.vonforst.evmap.ui.map
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
class UiSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
lateinit var langPref: ListPreference
lateinit var immediateNavPref: CheckBoxPreference
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_ui, rootKey)
setupLangPref()
val appLinkPref = findPreference<Preference>("applink_associate")!!
appLinkPref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
immediateNavPref = findPreference("navigate_use_maps")!!
immediateNavPref.isVisible = isGoogleMapsInstalled()
}
private fun setupLangPref() {
langPref = findPreference("language")!!
val configuredLocales = requireContext().configuredLocales
val numLocalesByLang = configuredLocales.map { it.language }.groupingBy { it }.eachCount()
val localeNames = configuredLocales.map {
val name = if (numLocalesByLang[it.language]!! > 1) {
it.getDisplayName(it)
} else {
it.getDisplayLanguage(it)
}
name.replaceFirstChar { c -> c.uppercase(it) }
}
val localeTags = configuredLocales.map { it.toLanguageTag() }
langPref.entries =
(listOf(getString(R.string.pref_language_device_default)) + localeNames).toTypedArray()
langPref.entryValues =
(listOf("default") + localeTags).toTypedArray()
langPref.setOnPreferenceChangeListener { _, newValue ->
updateAppLocale(newValue as String)
true
}
val appLinkPref = findPreference<Preference>("applink_associate")!!
appLinkPref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
private fun isGoogleMapsInstalled() =
requireContext().packageManager.isAppInstalled("com.google.android.apps.maps")
override fun onResume() {
super.onResume()
langPref.value = getAppLocale()
langPref.value = getAppLocale(requireContext())
immediateNavPref.isVisible = isGoogleMapsInstalled()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {

View File

@@ -10,6 +10,7 @@ import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.MaterialDialogFragment
import java.time.Instant
class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
private lateinit var binding: DialogOpensourceDonationsBinding
@@ -26,15 +27,15 @@ class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
override fun initView(view: View, savedInstanceState: Bundle?) {
val prefs = PreferenceDataSource(requireContext())
binding.btnOk.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
prefs.opensourceDonationsDialogLastShown = Instant.now()
dismiss()
}
binding.btnDonate.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
prefs.opensourceDonationsDialogLastShown = Instant.now()
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToDonate())
}
binding.btnGithubSponsors.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
prefs.opensourceDonationsDialogLastShown = Instant.now()
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToGithubSponsors())
}
}

View File

@@ -44,7 +44,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
try {
return locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for fused provider", e)
Log.w(TAG, "Permissions not granted for fused provider", e)
}
}
@@ -68,7 +68,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for provider: $provider", e)
Log.w(TAG, "Permissions not granted for provider: $provider", e)
}
}
return bestLocation
@@ -103,7 +103,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
enableFused(gpsInterval)
checkLastKnownFused()
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for fused provider", e)
Log.w(TAG, "Permissions not granted for fused provider", e)
}
}
@@ -159,7 +159,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for GPS updates.", e)
Log.w(TAG, "Unable to register for GPS updates.", e)
}
}
@@ -174,7 +174,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for network updates.", e)
Log.w(TAG, "Unable to register for network updates.", e)
}
}
@@ -189,7 +189,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for passive updates.", e)
Log.w(TAG, "Unable to register for passive updates.", e)
}
}
@@ -205,7 +205,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for passive updates.", e)
Log.w(TAG, "Unable to register for passive updates.", e)
}
}

View File

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

View File

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

View File

@@ -19,6 +19,8 @@ import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.model.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.utils.crossesAntimeridian
import net.vonforst.evmap.utils.splitAtAntimeridian
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.await
@@ -144,7 +146,15 @@ class ChargeLocationsRepository(
zoom: Float,
filters: FilterValues?,
overrideCache: Boolean = false
): LiveData<Resource<List<ChargepointListItem>>> {
): LiveData<Resource<ChargepointList>> {
if (bounds.crossesAntimeridian()) {
val (a, b) = bounds.splitAtAntimeridian()
val liveDataA = getChargepoints(a, zoom, filters, overrideCache)
val liveDataB = getChargepoints(b, zoom, filters, overrideCache)
return combineLiveData(liveDataA, liveDataB)
}
val api = api.value!!
val dbResult = if (filters == null) {
@@ -158,7 +168,7 @@ class ChargeLocationsRepository(
)
} else {
queryWithFilters(api, filters, bounds)
}.map { applyLocalClustering(it, zoom) }
}.map { ChargepointList(applyLocalClustering(it, zoom), true) }
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
@@ -208,12 +218,41 @@ class ChargeLocationsRepository(
}
}
private fun combineLiveData(
liveDataA: LiveData<Resource<ChargepointList>>,
liveDataB: LiveData<Resource<ChargepointList>>
) = MediatorLiveData<Resource<ChargepointList>>().apply {
listOf(liveDataA, liveDataB).forEach {
addSource(it) {
val valA = liveDataA.value
val valB = liveDataB.value
val combinedList = if (valA?.data != null && valB?.data != null) {
ChargepointList(
valA.data.items + valB.data.items,
valA.data.isComplete && valB.data.isComplete
)
} else if (valA?.data != null) {
ChargepointList(valA.data.items, false)
} else if (valB?.data != null) {
ChargepointList(valB.data.items, false)
} else null
if (valA?.status == Status.SUCCESS && valB?.status == Status.SUCCESS) {
Resource.success(combinedList)
} else if (valA?.status == Status.ERROR || valB?.status == Status.ERROR) {
Resource.error(valA?.message ?: valB?.message, combinedList)
} else {
Resource.loading(combinedList)
}
}
}
}
fun getChargepointsRadius(
location: LatLng,
radius: Int,
zoom: Float,
filters: FilterValues?
): LiveData<Resource<List<ChargepointListItem>>> {
): LiveData<Resource<ChargepointList>> {
val api = api.value!!
val radiusMeters = radius.toDouble() * 1000
@@ -227,7 +266,7 @@ class ChargeLocationsRepository(
)
} else {
queryWithFilters(api, filters, location, radiusMeters)
}.map { applyLocalClustering(it, zoom) }
}.map { ChargepointList(applyLocalClustering(it, zoom), true) }
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
@@ -277,18 +316,18 @@ class ChargeLocationsRepository(
private fun applyLocalClustering(
result: Resource<ChargepointList>,
zoom: Float
): Resource<List<ChargepointListItem>> {
): Resource<ChargepointList> {
val list = result.data ?: return Resource(result.status, null, result.message)
val chargers = list.items.filterIsInstance<ChargeLocation>()
if (chargers.size != list.items.size) return Resource(
result.status,
list.items,
list,
result.message
) // list already contains clusters
val clustered = applyLocalClustering(chargers, zoom)
return Resource(result.status, clustered, result.message)
return Resource(result.status, ChargepointList(clustered, list.isComplete), result.message)
}
private fun applyLocalClustering(

View File

@@ -3,13 +3,13 @@ package net.vonforst.evmap.storage
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import net.vonforst.evmap.api.availability.TeslaAvailabilityDetector
import net.vonforst.evmap.api.availability.TeslaOwnerAvailabilityDetector
/**
* Encrypted data storage for sensitive data such as API access tokens.
* This will not be included in backups.
*/
class EncryptedPreferenceDataStore(context: Context) : TeslaAvailabilityDetector.TokenStore {
class EncryptedPreferenceDataStore(context: Context) : TeslaOwnerAvailabilityDetector.TokenStore {
val sp = EncryptedSharedPreferences.create(
context,
"encrypted_prefs",

View File

@@ -147,6 +147,12 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putBoolean("update_0.6.0_androidauto_dialog_shown", value).apply()
}
var chargepriceNativeIntegration: Boolean
get() = sp.getBoolean("chargeprice_native_integration", true)
set(value) {
sp.edit().putBoolean("chargeprice_native_integration", value).apply()
}
var chargepriceMyVehicles: Set<String>
get() = try {
sp.getStringSet("chargeprice_my_vehicle", emptySet())!!
@@ -237,10 +243,11 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putLong("chargeprice_counter", value).apply()
}
var opensourceDonationsDialogShown: Boolean
get() = sp.getBoolean("opensource_donations_dialog_shown", false)
var opensourceDonationsDialogLastShown: Instant
get() = Instant.ofEpochMilli(sp.getLong("opensource_donations_dialog_last_shown", 0L))
set(value) {
sp.edit().putBoolean("opensource_donations_dialog_shown", value).apply()
sp.edit().putLong("opensource_donations_dialog_last_shown", value.toEpochMilli())
.apply()
}
var placeSearchResultAndroidAuto: LatLng?

View File

@@ -117,6 +117,16 @@ class Converters {
return stringSetAdapter.fromJson(value)
}
@TypeConverter
fun fromStringMutableSet(value: MutableSet<String>?): String {
return stringSetAdapter.toJson(value)
}
@TypeConverter
fun toStringMutableSet(value: String): MutableSet<String>? {
return stringSetAdapter.fromJson(value)?.toMutableSet()
}
@TypeConverter
fun fromStringList(value: List<String>?): String {
return stringListAdapter.toJson(value)

View File

@@ -9,6 +9,7 @@ import android.text.TextPaint
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
@@ -27,7 +28,6 @@ import kotlin.math.roundToInt
class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) {
private val dp = context.resources.displayMetrics.density
private val sp = context.resources.displayMetrics.scaledDensity
var zeroHeight = 4 * dp
var barWidth = 16 * dp
var barMargin = 2 * dp
@@ -35,7 +35,9 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
var legendLineLength = 4 * dp
var legendLineWidth = 1 * dp
var dashLength = 4 * dp
var bubbleTextSize = (12 * sp).roundToInt()
var bubbleTextSize =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, context.resources.displayMetrics)
.roundToInt()
var bubblePadding = (6 * dp).roundToInt()
var selectedBar: Int = 0
var bubbleStrokeWidth = 1 * dp

View File

@@ -6,7 +6,9 @@ import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.text.SpannableString
import android.text.format.DateUtils
import android.text.method.LinkMovementMethod
import android.text.util.Linkify
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
@@ -25,9 +27,14 @@ import androidx.viewpager2.widget.ViewPager2
import coil.load
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.slider.RangeSlider
import net.vonforst.evmap.*
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.isDarkMode
import net.vonforst.evmap.kmPerMile
import net.vonforst.evmap.meterPerFt
import net.vonforst.evmap.shouldUseImperialUnits
import java.time.Instant
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -67,7 +74,7 @@ fun invisibleUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) {
if (oldValue == newValue) {
if (!newValue && view.visibility == View.VISIBLE && view.alpha == 1f) {
// view is initially invisible
view.visibility = View.GONE
view.visibility = View.INVISIBLE
} else {
return
}
@@ -137,8 +144,8 @@ fun <T> setRecyclerViewData(recyclerView: ViewPager2, items: List<T>?) {
}
@BindingAdapter("connectorIcon")
fun getConnectorItem(view: ImageView, type: String) {
view.setImageResource(iconForPlugType(type))
fun getConnectorItem(view: ImageView, type: String?) {
view.setImageResource(type?.let { iconForPlugType(it) } ?: 0)
}
@BindingAdapter("srcCompat")
@@ -156,11 +163,21 @@ fun setImageTintAvailability(view: ImageView, available: List<ChargepointStatus>
view.imageTintList = ColorStateList.valueOf(availabilityColor(available, view.context))
}
@BindingAdapter("tintAvailability")
fun setImageTintAvailability(view: ImageView, available: ChargepointStatus?) {
view.imageTintList = ColorStateList.valueOf(availabilityColor(available, view.context))
}
@BindingAdapter("textColorAvailability")
fun setTextColorAvailability(view: TextView, available: List<ChargepointStatus>?) {
view.setTextColor(availabilityColor(available, view.context))
}
@BindingAdapter("textColorAvailability")
fun setTextColorAvailability(view: TextView, available: ChargepointStatus?) {
view.setTextColor(availabilityColor(available, view.context))
}
@BindingAdapter("backgroundTintAvailability")
fun setBackgroundTintAvailability(view: View, available: List<ChargepointStatus>?) {
view.backgroundTintList = ColorStateList.valueOf(availabilityColor(available, view.context))
@@ -199,21 +216,25 @@ fun setTopMargin(view: View, topMargin: Float) {
/**
* Linkify is already possible using the autoLink and linksClickable attributes, but this does not
* remove spans correctly. So we implement a new version that manually removes the spans.
* remove spans correctly after autoLink is set to false.
* So we implement a new version that manually uses Linkify to create links if necessary.
*/
@BindingAdapter("linkify")
fun setLinkify(textView: TextView, oldValue: Int, newValue: Int) {
if (oldValue == newValue) return
@BindingAdapter(value = ["linkify", "android:text"])
fun setLinkify(
textView: TextView,
oldLinkify: Int,
oldText: CharSequence?,
newLinkify: Int,
newText: CharSequence?
) {
if (oldLinkify == newLinkify && oldText == newText) return
textView.autoLinkMask = newValue
textView.linksClickable = newValue != 0
// remove spans
val text = textView.text
if (newValue == 0 && text != null && text is SpannableString) {
text.getSpans(0, text.length, Any::class.java).forEach {
text.removeSpan(it)
}
textView.text = newText
if (newLinkify != 0) {
Linkify.addLinks(textView, newLinkify)
textView.movementMethod = LinkMovementMethod.getInstance()
} else {
textView.movementMethod = null
}
}
@@ -269,6 +290,25 @@ private fun availabilityColor(
ta.getColor(0, 0)
}
private fun availabilityColor(
status: ChargepointStatus?,
context: Context
): Int = when (status) {
ChargepointStatus.UNKNOWN -> ContextCompat.getColor(context, R.color.unknown)
ChargepointStatus.AVAILABLE -> ContextCompat.getColor(context, R.color.available)
ChargepointStatus.FAULTED -> ContextCompat.getColor(context, R.color.unavailable)
ChargepointStatus.OCCUPIED, ChargepointStatus.CHARGING -> ContextCompat.getColor(
context,
R.color.charging
)
null -> {
val ta =
context.theme.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.colorControlNormal))
ta.getColor(0, 0)
}
}
fun availabilityText(status: List<ChargepointStatus>?): String? {
if (status == null) return null
@@ -281,6 +321,29 @@ fun availabilityText(status: List<ChargepointStatus>?): String? {
} else available.toString()
}
fun availabilityText(status: ChargepointStatus?, lastChange: Instant?, context: Context): String? {
if (status == null) return null
val statusText = when (status) {
ChargepointStatus.UNKNOWN -> context.getString(R.string.status_unknown)
ChargepointStatus.AVAILABLE -> context.getString(R.string.status_available)
ChargepointStatus.CHARGING -> context.getString(R.string.status_charging)
ChargepointStatus.OCCUPIED -> context.getString(R.string.status_occupied)
ChargepointStatus.FAULTED -> context.getString(R.string.status_faulted)
}
return if (lastChange != null) {
val relativeTime = DateUtils.getRelativeTimeSpanString(
lastChange.toEpochMilli(),
Instant.now().toEpochMilli(),
0
).toString()
return context.getString(R.string.status_since, statusText, relativeTime)
} else {
statusText
}
}
fun flatten(it: Iterable<Iterable<ChargepointStatus>>?): List<ChargepointStatus>? {
return it?.flatten()
}

View File

@@ -1,9 +1,12 @@
package net.vonforst.evmap.ui
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import net.vonforst.evmap.BuildConfig
import com.github.erfansn.localeconfigx.configuredLocales
import net.vonforst.evmap.storage.PreferenceDataSource
import java.util.Locale
fun updateNightMode(prefs: PreferenceDataSource) {
AppCompatDelegate.setDefaultNightMode(
@@ -25,13 +28,17 @@ fun updateAppLocale(language: String) {
)
}
fun getAppLocale(): String? {
fun getAppLocale(context: Context): String? {
val locales = AppCompatDelegate.getApplicationLocales()
return if (locales.isEmpty) {
"default"
} else {
val arr = Array(locales.size()) { locales.get(it)!!.toLanguageTag() }
LocaleListCompat.forLanguageTags(BuildConfig.supportedLocales).getFirstMatch(arr)
?.toLanguageTag()
val choices = context.configuredLocales
choices.getFirstMatch(arr)?.toLanguageTag()
}
}
inline fun <R> LocaleListCompat.map(transform: (Locale) -> R): List<R> = List(size()) {
transform(get(it)!!)
}

View File

@@ -10,50 +10,60 @@ import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputLayout
import net.vonforst.evmap.R
import kotlin.math.roundToInt
private fun dialogEditText(ctx: Context): Pair<View, EditText> {
val container = FrameLayout(ctx)
container.setPadding(
(16 * ctx.resources.displayMetrics.density).toInt(), 0,
(16 * ctx.resources.displayMetrics.density).toInt(), 0
)
val input = EditText(ctx)
input.isSingleLine = true
container.addView(input)
return container to input
private fun dialogEditText(ctx: Context): Pair<TextInputLayout, EditText> {
val view = LayoutInflater.from(ctx).inflate(R.layout.dialog_textinput, null)
return view as TextInputLayout to view.findViewById(R.id.input)
}
fun showEditTextDialog(
ctx: Context,
customize: (MaterialAlertDialogBuilder, EditText) -> Unit
customize: (MaterialAlertDialogBuilder, EditText) -> Unit,
okAction: (String) -> Unit
): AlertDialog {
val (container, input) = dialogEditText(ctx)
val dialogBuilder = MaterialAlertDialogBuilder(ctx)
.setView(container)
.setPositiveButton(R.string.ok) { _, _ -> }
.setNegativeButton(R.string.cancel) { _, _ -> }
customize(dialogBuilder, input)
val dialog = dialogBuilder.show()
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
val okButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
// focus and show keyboard
input.requestFocus()
input.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
val text = input.text
val button = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
if (text != null && button != null) {
button.performClick()
if (text != null && okButton != null) {
okButton.performClick()
return@setOnEditorActionListener true
}
}
false
}
okButton?.setOnClickListener {
if (input.text.isBlank()) {
container.isErrorEnabled = true
container.error = ctx.getString(R.string.required)
} else {
container.isErrorEnabled = false
okAction(input.text.toString())
dialog.dismiss()
}
}
return dialog
}

View File

@@ -1,112 +0,0 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import androidx.core.widget.NestedScrollView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
class HideOnExpandFabBehavior(context: Context, attrs: AttributeSet) :
FloatingActionButton.Behavior(context, attrs) {
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: FloatingActionButton,
directTargetChild: View,
target: View,
axes: Int,
type: Int
): Boolean {
return axes == ViewCompat.SCROLL_AXIS_VERTICAL || super.onStartNestedScroll(
coordinatorLayout,
child,
directTargetChild,
target,
axes,
type
)
}
override fun layoutDependsOn(
parent: CoordinatorLayout,
child: FloatingActionButton,
dependency: View
): Boolean {
if (dependency is NestedScrollView) {
try {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
behavior.addBottomSheetCallback(object :
BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
onDependentViewChanged(parent, child, dependency)
}
})
return true
} catch (e: IllegalArgumentException) {
}
}
return false
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: FloatingActionButton,
dependency: View
): Boolean {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
when (behavior.state) {
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING -> {
}
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
if (child.tag as? Boolean != false) child.show()
}
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED -> {
if (child.tag as? Boolean != false) child.show()
}
else -> {
child.hide()
}
}
return false
}
override fun onNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: FloatingActionButton,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
super.onNestedScroll(
coordinatorLayout,
child,
target,
dxConsumed,
dyConsumed,
dxUnconsumed,
dyUnconsumed,
type,
consumed
)
if (dyConsumed > 0 && child.visibility == View.VISIBLE) {
// User scrolled down and the FAB is currently visible -> hide the FAB
child.hide()
} else if (dyConsumed < 0 && child.visibility != View.VISIBLE) {
// User scrolled up and the FAB is currently not visible -> show the FAB
child.show()
}
}
}

View File

@@ -6,12 +6,19 @@ import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import androidx.core.widget.NestedScrollView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
FloatingActionButton.Behavior(context, attrs) {
var hidden: Boolean = false
companion object {
fun from(view: View): HideOnScrollFabBehavior? {
return ((view.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? HideOnScrollFabBehavior)
}
}
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
@@ -38,9 +45,9 @@ class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
): Boolean {
if (dependency is NestedScrollView) {
try {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
val behavior = BottomSheetBehavior.from<View>(dependency)
behavior.addBottomSheetCallback(object :
BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback() {
BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
}
@@ -61,13 +68,14 @@ class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
child: FloatingActionButton,
dependency: View
): Boolean {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
val behavior = BottomSheetBehavior.from(dependency)
when (behavior.state) {
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING -> {
BottomSheetBehavior.STATE_SETTLING -> {
}
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
child.show()
BottomSheetBehavior.STATE_HIDDEN -> {
if (!hidden) child.show()
}
else -> {
child.hide()
@@ -103,7 +111,7 @@ class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) :
child.hide()
} else if (dyConsumed < 0 && child.visibility != View.VISIBLE) {
// User scrolled up and the FAB is currently not visible -> show the FAB
child.show()
if (!hidden) child.show()
}
}
}

View File

@@ -5,12 +5,20 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.text.BidiFormatter
import androidx.core.content.ContextCompat
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import net.vonforst.evmap.model.Coordinate
import java.util.*
import kotlin.math.*
import java.util.Locale
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Adds a certain distance in meters to a location. Approximate calculation.
@@ -147,9 +155,32 @@ private fun dms(value: Double, lon: Boolean): String {
}
fun Coordinate.formatDecimal(accuracy: Int = 6): String {
return "%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, lat, lng)
return BidiFormatter.getInstance()
.unicodeWrap("%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, lat, lng))
}
fun Location.formatDecimal(accuracy: Int = 6): String {
return "%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, latitude, longitude)
return BidiFormatter.getInstance()
.unicodeWrap("%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, latitude, longitude))
}
fun LatLngBounds.normalize() = LatLngBounds(
LatLng(southwest.latitude, normalizeLongitude(southwest.longitude)),
LatLng(northeast.latitude, normalizeLongitude(northeast.longitude)),
)
private fun normalizeLongitude(long: Double) =
if (-180.0 <= long && long <= 180.0) long else (long + 180) % 360 - 180
fun LatLngBounds.crossesAntimeridian() = southwest.longitude > 0 && northeast.longitude < 0
fun LatLngBounds.splitAtAntimeridian(): Pair<LatLngBounds, LatLngBounds> {
if (!crossesAntimeridian()) throw IllegalArgumentException("does not cross antimeridian")
return LatLngBounds(
LatLng(southwest.latitude, southwest.longitude),
LatLng(northeast.latitude, 180.0),
) to LatLngBounds(
LatLng(southwest.latitude, -180.0),
LatLng(northeast.latitude, northeast.longitude),
)
}

View File

@@ -1,14 +1,29 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.viewModelScope
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceInclude
import net.vonforst.evmap.api.chargeprice.ChargepriceMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceOptions
import net.vonforst.evmap.api.chargeprice.ChargepriceRequest
import net.vonforst.evmap.api.chargeprice.ChargepriceRequestTariffMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceStation
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
@@ -55,6 +70,8 @@ class ChargepriceViewModel(
})
} catch (e: IOException) {
Resource.error(e.message, null)
} catch (e: HttpException) {
Resource.error(e.message, null)
}
}
}
@@ -116,6 +133,7 @@ class ChargepriceViewModel(
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
value = state["chargePrices"] ?: Resource.loading(null)
listOf(
vehicle,
batteryRange,
batteryRangeSliderDragging,
vehicleCompatibleConnectors,
@@ -295,4 +313,8 @@ class ChargepriceViewModel(
}
}
}
fun resetBatteryRangeToDefault() {
batteryRange.value = prefs.chargepriceBatteryRangeAndroidAuto
}
}

View File

@@ -33,7 +33,7 @@ class FavoritesViewModel(application: Application) :
MediatorLiveData<Map<Long, Resource<ChargeLocationStatus>>>().apply {
addSource(favorites) { favorites ->
if (favorites != null) {
reloadAvailability()
reloadAvailability(forceReload = false)
} else {
value = null
}
@@ -41,9 +41,10 @@ class FavoritesViewModel(application: Application) :
}
}
fun reloadAvailability(callback: (() -> Unit)? = null) {
fun reloadAvailability(forceReload: Boolean = true, callback: (() -> Unit)? = null) {
val favorites = favorites.value ?: return
val chargers = favorites.map { it.charger }
val previous = availability.value
viewModelScope.launch {
val data = hashMapOf<Long, Resource<ChargeLocationStatus>>()
@@ -54,8 +55,13 @@ class FavoritesViewModel(application: Application) :
chargers.map { charger ->
async {
data[charger.id] = availabilityRepo.getAvailability(charger)
availability.value = data
if (!forceReload && previous?.get(charger.id)?.status == Status.SUCCESS) {
data[charger.id] = previous[charger.id]!!
availability.value = data
} else {
data[charger.id] = availabilityRepo.getAvailability(charger)
availability.value = data
}
}
}.awaitAll()
callback?.invoke()
@@ -117,6 +123,24 @@ class FavoritesViewModel(application: Application) :
}
}
val deletedFavorite: MutableLiveData<FavoriteWithDetail?> by lazy {
MutableLiveData<FavoriteWithDetail?>()
}
fun deleteFavoriteWithUndo(fav: FavoriteWithDetail) {
deletedFavorite.value = fav
deleteFavorite(fav.favorite)
}
fun undoDeletion() {
deletedFavorite.value?.let {
viewModelScope.launch {
insertFavorite(it.charger)
}
deletedFavorite.value = null
}
}
fun deleteFavorite(fav: Favorite) {
viewModelScope.launch {
db.favoritesDao().delete(fav)

View File

@@ -1,38 +1,49 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import android.graphics.Point
import android.os.Parcelable
import androidx.lifecycle.*
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.liveData
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import com.car2go.maps.AnyMap
import com.car2go.maps.Projection
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.squareup.moshi.JsonDataException
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
import net.vonforst.evmap.api.availability.tesla.Pricing
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.fronyx.FronyxApi
import net.vonforst.evmap.api.fronyx.FronyxEvseIdResponse
import net.vonforst.evmap.api.fronyx.FronyxStatus
import net.vonforst.evmap.api.fronyx.PredictionData
import net.vonforst.evmap.api.fronyx.PredictionRepository
import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.openchargemap.OCMConnection
import net.vonforst.evmap.api.openchargemap.OCMReferenceData
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.model.*
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.model.FavoriteWithDetail
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterValues
import net.vonforst.evmap.model.getMultipleChoiceValue
import net.vonforst.evmap.model.getSliderValue
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
@@ -40,13 +51,8 @@ import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.utils.distanceBetween
import retrofit2.HttpException
import java.io.IOException
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.math.roundToInt
import net.vonforst.evmap.utils.normalize
import kotlin.math.cos
@Parcelize
data class MapPosition(val bounds: LatLngBounds, val zoom: Float) : Parcelable
@@ -72,7 +78,6 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
prefs
)
private val availabilityRepo = AvailabilityRepository(application)
var mapProjection: Projection? = null
val apiId = repo.api.map { it.id }
@@ -90,12 +95,13 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
val bottomSheetExpanded = MediatorLiveData<Boolean>().apply {
addSource(bottomSheetState) {
when (it) {
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED,
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
STATE_COLLAPSED,
STATE_HIDDEN -> {
value = false
}
BottomSheetBehaviorGoogleMapsLike.STATE_EXPANDED,
BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT -> {
STATE_EXPANDED,
STATE_HALF_EXPANDED -> {
value = true
}
}
@@ -139,10 +145,10 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}
}
val chargepoints: MediatorLiveData<Resource<List<ChargepointListItem>>> by lazy {
MediatorLiveData<Resource<List<ChargepointListItem>>>()
val chargepoints: MediatorLiveData<Resource<ChargepointList>> by lazy {
MediatorLiveData<Resource<ChargepointList>>()
.apply {
value = Resource.loading(emptyList())
value = Resource.loading(ChargepointList(emptyList(), false))
// this is not automatically updated with mapPosition, as we only want to update
// when map is idle.
listOf(filtersWithValue, repo.api).forEach {
@@ -162,9 +168,9 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
MutableLiveData<Set<Long>>()
}
val chargerSparse: MutableLiveData<ChargeLocation?> by lazy {
state.getLiveData("chargerSparse")
}
val chargerSparse: MutableLiveData<ChargeLocation?> =
state.getLiveData<ChargeLocation?>("chargerSparse")
private val triggerChargerDetailsRefresh = MutableLiveData(false)
val chargerDetails: LiveData<Resource<ChargeLocation>> = chargerSparse.switchMap { charger ->
triggerChargerDetailsRefresh.value = false
@@ -180,6 +186,15 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}
val selectedChargepoint: MutableLiveData<Chargepoint?> =
state.getLiveData("selectedChargepoint")
init {
chargerSparse.observeForever {
selectedChargepoint.value = null
}
}
val charger: MediatorLiveData<Resource<ChargeLocation>> by lazy {
MediatorLiveData<Resource<ChargeLocation>>().apply {
addSource(chargerDetails) {
@@ -249,16 +264,17 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
val teslaPricing = availability.map {
it.data?.extraData as? TeslaGraphQlApi.Pricing
it.data?.extraData as? Pricing
}
private val predictionRepository = PredictionRepository(application)
//private val predictionRepository = PredictionRepository(application)
val predictionData: LiveData<PredictionData> = availability.switchMap { av ->
liveData {
/*liveData {
val charger = charger.value?.data ?: return@liveData
emit(predictionRepository.getPredictionData(charger, av.data, filteredConnectors.value))
}
}*/
MutableLiveData()
}
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
@@ -287,6 +303,12 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}
val mapTrafficSupported: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = false
}
}
val mapTrafficEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = prefs.mapTrafficEnabled
@@ -373,7 +395,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
}.distinctUntilChanged()
private var chargepointsInternal: LiveData<Resource<List<ChargepointListItem>>>? = null
private var chargepointsInternal: LiveData<Resource<ChargepointList>>? = null
private var chargepointLoader =
throttleLatest(
500L,
@@ -400,13 +422,13 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
filteredConnectors.value = null
filteredMinPower.value = null
filteredChargeCards.value = null
chargepoints.value = Resource.success(chargersClustered)
chargepoints.value = Resource.success(ChargepointList(chargersClustered, true))
return@throttleLatest
}
val result = repo.getChargepoints(bounds, mapPosition.zoom, filters, overrideCache)
chargepointsInternal?.let { chargepoints.removeSource(it) }
chargepointsInternal = result
chargepointsInternal
chargepoints.addSource(result) {
val apiId = apiId.value
when (apiId) {
@@ -451,14 +473,26 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
* expands LatLngBounds beyond the viewport (1.5x the width and height)
*/
private fun extendBounds(bounds: LatLngBounds): LatLngBounds {
val mapProjection = mapProjection ?: return bounds
val swPoint = mapProjection.toScreenLocation(bounds.southwest)
val nePoint = mapProjection.toScreenLocation(bounds.northeast)
val dx = ((nePoint.x - swPoint.x) * 0.25).roundToInt()
val dy = ((nePoint.y - swPoint.y) * 0.25).roundToInt()
val newSw = mapProjection.fromScreenLocation(Point(swPoint.x - dx, swPoint.y - dy))
val newNe = mapProjection.fromScreenLocation(Point(nePoint.x + dx, nePoint.y + dy))
return LatLngBounds(newSw, newNe)
val sw = bounds.southwest
val ne = bounds.northeast
// do not expand bounds if the map area shown is very large
val expansion = if (ne.longitude - sw.longitude > 10) 1.0 else 1.5
val factor = (expansion - 1.0) * 0.5
var west = sw.longitude - (ne.longitude - sw.longitude) * factor
var east = ne.longitude + (ne.longitude - sw.longitude) * factor
val south =
sw.latitude - (ne.latitude - sw.latitude) * factor * cos(Math.toRadians(sw.latitude))
val north =
ne.latitude + (ne.latitude - sw.latitude) * factor * cos(Math.toRadians(ne.latitude))
if (east - west >= 360) {
west = -180.0
east = 180.0
}
return LatLngBounds(LatLng(south, west), LatLng(north, east)).normalize()
}
fun reloadAvailability() {

View File

@@ -11,6 +11,7 @@ import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import retrofit2.HttpException
import java.io.IOException
class SettingsViewModel(
@@ -58,6 +59,8 @@ class SettingsViewModel(
vehicles.value = Resource.success(result)
} catch (e: IOException) {
vehicles.value = Resource.error(e.message, null)
} catch (e: HttpException) {
vehicles.value = Resource.error(e.message, null)
}
}
}
@@ -69,6 +72,8 @@ class SettingsViewModel(
tariffs.value = Resource.success(result)
} catch (e: IOException) {
tariffs.value = Resource.error(e.message, null)
} catch (e: HttpException) {
tariffs.value = Resource.error(e.message, null)
}
}
}

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