Compare commits

..

104 Commits
1.4.8 ... 1.6.4

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

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

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

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

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

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

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

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

Translated using Weblate (Portuguese)

Currently translated at 100.0% (283 of 283 strings)

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

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

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

* Revert unused declaration.

---------

Co-authored-by: iboboc <Raluca2018>
2023-04-20 18:38:55 +02:00
Johan von Forstner
0bfa80bbe0 EnBW: expand list of countries 2023-04-14 21:04:30 +02:00
Johan von Forstner
d77f13682d enable portuguese locale 2023-04-07 23:01:15 +02:00
Johan von Forstner
0c19eb5833 minor fixes in Portuguese 2023-04-07 23:00:57 +02:00
Johan von Forstner
a5abedae55 Update translation files 2023-04-07 22:52:20 +02:00
johan12345
8405f4f4fa Release 1.4.9 2023-03-24 20:14:43 +01:00
johan12345
f435180c03 Chargeprice vehicle selection: add battery size and charging power to distinguish vehicle models
same as ee354d2cd1, but now also for Android Auto
2023-03-24 20:10:14 +01:00
Hosted Weblate
c2c3e96e97 Update translation files
Updated by "Squash Git commits" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android-google/
Translation: EVMap/Android (strings specific to Google Play variant)
2023-03-21 21:49:07 +01:00
Hosted Weblate
9100a6f442 Translated using Weblate (French)
Currently translated at 99.6% (282 of 283 strings)

Translated using Weblate (French)

Currently translated at 92.2% (261 of 283 strings)

Translated using Weblate (French)

Currently translated at 97.2% (35 of 36 strings)

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

Co-authored-by: nautilusx <translate@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2023-03-05 21:19:01 +01:00
Johan von Forstner
3d303b6535 GoingElectric HoursAdapter: catch parsing exception 2023-03-05 13:31:54 +01:00
johan12345
135fce43c3 CarModels: add Renault BCB -> Megane E-Tech
see #269
2023-02-18 21:09:56 +01:00
johan12345
ee354d2cd1 Chargeprice vehicle selection: Add battery size and charging power to distinguish vehicle models
fixes #268
2023-02-18 21:05:45 +01:00
185 changed files with 4816 additions and 703 deletions

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 11
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Decrypt keystore

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
EVMap [![Build Status](https://github.com/ev-map/EVMap/actions/workflows/tests.yml/badge.svg)](https://github.com/ev-map/EVMap/actions)
=====
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
<a href="https://ev-map.app" target="_blank">
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/></a>
Android app to find electric vehicle charging stations.

View File

@@ -1,23 +1,25 @@
<svg id="Ebene_5" data-name="Ebene 5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>.cls-1,.cls-2{fill:none;}.cls-2{stroke:#000;stroke-miterlimit:10;stroke-width:2px;}
</style>
</defs>
<title>connector_supercharger</title>
<path class="cls-1" d="M12,12H36V36H12Z" />
<path class="cls-2"
d="M13.45,17.08a8.24,8.24,0,0,1-3.11.6,8.34,8.34,0,0,1-6-14.18H16.3a8.35,8.35,0,0,1,1.07,10.33" />
<circle cx="10.34" cy="9.34" r="1.67" />
<circle cx="15.35" cy="9.34" r="1.67" />
<circle cx="12.84" cy="13.51" r="1.67" />
<circle cx="7.84" cy="13.51" r="1.67" />
<circle cx="5.34" cy="9.34" r="1.67" />
<circle cx="7.84" cy="5.59" r="1" />
<circle cx="12.84" cy="5.59" r="1.04" />
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_5" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24"
style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{display:none;fill:none;}
.st1{fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:10;}
</style>
<path class="st0" d="M12,12h24v24H12V12z" />
<path class="st1"
d="M6.2,13.8C4.1,10.6,4.6,6.3,7.3,3.5h12c1.5,1.6,2.4,3.7,2.4,5.9c0,4.6-3.8,8.3-8.4,8.3c-1.1,0-2.1-0.2-3.1-0.6" />
<circle cx="13.3" cy="9.3" r="1.7" />
<circle cx="8.3" cy="9.3" r="1.7" />
<circle cx="10.8" cy="13.5" r="1.7" />
<circle cx="15.8" cy="13.5" r="1.7" />
<circle cx="18.3" cy="9.3" r="1.7" />
<circle cx="15.8" cy="5.6" r="1" />
<circle cx="10.8" cy="5.6" r="1" />
<g id="T">
<path id="path35"
d="M18.18,22.23l1-5.48c.93,0,1.22.1,1.27.52a2.15,2.15,0,0,0,.93-.7,6.91,6.91,0,0,0-2.46-.6l-.71.88h0L17.46,16a7,7,0,0,0-2.46.6,2.22,2.22,0,0,0,.94.7c0-.42.33-.52,1.26-.52l1,5.48" />
<path id="path37"
d="M18.18,15.72a7.9,7.9,0,0,1,3.28.66,2.65,2.65,0,0,0,.2-.4,9.24,9.24,0,0,0-7,0,2.61,2.61,0,0,0,.19.4,7.94,7.94,0,0,1,3.29-.66h0" />
</g>
</svg>
<path id="path35" d="M5.4,22.3l1-5.5c0.9,0,1.3,0.1,1.3,0.5c0.4-0.1,0.7-0.4,0.9-0.7C7.8,16.3,7,16,6.1,16l-0.8,0.8l0,0L4.7,16
c-0.8,0-1.7,0.3-2.5,0.6c0.2,0.3,0.6,0.6,0.9,0.7c0.1-0.4,0.3-0.5,1.3-0.5L5.4,22.3" />
<path id="path37" d="M5.5,15.7L5.5,15.7c1.1,0,2.3,0.2,3.3,0.7c0.1-0.1,0.1-0.3,0.2-0.4c-2.2-0.9-4.8-0.9-7,0
c0.1,0.1,0.1,0.3,0.2,0.4C3.2,15.9,4.3,15.7,5.5,15.7" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

After

Width:  |  Height:  |  Size: 872 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 KiB

After

Width:  |  Height:  |  Size: 886 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

After

Width:  |  Height:  |  Size: 872 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1005 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 KiB

After

Width:  |  Height:  |  Size: 848 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

After

Width:  |  Height:  |  Size: 884 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 KiB

After

Width:  |  Height:  |  Size: 848 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -8,8 +8,9 @@ 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"
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
android {
compileSdkVersion 33
@@ -20,8 +21,8 @@ android {
minSdkVersion 21
targetSdkVersion 33
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 164
versionName "1.4.8"
versionCode 188
versionName "1.6.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(',')
@@ -90,6 +91,12 @@ android {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs).configureEach {
kotlinOptions {
jvmTarget = "1.8"
}
}
buildFeatures {
dataBinding = true
viewBinding true
@@ -103,6 +110,9 @@ android {
unitTests.includeAndroidResources true
}
resourcePlaceholders {
files = ['xml/shortcuts.xml']
}
namespace 'net.vonforst.evmap'
// add API keys from environment variable if not set in apikeys.xml
@@ -146,6 +156,12 @@ android {
}
}
packagingOptions {
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
}
}
configurations {
@@ -156,24 +172,26 @@ configurations {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.5"
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation "androidx.activity:activity-ktx:1.7.2"
implementation "androidx.fragment:fragment-ktx:1.5.7"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.android.material:material:1.8.0'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.browser:browser:1.5.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation "androidx.work:work-runtime-ktx:2.8.1"
implementation 'com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
implementation 'com.squareup.moshi:moshi-adapters:1.13.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.11.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
implementation 'com.squareup.moshi:moshi-adapters:1.15.0'
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
@@ -192,7 +210,7 @@ dependencies {
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
// AnyMaps
def anyMapsVersion = '7fdcf50fc4'
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'
@@ -208,7 +226,7 @@ dependencies {
fossImplementation 'com.github.ev-map:mapbox-events-android:a21c324501'
// Google Places
googleImplementation 'com.google.android.libraries.places:places:3.0.0'
googleImplementation 'com.google.android.libraries.places:places:3.1.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4'
// Mapbox Geocoding
@@ -219,18 +237,19 @@ dependencies {
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.5.1"
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.5.0"
def room_version = "2.5.1"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation 'com.github.anboralabs:spatia-room:0.2.7'
// billing library
def billing_version = "5.1.0"
def billing_version = "6.0.0"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
@@ -241,26 +260,31 @@ dependencies {
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
implementation 'com.facebook.stetho:stetho:1.6.0'
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
debugImplementation 'com.facebook.flipper:flipper:0.190.0'
debugImplementation 'com.facebook.soloader:soloader:0.10.5'
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.190.0'
// testing
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
testImplementation 'org.robolectric:robolectric:4.9.2'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'org.robolectric:robolectric:4.9'
testGoogleImplementation 'org.robolectric:robolectric:4.9.2'
testGoogleImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.arch.core:core-testing:2.2.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
}
private static String decode(String s, String key) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,13 @@
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.POI" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</service>
</application>
</manifest>

View File

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

View File

@@ -8,6 +8,9 @@ package net.vonforst.evmap.auto
private val models = mapOf(
"Audi" to mapOf(
"516 (G4x)" to "e-tron"
),
"Renault" to mapOf(
"BCB" to "Megane E-Tech"
)
)

View File

@@ -275,7 +275,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.maxByOrNull { it.power }
prices = result.data!!.map { cp ->
prices = result.data!!.mapNotNull { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
@@ -287,7 +287,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
chargepointPrices = filteredPrices
)
}
}.filterNotNull()
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
@@ -352,7 +352,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
} else if (vehicles.size > 1) {
if (modelName != null) {
vehicles = vehicles.filter {
it.name.startsWith(modelName)
it.name.lowercase().startsWith(modelName.lowercase())
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()

View File

@@ -23,8 +23,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.iconForPlugType
@@ -57,6 +57,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val imageSize = 128 // images should be 128dp according to docs
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
@@ -465,7 +466,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
invalidate()
availability = getAvailability(charger).data
availability = availabilityRepo.getAvailability(charger).data
invalidate()
} else {

View File

@@ -24,8 +24,8 @@ import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
@@ -79,6 +79,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val searchRadius = 5 // kilometers
private val distanceUpdateThreshold = Duration.ofSeconds(15)
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
@@ -325,7 +326,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
// power
val power = charger.maxPower;
val power = charger.maxPower
if (power != null) {
if (text.isNotEmpty()) text.append(" · ")
text.append("${power.roundToInt()} kW")
@@ -427,14 +428,15 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
} else {
// try multiple search radii until we have enough chargers
var chargers: List<ChargeLocation>? = null
for (radius in listOf(searchRadius, searchRadius * 10, searchRadius * 50)) {
val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50)
for (radius in radiusValues) {
val response = repo.getChargepointsRadius(
searchLocation,
radius,
zoom = 16f,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR) {
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
loadingError = true
this@MapScreen.chargers = null
invalidate()
@@ -577,7 +579,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
// when the list is scrolled, load corresponding availabilities
if (startIndex == visibleStart && endIndex == visibleEnd && !availabilities.isEmpty()) return
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
if (startIndex == -1 || endIndex == -1) return
if (availabilityUpdateCoroutine != null) return
@@ -606,7 +608,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
lifecycleScope.async {
val availability = getAvailability(it).data
val availability = availabilityRepo.getAvailability(it).data
val date = ZonedDateTime.now()
availabilities[it.id] = date to availability
}

View File

@@ -31,7 +31,11 @@ import java.io.IOException
import java.time.Instant
@ExperimentalCarApi
class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
class PlaceSearchScreen(
ctx: CarContext,
val session: EVMapSession,
val initialSearch: String = ""
) : Screen(ctx),
SearchTemplate.SearchCallback, LocationAwareScreen,
DefaultLifecycleObserver {
private val hardwareMan: CarHardwareManager by lazy {
@@ -64,13 +68,15 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
init {
lifecycle.addObserver(this)
update("")
update(initialSearch)
}
override fun onGetTemplate(): Template {
return SearchTemplate.Builder(this).apply {
setHeaderAction(Action.BACK)
setSearchHint(carContext.getString(R.string.search))
setInitialSearchText(initialSearch)
setShowKeyboardByDefault(initialSearch == "")
resultList?.let {
setItemList(buildItemList(it))
} ?: setLoading(true)

View File

@@ -86,6 +86,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
addItem(
Row.Builder()
.setTitle(getLabel(item))
.apply { getDetails(item)?.let { addText(it) } }
.setImage(if (isSelected(item)) checkedIcon else uncheckedIcon)
.setOnClickListener {
toggleSelected(item)
@@ -130,5 +131,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
abstract fun getLabel(it: T): String
open fun getDetails(it: T): String? = null
abstract suspend fun loadData(): List<T>
}

View File

@@ -390,6 +390,8 @@ class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<Chargepric
override fun getLabel(it: ChargepriceCar) = "${it.brand} ${it.name}"
override fun getDetails(it: ChargepriceCar) = it.formatSpecs()
override suspend fun loadData(): List<ChargepriceCar> {
return api.getVehicles()
}

View File

@@ -38,10 +38,10 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
): List<AutocompletePlace> {
val request = FindAutocompletePredictionsRequest.builder().apply {
if (location != null) {
setLocationBias(calcLocationBias(location))
setOrigin(LatLng(location.latitude, location.longitude))
locationBias = calcLocationBias(location)
origin = LatLng(location.latitude, location.longitude)
}
setSessionToken(token)
sessionToken = token
setQuery(query)
}.build()
try {
@@ -92,10 +92,11 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
}
}
override fun getAttributionString(): Int = R.string.places_powered_by_google
override fun getAttributionString(): Int =
com.google.android.libraries.places.R.string.places_powered_by_google
override fun getAttributionImage(dark: Boolean): Int =
if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light
if (dark) com.google.android.libraries.places.R.drawable.places_powered_by_google_dark else com.google.android.libraries.places.R.drawable.places_powered_by_google_light
private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds {
val radius = 100e3 // meters

View File

@@ -3,12 +3,8 @@ package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.MultiSelectListPreference
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.RangeSliderPreference
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
import java.text.NumberFormat
class AndroidAutoSettingsFragment : BaseSettingsFragment() {

View File

@@ -35,4 +35,6 @@
<string name="settings_android_auto_chargeprice_range">Plage de charge pour la comparaison des prix</string>
<string name="welcome_android_auto_detail">Vous pouvez également utiliser EVMap à partir d\'Android Auto sur les voitures prises en charge. Il suffit de sélectionner l\'application EVMap dans le menu Android Auto.</string>
<string name="loading">Chargement…</string>
<string name="auto_multipage_goto">Page %d</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
@@ -23,6 +24,10 @@
<application
android:name=".EvMapApplication"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/backup_rules_api31"
android:fullBackupOnly="true"
android:backupAgent=".storage.BackupAgent"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -264,6 +269,13 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
@@ -278,6 +290,18 @@
android:name="autoStoreLocales"
android:value="true" />
</service>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- Remove WorkManagerInitializer as we implement getWorkManagerConfiguration in application class -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View File

@@ -1,7 +1,9 @@
package net.vonforst.evmap
import android.app.Application
import com.facebook.stetho.Stetho
import android.os.Build
import androidx.work.*
import net.vonforst.evmap.storage.CleanupCacheWorker
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
@@ -10,8 +12,9 @@ import org.acra.config.limiter
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import java.time.Duration
class EvMapApplication : Application() {
class EvMapApplication : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
val prefs = PreferenceDataSource(this)
@@ -24,8 +27,8 @@ class EvMapApplication : Application() {
prefs.language = null
}
Stetho.initializeWithDefaults(this);
init(applicationContext)
addDebugInterceptors(applicationContext)
if (!BuildConfig.DEBUG) {
initAcra {
@@ -49,5 +52,20 @@ class EvMapApplication : Application() {
}
}
}
val cleanupCacheRequest = PeriodicWorkRequestBuilder<CleanupCacheWorker>(Duration.ofDays(1))
.setConstraints(Constraints.Builder().apply {
setRequiresBatteryNotLow(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setRequiresDeviceIdle(true)
}
}.build()).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.REPLACE, cleanupCacheRequest
)
}
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder().build()
}
}

View File

@@ -55,8 +55,8 @@ class MapsActivity : AppCompatActivity(),
private lateinit var prefs: PreferenceDataSource
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_maps)
@@ -124,7 +124,7 @@ class MapsActivity : AppCompatActivity(),
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
.createPendingIntent()
} else if (query != null && query.isNotEmpty()) {
} else if (!query.isNullOrEmpty()) {
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
@@ -171,6 +171,32 @@ class MapsActivity : AppCompatActivity(),
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
.createPendingIntent()
}
} else if (intent.scheme == "net.vonforst.evmap") {
intent.data?.let {
if (it.host == "find_charger") {
val lat = it.getQueryParameter("latitude")?.toDouble()
val lon = it.getQueryParameter("longitude")?.toDouble()
val name = it.getQueryParameter("name")
if (lat != null && lon != null) {
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(
MapFragmentArgs(
latLng = LatLng(lat, lon),
locationName = name
).toBundle()
)
.createPendingIntent()
} else if (name != null) {
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(locationName = name).toBundle())
.createPendingIntent()
}
}
}
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
deepLink = navController.createDeepLink()
.setDestination(R.id.map)
@@ -207,7 +233,7 @@ class MapsActivity : AppCompatActivity(),
intent.data = Uri.parse("google.navigation:q=${coord.lat},${coord.lng}")
intent.`package` = "com.google.android.apps.maps"
if (prefs.navigateUseMaps && intent.resolveActivity(packageManager) != null) {
startActivity(intent);
startActivity(intent)
} else {
// fallback: generic geo intent
showLocation(charger)
@@ -223,7 +249,7 @@ class MapsActivity : AppCompatActivity(),
})"
)
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent);
startActivity(intent)
} else {
val cb = fragmentCallback ?: return
Snackbar.make(
@@ -262,7 +288,7 @@ class MapsActivity : AppCompatActivity(),
fun shareUrl(url: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
setType("text/plain")
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(intent)

View File

@@ -90,7 +90,7 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
class ChargepriceAdapter() :
DataBindingAdapter<ChargePrice>() {
val viewPool = RecyclerView.RecycledViewPool();
val viewPool = RecyclerView.RecycledViewPool()
var meta: ChargepriceChargepointMeta? = null
set(value) {
field = value

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,16 @@
package net.vonforst.evmap.api.availability
import com.facebook.stetho.okhttp3.StethoInterceptor
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.cartesianProduct
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
@@ -133,7 +135,9 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String,
val evseIds: Map<Chargepoint, List<String>>? = null
val evseIds: Map<Chargepoint, List<String>>? = null,
val congestionHistogram: List<Double>? = null,
val extraData: Any? = null // API-specific data
) {
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
val statusFiltered = status.filterKeys {
@@ -158,38 +162,41 @@ private val cookieManager = CookieManager().apply {
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
}
private val okhttp = OkHttpClient.Builder()
.addInterceptor(RateLimitInterceptor())
.addNetworkInterceptor(StethoInterceptor())
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
class AvailabilityRepository(context: Context) {
private val okhttp = OkHttpClient.Builder()
.addInterceptor(RateLimitInterceptor())
.addDebugInterceptors()
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
private val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
if (!ad.isChargerSupported(charger)) continue
try {
value = Resource.success(ad.getAvailability(charger))
break
} catch (e: IOException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: HttpException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
value = Resource.error(e.message, null)
e.printStackTrace()
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
if (!ad.isChargerSupported(charger)) continue
try {
value = Resource.success(ad.getAvailability(charger))
break
} catch (e: IOException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: HttpException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
value = Resource.error(e.message, null)
e.printStackTrace()
}
}
}
return value ?: Resource.error(null, null)
}
return value ?: Resource.error(null, null)
}

View File

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

View File

@@ -15,12 +15,13 @@ private const val coordRange = 0.005 // range of latitude and longitude for loa
private const val maxDistance = 40 // max distance between reported positions in meters
interface NewMotionApi {
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}")
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}/{zoom}")
suspend fun getMarkers(
@Path("lngMin") lngMin: Double,
@Path("lngMax") lngMax: Double,
@Path("latMin") latMin: Double,
@Path("latMax") latMax: Double
@Path("latMax") latMax: Double,
@Path("zoom") zoom: Int = 22
): List<NMMarker>
@GET("locations/{id}")
@@ -76,7 +77,7 @@ interface NewMotionApi {
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): NewMotionApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://my.newmotion.com/api/map/v2/")
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
.addConverterFactory(MoshiConverterFactory.create())
.client(client)
.build()
@@ -181,7 +182,11 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
override fun isChargerSupported(charger: ChargeLocation): Boolean {
// NewMotion is our fallback
return true
return when (charger.dataSource) {
"goingelectric" -> charger.network != "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network !in listOf("23", "3534")
else -> false
}
}
}

View File

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

View File

@@ -1,13 +1,13 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import jsonapi.Document
import jsonapi.JsonApiFactory
import jsonapi.retrofit.DocumentConverterFactory
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.model.ChargeLocation
import okhttp3.Cache
import okhttp3.OkHttpClient
@@ -77,10 +77,10 @@ interface ChargepriceApi {
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
@@ -127,14 +127,16 @@ interface ChargepriceApi {
charger.chargepriceData?.country?.let { isCountrySupported(it, charger.dataSource) }
?: false
val networkSupported = charger.chargepriceData?.network?.let {
if (charger.dataSource == "openchargemap") {
it !in listOf(
when (charger.dataSource) {
"openchargemap" -> it !in listOf(
"1", // unknown operator
"44", // private residence/individual
"45" // business owner at location
"45", // business owner at location
"23", "3534" // Tesla
)
} else {
true
"goingelectric" -> it != "Tesla Supercharger"
else -> true
}
} ?: false
val powerAvailable = charger.chargepoints.all { it.hasKnownPower() }

View File

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

View File

@@ -1,9 +1,9 @@
package net.vonforst.evmap.api.fronyx
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.Cache
@@ -49,10 +49,10 @@ private interface FronyxApiRetrofit {
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
cache(Cache(context.cacheDir, cacheSize))
}
}.build()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,8 +80,8 @@ class FilterProfilesFragment : Fragment() {
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPos = viewHolder.bindingAdapterPosition;
val toPos = target.bindingAdapterPosition;
val fromPos = viewHolder.bindingAdapterPosition
val toPos = target.bindingAdapterPosition
val list = vm.filterProfiles.value?.toMutableList()
if (list != null) {

View File

@@ -40,8 +40,6 @@ import androidx.transition.TransitionInflater
import androidx.transition.TransitionManager
import coil.load
import coil.memory.MemoryCache
import coil.size.OriginalSize
import coil.size.SizeResolver
import com.car2go.maps.AnyMap
import com.car2go.maps.MapFragment
import com.car2go.maps.OnMapReadyCallback
@@ -75,6 +73,7 @@ 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
@@ -130,16 +129,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
val state = bottomSheetBehavior.state
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
if (bottomSheetCollapsible) {
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
}
} else if (state == STATE_COLLAPSED) {
vm.chargerSparse.value = null
} else if (state == STATE_HIDDEN) {
vm.searchResult.value = null
}
}
}
@@ -164,6 +161,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
println(binding.detailView.sourceButton)
binding.lifecycleOwner = this
binding.vm = vm
@@ -248,7 +246,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
mapFragment!!.getMapAsync(this)
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
bottomSheetBehavior = from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
@@ -377,6 +375,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
null, extras
)
}
binding.detailView.btnChargerWebsite.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
binding.detailView.btnLogin.setOnClickListener {
findNavController().navigate(
R.id.settings_data,
DataSettingsFragmentArgs(true).toBundle()
)
}
binding.detailView.imgPredictionSource.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url))
}
@@ -387,7 +395,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.show()
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
bottomSheetBehavior.state = STATE_ANCHOR_POINT
}
setupSearchAutocomplete()
binding.detailAppBar.toolbar.setNavigationOnClickListener {
@@ -414,7 +422,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val charger = vm.charger.value?.data
if (charger?.editUrl != null) {
(activity as? MapsActivity)?.openUrl(charger.editUrl)
if (vm.apiId.value == "going_electric") {
if (vm.apiId.value == "goingelectric") {
// instructions specific to GoingElectric
Toast.makeText(
requireContext(),
@@ -552,7 +560,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun setupObservers() {
bottomSheetBehavior.addBottomSheetCallback(object :
BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback() {
BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (bottomSheetBehavior.state == STATE_HIDDEN) {
map?.setPadding(0, mapTopPadding, 0, 0)
@@ -581,9 +589,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
})
vm.chargerSparse.observe(viewLifecycleOwner, Observer {
vm.chargerSparse.observe(viewLifecycleOwner) {
if (it != null) {
if (vm.bottomSheetState.value != BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT) {
if (vm.bottomSheetState.value != STATE_ANCHOR_POINT) {
bottomSheetBehavior.state =
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_ANCHOR_POINT
}
@@ -596,8 +604,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
bottomSheetBehavior.state = STATE_HIDDEN
unhighlightAllMarkers()
}
})
}
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
val chargepoints = res.data
if (chargepoints != null) {
updateMap(chargepoints)
}
when (res.status) {
Status.ERROR -> {
val view = view ?: return@Observer
@@ -617,32 +629,27 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
Status.LOADING -> {
}
}
val chargepoints = res.data
if (chargepoints != null) {
updateMap(chargepoints)
}
})
vm.useMiniMarkers.observe(viewLifecycleOwner) {
vm.chargepoints.value?.data?.let { updateMap(it) }
}
vm.favorites.observe(viewLifecycleOwner, Observer {
vm.favorites.observe(viewLifecycleOwner) {
updateFavoriteToggle()
})
vm.searchResult.observe(viewLifecycleOwner, Observer { place ->
}
vm.searchResult.observe(viewLifecycleOwner) { place ->
displaySearchResult(place, moveCamera = true)
})
vm.layersMenuOpen.observe(viewLifecycleOwner, Observer { open ->
}
vm.layersMenuOpen.observe(viewLifecycleOwner) { open ->
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
binding.layersSheet.visibility = if (open) View.VISIBLE else View.INVISIBLE
updateBackPressedCallback()
})
vm.mapType.observe(viewLifecycleOwner, Observer {
}
vm.mapType.observe(viewLifecycleOwner) {
map?.setMapType(it)
})
vm.mapTrafficEnabled.observe(viewLifecycleOwner, Observer {
}
vm.mapTrafficEnabled.observe(viewLifecycleOwner) {
map?.setTrafficEnabled(it)
})
}
updateBackPressedCallback()
}
@@ -697,7 +704,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
@@ -713,7 +720,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = true,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
@@ -728,7 +735,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
@@ -753,12 +760,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val photos = vm.charger.value?.data?.photos ?: return
viewer = StfalconImageViewer.Builder(context, photos) { imageView, photo ->
imageView.load(photo.getUrl(size = 1000)) {
imageView.load(photo.getUrl(size = 1000, allowOriginal = true)) {
if (photo == photos[position] && imageCacheKey != null) {
placeholderMemoryCacheKey(imageCacheKey)
}
size(SizeResolver(OriginalSize))
allowHardware(false)
}
}
.withTransitionFrom(view as ImageView)
@@ -810,6 +815,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
R.drawable.ic_payment -> {
showPaymentMethodsDialog(charger)
}
R.drawable.ic_network -> {
charger.networkUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
}
}
}
@@ -852,13 +860,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onMapReady(map: AnyMap) {
this.map = map
vm.mapProjection = map.projection
val context = this.context ?: return
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
// Google Maps: icons can be generated in background thread
lifecycleScope.launch {
withContext(Dispatchers.IO) {
withContext(Dispatchers.Default) {
chargerIconGenerator.preloadCache()
}
}
@@ -876,16 +885,39 @@ 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
)
}
binding.scaleView.apply {
when (prefs.mapScale) {
"both" -> {
visibility = View.VISIBLE
metersAndMiles()
}
"meters" -> {
visibility = View.VISIBLE
metersOnly()
}
"miles" -> {
visibility = View.VISIBLE
milesOnly()
}
"off" -> visibility = View.GONE
}
}
vm.mapPosition.observe(viewLifecycleOwner) {
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
}
@@ -958,11 +990,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.loadChargerById(chargerId)
vm.chargerSparse.observe(
viewLifecycleOwner,
object : Observer<ChargeLocation> {
override fun onChanged(item: ChargeLocation?) {
if (item?.id == chargerId) {
object : Observer<ChargeLocation?> {
override fun onChanged(value: ChargeLocation?) {
if (value?.id == chargerId) {
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(
LatLng(item.coordinates.lat, item.coordinates.lng), 16f
LatLng(value.coordinates.lat, value.coordinates.lng), 16f
)
map.moveCamera(cameraUpdate)
vm.chargerSparse.removeObserver(this)
@@ -981,9 +1013,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
if (res.data == null) return
for (item in res.data) {
override fun onChanged(value: Resource<List<ChargepointListItem>>) {
if (value.data == null) return
for (item in value.data) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
@@ -994,44 +1026,26 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
} else {
// mark location as search result
vm.searchResult.value = PlaceWithBounds(latLng, boundingBox(latLng, 750.0))
locationName?.let { binding.search.setText(it) }
}
positionSet = true
} else if (locationName != null) {
lifecycleScope.launch {
val address = withContext(Dispatchers.IO) {
try {
Geocoder(requireContext()).getFromLocationName(locationName, 1)
?.getOrNull(0)
} catch (e: IOException) {
null
}
}
address?.let {
val latLng = LatLng(it.latitude, it.longitude)
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(latLng, 16f)
map.moveCamera(cameraUpdate)
val bboxSize = if (it.subAdminArea != null) {
750.0 // this is a place within a city
} else if (it.adminArea != null && it.adminArea != it.featureName) {
4000.0 // this is a city
} else if (it.adminArea != null) {
100000.0 // this is a top-level administrative area (i.e. state)
} else {
500000.0 // this is a country
}
vm.searchResult.value = PlaceWithBounds(latLng, boundingBox(latLng, bboxSize))
}
}
binding.search.setText(locationName)
binding.search.requestFocus()
binding.search.setSelection(locationName.length)
}
if (context.checkAnyLocationPermission()) {
enableLocation(!positionSet, false)
positionSet = true
}
if (!positionSet) {
// center the camera on Europe
// use position saved in preferences, fall back to default (Europe)
val cameraUpdate =
map.cameraUpdateFactory.newLatLngZoom(LatLng(50.113388, 9.252536), 3.5f)
map.cameraUpdateFactory.newLatLngZoom(
prefs.currentMapLocation,
prefs.currentMapZoom
)
map.moveCamera(cameraUpdate)
}
@@ -1091,7 +1105,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = highlight,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
@@ -1112,7 +1126,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav =
charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
animator.animateMarkerDisappear(
marker, tint, highlight, fault, multi, fav,
vm.useMiniMarkers.value == true
@@ -1131,7 +1145,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val highlight = charger.id == vm.chargerSparse.value?.id
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
val fav =
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
val marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
@@ -1185,13 +1200,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val filterBadge = filterView?.findViewById<TextView>(R.id.filter_badge)
if (filterBadge != null) {
// set up badge showing number of active filters
vm.filtersCount.observe(viewLifecycleOwner, Observer {
vm.filtersCount.observe(viewLifecycleOwner) {
filterBadge.visibility = if (it > 0) View.VISIBLE else View.GONE
filterBadge.text = it.toString()
})
}
}
filterView?.setOnClickListener {
var profilesMap: MutableBiMap<Long, MenuItem> = HashBiMap()
val profilesMap: MutableBiMap<Long, MenuItem> = HashBiMap()
val popup = PopupMenu(
ContextThemeWrapper(requireContext(), R.style.RoundedPopup),
@@ -1232,7 +1247,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
vm.filterProfiles.observe(viewLifecycleOwner, { profiles ->
vm.filterProfiles.observe(viewLifecycleOwner) { profiles ->
popup.menu.removeGroup(R.id.menu_group_filter_profiles)
val noFiltersItem = popup.menu.add(
@@ -1262,25 +1277,28 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
profilesMap[FILTERS_CUSTOM] = customItem
profilesMap[FILTERS_FAVORITES] = favoritesItem
popup.menu.setGroupCheckable(R.id.menu_group_filter_profiles, true, true);
popup.menu.setGroupCheckable(R.id.menu_group_filter_profiles, true, true)
val manageFiltersItem = popup.menu.findItem(R.id.menu_manage_filter_profiles)
manageFiltersItem.isVisible = profiles.isNotEmpty()
vm.filterStatus.observe(viewLifecycleOwner, Observer { id ->
vm.filterStatus.observe(viewLifecycleOwner) { id ->
when (id) {
FILTERS_DISABLED -> {
customItem.isVisible = false
noFiltersItem.isChecked = true
}
FILTERS_CUSTOM -> {
customItem.isVisible = true
customItem.isChecked = true
}
FILTERS_FAVORITES -> {
customItem.isVisible = false
favoritesItem.isChecked = true
}
else -> {
customItem.isVisible = false
val item = profilesMap[id]
@@ -1290,8 +1308,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// else unknown ID -> wait for filterProfiles to update
}
}
})
})
}
}
popup.setTouchModal(false)
popup.show()
}
@@ -1365,6 +1383,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onPause() {
super.onPause()
removeLocationUpdates()
vm.mapPosition.value?.let {
prefs.currentMapLocation = it.bounds.center
prefs.currentMapZoom = it.zoom
}
}
override fun onDestroy() {

View File

@@ -17,7 +17,7 @@ class MultiSelectDialog : MaterialDialogFragment() {
companion object {
fun getInstance(
title: String,
data: Map<String, String>,
data: Map<String, CharSequence>,
selected: Set<String>,
commonChoices: Set<String>?,
showAllButton: Boolean = true
@@ -55,7 +55,7 @@ class MultiSelectDialog : MaterialDialogFragment() {
override fun initView(view: View, savedInstanceState: Bundle?) {
val args = requireArguments()
val data = args.getSerializable("data") as HashMap<String, String>
val data = args.getSerializable("data") as HashMap<String, CharSequence>
val selected = args.getSerializable("selected") as HashSet<String>
val title = args.getString("title")
val commonChoices = if (args.containsKey("commonChoices")) {
@@ -71,7 +71,7 @@ class MultiSelectDialog : MaterialDialogFragment() {
binding.btnAll.visibility = if (showAllButton) View.VISIBLE else View.INVISIBLE
items = data.entries.toList()
.sortedBy { it.value.lowercase(Locale.getDefault()) }
.sortedBy { it.value.toString().lowercase(Locale.getDefault()) }
.sortedBy {
when {
selected.contains(it.key) && commonChoices?.contains(it.key) == true -> 0
@@ -117,7 +117,7 @@ private fun search(
): List<MultiSelectItem> {
return items.filter { item ->
// search for string within name
text.lowercase(Locale.getDefault()) in item.name.lowercase(Locale.getDefault())
text.lowercase(Locale.getDefault()) in item.name.toString().lowercase(Locale.getDefault())
}
}
@@ -125,4 +125,5 @@ class Adapter() : DataBindingAdapter<MultiSelectItem>({ it.key }) {
override fun getItemViewType(position: Int) = R.layout.dialog_multi_select_item
}
data class MultiSelectItem(val key: String, val name: String, var selected: Boolean) : Equatable
data class MultiSelectItem(val key: String, val name: CharSequence, var selected: Boolean) :
Equatable

View File

@@ -0,0 +1,91 @@
package net.vonforst.evmap.fragment.oauth
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
class OAuthLoginFragment : Fragment() {
private lateinit var webView: WebView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.fragment_oauth_login, container, false)
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
val uri = Uri.parse(args.url)
webView = view.findViewById(R.id.webView)
args.color?.let { webView.setBackgroundColor(Color.parseColor(it)) }
val progress = view.findViewById<LinearProgressIndicator>(R.id.progress_indicator)
CookieManager.getInstance().removeAllCookies(null)
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val url = request.url
if (url.toString().startsWith(args.resultUrlPrefix)) {
val result = Bundle()
result.putString("url", url.toString())
setFragmentResult(args.url, result)
findNavController().popBackStack()
}
return url.host != uri.host
}
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
progress.show()
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
progress.hide()
webView.background = null
}
}
webView.settings.javaScriptEnabled = true
webView.loadUrl(args.url)
}
}

View File

@@ -2,6 +2,7 @@ package net.vonforst.evmap.fragment.preference
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
@@ -15,9 +16,13 @@ import com.mikepenz.aboutlibraries.LibsBuilder
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.PreferenceDataSource
class AboutFragment : PreferenceFragmentCompat() {
private lateinit var prefs: PreferenceDataSource
private var developerOptionsCounter = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialFadeThrough()
@@ -33,6 +38,8 @@ class AboutFragment : PreferenceFragmentCompat() {
(requireActivity() as MapsActivity).appBarConfiguration
)
prefs = PreferenceDataSource(requireContext())
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
}
@@ -45,6 +52,21 @@ class AboutFragment : PreferenceFragmentCompat() {
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
"version" -> {
if (!prefs.developerModeEnabled) {
developerOptionsCounter += 1
if (developerOptionsCounter >= 7) {
prefs.developerModeEnabled = true
Toast.makeText(
requireContext(),
getString(R.string.developer_mode_enabled),
Toast.LENGTH_SHORT
).show()
}
}
true
}
"contributors" -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.about_contributors)
@@ -53,14 +75,22 @@ class AboutFragment : PreferenceFragmentCompat() {
.show()
true
}
"website" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.website_url))
true
}
"github_link" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link))
true
}
"privacy" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.privacy_link))
true
}
"faq" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link))
true

View File

@@ -12,14 +12,18 @@ import com.google.android.material.transition.MaterialFadeThrough
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.storage.PreferenceDataSource
abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener {
protected lateinit var prefs: PreferenceDataSource
protected lateinit var encryptedPrefs: EncryptedPreferenceDataStore
protected abstract val isTopLevel: Boolean
override fun onCreate(savedInstanceState: Bundle?) {
prefs = PreferenceDataSource(requireContext())
encryptedPrefs = EncryptedPreferenceDataStore(requireContext())
super.onCreate(savedInstanceState)
if (isTopLevel) {
@@ -40,8 +44,6 @@ abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
(requireActivity() as MapsActivity).appBarConfiguration
)
prefs = PreferenceDataSource(requireContext())
// 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

@@ -2,13 +2,17 @@ package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.RelativeSizeSpan
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.MultiSelectListPreference
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.MultiSelectDialogPreference
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class ChargepriceSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
@@ -22,8 +26,8 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
}
})
private lateinit var myVehiclePreference: MultiSelectListPreference
private lateinit var myTariffsPreference: MultiSelectListPreference
private lateinit var myVehiclePreference: MultiSelectDialogPreference
private lateinit var myTariffsPreference: MultiSelectDialogPreference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -34,8 +38,16 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
res.data?.let { cars ->
val sortedCars = cars.sortedBy { it.brand }
myVehiclePreference.entryValues = sortedCars.map { it.id }.toTypedArray()
myVehiclePreference.entries =
sortedCars.map { "${it.brand} ${it.name}" }.toTypedArray()
myVehiclePreference.entries = sortedCars.map {
SpannableStringBuilder().apply {
appendLine("${it.brand} ${it.name}")
append(
it.formatSpecs(),
RelativeSizeSpan(0.86f),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}.toTypedArray()
myVehiclePreference.isEnabled = true
updateMyVehiclesSummary()
}
@@ -78,9 +90,9 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
private fun updateMyVehiclesSummary() {
vm.vehicles.value?.data?.let { cars ->
val vehicles = cars.filter { it.id in prefs.chargepriceMyVehicles }
val summary = vehicles.map {
val summary = vehicles.joinToString(", ") {
"${it.brand} ${it.name}"
}.joinToString(", ")
}
myVehiclePreference.summary = summary
}
}

View File

@@ -1,14 +1,28 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
import okhttp3.OkHttpClient
import okio.IOException
import java.time.Instant
class DataSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
@@ -23,8 +37,53 @@ class DataSettingsFragment : BaseSettingsFragment() {
}
})
private lateinit var teslaAccountPreference: Preference
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_data, rootKey)
teslaAccountPreference = findPreference("tesla_account")!!
refreshTeslaAccountStatus()
vm.chargerCacheCount.observe(this) {
updateCacheSizeSummary()
}
vm.chargerCacheSize.observe(this) {
updateCacheSizeSummary()
}
}
private fun updateCacheSizeSummary() {
val count = vm.chargerCacheCount.value ?: return
val size = vm.chargerCacheSize.value ?: return
val sizeMb = size.toFloat() / 1024 / 1024
findPreference<Preference>("cache_size")!!.summary =
getString(R.string.settings_cache_count_summary, count, sizeMb)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
arguments?.let {
val args = DataSettingsFragmentArgs.fromBundle(it)
if (args.startTeslaLogin) {
teslaLogin()
arguments = null
}
}
}
override fun onResume() {
super.onResume()
refreshTeslaAccountStatus()
}
private fun refreshTeslaAccountStatus() {
teslaAccountPreference.summary =
if (encryptedPrefs.teslaRefreshToken != null) {
getString(R.string.pref_tesla_account_enabled, encryptedPrefs.teslaEmail)
} else {
getString(R.string.pref_tesla_account_disabled)
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
@@ -60,7 +119,91 @@ class DataSettingsFragment : BaseSettingsFragment() {
vm.deleteRecentSearchResults()
true
}
"tesla_account" -> {
if (encryptedPrefs.teslaRefreshToken != null) {
teslaLogout()
} else {
teslaLogin()
}
true
}
"cache_clear" -> {
vm.clearChargerCache()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
private fun teslaLogin() {
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
val uri = Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
.appendQueryParameter("client_id", "ownerapi")
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid email offline_access")
.appendQueryParameter("state", "123").build()
val args = OAuthLoginFragmentArgs(
uri.toString(),
"https://auth.tesla.com/void/callback",
"#000000"
).toBundle()
setFragmentResultListener(uri.toString()) { _, result ->
teslaGetAccessToken(result, codeVerifier)
}
findNavController().navigate(R.id.oauth_login, args)
}
private fun teslaGetAccessToken(result: Bundle, codeVerifier: String) {
teslaAccountPreference.summary = getString(R.string.logging_in)
val url = Uri.parse(result.getString("url"))
val code = url.getQueryParameter("code") ?: return
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
lifecycleScope.launch {
try {
val time = Instant.now().epochSecond
val response =
TeslaAuthenticationApi.create(okhttp).getToken(request)
val userResponse =
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
encryptedPrefs.teslaEmail = userResponse.response.email
encryptedPrefs.teslaAccessToken = response.accessToken
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
encryptedPrefs.teslaRefreshToken = response.refreshToken
} catch (e: IOException) {
view?.let {
Snackbar.make(it, R.string.connection_error, Snackbar.LENGTH_SHORT).show()
}
}
refreshTeslaAccountStatus()
}
}
private fun teslaLogout() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(getString(R.string.pref_tesla_account_enabled, encryptedPrefs.teslaEmail))
.setPositiveButton(R.string.ok) { _, _ -> }
.setNegativeButton(R.string.log_out) { _, _ ->
// sign out
encryptedPrefs.teslaRefreshToken = null
encryptedPrefs.teslaAccessToken = null
encryptedPrefs.teslaAccessTokenExpiry = -1
encryptedPrefs.teslaEmail = null
view?.let { Snackbar.make(it, R.string.logged_out, Snackbar.LENGTH_SHORT).show() }
refreshTeslaAccountStatus()
}
.show()
}
}

View File

@@ -0,0 +1,102 @@
package net.vonforst.evmap.fragment.preference
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.text.format.DateUtils
import android.view.View
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import net.vonforst.evmap.R
class DeveloperSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
private val locationManager: LocationManager by lazy {
requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_developer, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val locationPref = findPreference<Preference>("location_status")!!
val coarseGranted = ContextCompat.checkSelfPermission(
requireContext(),
android.Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val fineGranted = ContextCompat.checkSelfPermission(
requireContext(),
android.Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
locationPref.summary = buildString {
append("Coarse location permission: ")
appendLine(if (coarseGranted) "granted" else "not granted")
append("Fine location permission: ")
appendLine(if (fineGranted) "granted" else "not granted")
appendLine()
if (coarseGranted) {
append("Last network location: ")
appendLine(printLocation(locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)))
}
if (fineGranted) {
append("Last GPS location: ")
appendLine(printLocation(locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.allProviders.contains(
LocationManager.FUSED_PROVIDER
)
) {
append("Last fused location: ")
append(printLocation(locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)))
} else {
append("System's fused location provider not available")
}
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
"disable_developer_mode" -> {
prefs.developerModeEnabled = false
Toast.makeText(
requireContext(),
getString(R.string.developer_mode_disabled),
Toast.LENGTH_SHORT
).show()
findNavController().popBackStack()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
fun printLocation(location: Location?): String {
if (location == null) return "not available"
return buildString {
append("%.4f".format(location.latitude))
append(",")
append("%.4f".format(location.longitude))
append(" (")
append(DateUtils.getRelativeTimeSpanString(location.time))
append(")")
}
}
}

View File

@@ -2,20 +2,22 @@ package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.preference.Preference
import net.vonforst.evmap.R
class SettingsFragment : BaseSettingsFragment() {
override val isTopLevel = true
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.settings)
addPreferencesFromResource(R.xml.settings_variantspecific)
findPreference<Preference>("developer_options")?.isVisible = prefs.developerModeEnabled
}
override fun onResume() {
super.onResume()
findPreference<Preference>("developer_options")?.isVisible = prefs.developerModeEnabled
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {

View File

@@ -31,6 +31,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var gpsLocation: Location? = null
private var networkLocation: Location? = null
private var fusedLocation: Location? = null
private val supportsSystemFusedProvider: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.allProviders.contains(
@@ -101,7 +102,6 @@ class FusionEngine(context: Context) : LocationEngine(context),
try {
enableFused(gpsInterval)
checkLastKnownFused()
return
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for fused provider", e)
}
@@ -143,6 +143,9 @@ class FusionEngine(context: Context) : LocationEngine(context),
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
override fun disable() {
locationManager.removeUpdates(this)
gpsLocation = null
networkLocation = null
fusedLocation = null
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
@@ -235,15 +238,16 @@ class FusionEngine(context: Context) : LocationEngine(context),
override fun onLocationChanged(location: Location) {
if (LocationManager.FUSED_PROVIDER == location.provider) {
fusedLocation = location
requests.forEach { it.listener.onLocationChanged(location) }
} else if (LocationManager.GPS_PROVIDER == location.provider) {
gpsLocation = location
if (gpsLocation.isBetterThan(networkLocation)) {
if (gpsLocation.isBetterThan(networkLocation) && fusedLocation == null) {
requests.forEach { it.listener.onLocationChanged(location) }
}
} else if (LocationManager.NETWORK_PROVIDER == location.provider) {
networkLocation = location
if (networkLocation.isBetterThan(gpsLocation)) {
if (networkLocation.isBetterThan(gpsLocation) && fusedLocation == null) {
requests.forEach { it.listener.onLocationChanged(location) }
}
}

View File

@@ -58,12 +58,12 @@ data class ChargeLocation(
val id: Long,
val dataSource: String,
val name: String,
@Embedded val coordinates: Coordinate,
val coordinates: Coordinate,
@Embedded val address: Address?,
val chargepoints: List<Chargepoint>,
val network: String?,
val url: String,
val editUrl: String?,
val url: String, // URL of this charger at the data source
val editUrl: String?, // URL to edit this charger at the data source
@Embedded(prefix = "fault_report_") val faultReport: FaultReport?,
val verified: Boolean,
val barrierFree: Boolean?,
@@ -78,6 +78,8 @@ data class ChargeLocation(
@Embedded val cost: Cost?,
val license: String?,
@Embedded(prefix = "chargeprice") val chargepriceData: ChargepriceData?,
val networkUrl: String?, // Website of the network
val chargerUrl: String?, // Website for this specific charging site. Might be an ad-hoc payment page.
val timeRetrieved: Instant,
val isDetailed: Boolean
) : ChargepointListItem(), Equatable, Parcelable {
@@ -136,9 +138,9 @@ data class ChargeLocation(
get() = chargepoints.sumOf { it.count }
fun formatChargepoints(sp: StringProvider): String {
return chargepointsMerged.map {
return chargepointsMerged.joinToString(" · ") {
"${it.count} × ${nameForPlugType(sp, it.type)} ${it.formatPower()}"
}.joinToString(" · ")
}
}
}
@@ -332,12 +334,25 @@ data class Hours(
}
abstract class ChargerPhoto(open val id: String) : Parcelable {
abstract fun getUrl(height: Int? = null, width: Int? = null, size: Int? = null): String
/**
* Gets a URL of the image corresponding to a given size.
*
* If the data source supports accessing the image in its original (potentially unlimited) size,
* this size will only be returned if allowOriginal is set to true. Otherwise, only scaled
* versions of the images will be returned.
*/
abstract fun getUrl(
height: Int? = null,
width: Int? = null,
size: Int? = null,
allowOriginal: Boolean = false
): String
}
data class ChargeLocationCluster(
val clusterCount: Int,
val coordinates: Coordinate
val coordinates: Coordinate,
val items: List<ChargeLocation>? = null
) : ChargepointListItem()
@Parcelize

View File

@@ -23,6 +23,6 @@ data class Favorite(
)
data class FavoriteWithDetail(
@Embedded() val favorite: Favorite,
@Embedded val favorite: Favorite,
@Embedded val charger: ChargeLocation
)

View File

@@ -6,6 +6,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.storage.FilterProfile
import java.net.URLEncoder
import kotlin.reflect.KClass
sealed class Filter<out T : FilterValue> : Equatable {
@@ -51,6 +52,8 @@ sealed class FilterValue : BaseObservable(), Equatable {
var profile: Long = FILTERS_CUSTOM
abstract fun hasSameValueAs(other: FilterValue): Boolean
abstract fun serializeValue(): String
}
@Entity(
@@ -72,6 +75,8 @@ data class BooleanFilterValue(
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is BooleanFilterValue && other.value == this.value
}
override fun serializeValue(): String = value.toString()
}
@Entity(
@@ -99,6 +104,12 @@ data class MultipleChoiceFilterValue(
!this.all && other.values == this.values
}
}
override fun serializeValue(): String = if (all) {
"ALL"
} else {
"[" + values.sorted().joinToString(",") { URLEncoder.encode(it, "UTF-8") } + "]"
}
}
@Entity(
@@ -120,6 +131,8 @@ data class SliderFilterValue(
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is SliderFilterValue && other.value == this.value
}
override fun serializeValue() = value.toString()
}
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
@@ -138,6 +151,9 @@ fun FilterValues.getMultipleChoiceFilter(key: String) =
fun FilterValues.getMultipleChoiceValue(key: String) =
this.find { it.value.key == key }?.value as MultipleChoiceFilterValue?
fun FilterValues.serialize() = this.sortedBy { it.value.key }
.joinToString(",") { it.value.key + "=" + it.value.serializeValue() }
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L
const val FILTERS_FAVORITES = -3L

View File

@@ -0,0 +1,54 @@
package net.vonforst.evmap.storage
import android.app.backup.BackupAgent
import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput
import android.app.backup.FullBackupDataOutput
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.runBlocking
import java.time.Instant
private const val backupFileName = "evmap-backup.db"
class BackupAgent : BackupAgent() {
override fun onBackup(
oldState: ParcelFileDescriptor,
data: BackupDataOutput,
newState: ParcelFileDescriptor
) {
// unused on Android M+, we don't support backups on older versions
}
override fun onRestore(
data: BackupDataInput,
appVersionCode: Int,
newState: ParcelFileDescriptor
) {
// unused on Android M+, we don't support backups on older versions
}
override fun onFullBackup(data: FullBackupDataOutput) {
runBlocking {
// creates a backup of the app database to evmap-backup.db
AppDatabase.getInstance(applicationContext).createBackup(
applicationContext,
backupFileName
)
}
super.onFullBackup(data)
val backupDb = applicationContext.getDatabasePath(backupFileName)
if (backupDb.exists()) backupDb.delete()
}
override fun onRestoreFinished() {
super.onRestoreFinished()
// rename restored backup DB as evmap.db
val backupDb = applicationContext.getDatabasePath(backupFileName)
if (backupDb.exists()) {
backupDb.renameTo(applicationContext.getDatabasePath("evmap.db"))
}
// clear cache age
PreferenceDataSource(applicationContext).lastGeReferenceDataUpdate = Instant.EPOCH
PreferenceDataSource(applicationContext).lastOcmReferenceDataUpdate = Instant.EPOCH
}
}

View File

@@ -0,0 +1,131 @@
package net.vonforst.evmap.storage
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import java.time.Duration
import java.time.Instant
/**
* LiveData implementation that allows loading data both from a cache and an API.
*
* It gives the cache result while loading, and then switches to the API result if the API call was
* successful.
*/
class CacheLiveData<T>(
cache: LiveData<T>,
api: LiveData<Resource<T>>,
skipApi: LiveData<Boolean>? = null
) :
MediatorLiveData<Resource<T>>() {
private var cacheResult: T? = null
private var apiResult: Resource<T>? = null
private var skipApiResult: Boolean = false
init {
updateValue()
addSource(cache) {
cacheResult = it
removeSource(cache)
updateValue()
}
if (skipApi == null) {
addSource(api) {
apiResult = it
updateValue()
}
} else {
addSource(skipApi) { skip ->
removeSource(skipApi)
skipApiResult = skip
updateValue()
if (!skip) {
addSource(api) {
apiResult = it
updateValue()
}
}
}
}
}
private fun updateValue() {
val api = apiResult
val cache = cacheResult
if (api == null && cache == null) {
Log.d("CacheLiveData", "both API and cache are still loading")
// both API and cache are still loading
value = Resource.loading(null)
} else if (cache != null && api == null) {
Log.d("CacheLiveData", "cache has finished loading before API")
// cache has finished loading before API
if (skipApiResult) {
value = Resource.success(cache)
} else {
value = Resource.loading(cache)
}
} else if (cache == null && api != null) {
Log.d("CacheLiveData", "API has finished loading before cache")
// API has finished loading before cache
value = when (api.status) {
Status.SUCCESS -> api
Status.ERROR -> Resource.loading(api.data)
Status.LOADING -> api // should not occur
}
} else if (cache != null && api != null) {
Log.d("CacheLiveData", "Both cache and API have finished loading")
// Both cache and API have finished loading
value = when (api.status) {
Status.SUCCESS -> api
Status.ERROR -> Resource.error(api.message, cache)
Status.LOADING -> api // should not occur
}
}
}
}
/**
* LiveData implementation that allows loading data both from a cache and an API.
*
* It first tries loading from cache, and if the result is newer than `cacheSoftLimit` it does not
* reload from the API.
*/
class PreferCacheLiveData(
cache: LiveData<ChargeLocation>,
val api: LiveData<Resource<ChargeLocation>>,
cacheSoftLimit: Duration
) :
MediatorLiveData<Resource<ChargeLocation>>() {
init {
value = Resource.loading(null)
addSource(cache) { cacheRes ->
removeSource(cache)
if (cacheRes != null) {
if (cacheRes.isDetailed && cacheRes.timeRetrieved > Instant.now() - cacheSoftLimit) {
value = Resource.success(cacheRes)
} else {
value = Resource.loading(cacheRes)
loadFromApi(cacheRes)
}
} else {
loadFromApi(null)
}
}
}
private fun loadFromApi(
cache: ChargeLocation?
) {
addSource(api) { apiRes ->
value = when (apiRes.status) {
Status.SUCCESS -> apiRes
Status.ERROR -> Resource.error(apiRes.message, cache)
Status.LOADING -> Resource.loading(cache)
}
}
}
}

View File

@@ -1,34 +1,103 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.*
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.*
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import co.anbora.labs.spatia.geometry.Mbr
import co.anbora.labs.spatia.geometry.Polygon
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.car2go.maps.util.SphericalUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.goingelectric.GEReferenceData
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.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.await
import net.vonforst.evmap.viewmodel.getClusterDistance
import net.vonforst.evmap.viewmodel.singleSwitchMap
import java.time.Duration
import java.time.Instant
import kotlin.math.sqrt
@Dao
abstract class ChargeLocationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg locations: ChargeLocation)
@Query("SELECT EXISTS(SELECT 1 FROM chargelocation WHERE dataSource == :dataSource AND id == :id AND isDetailed == 1 AND timeRetrieved > :after )")
abstract suspend fun checkExistsDetailed(id: Long, dataSource: String, after: Long): Boolean
suspend fun insertOrReplaceIfNoDetailedExists(
afterDate: Long,
vararg locations: ChargeLocation
) {
locations.forEach {
if (it.isDetailed || !checkExistsDetailed(it.id, it.dataSource, afterDate)) {
insert(it)
}
}
}
@Delete
abstract suspend fun delete(vararg locations: ChargeLocation)
@Query("DELETE FROM chargelocation WHERE dataSource == :dataSource AND timeRetrieved <= :before AND NOT EXISTS (SELECT 1 FROM favorite WHERE favorite.chargerId = chargelocation.id)")
abstract suspend fun deleteOutdatedIfNotFavorite(dataSource: String, before: Long)
@Query("DELETE FROM chargelocation WHERE NOT EXISTS (SELECT 1 FROM favorite WHERE favorite.chargerId = chargelocation.id)")
abstract suspend fun deleteAllIfNotFavorite()
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND id == :id AND isDetailed == 1 AND timeRetrieved > :after")
abstract fun getChargeLocationById(
id: Long,
dataSource: String,
after: Long
): LiveData<ChargeLocation>
@SkipQueryVerification
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND Within(coordinates, BuildMbr(:lng1, :lat1, :lng2, :lat2)) AND timeRetrieved > :after")
abstract fun getChargeLocationsInBounds(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double,
dataSource: String,
after: Long
): LiveData<List<ChargeLocation>>
@SkipQueryVerification
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND PtDistWithin(coordinates, MakePoint(:lng, :lat, 4326), :radius) AND timeRetrieved > :after ORDER BY Distance(coordinates, MakePoint(:lng, :lat, 4326))")
abstract fun getChargeLocationsRadius(
lat: Double,
lng: Double,
radius: Double,
dataSource: String,
after: Long
): LiveData<List<ChargeLocation>>
@RawQuery(observedEntities = [ChargeLocation::class])
abstract fun getChargeLocationsCustom(query: SupportSQLiteQuery): LiveData<List<ChargeLocation>>
@Query("SELECT COUNT(*) FROM chargelocation")
abstract fun getCount(): LiveData<Long>
@SkipQueryVerification
@Query("SELECT SUM(pgsize) FROM dbstat WHERE name == \"ChargeLocation\"")
abstract suspend fun getSize(): Long
}
/**
* The ChargeLocationsRepository wraps the ChargepointApi and the DB to provide caching
* functionality.
* and clustering functionality.
*/
class ChargeLocationsRepository(
api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
@@ -36,6 +105,13 @@ class ChargeLocationsRepository(
) {
val api = MutableLiveData<ChargepointApi<ReferenceData>>().apply { value = api }
// if zoom level is below this value, server-side clustering will be used (if the API provides it)
private val serverSideClusteringThreshold = 9f
private fun shouldUseServerSideClustering(zoom: Float) = zoom < serverSideClusteringThreshold
// if cached data is available and more recent than this duration, API will not be queried
private val cacheSoftLimit = Duration.ofDays(1)
val referenceData = this.api.switchMap { api ->
when (api) {
is GoingElectricApiWrapper -> {
@@ -61,18 +137,70 @@ class ChargeLocationsRepository(
}
private val chargeLocationsDao = db.chargeLocationsDao()
private val savedRegionDao = db.savedRegionDao()
fun getChargepoints(
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues?
): LiveData<Resource<List<ChargepointListItem>>> {
return liveData {
val refData = referenceData.await()
val result = api.value!!.getChargepoints(refData, bounds, zoom, filters)
val api = api.value!!
emit(result)
val dbResult = if (filters == null) {
chargeLocationsDao.getChargeLocationsInBounds(
bounds.southwest.latitude,
bounds.northeast.latitude,
bounds.southwest.longitude,
bounds.northeast.longitude,
api.id,
cacheLimitDate(api)
)
} else {
queryWithFilters(api, filters, bounds)
}.map { applyLocalClustering(it, zoom) }
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
val requiresDetail = filters?.let { api.filteringInSQLRequiresDetails(it) } ?: false
val savedRegionResult = savedRegionDao.savedRegionCovers(
bounds.southwest.latitude,
bounds.northeast.latitude,
bounds.southwest.longitude,
bounds.northeast.longitude,
api.id,
cacheSoftLimitDate(api),
filtersSerialized,
requiresDetail
)
val useClustering = shouldUseServerSideClustering(zoom)
val apiResult = liveData {
val refData = referenceData.await()
val time = Instant.now()
val result = api.getChargepoints(refData, bounds, zoom, useClustering, filters)
emit(applyLocalClustering(result, zoom))
if (result.status == Status.SUCCESS) {
val chargers = result.data!!.items.filterIsInstance<ChargeLocation>()
chargeLocationsDao.insertOrReplaceIfNoDetailedExists(
cacheLimitDate(api), *chargers.toTypedArray()
)
if (chargers.size == result.data.items.size && result.data.isComplete) {
val region = Mbr(
bounds.southwest.longitude,
bounds.southwest.latitude,
bounds.northeast.longitude,
bounds.northeast.latitude, 4326
).asPolygon()
savedRegionDao.insert(
SavedRegion(
region, api.id, time,
filtersSerialized,
false
)
)
}
}
}
return CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged()
}
fun getChargepointsRadius(
@@ -81,23 +209,119 @@ class ChargeLocationsRepository(
zoom: Float,
filters: FilterValues?
): LiveData<Resource<List<ChargepointListItem>>> {
return liveData {
val refData = referenceData.await()
val result = api.value!!.getChargepointsRadius(refData, location, radius, zoom, filters)
val api = api.value!!
emit(result)
val radiusMeters = radius.toDouble() * 1000
val dbResult = if (filters == null) {
chargeLocationsDao.getChargeLocationsRadius(
location.latitude,
location.longitude,
radiusMeters,
api.id,
cacheLimitDate(api)
)
} else {
queryWithFilters(api, filters, location, radiusMeters)
}.map { applyLocalClustering(it, zoom) }
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
val requiresDetail = filters?.let { api.filteringInSQLRequiresDetails(it) } ?: false
val savedRegionResult = savedRegionDao.savedRegionCoversRadius(
location.latitude,
location.longitude,
radiusMeters * 0.999, // to account for float rounding errors
api.id,
cacheSoftLimitDate(api),
filtersSerialized,
requiresDetail
)
val useClustering = shouldUseServerSideClustering(zoom)
val apiResult = liveData {
val refData = referenceData.await()
val time = Instant.now()
val result =
api.getChargepointsRadius(refData, location, radius, zoom, useClustering, filters)
emit(applyLocalClustering(result, zoom))
if (result.status == Status.SUCCESS) {
val chargers = result.data!!.items.filterIsInstance<ChargeLocation>()
chargeLocationsDao.insertOrReplaceIfNoDetailedExists(
cacheLimitDate(api), *chargers.toTypedArray()
)
if (chargers.size == result.data.items.size && result.data.isComplete) {
val region = Polygon(
savedRegionDao.makeCircle(
location.latitude,
location.longitude,
radiusMeters
)
)
savedRegionDao.insert(
SavedRegion(
region, api.id, time,
filtersSerialized,
false
)
)
}
}
}
return CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged()
}
private fun applyLocalClustering(
result: Resource<ChargepointList>,
zoom: Float
): Resource<List<ChargepointListItem>> {
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,
result.message
) // list already contains clusters
val clustered = applyLocalClustering(chargers, zoom)
return Resource(result.status, clustered, result.message)
}
private fun applyLocalClustering(
chargers: List<ChargeLocation>,
zoom: Float
): List<ChargepointListItem> {
/* in very crowded places (good example: central London on OpenChargeMap without filters)
we have to cluster even at pretty high zoom levels to make sure the map does not get
laggy. Otherwise, only cluster at zoom levels <= 11. */
val useClustering = chargers.size > 500 || zoom <= 11f
val clusterDistance = getClusterDistance(zoom)
val chargersClustered = if (useClustering && clusterDistance != null) {
Dispatchers.Default.run {
cluster(chargers, zoom, clusterDistance)
}
} else chargers
return chargersClustered
}
fun getChargepointDetail(
id: Long
): LiveData<Resource<ChargeLocation>> {
return liveData {
val dbResult = chargeLocationsDao.getChargeLocationById(
id,
prefs.dataSource,
cacheLimitDate(api.value!!)
)
val apiResult = liveData {
emit(Resource.loading(null))
val refData = referenceData.await()
val result = api.value!!.getChargepointDetail(refData, id)
emit(result)
if (result.status == Status.SUCCESS) {
chargeLocationsDao.insert(result.data!!)
}
}
return PreferCacheLiveData(dbResult, apiResult, cacheSoftLimit)
}
fun getFilters(sp: StringProvider) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
@@ -122,4 +346,79 @@ class ChargeLocationsRepository(
}
}
}
private fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
bounds: LatLngBounds
): LiveData<List<ChargeLocation>> {
val region =
"Within(coordinates, BuildMbr(${bounds.southwest.longitude}, ${bounds.southwest.latitude}, ${bounds.northeast.longitude}, ${bounds.northeast.latitude}))"
return queryWithFilters(api, filters, region)
}
private fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
location: LatLng,
radius: Double
): LiveData<List<ChargeLocation>> {
val region =
"PtDistWithin(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326), ${radius})"
val order =
"ORDER BY Distance(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326))"
return queryWithFilters(api, filters, region, order)
}
private fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
regionSql: String,
orderSql: String? = null
): LiveData<List<ChargeLocation>> = referenceData.singleSwitchMap { refData ->
try {
val query = api.convertFiltersToSQL(filters, refData)
val after = cacheLimitDate(api)
val sql = StringBuilder().apply {
append("SELECT")
if (query.requiresChargeCardQuery or query.requiresChargepointQuery) {
append(" DISTINCT chargelocation.*")
} else {
append(" *")
}
append(" FROM chargelocation")
if (query.requiresChargepointQuery) {
append(" JOIN json_each(chargelocation.chargepoints) AS cp")
}
if (query.requiresChargeCardQuery) {
append(" JOIN json_each(chargelocation.chargecards) AS cc")
}
append(" WHERE dataSource == '${prefs.dataSource}'")
append(" AND $regionSql")
append(" AND timeRetrieved > $after")
append(query.query)
orderSql?.let { append(" " + orderSql) }
}.toString()
chargeLocationsDao.getChargeLocationsCustom(
SimpleSQLiteQuery(
sql,
null
)
)
} catch (e: NotImplementedError) {
MutableLiveData() // in this case we cannot get a DB result
}
}
private fun cacheLimitDate(api: ChargepointApi<ReferenceData>): Long {
val cacheLimit = api.cacheLimit
return Instant.now().minus(cacheLimit).toEpochMilli()
}
private fun cacheSoftLimitDate(api: ChargepointApi<ReferenceData>): Long {
val cacheLimit = maxOf(api.cacheLimit, Duration.ofDays(2))
return Instant.now().minus(cacheLimit).toEpochMilli()
}
}

View File

@@ -0,0 +1,27 @@
package net.vonforst.evmap.storage
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import net.vonforst.evmap.api.createApi
import java.time.Instant
class CleanupCacheWorker(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val db = AppDatabase.getInstance(applicationContext)
val chargeLocations = db.chargeLocationsDao()
val savedRegionDao = db.savedRegionDao()
val now = Instant.now()
val dataSources = listOf("openchargemap", "goingelectric")
for (dataSource in dataSources) {
val api = createApi(dataSource, applicationContext)
val limit = now.minus(api.cacheLimit).toEpochMilli()
chargeLocations.deleteOutdatedIfNotFavorite(dataSource, limit)
savedRegionDao.deleteOutdated(dataSource, limit)
}
return Result.success()
}
}

View File

@@ -5,11 +5,13 @@ import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import co.anbora.labs.spatia.builder.SpatiaRoom
import co.anbora.labs.spatia.geometry.GeometryConverters
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.api.goingelectric.GEChargeCard
import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.openchargemap.OCMConnectionType
@@ -31,16 +33,18 @@ import net.vonforst.evmap.model.*
GEChargeCard::class,
OCMConnectionType::class,
OCMCountry::class,
OCMOperator::class
], version = 18
OCMOperator::class,
SavedRegion::class
], version = 21
)
@TypeConverters(Converters::class)
@TypeConverters(Converters::class, GeometryConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chargeLocationsDao(): ChargeLocationsDao
abstract fun favoritesDao(): FavoritesDao
abstract fun filterValueDao(): FilterValueDao
abstract fun filterProfileDao(): FilterProfileDao
abstract fun recentAutocompletePlaceDao(): RecentAutocompletePlaceDao
abstract fun savedRegionDao(): SavedRegionDao
// GoingElectric API specific
abstract fun geReferenceDataDao(): GEReferenceDataDao
@@ -51,21 +55,7 @@ abstract class AppDatabase : RoomDatabase() {
companion object {
private lateinit var context: Context
private val database: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
Room.databaseBuilder(context, AppDatabase::class.java, "evmap.db")
.addMigrations(
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16,
MIGRATION_17, MIGRATION_18
)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
// create default filter profile for each data source
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('goingelectric', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
}
})
.build()
initDb(SpatiaRoom.databaseBuilder(context, AppDatabase::class.java, "evmap.db"))
}
fun getInstance(context: Context): AppDatabase {
@@ -73,12 +63,44 @@ abstract class AppDatabase : RoomDatabase() {
return database
}
/**
* creates an in-memory AppDatabase instance - only for testing
*/
fun createInMemory(context: Context): AppDatabase {
return initDb(SpatiaRoom.inMemoryDatabaseBuilder(context, AppDatabase::class.java))
}
private fun initDb(builder: SpatiaRoom.Builder<AppDatabase>): AppDatabase {
return builder.addMigrations(
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16,
MIGRATION_17, MIGRATION_18, MIGRATION_19, MIGRATION_20, MIGRATION_21
)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
// create default filter profile for each data source
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('goingelectric', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
// initialize spatialite columns
db.query("SELECT RecoverGeometryColumn('ChargeLocation', 'coordinates', 4326, 'POINT', 'XY');")
.moveToNext()
db.query("SELECT CreateSpatialIndex('ChargeLocation', 'coordinates');")
.moveToNext()
db.query("SELECT RecoverGeometryColumn('SavedRegion', 'region', 4326, 'POLYGON', 'XY');")
.moveToNext()
db.query("SELECT CreateSpatialIndex('SavedRegion', 'region');")
.moveToNext()
}
}).build()
}
private val MIGRATION_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// SQL for creating tables copied from build/generated/source/kapt/debug/net/vonforst/evmap/storage/AppDatbase_Impl
db.execSQL("CREATE TABLE IF NOT EXISTS `BooleanFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `MultipleChoiceFilterValue` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, PRIMARY KEY(`key`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))");
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))")
}
}
@@ -87,8 +109,8 @@ abstract class AppDatabase : RoomDatabase() {
// recreate ChargeLocation table to make postcode nullable
db.beginTransaction()
try {
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT NOT NULL, `country` TEXT NOT NULL, `postcode` TEXT, `street` TEXT NOT NULL, `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, PRIMARY KEY(`id`))");
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`");
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT NOT NULL, `country` TEXT NOT NULL, `postcode` TEXT, `street` TEXT NOT NULL, `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, PRIMARY KEY(`id`))")
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`")
db.execSQL("DROP TABLE `ChargeLocation`")
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.setTransactionSuccessful()
@@ -109,8 +131,8 @@ abstract class AppDatabase : RoomDatabase() {
// recreate ChargeLocation table to make other address fields nullable
db.beginTransaction()
try {
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` 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, PRIMARY KEY(`id`))");
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`");
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` 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, PRIMARY KEY(`id`))")
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`")
db.execSQL("DROP TABLE `ChargeLocation`")
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.setTransactionSuccessful()
@@ -160,7 +182,7 @@ abstract class AppDatabase : RoomDatabase() {
// add profile column to existing filtervalue tables
db.execSQL("CREATE TABLE `BooleanFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE `MultipleChoiceFilterValueNew` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )");
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
for (table in listOf(
"BooleanFilterValue",
@@ -202,7 +224,7 @@ abstract class AppDatabase : RoomDatabase() {
//////////////////////////////////////////
db.execSQL("CREATE TABLE `OCMConnectionType` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))")
db.execSQL("CREATE TABLE `OCMCountry` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))")
db.execSQL("CREATE TABLE `OCMOperator` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))");
db.execSQL("CREATE TABLE `OCMOperator` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))")
//////////////////////////////////////////
// rename GoingElectric-specific tables //
@@ -295,7 +317,7 @@ abstract class AppDatabase : RoomDatabase() {
// update ChargeLocation table to change primary key
db.execSQL(
"CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT 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, `lat` REAL NOT NULL, `lng` REAL 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`))"
);
)
val columnList =
"`id`,`dataSource`,`name`,`chargepoints`,`network`,`url`,`editUrl`,`verified`,`barrierFree`,`operator`,`generalInformation`,`amenities`,`locationDescription`,`photos`,`chargecards`,`license`,`lat`,`lng`,`city`,`country`,`postcode`,`street`,`fault_report_created`,`fault_report_description`,`twentyfourSeven`,`description`,`mostart`,`moend`,`tustart`,`tuend`,`westart`,`weend`,`thstart`,`thend`,`frstart`,`frend`,`sastart`,`saend`,`sustart`,`suend`,`hostart`,`hoend`,`freecharging`,`freeparking`,`descriptionShort`,`descriptionLong`,`chargepricecountry`,`chargepricenetwork`,`chargepriceplugTypes`"
db.execSQL("INSERT INTO `ChargeLocationNew`($columnList) SELECT $columnList FROM `ChargeLocation`")
@@ -311,7 +333,7 @@ abstract class AppDatabase : RoomDatabase() {
private val MIGRATION_14 = object : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `RecentAutocompletePlace` (`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`))");
db.execSQL("CREATE TABLE IF NOT EXISTS `RecentAutocompletePlace` (`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`))")
}
}
@@ -321,7 +343,7 @@ abstract class AppDatabase : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) {
try {
db.beginTransaction()
db.execSQL("CREATE TABLE IF NOT EXISTS `Favorite` (`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 RESTRICT )");
db.execSQL("CREATE TABLE IF NOT EXISTS `Favorite` (`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 RESTRICT )")
val cursor = db.query("SELECT * FROM `ChargeLocation`")
while (cursor.moveToNext()) {
@@ -361,7 +383,7 @@ abstract class AppDatabase : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) {
try {
db.beginTransaction()
db.execSQL("CREATE TABLE IF NOT EXISTS `FavoriteNew` (`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 )");
db.execSQL("CREATE TABLE IF NOT EXISTS `FavoriteNew` (`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 )")
val columnList =
"`favoriteId`,`chargerId`,`chargerDataSource`"
db.execSQL("INSERT INTO `FavoriteNew`($columnList) SELECT $columnList FROM `Favorite`")
@@ -376,5 +398,85 @@ abstract class AppDatabase : RoomDatabase() {
}
}
private val MIGRATION_19 = object : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `ChargeLocation` ADD `networkUrl` TEXT")
db.execSQL("ALTER TABLE `ChargeLocation` ADD `chargerUrl` TEXT")
}
}
private val MIGRATION_20 = object : Migration(19, 20) {
override fun migrate(db: SupportSQLiteDatabase) {
try {
db.beginTransaction()
// init spatialite
db.query("SELECT InitSpatialMetaData();").moveToNext()
// add geometry column and set it based on lat/lng columns
db.query("SELECT AddGeometryColumn('ChargeLocation', 'coordinates', 4326, 'POINT', 'XY');")
.moveToNext()
db.execSQL("UPDATE `ChargeLocation` SET `coordinates` = GeomFromText('POINT('||\"lng\"||' '||\"lat\"||')',4326);")
// recreate table to remove lat/lng columns
db.execSQL(
"CREATE TABLE `ChargeLocationNew` (`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, `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, `networkUrl` TEXT, `chargerUrl` TEXT, PRIMARY KEY(`id`, `dataSource`))"
)
db.query("SELECT AddGeometryColumn('ChargeLocationNew', 'coordinates', 4326, 'POINT', 'XY');")
.moveToNext()
db.query("SELECT CreateSpatialIndex('ChargeLocationNew', 'coordinates');")
.moveToNext()
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT `id`, `dataSource`, `name`, `coordinates`, `chargepoints`, `network`, `url`, `editUrl`, `verified`, `barrierFree`, `operator`, `generalInformation`, `amenities`, `locationDescription`, `photos`, `chargecards`, `license`, `timeRetrieved`, `isDetailed`, `city`, `country`, `postcode`, `street`, `fault_report_created`, `fault_report_description`, `twentyfourSeven`, `description`, `mostart`, `moend`, `tustart`, `tuend`, `westart`, `weend`, `thstart`, `thend`, `frstart`, `frend`, `sastart`, `saend`, `sustart`, `suend`, `hostart`, `hoend`, `freecharging`, `freeparking`, `descriptionShort`, `descriptionLong`, `chargepricecountry`, `chargepricenetwork`, `chargepriceplugTypes`, `networkUrl`, `chargerUrl` FROM `ChargeLocation`")
db.execSQL("DROP TABLE `ChargeLocation`")
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.execSQL("CREATE TABLE IF NOT EXISTS `SavedRegion` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)");
db.execSQL("CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `SavedRegion` (`filters`, `dataSource`)");
db.query("SELECT AddGeometryColumn('SavedRegion', 'region', 4326, 'POLYGON', 'XY');")
.moveToNext()
db.query("SELECT CreateSpatialIndex('SavedRegion', 'region');")
.moveToNext()
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
private val MIGRATION_21 = object : Migration(20, 21) {
override fun migrate(db: SupportSQLiteDatabase) {
// clear cache with this update
db.execSQL("DELETE FROM savedregion")
}
}
}
/**
* Creates a backup of the database to evmap-backup.db.
*
* The backup excludes cached data which can easily be retrieved from the network on restore.
*/
suspend fun createBackup(context: Context, fileName: String) {
val db = getInstance(context.applicationContext)
val backupDb = initDb(
SpatiaRoom.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
fileName
)
)
backupDb.clearAllTables()
val favorites = db.favoritesDao().getAllFavoritesAsync()
backupDb.chargeLocationsDao().insert(*favorites.map { it.charger }.toTypedArray())
backupDb.favoritesDao().insert(*favorites.map { it.favorite }.toTypedArray())
backupDb.filterProfileDao().insert(*db.filterProfileDao().getAllProfiles().toTypedArray())
backupDb.filterValueDao().insert(*db.filterValueDao().getAllFilterValues().toTypedArray())
backupDb.recentAutocompletePlaceDao()
.insert(*db.recentAutocompletePlaceDao().getAllAsync().toTypedArray())
backupDb.close()
}
}

View File

@@ -0,0 +1,45 @@
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
/**
* Encrypted data storage for sensitive data such as API access tokens.
* This will not be included in backups.
*/
class EncryptedPreferenceDataStore(context: Context) : TeslaAvailabilityDetector.TokenStore {
val sp = EncryptedSharedPreferences.create(
context,
"encrypted_prefs",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
override var teslaRefreshToken: String?
get() = sp.getString(
"tesla_refresh_token", null
)
set(value) {
sp.edit().putString("tesla_refresh_token", value).apply()
}
override var teslaAccessToken: String?
get() = sp.getString("tesla_access_token", null)
set(value) {
sp.edit().putString("tesla_access_token", value).apply()
}
override var teslaAccessTokenExpiry: Long
get() = sp.getLong("tesla_access_token_expiry", -1)
set(value) {
sp.edit().putLong("tesla_access_token_expiry", value).apply()
}
var teslaEmail: String?
get() = sp.getString("tesla_email", null)
set(value) {
sp.edit().putString("tesla_email", value).apply()
}
}

View File

@@ -19,7 +19,8 @@ interface FavoritesDao {
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id")
suspend fun getAllFavoritesAsync(): List<FavoriteWithDetail>
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE lat >= :lat1 AND lat <= :lat2 AND lng >= :lng1 AND lng <= :lng2")
@SkipQueryVerification
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE Within(chargelocation.coordinates, BuildMbr(:lng1, :lat1, :lng2, :lat2))")
suspend fun getFavoritesInBoundsAsync(
lat1: Double,
lat2: Double,

View File

@@ -19,7 +19,7 @@ data class FilterProfile(
@Dao
interface FilterProfileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(profile: FilterProfile): Long
suspend fun insert(vararg profile: FilterProfile)
@Update
suspend fun update(vararg profiles: FilterProfile)
@@ -30,6 +30,9 @@ interface FilterProfileDao {
@Query("SELECT * FROM filterProfile WHERE dataSource = :dataSource AND id != $FILTERS_CUSTOM ORDER BY `order` ASC, `name` ASC")
fun getProfiles(dataSource: String): LiveData<List<FilterProfile>>
@Query("SELECT * FROM filterProfile")
suspend fun getAllProfiles(): List<FilterProfile>
@Query("SELECT * FROM filterProfile WHERE dataSource = :dataSource AND name = :name")
suspend fun getProfileByName(name: String, dataSource: String): FilterProfile?

View File

@@ -26,6 +26,15 @@ abstract class FilterValueDao {
dataSource: String
): List<SliderFilterValue>
@Query("SELECT * FROM booleanfiltervalue")
protected abstract suspend fun getAllBooleanFilterValuesAsync(): List<BooleanFilterValue>
@Query("SELECT * FROM multiplechoicefiltervalue")
protected abstract suspend fun getAllMultipleChoiceFilterValuesAsync(): List<MultipleChoiceFilterValue>
@Query("SELECT * FROM sliderfiltervalue")
protected abstract suspend fun getAllSliderFilterValuesAsync(): List<SliderFilterValue>
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract fun getBooleanFilterValues(
profile: Long,
@@ -105,6 +114,11 @@ abstract class FilterValueDao {
}
}
open suspend fun getAllFilterValues(): List<FilterValue> =
getAllBooleanFilterValuesAsync() +
getAllMultipleChoiceFilterValuesAsync() +
getAllSliderFilterValuesAsync()
@Transaction
open suspend fun insert(vararg values: FilterValue) {
values.forEach {

View File

@@ -1,6 +1,8 @@
package net.vonforst.evmap.storage
import android.content.Context
import android.content.SharedPreferences
import android.content.SharedPreferences.Editor
import androidx.preference.PreferenceManager
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
@@ -224,21 +226,9 @@ class PreferenceDataSource(val context: Context) {
}
var placeSearchResultAndroidAuto: LatLng?
get() = if (sp.contains("place_search_result_android_auto_lat")) {
LatLng(
Double.fromBits(sp.getLong("place_search_result_android_auto_lat", 0L)),
Double.fromBits(sp.getLong("place_search_result_android_auto_lng", 0L))
)
} else null
get() = sp.getLatLng("place_search_result_android_auto")
set(value) {
if (value == null) {
sp.edit().remove("place_search_result_android_auto_lat")
.remove("place_search_result_android_auto_lng").apply()
} else {
sp.edit().putLong("place_search_result_android_auto_lat", value.latitude.toBits())
.putLong("place_search_result_android_auto_lng", value.longitude.toBits())
.apply()
}
sp.edit().putLatLng("place_search_result_android_auto", value).apply()
}
var placeSearchResultAndroidAutoName: String?
@@ -261,4 +251,38 @@ class PreferenceDataSource(val context: Context) {
set(value) {
sp.edit().putBoolean("dev_mode_enabled", value).apply()
}
}
val mapScale: String
get() = sp.getString("map_scale", null) ?: "both"
var currentMapLocation: LatLng
get() = sp.getLatLng("current_map_location") ?: LatLng(50.113388, 9.252536)
set(value) {
sp.edit().putLatLng("current_map_location", value).apply()
}
var currentMapZoom: Float
get() = sp.getFloat("current_map_zoom", 3.5f)
set(value) {
sp.edit().putFloat("current_map_zoom", value).apply()
}
}
fun SharedPreferences.getLatLng(key: String): LatLng? =
if (contains("${key}_lat") && contains("${key}_lng")) {
LatLng(
Double.fromBits(getLong("${key}_lat", 0L)),
Double.fromBits(getLong("${key}_lng", 0L))
)
} else null
fun Editor.putLatLng(key: String, value: LatLng?): Editor {
if (value == null) {
remove("${key}_lat")
remove("${key}_lng")
} else {
putLong("${key}_lat", value.latitude.toBits())
putLong("${key}_lng", value.longitude.toBits())
}
return this
}

View File

@@ -80,4 +80,7 @@ abstract class RecentAutocompletePlaceDao {
dataSource: String,
limit: Int? = null
): List<RecentAutocompletePlace>
@Query("SELECT * FROM recentautocompleteplace")
abstract suspend fun getAllAsync(): List<RecentAutocompletePlace>
}

View File

@@ -0,0 +1,114 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.room.*
import co.anbora.labs.spatia.geometry.Geometry
import co.anbora.labs.spatia.geometry.LineString
import co.anbora.labs.spatia.geometry.Polygon
import net.vonforst.evmap.utils.circleAsEllipse
import java.time.Instant
@Entity(
indices = [Index(value = ["filters", "dataSource"])]
)
data class SavedRegion(
val region: Polygon,
val dataSource: String,
val timeRetrieved: Instant,
val filters: String?,
val isDetailed: Boolean,
@PrimaryKey(autoGenerate = true)
val id: Long? = null
)
@Dao
abstract class SavedRegionDao {
@SkipQueryVerification
@Query("SELECT GUnion(region) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)")
abstract fun getSavedRegion(
dataSource: String,
after: Long,
filters: String? = null,
isDetailed: Boolean = false
): Geometry
@SkipQueryVerification
@Query("SELECT Covers(GUnion(region), BuildMbr(:lng1, :lat1, :lng2, :lat2, 4326)) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND Intersects(region, BuildMbr(:lng1, :lat1, :lng2, :lat2, 4326)) AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)")
protected abstract fun savedRegionCoversInt(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): LiveData<Int>
@SkipQueryVerification
@Query("SELECT Covers(GUnion(region), MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND Intersects(region, MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)) AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)")
protected abstract fun savedRegionCoversRadiusInt(
lat: Double,
lng: Double,
radiusLat: Double,
radiusLng: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): LiveData<Int>
fun savedRegionCovers(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): LiveData<Boolean> {
return savedRegionCoversInt(
lat1,
lat2,
lng1,
lng2,
dataSource,
after,
filters,
isDetailed
).map { it == 1 }
}
fun savedRegionCoversRadius(
lat: Double,
lng: Double,
radius: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): LiveData<Boolean> {
val (radiusLat, radiusLng) = circleAsEllipse(lat, lng, radius)
return savedRegionCoversRadiusInt(
lat,
lng,
radiusLat,
radiusLng,
dataSource,
after,
filters,
isDetailed
).map { it == 1 }
}
@Insert
abstract suspend fun insert(savedRegion: SavedRegion)
@Query("DELETE FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved <= :before")
abstract suspend fun deleteOutdated(dataSource: String, before: Long)
@Query("DELETE FROM savedregion")
abstract suspend fun deleteAll()
@SkipQueryVerification
@Query("SELECT MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)")
protected abstract suspend fun makeEllipse(
lat: Double, lng: Double,
radiusLat: Double, radiusLng: Double
): LineString
suspend fun makeCircle(lat: Double, lng: Double, radius: Double): LineString {
val (radiusLat, radiusLng) = circleAsEllipse(lat, lng, radius)
return makeEllipse(lat, lng, radiusLat, radiusLng)
}
}

View File

@@ -1,6 +1,7 @@
package net.vonforst.evmap.storage
import androidx.room.TypeConverter
import co.anbora.labs.spatia.geometry.Point
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.squareup.moshi.Moshi
@@ -12,6 +13,7 @@ import net.vonforst.evmap.autocomplete.AutocompletePlaceType
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.Coordinate
import java.time.Instant
import java.time.LocalTime
@@ -68,7 +70,7 @@ class Converters {
@TypeConverter
fun toChargerPhotoList(value: String): List<ChargerPhoto>? {
return chargerPhotoListAdapter.fromJson(value)?.filterNotNull()
return chargerPhotoListAdapter.fromJson(value)
}
@TypeConverter
@@ -154,4 +156,15 @@ class Converters {
fun toAutocompletePlaceTypeList(value: String): List<AutocompletePlaceType> {
return value.split(",").map { AutocompletePlaceType.valueOf(it) }
}
@TypeConverter
fun toCoordinate(value: Point): Coordinate {
if (value.srid != 4326) throw IllegalArgumentException("expected WGS-84")
return Coordinate(value.y, value.x)
}
@TypeConverter
fun fromCoordinate(value: Coordinate): Point {
return Point(value.lng, value.lat)
}
}

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