Compare commits

..

62 Commits

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

#313

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

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

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

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

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

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

Translated using Weblate (Czech)

Currently translated at 100.0% (356 of 356 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (355 of 355 strings)

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

Co-authored-by: mcliquid <info@mcliquid.de>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2024-10-17 18:43:40 +02:00
johan12345
90b3645a0b fix crash when trying to rename a filter profile to a name that already exists 2024-10-15 20:20:27 +02:00
johan12345
de901aa825 Release 1.9.7 (only for Faurecia Aptoide) 2024-08-16 19:13:30 +02:00
johan12345
2ce61f2f6b AAOS: more navigateToCharger fixes 2024-08-16 19:12:34 +02:00
246 changed files with 3304 additions and 7763 deletions

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Java environment
uses: actions/setup-java@v4
with:
java-version: 21
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Decrypt keystore
@@ -24,7 +24,7 @@ jobs:
- name: Extract version code
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s*=\s*[0-9]\+" app/build.gradle.kts | awk '{ print $3 }' | tr -d \''"\\')" >> $GITHUB_ENV
- name: Build app release & export libraries
- name: Build app release
env:
GOINGELECTRIC_API_KEY: ${{ secrets.GOINGELECTRIC_API_KEY }}
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
@@ -38,7 +38,7 @@ jobs:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
run: ./gradlew exportLibraryDefinitions assembleRelease --no-daemon
run: ./gradlew assembleRelease --no-daemon
- name: release
uses: actions/create-release@v1
@@ -88,12 +88,3 @@ jobs:
asset_path: app/build/outputs/apk/fossAutomotive/release/app-foss-automotive-release.apk
asset_name: app-foss-automotive-release.apk
asset_content_type: application/vnd.android.package-archive
- name: upload Licenses
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/generated/aboutLibraries/aboutlibraries.json
asset_name: aboutlibraries.json
asset_content_type: application/json

View File

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

View File

@@ -86,13 +86,10 @@ Sponsors
Many users currently support the development EVMap with their donations. You can find more
information on the [Donate page](https://ev-map.app/donate/) on the EVMap website.
<a href="https://www.jawg.io"><img src="https://www.jawg.io/static/Blue@10x-9cdc4596e4e59acbd9ead55e9c28613e.png" alt="JawgMaps" height="38"/></a><br>
<a href="https://www.jawg.io"><img src="https://www.jawg.io/static/Blue@10x-9cdc4596e4e59acbd9ead55e9c28613e.png" alt="JawgMaps" height="58"/></a><br>
Since May 2024, **JawgMaps** provides their OpenStreetMap vector map tiles service to EVMap for
free, i.e. the background map displayed in the app if OpenStreetMap is selected as the data source.
<a href="https://chargeprice.app"><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/powered_by_chargeprice.svg" alt="Powered by Chargeprice" height="38"/></a><br>
<a href="https://chargeprice.app"><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/powered_by_chargeprice.svg" alt="Powered by Chargeprice" height="58"/></a><br>
Since April 2021, **Chargeprice.app** provide their price comparison API at a greatly reduced
price for EVMap. This data is used in EVMap's price comparison feature.
<a href="https://www.jetbrains.com/community/opensource/"><img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains logo" height="38"/></a><br>
As part of its support program for Open-source projects, **JetBrains** supports the development of EVMap since December 2023 with a license of their software suite.
price for EVMap. This data is used in EVMap's price comparison feature.

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" version="1.1"
viewBox="0 0 108 108">
<defs>
<style>
.cls-1 {
fill: #000;
stroke-width: 0px;
}
</style>
</defs>
<path class="cls-1"
d="M53.9,28c-8.8,0-15.9,7.1-15.9,15.9s13.4,18.2,15,35.3c0,.5.5.9,1,.9s.9-.4,1-.9c1.6-17.1,15-23.3,15-35.3-.1-8.8-7.2-15.9-16-15.9ZM59,43.1l-6.1,10.5v-7.9h-2.6v-9.6s8.8,0,8.7,0l-3.5,7h3.5Z" />
</svg>

Before

Width:  |  Height:  |  Size: 529 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z" />
</svg>

Before

Width:  |  Height:  |  Size: 395 B

View File

@@ -1,7 +1,7 @@
import java.util.Base64
plugins {
id("com.adarshr.test-logger") version "4.0.0"
id("com.adarshr.test-logger") version "3.1.0"
id("com.android.application")
id("kotlin-android")
id("kotlin-parcelize")
@@ -17,18 +17,18 @@ android {
defaultConfig {
applicationId = "net.vonforst.evmap"
compileSdk = 36
compileSdk = 35
minSdk = 21
targetSdk = 36
targetSdk = 35
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode = 230
versionName = "1.9.6"
versionCode = 256
versionName = "1.9.18"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
val isRunningOnCI = System.getenv("CI") == "true"
@@ -78,7 +78,6 @@ android {
productFlavors {
create("foss") {
dimension = "dependencies"
isDefault = true
}
create("google") {
dimension = "dependencies"
@@ -86,7 +85,6 @@ android {
}
create("normal") {
dimension = "automotive"
isDefault = true
}
create("automotive") {
dimension = "automotive"
@@ -258,21 +256,18 @@ configurations {
}
aboutLibraries {
license {
allowedLicenses = setOf(
"Apache-2.0", "mit", "BSD-2-Clause", "BSD-3-Clause", "EPL-1.0",
"asdkl", // Android SDK
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
"Google Maps Platform Terms of Service", // Google Maps SDK
"Unicode/ICU License", "Unicode-3.0", // icu4j
"Bouncy Castle Licence", // bcprov
"CDDL + GPLv2 with classpath exception", // javax.annotation-api
)
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
}
export {
excludeFields = setOf("generated")
}
allowedLicenses = arrayOf(
"Apache-2.0", "mit", "BSD-2-Clause", "BSD-3-Clause", "EPL-1.0",
"asdkl", // Android SDK
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
"Google Maps Platform Terms of Service", // Google Maps SDK
"provided without support or warranty", // org.json
"Unicode/ICU License", "Unicode-3.0", // icu4j
"Bouncy Castle Licence", // bcprov
"CDDL + GPLv2 with classpath exception", // javax.annotation-api
)
excludeFields = arrayOf("generated")
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
}
dependencies {
@@ -286,99 +281,101 @@ dependencies {
val testGoogleImplementation by configurations
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("androidx.fragment:fragment-ktx:1.8.9")
implementation("androidx.activity:activity-ktx:1.9.0")
implementation("androidx.fragment:fragment-ktx:1.7.1")
implementation("androidx.cardview:cardview:1.0.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.google.android.material:material:1.13.0-rc01")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("androidx.browser:browser:1.9.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.viewpager2:viewpager2:1.1.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.work:work-runtime-ktx:2.10.3")
implementation("com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b")
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-moshi:3.0.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.12.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
implementation("com.squareup.moshi:moshi-adapters:1.15.2")
implementation("com.markomilos.jsonapi:jsonapi-retrofit:1.1.0")
implementation("io.coil-kt:coil:2.7.0")
implementation("io.coil-kt:coil:2.6.0")
implementation("com.github.ev-map:StfalconImageViewer:5082ebd392")
implementation("com.mikepenz:aboutlibraries-core:$aboutLibsVersion")
implementation("com.mikepenz:aboutlibraries:$aboutLibsVersion")
implementation("com.airbnb.android:lottie:6.6.7")
implementation("com.airbnb.android:lottie:4.1.0")
implementation("io.michaelrocks.bimap:bimap:1.1.0")
implementation("com.google.guava:guava:29.0-android")
implementation("com.github.pengrad:mapscaleview:1.6.0")
implementation("com.github.romandanylyk:PageIndicatorView:b1bad589b5")
implementation("com.github.erfansn:locale-config-x:1.0.1")
// Android Auto
val carAppVersion = "1.7.0"
val carAppVersion = "1.7.0-rc01"
implementation("androidx.car.app:app:$carAppVersion")
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
// AnyMaps
val anyMapsVersion = "1174ef9375"
val anyMapsVersion = "a3290b148d"
implementation("com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion")
googleImplementation("com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion")
googleImplementation("com.google.android.gms:play-services-maps:19.2.0")
googleImplementation("com.google.android.gms:play-services-maps:19.0.0")
implementation("com.github.ev-map.AnyMaps:anymaps-maplibre:$anyMapsVersion") {
// duplicates classes from mapbox-sdk-services
exclude("org.maplibre.gl", "android-sdk-geojson")
}
implementation("org.maplibre.gl:android-sdk:10.3.5") {
implementation("org.maplibre.gl:android-sdk:10.3.4") {
exclude("org.maplibre.gl", "android-sdk-geojson")
}
// Google Places
googleImplementation("com.google.android.libraries.places:places:3.5.0")
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2")
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Mapbox Geocoding
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.8.0")
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0")
// navigation library
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
// viewmodel library
val lifecycleVersion = "2.9.2"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
val lifecycle_version = "2.8.1"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
// room library
val roomVersion = "2.7.2"
implementation("androidx.room:room-runtime:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
val room_version = "2.7.1"
implementation("androidx.room:room-runtime:$room_version")
ksp("androidx.room:room-compiler:$room_version")
implementation("androidx.room:room-ktx:$room_version")
implementation("com.github.anboralabs:spatia-room:0.3.0") {
exclude("com.github.dalgarins", "android-spatialite")
}
// forked version with upgraded sqlite & libxml & 16 KB page size support
// https://github.com/dalgarins/android-spatialite/pull/11
// https://github.com/dalgarins/android-spatialite/pull/12
implementation("io.github.ev-map:android-spatialite:2.2.1-alpha")
// forked version with upgraded sqlite & libxml
// https://github.com/dalgarins/android-spatialite/pull/10
implementation("com.github.ev-map:android-spatialite:31495dcd81")
// billing library
val billingVersion = "7.0.0"
googleImplementation("com.android.billingclient:billing:$billingVersion")
googleImplementation("com.android.billingclient:billing-ktx:$billingVersion")
val billing_version = "7.0.0"
googleImplementation("com.android.billingclient:billing:$billing_version")
googleImplementation("com.android.billingclient:billing-ktx:$billing_version")
// ACRA (crash reporting)
val acraVersion = "5.12.0"
val acraVersion = "5.11.1"
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
debugImplementation("com.facebook.flipper:flipper:0.238.0")
debugImplementation("com.facebook.soloader:soloader:0.10.5")
debugImplementation("com.facebook.flipper:flipper-network-plugin:0.238.0")
debugImplementation("com.jakewharton.timber:timber:5.0.1")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
@@ -386,18 +383,20 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
//noinspection GradleDependency
testImplementation("org.robolectric:robolectric:4.16-beta-1")
testImplementation("androidx.test:core:1.7.0")
testImplementation("org.json:json:20080701")
testImplementation("org.robolectric:robolectric:4.11.1")
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.car.app:app-testing:$carAppVersion")
testImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}
fun decode(s: String, key: String): String {

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,114 +0,0 @@
package net.vonforst.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 kotlinx.coroutines.runBlocking
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargeLocationCluster
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.Coordinate
import net.vonforst.evmap.ui.cluster
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
class ChargeLocationsDaoTest {
private lateinit var database: AppDatabase
private lateinit var dao: ChargeLocationsDao
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = AppDatabase.createInMemory(context)
dao = database.chargeLocationsDao()
}
@After
fun tearDown() {
database.close()
}
@Test
fun testClustering() {
val lat1 = 53.0
val lng1 = 9.0
val lat2 = 54.0
val lng2 = 10.0
val chargeLocations = (0..100).map { i ->
val lat = Random.nextDouble(lat1, lat2)
val lng = Random.nextDouble(lng1, lng2)
ChargeLocation(
i.toLong(),
"test",
"test",
Coordinate(lat, lng),
null,
emptyList(),
null,
"https://google.com",
null,
null,
false,
null,
null,
null,
null,
null,
null,
null, null, null, null, null, null, null, Instant.now(), true
)
}
runBlocking {
dao.insert(*chargeLocations.toTypedArray())
}
val zoom = 10f
val clusteredInMemory = cluster(chargeLocations, zoom).sorted()
val clusteredInDB = runBlocking {
dao.getChargeLocationsClustered(lat1, lat2, lng1, lng2, "test", 0L, zoom)
}.sorted()
assertEquals(clusteredInMemory.size, clusteredInDB.size)
clusteredInDB.zip(clusteredInMemory).forEach { (a, b) ->
when (a) {
is ChargeLocation -> {
assertTrue(b is ChargeLocation)
assertEquals(a, b)
}
is ChargeLocationCluster -> {
assertTrue(b is ChargeLocationCluster)
assertEquals(a.clusterCount, (b as ChargeLocationCluster).clusterCount)
assertEquals(a.coordinates.lat, b.coordinates.lat, 1e-5)
assertEquals(a.coordinates.lng, b.coordinates.lng, 1e-5)
}
}
}
}
private fun List<ChargepointListItem>.sorted() = sortedBy {
when (it) {
is ChargeLocationCluster -> it.coordinates.lat
is ChargeLocation -> it.coordinates.lat
else -> 0.0
}
}.sortedBy {
when (it) {
is ChargeLocationCluster -> it.coordinates.lng
is ChargeLocation -> it.coordinates.lng
else -> 0.0
}
}
}

View File

@@ -2,4 +2,4 @@
<resources>
<string name="grant_on_phone">Povolit</string>
<string name="auto_location_permission_needed">Pro spuštění aplikace EVMap ve vašem autě musíte povolit přístup k vaší poloze.</string>
</resources>
</resources>

View File

@@ -2,4 +2,4 @@
<resources>
<string name="grant_on_phone">Zulassen</string>
<string name="auto_location_permission_needed">Um EVMap auf deinem Fahrzeug zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
</resources>
</resources>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Luba</string>
<string name="auto_location_permission_needed">Et EVMap toimiks sinu autos, palun luba tal asukohta tuvastada.</string>
</resources>

View File

@@ -2,4 +2,4 @@
<resources>
<string name="grant_on_phone">Autoriser</string>
<string name="auto_location_permission_needed">Pour exécuter EVMap sur Android Auto, vous devez autoriser l\'accès à votre emplacement.</string>
</resources>
</resources>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Consenti</string>
<string name="auto_location_permission_needed">Per eseguire EVMap sulla propria auto, è necessario concedere l\'accesso alla propria posizione.</string>
</resources>

View File

@@ -2,4 +2,4 @@
<resources>
<string name="auto_location_permission_needed">Du må du innvilge posisjonstilgang for å kjøre EVMap i bilen din.</string>
<string name="grant_on_phone">Tillat</string>
</resources>
</resources>

View File

@@ -2,4 +2,4 @@
<resources>
<string name="grant_on_phone">Toestaan</string>
<string name="auto_location_permission_needed">Om EVmap te gebruiken in je wagen, moet je toegang geven tot je locatie.</string>
</resources>
</resources>

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,44 @@ 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
import timber.log.Timber
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()
Timber.plant(Timber.DebugTree())
}
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

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

View File

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

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Kas EVMap on sulle kasulik? Oma arendajale saadetava rahalise toetusega edendad ka arendustegevust.</string>
<string name="donate_paypal">Toeta PayPali abil</string>
<string name="data_sources_hint">Selles rakenduses näidatavad kaardiandmed on pärit OpenStreetMapist.</string>
</resources>

View File

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

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Trovi utile EVMap? Sostieni il suo sviluppo inviando una donazione allo sviluppatore.</string>
<string name="donate_paypal">Dona attraverso PayPal</string>
<string name="data_sources_hint">I dati cartografici dell\'applicazione sono forniti da OpenStreetMap.</string>
</resources>

View File

@@ -2,5 +2,5 @@
<resources>
<string name="donate_paypal">Doner med PayPal</string>
<string name="data_sources_hint">Kartdata i programmet tilbys av OpenStreetMap.</string>
<string name="donations_info" formatted="false">Synes du EVMap er nyttig? Støtt utviklingen ved å sende en slant til utvikleren.</string>
</resources>
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende en slant til utvikleren.</string>
</resources>

View File

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

View File

@@ -2,5 +2,5 @@
<resources>
<string name="data_sources_hint">Os dados do mapa são fornecidos pelo OpenStreetMap.</string>
<string name="donate_paypal">Doar com o PayPal</string>
<string name="donations_info" formatted="false">Acha que o EVMap é útil? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.</string>
</resources>
<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

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>@string/pref_provider_osm</item>
<item>@string/pref_provider_osm_mapbox</item>
</string-array>
<string-array name="pref_map_provider_values" translatable="false">
<item>mapbox</item>

View File

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

View File

@@ -2,4 +2,4 @@
<resources>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap für die Kartendaten wechseln.</string>
</resources>
</resources>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="data_sources_hint">Seadistustes saad valida kahe kaardiandmete allika vahel: Google Maps ja OpenStreetMap.</string>
<string name="donations_info" formatted="false">EVMap on sinu jaoks kasulik? Toeta edasist arendust oma rahalise panusega.\n\nGoogle võtab igast toestussummast teenustasuna 15%.</string>
</resources>

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile ? Soutenez son développement en envoyant un don au développeur. \n \nGoogle prend 15% sur chaque don.</string>
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.
\n
\nGoogle prend 15% sur chaque don.</string>
<string name="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap pour les données cartographiques.</string>
</resources>
</resources>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Trovi utile EVMap? Sostieni il suo sviluppo inviando una donazione allo sviluppatore.\n\nGoogle si prende il 15% su ogni donazione.</string>
<string name="data_sources_hint">Nelle impostazioni si può anche scegliere tra Google Maps e OpenStreetMap per i dati cartografici.</string>
</resources>

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Synes du EVMap er nyttig? Støtt utviklingen ved å sende penger til utvikleren. \n \nGoogle tar 15% av alle donasjoner.</string>
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende penger til utvikleren.
\n
\nGoogle tar 15% av alle donasjoner.</string>
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap for kartdata.</string>
</resources>
</resources>

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Vind je EVMap nuttig? Je kan de ontwikkeling steunen via een donatie aan de ontwikkelaar. \n \nGoogle houdt 15% in van elke donatie.</string>
<string name="donations_info" formatted="false">Vind je EVMap nuttig\? Je kan de ontwikkeling steunen via een donatie aan de ontwikkelaar.
\n
\nGoogle houdt 15% in van elke donatie.</string>
<string name="data_sources_hint">In de instellingen kan je ook wisselen tussen Google Maps en OpenStreetMap voor de kaartgegevens.</string>
</resources>
</resources>

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Acha que o EVMap é útil? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app. \n \nA Google cobra 15% de cada doação.</string>
<string name="donations_info" formatted="false">Acha que o EVMap é útil\? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.
\n
\nA Google cobra 15% de cada doação.</string>
<string name="data_sources_hint">Também pode mudar entre o Google Maps e OpenStreetMap nas definições da app.</string>
</resources>
</resources>

View File

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

View File

@@ -9,7 +9,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
@@ -46,7 +45,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:enableOnBackInvokedCallback="true">
<meta-data
android:name="com.mapbox.ACCESS_TOKEN"

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering;
import com.car2go.maps.model.LatLng;
import java.util.Collection;
/**
* A collection of ClusterItems that are nearby each other.
*/
public interface Cluster<T extends ClusterItem> {
LatLng getPosition();
Collection<T> getItems();
int getSize();
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.car2go.maps.model.LatLng;
/**
* ClusterItem represents a marker on the map.
*/
public interface ClusterItem {
/**
* The position of this marker. This must always return the same value.
*/
@NonNull
LatLng getPosition();
/**
* The title of this marker.
*/
@Nullable
String getTitle();
/**
* The description of this marker.
*/
@Nullable
String getSnippet();
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2020 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering.algo;
import com.google.maps.android.clustering.ClusterItem;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Base Algorithm class that implements lock/unlock functionality.
*/
public abstract class AbstractAlgorithm<T extends ClusterItem> implements Algorithm<T> {
private final ReadWriteLock mLock = new ReentrantReadWriteLock();
@Override
public void lock() {
mLock.writeLock().lock();
}
@Override
public void unlock() {
mLock.writeLock().unlock();
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering.algo;
import com.google.maps.android.clustering.Cluster;
import com.google.maps.android.clustering.ClusterItem;
import java.util.Collection;
import java.util.Set;
/**
* Logic for computing clusters
*/
public interface Algorithm<T extends ClusterItem> {
/**
* Adds an item to the algorithm
*
* @param item the item to be added
* @return true if the algorithm contents changed as a result of the call
*/
boolean addItem(T item);
/**
* Adds a collection of items to the algorithm
*
* @param items the items to be added
* @return true if the algorithm contents changed as a result of the call
*/
boolean addItems(Collection<T> items);
void clearItems();
/**
* Removes an item from the algorithm
*
* @param item the item to be removed
* @return true if this algorithm contained the specified element (or equivalently, if this
* algorithm changed as a result of the call).
*/
boolean removeItem(T item);
/**
* Updates the provided item in the algorithm
*
* @param item the item to be updated
* @return true if the item existed in the algorithm and was updated, or false if the item did
* not exist in the algorithm and the algorithm contents remain unchanged.
*/
boolean updateItem(T item);
/**
* Removes a collection of items from the algorithm
*
* @param items the items to be removed
* @return true if this algorithm contents changed as a result of the call
*/
boolean removeItems(Collection<T> items);
Set<? extends Cluster<T>> getClusters(float zoom);
Collection<T> getItems();
void setMaxDistanceBetweenClusteredItems(int maxDistance);
int getMaxDistanceBetweenClusteredItems();
void lock();
void unlock();
}

View File

@@ -0,0 +1,314 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering.algo;
import com.car2go.maps.model.LatLng;
import com.google.maps.android.clustering.Cluster;
import com.google.maps.android.clustering.ClusterItem;
import com.google.maps.android.geometry.Bounds;
import com.google.maps.android.geometry.Point;
import com.google.maps.android.projection.SphericalMercatorProjection;
import com.google.maps.android.quadtree.PointQuadTree;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/**
* A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not
* hierarchical.
* <p/>
* High level algorithm:<br>
* 1. Iterate over items in the order they were added (candidate clusters).<br>
* 2. Create a cluster with the center of the item. <br>
* 3. Add all items that are within a certain distance to the cluster. <br>
* 4. Move any items out of an existing cluster if they are closer to another cluster. <br>
* 5. Remove those items from the list of candidate clusters.
* <p/>
* Clusters have the center of the first element (not the centroid of the items within it).
*/
public class NonHierarchicalDistanceBasedAlgorithm<T extends ClusterItem> extends AbstractAlgorithm<T> {
private static final int DEFAULT_MAX_DISTANCE_AT_ZOOM = 100; // essentially 100 dp.
private int mMaxDistance = DEFAULT_MAX_DISTANCE_AT_ZOOM;
/**
* Any modifications should be synchronized on mQuadTree.
*/
private final Collection<QuadItem<T>> mItems = new LinkedHashSet<>();
/**
* Any modifications should be synchronized on mQuadTree.
*/
private final PointQuadTree<QuadItem<T>> mQuadTree = new PointQuadTree<>(0, 1, 0, 1);
private static final SphericalMercatorProjection PROJECTION = new SphericalMercatorProjection(1);
/**
* Adds an item to the algorithm
*
* @param item the item to be added
* @return true if the algorithm contents changed as a result of the call
*/
@Override
public boolean addItem(T item) {
boolean result;
final QuadItem<T> quadItem = new QuadItem<>(item);
synchronized (mQuadTree) {
result = mItems.add(quadItem);
if (result) {
mQuadTree.add(quadItem);
}
}
return result;
}
/**
* Adds a collection of items to the algorithm
*
* @param items the items to be added
* @return true if the algorithm contents changed as a result of the call
*/
@Override
public boolean addItems(Collection<T> items) {
boolean result = false;
for (T item : items) {
boolean individualResult = addItem(item);
if (individualResult) {
result = true;
}
}
return result;
}
@Override
public void clearItems() {
synchronized (mQuadTree) {
mItems.clear();
mQuadTree.clear();
}
}
/**
* Removes an item from the algorithm
*
* @param item the item to be removed
* @return true if this algorithm contained the specified element (or equivalently, if this
* algorithm changed as a result of the call).
*/
@Override
public boolean removeItem(T item) {
boolean result;
// QuadItem delegates hashcode() and equals() to its item so,
// removing any QuadItem to that item will remove the item
final QuadItem<T> quadItem = new QuadItem<>(item);
synchronized (mQuadTree) {
result = mItems.remove(quadItem);
if (result) {
mQuadTree.remove(quadItem);
}
}
return result;
}
/**
* Removes a collection of items from the algorithm
*
* @param items the items to be removed
* @return true if this algorithm contents changed as a result of the call
*/
@Override
public boolean removeItems(Collection<T> items) {
boolean result = false;
synchronized (mQuadTree) {
for (T item : items) {
// QuadItem delegates hashcode() and equals() to its item so,
// removing any QuadItem to that item will remove the item
final QuadItem<T> quadItem = new QuadItem<>(item);
boolean individualResult = mItems.remove(quadItem);
if (individualResult) {
mQuadTree.remove(quadItem);
result = true;
}
}
}
return result;
}
/**
* Updates the provided item in the algorithm
*
* @param item the item to be updated
* @return true if the item existed in the algorithm and was updated, or false if the item did
* not exist in the algorithm and the algorithm contents remain unchanged.
*/
@Override
public boolean updateItem(T item) {
// TODO - Can this be optimized to update the item in-place if the location hasn't changed?
boolean result;
synchronized (mQuadTree) {
result = removeItem(item);
if (result) {
// Only add the item if it was removed (to help prevent accidental duplicates on map)
result = addItem(item);
}
}
return result;
}
@Override
public Set<? extends Cluster<T>> getClusters(float zoom) {
final int discreteZoom = (int) zoom;
final double zoomSpecificSpan = mMaxDistance / Math.pow(2, discreteZoom) / 256;
final Set<QuadItem<T>> visitedCandidates = new HashSet<>();
final Set<Cluster<T>> results = new HashSet<>();
final Map<QuadItem<T>, Double> distanceToCluster = new HashMap<>();
final Map<QuadItem<T>, StaticCluster<T>> itemToCluster = new HashMap<>();
synchronized (mQuadTree) {
for (QuadItem<T> candidate : getClusteringItems(mQuadTree, zoom)) {
if (visitedCandidates.contains(candidate)) {
// Candidate is already part of another cluster.
continue;
}
Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan);
Collection<QuadItem<T>> clusterItems;
clusterItems = mQuadTree.search(searchBounds);
if (clusterItems.size() == 1) {
// Only the current marker is in range. Just add the single item to the results.
results.add(candidate);
visitedCandidates.add(candidate);
distanceToCluster.put(candidate, 0d);
continue;
}
StaticCluster<T> cluster = new StaticCluster<>(candidate.mClusterItem.getPosition());
results.add(cluster);
for (QuadItem<T> clusterItem : clusterItems) {
Double existingDistance = distanceToCluster.get(clusterItem);
double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint());
if (existingDistance != null) {
// Item already belongs to another cluster. Check if it's closer to this cluster.
if (existingDistance < distance) {
continue;
}
// Move item to the closer cluster.
itemToCluster.get(clusterItem).remove(clusterItem.mClusterItem);
}
distanceToCluster.put(clusterItem, distance);
cluster.add(clusterItem.mClusterItem);
itemToCluster.put(clusterItem, cluster);
}
visitedCandidates.addAll(clusterItems);
}
}
return results;
}
protected Collection<QuadItem<T>> getClusteringItems(PointQuadTree<QuadItem<T>> quadTree, float zoom) {
return mItems;
}
@Override
public Collection<T> getItems() {
final Set<T> items = new LinkedHashSet<>();
synchronized (mQuadTree) {
for (QuadItem<T> quadItem : mItems) {
items.add(quadItem.mClusterItem);
}
}
return items;
}
@Override
public void setMaxDistanceBetweenClusteredItems(int maxDistance) {
mMaxDistance = maxDistance;
}
@Override
public int getMaxDistanceBetweenClusteredItems() {
return mMaxDistance;
}
private double distanceSquared(Point a, Point b) {
return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y);
}
private Bounds createBoundsFromSpan(Point p, double span) {
// TODO: Use a span that takes into account the visual size of the marker, not just its
// LatLng.
double halfSpan = span / 2;
return new Bounds(
p.x - halfSpan, p.x + halfSpan,
p.y - halfSpan, p.y + halfSpan);
}
protected static class QuadItem<T extends ClusterItem> implements PointQuadTree.Item, Cluster<T> {
private final T mClusterItem;
private final Point mPoint;
private final LatLng mPosition;
private Set<T> singletonSet;
private QuadItem(T item) {
mClusterItem = item;
mPosition = item.getPosition();
mPoint = PROJECTION.toPoint(mPosition);
singletonSet = Collections.singleton(mClusterItem);
}
@Override
public Point getPoint() {
return mPoint;
}
@Override
public LatLng getPosition() {
return mPosition;
}
@Override
public Set<T> getItems() {
return singletonSet;
}
@Override
public int getSize() {
return 1;
}
@Override
public int hashCode() {
return mClusterItem.hashCode();
}
@Override
public boolean equals(Object other) {
if (!(other instanceof QuadItem<?>)) {
return false;
}
return ((QuadItem<?>) other).mClusterItem.equals(mClusterItem);
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.clustering.algo;
import com.car2go.maps.model.LatLng;
import com.google.maps.android.clustering.Cluster;
import com.google.maps.android.clustering.ClusterItem;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* A cluster whose center is determined upon creation.
*/
public class StaticCluster<T extends ClusterItem> implements Cluster<T> {
private final LatLng mCenter;
private final List<T> mItems = new ArrayList<T>();
public StaticCluster(LatLng center) {
mCenter = center;
}
public boolean add(T t) {
return mItems.add(t);
}
@Override
public LatLng getPosition() {
return mCenter;
}
public boolean remove(T t) {
return mItems.remove(t);
}
@Override
public Collection<T> getItems() {
return mItems;
}
@Override
public int getSize() {
return mItems.size();
}
@Override
public String toString() {
return "StaticCluster{" +
"mCenter=" + mCenter +
", mItems.size=" + mItems.size() +
'}';
}
@Override
public int hashCode() {
return mCenter.hashCode() + mItems.hashCode();
}
@Override
public boolean equals(Object other) {
if (!(other instanceof StaticCluster<?>)) {
return false;
}
return ((StaticCluster<?>) other).mCenter.equals(mCenter)
&& ((StaticCluster<?>) other).mItems.equals(mItems);
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.geometry;
/**
* Represents an area in the cartesian plane.
*/
public class Bounds {
public final double minX;
public final double minY;
public final double maxX;
public final double maxY;
public final double midX;
public final double midY;
public Bounds(double minX, double maxX, double minY, double maxY) {
this.minX = minX;
this.minY = minY;
this.maxX = maxX;
this.maxY = maxY;
midX = (minX + maxX) / 2;
midY = (minY + maxY) / 2;
}
public boolean contains(double x, double y) {
return minX <= x && x <= maxX && minY <= y && y <= maxY;
}
public boolean contains(Point point) {
return contains(point.x, point.y);
}
public boolean intersects(double minX, double maxX, double minY, double maxY) {
return minX < this.maxX && this.minX < maxX && minY < this.maxY && this.minY < maxY;
}
public boolean intersects(Bounds bounds) {
return intersects(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY);
}
public boolean contains(Bounds bounds) {
return bounds.minX >= minX && bounds.maxX <= maxX && bounds.minY >= minY && bounds.maxY <= maxY;
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.geometry;
public class Point {
public final double x;
public final double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Point{" +
"x=" + x +
", y=" + y +
'}';
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.projection;
/**
* @deprecated since 0.2. Use {@link com.google.maps.android.geometry.Point} instead.
*/
@Deprecated
public class Point extends com.google.maps.android.geometry.Point {
public Point(double x, double y) {
super(x, y);
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.projection;
import com.car2go.maps.model.LatLng;
public class SphericalMercatorProjection {
final double mWorldWidth;
public SphericalMercatorProjection(final double worldWidth) {
mWorldWidth = worldWidth;
}
@SuppressWarnings("deprecation")
public Point toPoint(final LatLng latLng) {
final double x = latLng.longitude / 360 + .5;
final double siny = Math.sin(Math.toRadians(latLng.latitude));
final double y = 0.5 * Math.log((1 + siny) / (1 - siny)) / -(2 * Math.PI) + .5;
return new Point(x * mWorldWidth, y * mWorldWidth);
}
public LatLng toLatLng(com.google.maps.android.geometry.Point point) {
final double x = point.x / mWorldWidth - 0.5;
final double lng = x * 360;
double y = .5 - (point.y / mWorldWidth);
final double lat = 90 - Math.toDegrees(Math.atan(Math.exp(-y * 2 * Math.PI)) * 2);
return new LatLng(lat, lng);
}
}

View File

@@ -0,0 +1,226 @@
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.maps.android.quadtree;
import com.google.maps.android.geometry.Bounds;
import com.google.maps.android.geometry.Point;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* A quad tree which tracks items with a Point geometry.
* See http://en.wikipedia.org/wiki/Quadtree for details on the data structure.
* This class is not thread safe.
*/
public class PointQuadTree<T extends PointQuadTree.Item> {
public interface Item {
Point getPoint();
}
/**
* The bounds of this quad.
*/
private final Bounds mBounds;
/**
* The depth of this quad in the tree.
*/
private final int mDepth;
/**
* Maximum number of elements to store in a quad before splitting.
*/
private final static int MAX_ELEMENTS = 50;
/**
* The elements inside this quad, if any.
*/
private Set<T> mItems;
/**
* Maximum depth.
*/
private final static int MAX_DEPTH = 40;
/**
* Child quads.
*/
private List<PointQuadTree<T>> mChildren = null;
/**
* Creates a new quad tree with specified bounds.
*
* @param minX
* @param maxX
* @param minY
* @param maxY
*/
public PointQuadTree(double minX, double maxX, double minY, double maxY) {
this(new Bounds(minX, maxX, minY, maxY));
}
public PointQuadTree(Bounds bounds) {
this(bounds, 0);
}
private PointQuadTree(double minX, double maxX, double minY, double maxY, int depth) {
this(new Bounds(minX, maxX, minY, maxY), depth);
}
private PointQuadTree(Bounds bounds, int depth) {
mBounds = bounds;
mDepth = depth;
}
/**
* Insert an item.
*/
public void add(T item) {
Point point = item.getPoint();
if (this.mBounds.contains(point.x, point.y)) {
insert(point.x, point.y, item);
}
}
private void insert(double x, double y, T item) {
if (this.mChildren != null) {
if (y < mBounds.midY) {
if (x < mBounds.midX) { // top left
mChildren.get(0).insert(x, y, item);
} else { // top right
mChildren.get(1).insert(x, y, item);
}
} else {
if (x < mBounds.midX) { // bottom left
mChildren.get(2).insert(x, y, item);
} else {
mChildren.get(3).insert(x, y, item);
}
}
return;
}
if (mItems == null) {
mItems = new LinkedHashSet<>();
}
mItems.add(item);
if (mItems.size() > MAX_ELEMENTS && mDepth < MAX_DEPTH) {
split();
}
}
/**
* Split this quad.
*/
private void split() {
mChildren = new ArrayList<PointQuadTree<T>>(4);
mChildren.add(new PointQuadTree<T>(mBounds.minX, mBounds.midX, mBounds.minY, mBounds.midY, mDepth + 1));
mChildren.add(new PointQuadTree<T>(mBounds.midX, mBounds.maxX, mBounds.minY, mBounds.midY, mDepth + 1));
mChildren.add(new PointQuadTree<T>(mBounds.minX, mBounds.midX, mBounds.midY, mBounds.maxY, mDepth + 1));
mChildren.add(new PointQuadTree<T>(mBounds.midX, mBounds.maxX, mBounds.midY, mBounds.maxY, mDepth + 1));
Set<T> items = mItems;
mItems = null;
for (T item : items) {
// re-insert items into child quads.
insert(item.getPoint().x, item.getPoint().y, item);
}
}
/**
* Remove the given item from the set.
*
* @return whether the item was removed.
*/
public boolean remove(T item) {
Point point = item.getPoint();
if (this.mBounds.contains(point.x, point.y)) {
return remove(point.x, point.y, item);
} else {
return false;
}
}
private boolean remove(double x, double y, T item) {
if (this.mChildren != null) {
if (y < mBounds.midY) {
if (x < mBounds.midX) { // top left
return mChildren.get(0).remove(x, y, item);
} else { // top right
return mChildren.get(1).remove(x, y, item);
}
} else {
if (x < mBounds.midX) { // bottom left
return mChildren.get(2).remove(x, y, item);
} else {
return mChildren.get(3).remove(x, y, item);
}
}
} else {
if (mItems == null) {
return false;
} else {
return mItems.remove(item);
}
}
}
/**
* Removes all points from the quadTree
*/
public void clear() {
mChildren = null;
if (mItems != null) {
mItems.clear();
}
}
/**
* Search for all items within a given bounds.
*/
public Collection<T> search(Bounds searchBounds) {
final List<T> results = new ArrayList<T>();
search(searchBounds, results);
return results;
}
private void search(Bounds searchBounds, Collection<T> results) {
if (!mBounds.intersects(searchBounds)) {
return;
}
if (this.mChildren != null) {
for (PointQuadTree<T> quad : mChildren) {
quad.search(searchBounds, results);
}
} else if (mItems != null) {
if (searchBounds.contains(mBounds)) {
results.addAll(mItems);
} else {
for (T item : mItems) {
if (searchBounds.contains(item.getPoint())) {
results.add(item);
}
}
}
}
}
}

View File

@@ -6,12 +6,10 @@ import android.os.Build
import androidx.work.Configuration
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import net.vonforst.evmap.storage.CleanupCacheWorker
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.UpdateFullDownloadWorker
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
import org.acra.config.dialog
@@ -70,7 +68,6 @@ class EvMapApplication : Application(), Configuration.Provider {
}
}
val workManager = WorkManager.getInstance(this)
val cleanupCacheRequest = PeriodicWorkRequestBuilder<CleanupCacheWorker>(Duration.ofDays(1))
.setConstraints(Constraints.Builder().apply {
setRequiresBatteryNotLow(true)
@@ -78,24 +75,9 @@ class EvMapApplication : Application(), Configuration.Provider {
setRequiresDeviceIdle(true)
}
}.build()).build()
workManager.enqueueUniquePeriodicWork(
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.UPDATE, cleanupCacheRequest
)
val updateFullDownloadRequest =
PeriodicWorkRequestBuilder<UpdateFullDownloadWorker>(Duration.ofDays(7))
.setConstraints(Constraints.Builder().apply {
setRequiresBatteryNotLow(true)
setRequiredNetworkType(NetworkType.UNMETERED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setRequiresDeviceIdle(true)
}
}.build()).build()
workManager.enqueueUniquePeriodicWork(
"UpdateOsmWorker",
ExistingPeriodicWorkPolicy.UPDATE,
updateFullDownloadRequest
)
}
override val workManagerConfiguration = Configuration.Builder().build()

View File

@@ -18,7 +18,6 @@ import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController
@@ -56,7 +55,6 @@ class MapsActivity : AppCompatActivity(),
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
WindowCompat.enableEdgeToEdge(window)
setContentView(R.layout.activity_maps)
@@ -285,7 +283,7 @@ class MapsActivity : AppCompatActivity(),
}
}
fun openUrl(url: String, rootView: View, preferBrowser: Boolean = false) {
fun openUrl(url: String, rootView: View, preferBrowser: Boolean = true) {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()

View File

@@ -14,7 +14,6 @@ import net.vonforst.evmap.joinToSpannedString
import net.vonforst.evmap.model.ChargeCard
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Coordinate
import net.vonforst.evmap.model.OpeningHoursDays
import net.vonforst.evmap.plus
import net.vonforst.evmap.ui.currency

View File

@@ -6,7 +6,6 @@ import com.car2go.maps.model.LatLngBounds
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.openstreetmap.OpenStreetMapApiWrapper
import net.vonforst.evmap.model.*
import net.vonforst.evmap.viewmodel.Resource
import java.time.Duration
@@ -59,27 +58,6 @@ interface ChargepointApi<out T : ReferenceData> {
* Duration we are limited to if there is a required API local cache time limit.
*/
val cacheLimit: Duration
/**
* Whether this API supports querying for chargers at the backend
*
* This determines whether the getChargepoints, getChargepointsRadius and getChargepointDetail functions are supported.
*/
val supportsOnlineQueries: Boolean
/**
* Whether this API supports downloading the whole dataset into local storage
*
* This determines whether the getAllChargepoints function is supported.
*/
val supportsFullDownload: Boolean
/**
* Fetches all available chargers from this API.
*
* This may take a long time and should only be used when the user explicitly wants to download all chargers.
*/
suspend fun fullDownload(): FullDownloadResult<T>
}
interface StringProvider {
@@ -101,7 +79,6 @@ fun createApi(type: String, ctx: Context): ChargepointApi<ReferenceData> {
)
)
}
"goingelectric" -> {
GoingElectricApiWrapper(
ctx.getString(
@@ -109,11 +86,6 @@ fun createApi(type: String, ctx: Context): ChargepointApi<ReferenceData> {
)
)
}
"openstreetmap" -> {
OpenStreetMapApiWrapper()
}
else -> throw IllegalArgumentException()
}
}
@@ -128,20 +100,4 @@ data class ChargepointList(val items: List<ChargepointListItem>, val isComplete:
companion object {
fun empty() = ChargepointList(emptyList(), true)
}
}
/**
* Result returned from fullDownload() function.
*
* Note that [chargers] is implemented as a [Sequence] so that downloaded chargers can be saved
* while they are being parsed instead of having to keep all of them in RAM at once.
*
* [progress] is updated regularly to indicate the current download progress.
* [referenceData] will typically only be available once the download is completed, i.e. you have
* iterated over the whole sequence of [chargers].
*/
interface FullDownloadResult<out T : ReferenceData> {
val chargers: Sequence<ChargeLocation>
val progress: Float
val referenceData: T
}

View File

@@ -1,20 +1,18 @@
package net.vonforst.evmap.api
import com.google.common.util.concurrent.RateLimiter
import okhttp3.Interceptor
import okhttp3.Response
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
class RateLimitInterceptor : Interceptor {
private val rateLimiter = SimpleRateLimiter(3.0)
private val rateLimiter = RateLimiter.create(3.0)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.url.host == "ui-map.shellrecharge.com") {
// limit requests sent to NewMotion to 3 per second
rateLimiter.acquire()
rateLimiter.acquire(1)
var response: Response = chain.proceed(request)
// 403 is how the NewMotion API indicates a rate limit error
@@ -32,27 +30,4 @@ class RateLimitInterceptor : Interceptor {
return chain.proceed(request)
}
}
}
internal class SimpleRateLimiter(private val permitsPerSecond: Double) {
private val interval: Duration = (1.0 / permitsPerSecond).seconds
private var nextAvailable = TimeSource.Monotonic.markNow()
@Synchronized
fun acquire() {
val now = TimeSource.Monotonic.markNow()
if (now < nextAvailable) {
val waitTime = nextAvailable - now
waitTime.sleep()
nextAvailable += interval
} else {
nextAvailable = now + interval
}
}
}
fun Duration.sleep() {
if (this.isPositive()) {
Thread.sleep(this.inWholeMilliseconds, (this.inWholeNanoseconds % 1_000_000).toInt())
}
}

View File

@@ -1,17 +1,54 @@
package net.vonforst.evmap.api
import androidx.annotation.DrawableRes
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import net.vonforst.evmap.R
import net.vonforst.evmap.model.Chargepoint
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import org.json.JSONArray
import java.io.IOException
import kotlin.coroutines.resumeWithException
import kotlin.math.abs
operator fun <T> JSONArray.iterator(): Iterator<T> =
(0 until length()).asSequence().map {
@Suppress("UNCHECKED_CAST")
get(it) as T
}.iterator()
@ExperimentalCoroutinesApi
suspend fun Call.await(): Response {
return suspendCancellableCoroutine { continuation ->
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) {}
}
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) return
continuation.resumeWithException(e)
}
})
continuation.invokeOnCancellation {
try {
cancel()
} catch (ex: Throwable) {
//Ignore cancel exception
}
}
}
}
private val plugNames = mapOf(
Chargepoint.TYPE_1 to R.string.plug_type_1,
Chargepoint.TYPE_2_UNKNOWN to R.string.plug_type_2,
Chargepoint.TYPE_2_PLUG to R.string.plug_type_2,
Chargepoint.TYPE_2_SOCKET to R.string.plug_type_2,
Chargepoint.TYPE_3A to R.string.plug_type_3a,
Chargepoint.TYPE_3C to R.string.plug_type_3c,
Chargepoint.TYPE_3 to R.string.plug_type_3,
Chargepoint.CCS_UNKNOWN to R.string.plug_ccs,
Chargepoint.CCS_TYPE_1 to R.string.plug_ccs,
Chargepoint.CCS_TYPE_2 to R.string.plug_ccs,
@@ -64,7 +101,7 @@ fun iconForPlugType(type: String): Int =
Chargepoint.CEE_ROT -> R.drawable.ic_connector_cee_rot
Chargepoint.TYPE_1 -> R.drawable.ic_connector_typ1
// TODO: add other connectors
else -> R.drawable.ic_connector_unknown
else -> 0
}
val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300, 350)

View File

@@ -4,6 +4,7 @@ import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import net.vonforst.evmap.api.availability.tesla.LocalTimeAdapter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
@@ -14,6 +15,7 @@ import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import java.time.Instant
import java.time.LocalTime
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
private const val maxDistance = 60 // max distance between reported positions in meters
@@ -200,8 +202,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
val id = index.toLong()
val power = connector.maxPowerInKw ?: 0.0
val type = when (connector.plugTypeName) {
"Typ 3A" -> Chargepoint.TYPE_3A
"Typ 3C \"Scame\"" -> Chargepoint.TYPE_3C
"Typ 3A" -> Chargepoint.TYPE_3
"Typ 2" -> Chargepoint.TYPE_2_UNKNOWN
"Typ 1" -> Chargepoint.TYPE_1
"Steckdose(D)" -> Chargepoint.SCHUKO
@@ -242,8 +243,8 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
val country = charger.chargepriceData?.country ?: charger.address?.country
val country = charger.chargepriceData?.country
?: charger.address?.country ?: return false
return when (charger.dataSource) {
// list of countries as of 2023/04/14, according to
// https://www.enbw.com/elektromobilitaet/produkte/ladetarife
@@ -285,12 +286,6 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
"ES",
"CZ"
) && charger.chargepriceData?.network !in listOf("23", "3534")
/* TODO: OSM usually does not have the country tagged. Therefore we currently just use
a bounding box to determine whether the charger is roughly in Europe */
"openstreetmap" -> charger.coordinates.lat in 35.0..72.0
&& charger.coordinates.lng in 25.0..65.0
&& charger.operator !in listOf("Tesla, Inc.", "Tesla")
else -> false
}
}

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap.api.availability
import androidx.car.app.model.DateTimeWithZone
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
@@ -12,8 +13,12 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.util.Locale
import java.time.format.DateTimeParseException
import java.util.*
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
private const val maxDistance = 60 // max distance between reported positions in meters
@@ -175,7 +180,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
val id = connector.uid
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
"type3" -> Chargepoint.TYPE_3C
"type3" -> Chargepoint.TYPE_3
"type2" -> Chargepoint.TYPE_2_UNKNOWN
"type1" -> Chargepoint.TYPE_1
"domestic" -> Chargepoint.SCHUKO
@@ -221,7 +226,6 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
return when (charger.dataSource) {
"goingelectric" -> charger.network != "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network !in listOf("23", "3534")
"openstreetmap" -> charger.operator !in listOf("Tesla, Inc.", "Tesla")
else -> false
}
}

View File

@@ -23,10 +23,6 @@ class TeslaGuestAvailabilityDetector(
private var api = TeslaChargingGuestGraphQlApi.create(client, baseUrl)
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
if (location.chargepoints.isEmpty() || location.chargepoints.any { !it.hasKnownPower() }) {
throw AvailabilityDetectorException("no candidates found.")
}
val results = cuaApi.getTeslaLocations()
val result =
@@ -153,7 +149,7 @@ class TeslaGuestAvailabilityDetector(
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
val labelsMap = detailsMap.mapValues { it.value.map { it.label } }
val pricing = details.pricing?.copy(memberRates = guestPricing.await()?.userRates)
val pricing = details.pricing.copy(memberRates = guestPricing.await()?.userRates)
return ChargeLocationStatus(
statusMap,
@@ -167,7 +163,6 @@ class TeslaGuestAvailabilityDetector(
return when (charger.dataSource) {
"goingelectric" -> charger.network == "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
"openstreetmap" -> charger.operator in listOf("Tesla, Inc.", "Tesla")
else -> false
}
}

View File

@@ -29,10 +29,6 @@ class TeslaOwnerAvailabilityDetector(
}
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
if (location.chargepoints.isEmpty() || location.chargepoints.any { !it.hasKnownPower() }) {
throw AvailabilityDetectorException("no candidates found.")
}
val api = initApi()
val req = TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesRequest(
TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesVariables(
@@ -45,7 +41,8 @@ class TeslaOwnerAvailabilityDetector(
TeslaChargingOwnershipGraphQlApi.Coordinate(
location.coordinates.lat - coordRange,
location.coordinates.lng + coordRange
)
),
TeslaChargingOwnershipGraphQlApi.OpenToNonTeslasFilterValue(false)
)
)
)
@@ -166,7 +163,6 @@ class TeslaOwnerAvailabilityDetector(
return when (charger.dataSource) {
"goingelectric" -> charger.network == "Tesla Supercharger"
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
"openstreetmap" -> charger.operator in listOf("Tesla, Inc.", "Tesla")
else -> false
}
}

View File

@@ -117,7 +117,7 @@ interface TeslaChargingGuestGraphQlApi {
val trtId: Long,
val maxPowerKw: Int,
val name: String,
val pricing: Pricing?,
val pricing: Pricing,
val publicStallCount: Int
)

View File

@@ -100,8 +100,7 @@ interface TeslaAuthenticationApi {
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid email offline_access phone")
.appendQueryParameter("is_in_app", "true")
.appendQueryParameter("scope", "openid email offline_access")
.appendQueryParameter("state", "123").build()
val resultUrlPrefix = "https://auth.tesla.com/void/callback"
@@ -184,7 +183,7 @@ interface TeslaChargingOwnershipGraphQlApi {
val userLocation: Coordinate,
val northwestCorner: Coordinate,
val southeastCorner: Coordinate,
val filters: List<String> = emptyList(),
val openToNonTeslasFilter: OpenToNonTeslasFilterValue,
val languageCode: String = "en",
val countryCode: String = "US",
//val vin: String = "",

View File

@@ -1,7 +1,10 @@
package net.vonforst.evmap.api.fronyx
import android.content.Context
import com.squareup.moshi.JsonDataException
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.nameForPlugType
@@ -10,6 +13,8 @@ import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.Resource
import retrofit2.HttpException
import java.io.IOException
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
@@ -50,9 +55,9 @@ class PredictionRepository(private val context: Context) {
evseIds: Map<Chargepoint, List<String>>,
filteredConnectors: Set<String>?
): Resource<List<FronyxEvseIdResponse>> {
return Resource.success(null)
if (!prefs.predictionEnabled) return Resource.success(null)
/*val allEvseIds =
val allEvseIds =
evseIds.filterKeys {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors?.let { filtered ->
@@ -84,7 +89,7 @@ class PredictionRepository(private val context: Context) {
// malformed JSON response from fronyx API
e.printStackTrace()
return Resource.error(e.message, null)
}*/
}
}
private fun buildPredictionGraph(

View File

@@ -15,7 +15,6 @@ import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.FullDownloadResult
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
@@ -160,12 +159,6 @@ class GoingElectricApiWrapper(
override val name = "GoingElectric.de"
override val id = "goingelectric"
override val cacheLimit = Duration.ofDays(1)
override val supportsOnlineQueries = true
override val supportsFullDownload = false
override suspend fun fullDownload(): FullDownloadResult<GEReferenceData> {
throw NotImplementedError()
}
override suspend fun getChargepoints(
referenceData: ReferenceData,

View File

@@ -208,7 +208,7 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) {
return when (type) {
Chargepoint.TYPE_1 -> "Typ1"
Chargepoint.TYPE_2_UNKNOWN -> "Typ2"
Chargepoint.TYPE_3C -> "Typ3"
Chargepoint.TYPE_3 -> "Typ3"
Chargepoint.CCS_UNKNOWN -> "CCS"
Chargepoint.CCS_TYPE_2 -> "Typ2"
Chargepoint.SCHUKO -> "Schuko"
@@ -225,7 +225,7 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) {
return when (type) {
"Typ1" -> Chargepoint.TYPE_1
"Typ2" -> Chargepoint.TYPE_2_UNKNOWN
"Typ3" -> Chargepoint.TYPE_3C
"Typ3" -> Chargepoint.TYPE_3
"Tesla Supercharger CCS" -> Chargepoint.CCS_UNKNOWN
"CCS" -> Chargepoint.CCS_UNKNOWN
"Schuko" -> Chargepoint.SCHUKO

View File

@@ -11,7 +11,6 @@ import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.FullDownloadResult
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
@@ -131,12 +130,6 @@ class OpenChargeMapApiWrapper(
override val name = "OpenChargeMap.org"
override val id = "openchargemap"
override val supportsOnlineQueries = true
override val supportsFullDownload = false
override suspend fun fullDownload(): FullDownloadResult<OCMReferenceData> {
throw NotImplementedError()
}
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
if (value == null || value.all) null else value.values.joinToString(",")

View File

@@ -76,7 +76,7 @@ data class OCMChargepoint(
mediaItems?.mapNotNull { it.convert() },
null,
null,
cost?.takeIf { it.isNotBlank() }.let { Cost(descriptionShort = it) },
cost?.let { Cost(descriptionShort = it) },
dataProvider?.let { "© ${it.title}" + if (it.license != null) ". ${it.license}" else "" },
ChargepriceData(
addressInfo.countryISOCode(refData),
@@ -180,8 +180,8 @@ data class OCMConnection(
25L -> Chargepoint.TYPE_2_SOCKET
1036L -> Chargepoint.TYPE_2_PLUG
1L -> Chargepoint.TYPE_1
36L -> Chargepoint.TYPE_3A
26L -> Chargepoint.TYPE_3C
36L -> Chargepoint.TYPE_3
26L -> Chargepoint.TYPE_3
else -> title ?: ""
}
}

View File

@@ -1,67 +0,0 @@
package net.vonforst.evmap.api.openstreetmap
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.rawType
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
import java.lang.reflect.Type
import java.time.Instant
import kotlin.math.floor
internal class InstantAdapter {
@FromJson
fun fromJson(value: Double?): Instant? = value?.let {
val seconds = floor(it).toLong()
val nanos = ((value - seconds) * 1e9).toLong()
Instant.ofEpochSecond(seconds, nanos)
}
@ToJson
fun toJson(value: Instant?): Double? = value?.let {
it.epochSecond.toDouble() + it.nano / 1e9
}
}
internal class OSMConverterFactory(val moshi: Moshi) : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
if (type.rawType != OSMDocument::class.java) return null
val instantAdapter = moshi.adapter(Instant::class.java)
val osmChargingStationAdapter = moshi.adapter(OSMChargingStation::class.java)
val longAdapter = moshi.adapter(Long::class.java)
return Converter<ResponseBody, OSMDocument> { body ->
val reader = JsonReader.of(body.source())
reader.beginObject()
var timestamp: Instant? = null
var doc: Sequence<OSMChargingStation>? = null
var count: Long? = null
while (reader.hasNext()) {
when (reader.nextName()) {
"timestamp" -> timestamp = instantAdapter.fromJson(reader)!!
"count" -> count = longAdapter.fromJson(reader)!!
"elements" -> {
doc = sequence {
reader.beginArray()
while (reader.hasNext()) {
yield(osmChargingStationAdapter.fromJson(reader)!!)
}
reader.endArray()
reader.close()
}
break
}
}
}
OSMDocument(timestamp!!, count!!, doc!!)
}
}
}

View File

@@ -1,269 +0,0 @@
package net.vonforst.evmap.api.openstreetmap
import android.database.DatabaseUtils
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.FullDownloadResult
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.mapPower
import net.vonforst.evmap.api.mapPowerInverse
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.openchargemap.ZonedDateTimeAdapter
import net.vonforst.evmap.api.powerSteps
import net.vonforst.evmap.model.BooleanFilter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.Filter
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterValues
import net.vonforst.evmap.model.MultipleChoiceFilter
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.model.SliderFilter
import net.vonforst.evmap.model.getBooleanValue
import net.vonforst.evmap.model.getMultipleChoiceValue
import net.vonforst.evmap.model.getSliderValue
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.http.GET
import java.io.IOException
import java.time.Duration
interface OpenStreetMapApi {
@GET("charging-stations-osm.json")
suspend fun getAllChargingStations(): Response<OSMDocument>
companion object {
private val moshi = Moshi.Builder()
.add(ZonedDateTimeAdapter())
.add(InstantAdapter())
.build()
fun create(
baseurl: String = "https://osm.ev-map.app/"
): OpenStreetMapApi {
val client = OkHttpClient.Builder().apply {
if (BuildConfig.DEBUG) addDebugInterceptors()
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseurl)
.addConverterFactory(OSMConverterFactory(moshi))
.client(client)
.build()
return retrofit.create(OpenStreetMapApi::class.java)
}
}
}
class OpenStreetMapApiWrapper(baseurl: String = "https://osm.ev-map.app/") :
ChargepointApi<OSMReferenceData> {
override val name = "OpenStreetMap"
override val id = "openstreetmap"
override val cacheLimit = Duration.ofDays(300L)
override val supportsOnlineQueries = false
override val supportsFullDownload = true
val api = OpenStreetMapApi.create(baseurl)
override suspend fun getChargepoints(
referenceData: ReferenceData,
bounds: LatLngBounds,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?
): Resource<ChargepointList> {
throw NotImplementedError()
}
override suspend fun getChargepointsRadius(
referenceData: ReferenceData,
location: LatLng,
radius: Int,
zoom: Float,
useClustering: Boolean,
filters: FilterValues?
): Resource<ChargepointList> {
throw NotImplementedError()
}
override suspend fun getChargepointDetail(
referenceData: ReferenceData,
id: Long
): Resource<ChargeLocation> {
throw NotImplementedError()
}
override suspend fun getReferenceData(): Resource<OSMReferenceData> {
throw NotImplementedError()
}
override fun getFilters(
referenceData: ReferenceData,
sp: StringProvider
): List<Filter<FilterValue>> {
val plugs = listOf(
Chargepoint.TYPE_1,
Chargepoint.CCS_TYPE_1,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG,
Chargepoint.CCS_TYPE_2,
Chargepoint.CHADEMO,
Chargepoint.SUPERCHARGER,
Chargepoint.CEE_BLAU,
Chargepoint.CEE_ROT,
Chargepoint.SCHUKO
)
val plugMap = plugs.associateWith { plug ->
nameForPlugType(sp, plug)
}
val refData = referenceData as OSMReferenceData
val networkMap = refData.networks.associateWith { it }
return listOf(
BooleanFilter(sp.getString(R.string.filter_free), "freecharging"),
BooleanFilter(sp.getString(R.string.filter_free_parking), "freeparking"),
BooleanFilter(sp.getString(R.string.filter_open_247), "open_247"),
SliderFilter(
sp.getString(R.string.filter_min_power), "min_power",
powerSteps.size - 1,
mapping = ::mapPower,
inverseMapping = ::mapPowerInverse,
unit = "kW"
),
MultipleChoiceFilter(
sp.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = setOf(
Chargepoint.TYPE_1,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG,
Chargepoint.CCS_TYPE_1,
Chargepoint.CCS_TYPE_2,
Chargepoint.CHADEMO
),
manyChoices = true
),
MultipleChoiceFilter(
sp.getString(R.string.filter_networks), "networks",
networkMap, manyChoices = true
),
SliderFilter(
sp.getString(R.string.filter_min_connectors),
"min_connectors",
10,
min = 1
)
)
}
override fun convertFiltersToSQL(
filters: FilterValues,
referenceData: ReferenceData
): FiltersSQLQuery {
if (filters.isEmpty()) return FiltersSQLQuery("", false, false)
var requiresChargepointQuery = 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")
}
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(it)
}
}
result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})")
requiresChargepointQuery = true
}
val minConnectors = filters.getSliderValue("min_connectors")
if (minConnectors != null && minConnectors > 1) {
result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= $minConnectors")
requiresChargepointQuery = true
}
val networks = filters.getMultipleChoiceValue("networks")
if (networks != null && !networks.all) {
val networksList = if (networks.values.size == 0) {
""
} else {
networks.values.joinToString(",") { DatabaseUtils.sqlEscapeString(it) }
}
result.append(" AND network IN (${networksList})")
}
return FiltersSQLQuery(result.toString(), requiresChargepointQuery, false)
}
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
return true
}
override suspend fun fullDownload(): FullDownloadResult<OSMReferenceData> {
val response = api.getAllChargingStations()
if (!response.isSuccessful) {
throw IOException(response.message())
} else {
val body = response.body()!!
return OSMFullDownloadResult(body)
}
}
}
data class OSMReferenceData(val networks: List<String>) : ReferenceData()
class OSMFullDownloadResult(private val body: OSMDocument) : FullDownloadResult<OSMReferenceData> {
private var downloadProgress = 0f
private var refData: OSMReferenceData? = null
override val chargers: Sequence<ChargeLocation>
get() {
val time = body.timestamp
val networks = mutableListOf<String>()
return sequence {
body.elements.forEachIndexed { i, it ->
val charger = it.convert(time)
yield(charger)
downloadProgress = i.toFloat() / body.count
charger.network?.let { networks.add(it) }
}
refData = OSMReferenceData(networks)
}
}
override val progress: Float
get() = downloadProgress
override val referenceData: OSMReferenceData
get() = refData
?: throw UnsupportedOperationException("referenceData is only available once download is complete")
}

View File

@@ -2,7 +2,6 @@ package net.vonforst.evmap.api.openstreetmap
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.model.*
import okhttp3.internal.immutableListOf
import java.time.Instant
@@ -41,7 +40,6 @@ private val SOCKET_TYPES = immutableListOf(
// Tesla
OsmSocket("tesla_standard", null),
OsmSocket("tesla_supercharger", Chargepoint.SUPERCHARGER),
OsmSocket("tesla_supercharger_ccs", Chargepoint.CCS_UNKNOWN),
// CEE
OsmSocket("cee_blue", Chargepoint.CEE_BLAU), // Also known as "caravan socket"
@@ -60,12 +58,6 @@ private val SOCKET_TYPES = immutableListOf(
OsmSocket("sev1011_t25", null),
)
data class OSMDocument(
val timestamp: Instant,
val count: Long,
val elements: Sequence<OSMChargingStation>
)
@JsonClass(generateAdapter = true)
data class OSMChargingStation(
// Unique numeric ID
@@ -95,7 +87,7 @@ data class OSMChargingStation(
"openstreetmap",
getName(),
Coordinate(lat, lon),
getAddress(),
null, // TODO: Can we determine this with overpass?
getChargepoints(),
tags["network"],
"https://www.openstreetmap.org/node/$id",
@@ -107,31 +99,18 @@ data class OSMChargingStation(
tags["description"],
null,
null,
getPhotos(),
null,
null,
getOpeningHours(),
getCost(),
"© OpenStreetMap contributors",
null,
null,
tags["website"],
null,
dataFetchTimestamp,
true,
)
private fun getAddress(): Address? {
val city = tags["addr:city"]
val country = tags["addr:country"]
val postcode = tags["addr:postcode"]
val street = tags["addr:street"]
val housenumber = tags["addr:housenumber"] ?: tags["addr:housename"]
return if (listOf(city, country, postcode, street, housenumber).any { it != null }) {
Address(city, country, postcode, "$street $housenumber")
} else {
null
}
}
/**
* Return the name for this charging station.
*/
@@ -186,7 +165,7 @@ data class OSMChargingStation(
return null
}
private fun getCost(): Cost {
private fun getCost(): Cost? {
val freecharging = when (tags["fee"]?.lowercase()) {
"yes", "y" -> false
"no", "n" -> true
@@ -197,28 +176,7 @@ data class OSMChargingStation(
"yes", "y", "interval" -> false
else -> null
}
val description = listOfNotNull(tags["charge"], tags["charge:conditional"]).ifEmpty { null }
?.joinToString("\n")
return Cost(freecharging, freeparking, null, description)
}
private fun getPhotos(): List<ChargerPhoto> {
val photos = mutableListOf<ChargerPhoto>()
for (i in -1..9) {
val url = tags["image" + if (i >= 0) ":$i" else ""]
if (url != null) {
if (url.startsWith("https://i.imgur.com")) {
ImgurChargerPhoto.create(url)?.let { photos.add(it) }
}
/*
TODO: Imgur seems to be by far the most common image hoster (650 images),
followed by Mapillary (450, requires an API key to retrieve images)
Other than that, we have Google Photos, Wikimedia Commons (100-150 images each).
And there are some other links to various sites, but not all are valid links pointing directly to a JPEG file...
*/
}
}
return photos
return Cost(freecharging, freeparking)
}
companion object {
@@ -243,26 +201,4 @@ data class OSMChargingStation(
return numberString.toDoubleOrNull()
}
}
}
@Parcelize
@JsonClass(generateAdapter = true)
class ImgurChargerPhoto(override val id: String) : ChargerPhoto(id) {
override fun getUrl(height: Int?, width: Int?, size: Int?, allowOriginal: Boolean): String {
return if (allowOriginal) {
"https://i.imgur.com/$id.jpg"
} else {
val value = width ?: size ?: height
"https://i.imgur.com/${id}_d.jpg?maxwidth=$value"
}
}
companion object {
private val regex = Regex("https?://i.imgur.com/([\\w\\d]+)(?:_d)?.(?:webp|jpg)")
fun create(url: String): ImgurChargerPhoto? {
val id = regex.find(url)?.groups?.get(1)?.value
return id?.let { ImgurChargerPhoto(it) }
}
}
}
}

View File

@@ -29,7 +29,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.car2go.maps.model.LatLng
import net.vonforst.evmap.R
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
@@ -132,11 +131,8 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
override fun onCreateScreen(intent: Intent): Screen {
val mapScreen = if (supportsNewMapScreen(carContext)) {
MapScreen(carContext, this)
} else {
LegacyMapScreen(carContext, this)
}
val mapScreen = MapScreen(carContext, this)
val screens = mutableListOf<Screen>(mapScreen)
handleActionsIntent(intent)?.let {
@@ -196,7 +192,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
val lon = it.getQueryParameter("longitude")?.toDouble()
val name = it.getQueryParameter("name")
if (lat != null && lon != null) {
prefs.placeSearchResultAndroidAuto = PlaceWithBounds(LatLng(lat, lon), null)
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
return null
} else if (name != null) {

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
@@ -10,8 +11,10 @@ import android.net.Uri
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.HostException
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
@@ -51,7 +54,9 @@ import net.vonforst.evmap.api.availability.tesla.Pricing
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.fronyx.PredictionData
import net.vonforst.evmap.api.fronyx.PredictionRepository
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FaultReport
@@ -61,8 +66,8 @@ import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.formatDMS
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.awaitFinished
import java.time.ZoneId
@@ -71,6 +76,8 @@ import java.time.format.FormatStyle
import kotlin.math.floor
import kotlin.math.roundToInt
private const val TAG = "ChargerDetailScreen"
@ExperimentalCarApi
class ChargerDetailScreen(
ctx: CarContext,
@@ -90,7 +97,7 @@ class ChargerDetailScreen(
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val predictionRepo = PredictionRepository(ctx)
//private val predictionRepo = PredictionRepository(ctx)
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val imageSize = 128 // images should be 128dp according to docs
@@ -136,7 +143,7 @@ class ChargerDetailScreen(
.setFlags(Action.FLAG_PRIMARY)
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
navigateToCharger(carContext, session.cas, charger)
navigateToCharger(charger)
}
.build())
if (ChargepriceApi.isChargerSupported(charger)) {
@@ -259,7 +266,7 @@ class ChargerDetailScreen(
// Row 1: address + chargepoints
rows.add(Row.Builder().apply {
setTitle(charger.address?.toString() ?: charger.coordinates.formatDMS())
setTitle(charger.address.toString())
if (photo == null) {
// show just the icon
@@ -279,7 +286,7 @@ class ChargerDetailScreen(
Row.IMAGE_TYPE_LARGE
)
}
addText(generateChargepointsText(charger, availability, carContext))
addText(generateChargepointsText(charger))
}.build())
if (maxRows <= 3) {
// row 2: operator + cost + fault report
@@ -492,6 +499,47 @@ class ChargerDetailScreen(
return string
}
private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder {
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
chargepointsText.apply {
if (i > 0) append(" · ")
append("${cp.count}× ")
val plugIcon = iconForPlugType(cp.type)
if (plugIcon != 0) {
append(
nameForPlugType(carContext.stringProvider(), cp.type),
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
plugIcon
)
).setTint(
CarColor.createCustom(Color.WHITE, Color.BLACK)
).build()
),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
} else {
append(nameForPlugType(carContext.stringProvider(), cp.type))
}
cp.formatPower(carContext.currentOrDefaultLocale)?.let {
append(" ")
append(it)
}
}
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
return chargepointsText
}
private fun generateOperatorText(charger: ChargeLocation) =
if (charger.operator != null && charger.network != null) {
if (charger.operator.contains(charger.network)) {
@@ -509,6 +557,58 @@ class ChargerDetailScreen(
carContext.getString(R.string.unknown_operator)
}
private fun navigateToCharger(charger: ChargeLocation) {
var success = navigateCarApp(charger)
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
// on AAOS, some OEMs' navigation apps might not support
success = navigateRegularApp(charger)
}
if (!success) {
CarToast.makeText(carContext, R.string.no_maps_app_found, CarToast.LENGTH_SHORT).show()
}
}
private fun navigateCarApp(charger: ChargeLocation): Boolean {
val coord = charger.coordinates
val intent =
Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:${coord.lat},${coord.lng}")
)
try {
carContext.startCarApp(intent)
return true
} catch (e: HostException) {
Log.w(TAG, "Could not start navigation using car app intent")
Log.w(TAG, intent.toString())
e.printStackTrace()
} catch (e: SecurityException) {
Log.w(TAG, "Could not start navigation using car app intent")
Log.w(TAG, intent.toString())
e.printStackTrace()
}
return false
}
private fun navigateRegularApp(charger: ChargeLocation): Boolean {
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(
"geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
Uri.encode(charger.name)
})"
)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(carContext.packageManager) != null) {
carContext.startActivity(intent)
return true
} else {
Log.w(TAG, "Could not start navigation using regular intent")
Log.w(TAG, intent.toString())
}
return false
}
private fun loadCharger() {
lifecycleScope.launch {
favorite = db.favoritesDao().findFavorite(chargerSparse.id, chargerSparse.dataSource)
@@ -574,7 +674,7 @@ class ChargerDetailScreen(
invalidate()
prediction = predictionRepo.getPredictionData(charger, availability)
//prediction = predictionRepo.getPredictionData(charger, availability)
invalidate()
} else {

View File

@@ -1,249 +0,0 @@
package net.vonforst.evmap.auto
import android.location.Location
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarIconSpan
import androidx.car.app.model.CarLocation
import androidx.car.app.model.CarText
import androidx.car.app.model.DistanceSpan
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.model.ItemList
import androidx.car.app.model.Metadata
import androidx.car.app.model.Pane
import androidx.car.app.model.Place
import androidx.car.app.model.PlaceMarker
import androidx.car.app.model.Row
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.utils.formatDecimal
import java.time.ZonedDateTime
import kotlin.math.roundToInt
interface ChargerListDelegate : ItemList.OnItemVisibilityChangedListener {
val locationError: Boolean
val loadingError: Boolean
val maxRows: Int
val filterStatus: Long
val location: Location?
val energyLevel: EnergyLevel?
fun onChargerClick(charger: ChargeLocation)
}
@ExperimentalCarApi
class ChargerListFormatter(
val carContext: CarContext,
val screen: ChargerListDelegate,
val cas: CarAppService
) {
private val iconGen = ChargerIconGenerator(carContext, null, height = 96)
var favorites: Set<Long> = emptySet()
fun buildChargerList(
chargers: List<ChargeLocation>?,
availabilities: Map<Long, Pair<ZonedDateTime, ChargeLocationStatus?>>
): ItemList? {
return if (chargers != null) {
val chargerList = chargers.take(screen.maxRows)
val builder = ItemList.Builder()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
chargerList.forEach { charger ->
builder.addItem(
formatCharger(
charger,
availabilities,
showCity,
charger.id in favorites
)
)
}
builder.setNoItemsMessage(
carContext.getString(
if (screen.filterStatus == FILTERS_FAVORITES) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
}
)
)
builder.setOnItemsVisibilityChangedListener(screen)
builder.build()
} else {
if (screen.loadingError) {
val builder = ItemList.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.connection_error)
)
builder.build()
} else if (screen.locationError) {
val builder = ItemList.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.location_error)
)
builder.build()
} else {
null
}
}
}
private fun formatCharger(
charger: ChargeLocation,
availabilities: Map<Long, Pair<ZonedDateTime, ChargeLocationStatus?>>,
showCity: Boolean,
isFavorite: Boolean
): Row {
val markerTint = getMarkerTint(charger)
val backgroundTint = if ((charger.maxPower ?: 0.0) > 100) {
R.color.charger_100kw_dark // slightly darker color for better contrast
} else {
markerTint
}
val color = ContextCompat.getColor(carContext, backgroundTint)
val place =
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
.setMarker(
PlaceMarker.Builder()
.setColor(CarColor.createCustom(color, color))
.build()
)
.build()
val icon = iconGen.getBitmap(
markerTint,
fault = charger.faultReport != null,
multi = charger.isMulti(),
fav = isFavorite
)
val iconSpan =
CarIconSpan.create(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build())
return Row.Builder().apply {
// only show the city if not all chargers are in the same city (-> showCity == true)
// and the city is not already contained in the charger name
val title = SpannableStringBuilder().apply {
append(" ", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
append(" ")
append(charger.name)
}
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
val titleWithCity = SpannableStringBuilder().apply {
append("", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
append(" ")
append("${charger.name} · ${charger.address.city}")
}
setTitle(CarText.Builder(titleWithCity).addVariant(title).build())
} else {
setTitle(title)
}
val text = SpannableStringBuilder()
// distance
screen.location?.let {
val distanceMeters = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
)
text.append(
"distance",
DistanceSpan.create(
roundValueToDistance(
distanceMeters,
screen.energyLevel?.distanceDisplayUnit?.value,
carContext
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
// power
val power = charger.maxPower
if (power != null) {
if (text.isNotEmpty()) text.append(" · ")
text.append("${power.roundToInt()} kW")
}
// availability
availabilities[charger.id]?.second?.let { av ->
val status = av.status.values.flatten()
val available = availabilityText(status)
val total = charger.chargepoints.sumOf { it.count }
if (text.isNotEmpty()) text.append(" · ")
text.append(
"$available/$total",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
addText(text)
setMetadata(
Metadata.Builder()
.setPlace(place)
.build()
)
setOnClickListener {
screen.onChargerClick(charger)
}
}.build()
}
fun buildSingleCharger(
charger: ChargeLocation,
availability: ChargeLocationStatus?,
onClick: () -> Unit
) = Pane.Builder().apply {
val icon = iconGen.getBitmap(
getMarkerTint(charger),
fault = charger.faultReport != null,
multi = charger.isMulti(),
fav = charger.id in favorites
)
addRow(Row.Builder().apply {
setImage(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build())
setTitle(charger.address?.toString() ?: charger.coordinates.formatDecimal())
addText(generateChargepointsText(charger, availability, carContext))
}.build())
addAction(Action.Builder().apply {
setTitle(carContext.getString(R.string.show_more))
setOnClickListener(onClick)
}.build())
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).build()
)
setTitle(carContext.getString(R.string.navigate))
setBackgroundColor(CarColor.PRIMARY)
setOnClickListener {
navigateToCharger(carContext, cas, charger)
}
}.build())
}.build()
}

View File

@@ -1,532 +0,0 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.CarInfo
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarLocation
import androidx.car.app.model.OnContentRefreshListener
import androidx.car.app.model.Place
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.model.PlaceMarker
import androidx.car.app.model.Template
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterWithValue
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.bearingBetween
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.utils.headingDiff
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.await
import net.vonforst.evmap.viewmodel.awaitFinished
import net.vonforst.evmap.viewmodel.filtersWithValue
import retrofit2.HttpException
import java.io.IOException
import java.time.Duration
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.abs
import kotlin.math.min
/**
* Main map screen showing either nearby chargers or favorites
*
* Legacy implementation for Car App API level < 7
*/
@androidx.car.app.annotations.ExperimentalCarApi
class LegacyMapScreen(ctx: CarContext, val session: EVMapSession) :
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
ChargerListDelegate, DefaultLifecycleObserver {
private val db = AppDatabase.getInstance(carContext)
private var prefs = PreferenceDataSource(ctx)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private var updateCoroutine: Job? = null
private var availabilityUpdateCoroutine: Job? = null
private var visibleStart: Int? = null
private var visibleEnd: Int? = null
override var location: Location? = null
private var lastDistanceUpdateTime: Instant? = null
private var lastChargersUpdateTime: Instant? = null
private var chargers: List<ChargeLocation>? = null
private val favorites = db.favoritesDao().getAllFavorites()
override var loadingError = false
override var locationError = false
private val searchRadius = 5 // kilometers
private val distanceUpdateThreshold = Duration.ofSeconds(15)
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private val chargersUpdateThresholdDistance = 500 // meters
private val chargersUpdateThresholdTime = Duration.ofSeconds(30)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
HashMap()
override val maxRows =
min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
override var filterStatus = prefs.filterStatus
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
private val carInfo: CarInfo by lazy {
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
}
private val carSensors: CarSensors by lazy { carContext.patchedCarSensors }
override var energyLevel: EnergyLevel? = null
private var heading: Compass? = null
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
"android.car.permission.CAR_ENERGY",
"android.car.permission.CAR_ENERGY_PORTS",
"android.car.permission.READ_CAR_DISPLAY_UNITS",
)
} else {
listOf(
"com.google.android.gms.permission.CAR_FUEL"
)
}
private var searchLocation: LatLng? = null
private val formatter = ChargerListFormatter(ctx, this, session.cas)
init {
lifecycle.addObserver(this)
marker = MapScreen.MARKER
}
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
prefs.placeSearchResultAndroidAutoName?.let {
carContext.getString(R.string.auto_chargers_near_location, it)
} ?: carContext.getString(
if (filterStatus == FILTERS_FAVORITES) {
R.string.auto_favorites
} else {
R.string.auto_chargers_closeby
}
)
)
if (prefs.placeSearchResultAndroidAutoName != null) {
searchLocation?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply {
if (prefs.placeSearchResultAndroidAutoName != null) {
setMarker(
PlaceMarker.Builder()
.setColor(CarColor.PRIMARY)
.build()
)
}
}.build())
}
} else {
location?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build())
}
}
formatter.buildChargerList(chargers, availabilities)?.let {
setItemList(it)
} ?: setLoading(true)
setCurrentLocationEnabled(true)
setHeaderAction(Action.APP_ICON)
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
filtersWithValue?.count {
!it.value.hasSameValueAs(it.filter.defaultValue())
}
}
setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_settings
)
).setTint(CarColor.DEFAULT).build()
)
.setOnClickListener {
screenManager.push(SettingsScreen(carContext, session))
session.mapScreen = null
}
.build())
.addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (prefs.placeSearchResultAndroidAuto != null) {
R.drawable.ic_search_off
} else {
R.drawable.ic_search
}
)
).build()
)
setOnClickListener {
if (prefs.placeSearchResultAndroidAuto != null) {
prefs.placeSearchResultAndroidAutoName = null
prefs.placeSearchResultAndroidAuto = null
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
chargers = null
loadChargers()
}
} else {
chargers = null
loadChargers()
}
} else {
screenManager.pushForResult(
PlaceSearchScreen(
carContext,
session
)
) {
chargers = null
loadChargers()
}
session.mapScreen = null
}
}
}.build())
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_filter
)
)
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
)
.setOnClickListener {
screenManager.push(FilterScreen(carContext, session))
session.mapScreen = null
}
.build())
.build())
if (carContext.carAppApiLevel >= 5 ||
(BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
) {
setOnContentRefreshListener(this@LegacyMapScreen)
}
}.build()
}
override fun onChargerClick(charger: ChargeLocation) {
screenManager.push(ChargerDetailScreen(carContext, charger, session))
session.mapScreen = null
}
override fun updateLocation(location: Location) {
if (location.latitude == this.location?.latitude
&& location.longitude == this.location?.longitude
) {
return
}
val previousLocation = this.location
this.location = location
if (previousLocation == null) {
loadChargers()
return
}
val now = Instant.now()
if (lastDistanceUpdateTime == null ||
Duration.between(lastDistanceUpdateTime, now) > distanceUpdateThreshold
) {
lastDistanceUpdateTime = now
// update displayed distances
invalidate()
}
// if chargers are searched around current location, consider app-driven refresh
val searchLocation =
if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null
val distance = searchLocation?.let {
distanceBetween(
it.latitude, it.longitude, location.latitude, location.longitude
)
} ?: 0.0
if (supportsRefresh && (lastChargersUpdateTime == null ||
Duration.between(
lastChargersUpdateTime,
now
) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance)
) {
onContentRefreshRequested()
}
}
private fun loadChargers() {
val location = location ?: return
val searchLocation =
prefs.placeSearchResultAndroidAuto?.latLng ?: LatLng.fromLocation(location)
this.searchLocation = searchLocation
updateCoroutine = lifecycleScope.launch {
loadingError = false
try {
filterStatus = prefs.filterStatus
val filterValues =
db.filterValueDao().getFilterValuesAsync(filterStatus, prefs.dataSource)
val filters = repo.getFiltersAsync(carContext.stringProvider())
filtersWithValue = filtersWithValue(filters, filterValues)
val apiId = repo.api.value!!.id
// load chargers
if (filterStatus == FILTERS_FAVORITES) {
val chargers = favorites.await().map { it.charger }.sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
this@LegacyMapScreen.chargers = chargers
} else {
// try multiple search radii until we have enough chargers
var chargers: List<ChargeLocation>? = null
val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50)
for (radius in radiusValues) {
val response = repo.getChargepointsRadius(
searchLocation,
radius,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
loadingError = true
this@LegacyMapScreen.chargers = null
invalidate()
return@launch
}
chargers = response.data
if (prefs.placeSearchResultAndroidAutoName == null) {
chargers = headingFilter(
chargers,
searchLocation
)
}
if (chargers == null || chargers.size >= maxRows) {
break
}
}
this@LegacyMapScreen.chargers = chargers
}
updateCoroutine = null
lastChargersUpdateTime = Instant.now()
lastDistanceUpdateTime = Instant.now()
invalidate()
} catch (e: IOException) {
loadingError = true
invalidate()
} catch (e: HttpException) {
loadingError = true
invalidate()
}
}
}
/**
* Filters by heading if heading available and enabled
*/
private fun headingFilter(
chargers: List<ChargeLocation>?,
searchLocation: LatLng
): List<ChargeLocation>? {
// use compass heading if available, otherwise fall back to GPS
val location = location
val heading = heading?.orientations?.value?.get(0)
?: if (location?.hasBearing() == true) location.bearing else null
return heading?.let {
if (!prefs.showChargersAheadAndroidAuto) return@let chargers
chargers?.filter {
val bearing = bearingBetween(
searchLocation.latitude,
searchLocation.longitude,
it.coordinates.lat,
it.coordinates.lng
)
val diff = headingDiff(bearing, heading.toDouble())
abs(diff) < 30
}
} ?: chargers
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
val isUpdate = this.energyLevel == null
this.energyLevel = energyLevel
if (isUpdate) invalidate()
}
private fun onCompassUpdated(compass: Compass) {
this.heading = compass
}
override fun onStart(owner: LifecycleOwner) {
setupListeners()
session.requestLocationUpdates()
locationError = false
Handler(Looper.getMainLooper()).postDelayed({
if (location == null) {
locationError = true
invalidate()
}
}, 5000)
// Reloading chargers in onStart does not seem to count towards content limit.
// So let's do this so the user gets fresh chargers when re-entering the app.
if (prefs.dataSource != repo.api.value?.id) {
repo.api.value = createApi(prefs.dataSource, carContext)
}
invalidate()
loadChargers()
}
private fun setupListeners() {
val exec = ContextCompat.getMainExecutor(carContext)
if (supportsCarApiLevel3(carContext)) {
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCompassUpdated
)
}
if (!permissions.all {
ContextCompat.checkSelfPermission(
carContext,
it
) == PackageManager.PERMISSION_GRANTED
})
return
if (supportsCarApiLevel3(carContext)) {
println("Setting up energy level listener")
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
}
override fun onStop(owner: LifecycleOwner) {
// Reloading chargers in onStart does not seem to count towards content limit.
// So let's do this so the user gets fresh chargers when re-entering the app.
// Deleting the data already in onStop makes sure that we show a loading screen directly
// (i.e. onGetTemplate is not called while the old data is still there)
chargers = null
availabilities.clear()
location = null
removeListeners()
}
private fun removeListeners() {
if (supportsCarApiLevel3(carContext)) {
println("Removing energy level listener")
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
carSensors.removeCompassListener(::onCompassUpdated)
}
}
override fun onContentRefreshRequested() {
loadChargers()
availabilities.clear()
val start = visibleStart
val end = visibleEnd
if (start != null && end != null) {
onItemVisibilityChanged(start, end)
}
}
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
// when the list is scrolled, load corresponding availabilities
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
if (startIndex == -1 || endIndex == -1) return
if (availabilityUpdateCoroutine != null) return
visibleEnd = endIndex
visibleStart = startIndex
// remove outdated availabilities
availabilities = availabilities.filter {
Duration.between(
it.value.first,
ZonedDateTime.now()
) <= availabilityUpdateThreshold
}.toMutableMap()
// update availabilities
availabilityUpdateCoroutine = lifecycleScope.launch {
delay(300L)
val chargers = chargers ?: return@launch
if (chargers.isEmpty()) return@launch
val tasks = chargers.subList(
min(startIndex, chargers.size - 1),
min(endIndex, chargers.size - 1)
).mapNotNull {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
lifecycleScope.async {
val availability = availabilityRepo.getAvailability(it).data
val date = ZonedDateTime.now()
availabilities[it.id] = date to availability
}
} else null
}
if (tasks.isNotEmpty()) {
tasks.awaitAll()
invalidate()
}
availabilityUpdateCoroutine = null
}
}
}

View File

@@ -1,46 +0,0 @@
package net.vonforst.evmap.auto
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.model.Action
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.ParkedOnlyOnClickListener
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import com.car2go.maps.AttributionClickListener
import net.vonforst.evmap.R
@ExperimentalCarApi
class MapAttributionScreen(
ctx: CarContext,
val session: EVMapSession,
val attributions: List<AttributionClickListener.Attribution>
) : Screen(ctx) {
override fun onGetTemplate(): Template {
return ListTemplate.Builder()
.setHeader(
Header.Builder()
.setStartHeaderAction(Action.BACK)
.setTitle(carContext.getString(R.string.maplibre_attributionsDialogTitle))
.build()
)
.setSingleList(ItemList.Builder().apply {
attributions.forEach { attr ->
addItem(
Row.Builder()
.setTitle(attr.title)
.setBrowsable(true)
.setOnClickListener(
ParkedOnlyOnClickListener.create {
openUrl(carContext, session.cas, attr.url)
}).build()
)
}
}.build())
.build()
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,284 +0,0 @@
package net.vonforst.evmap.auto
import android.animation.ValueAnimator
import android.app.Presentation
import android.content.Context
import android.graphics.Rect
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.os.Build
import android.os.SystemClock
import android.util.Log
import android.view.MotionEvent
import androidx.car.app.CarContext
import androidx.car.app.SurfaceCallback
import androidx.car.app.SurfaceContainer
import androidx.car.app.annotations.RequiresCarApi
import androidx.core.animation.doOnEnd
import androidx.core.content.ContextCompat
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import androidx.lifecycle.LifecycleCoroutineScope
import com.car2go.maps.AnyMap
import com.car2go.maps.AnyMap.CancelableCallback
import com.car2go.maps.CameraUpdate
import com.car2go.maps.MapContainerView
import com.car2go.maps.MapFactory
import com.car2go.maps.OnMapReadyCallback
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.hypot
import kotlin.math.roundToInt
import kotlin.math.roundToLong
class MapSurfaceCallback(val ctx: CarContext, val lifecycleScope: LifecycleCoroutineScope) :
SurfaceCallback, OnMapReadyCallback {
private val VIRTUAL_DISPLAY_NAME = "evmap_map"
private val VELOCITY_THRESHOLD_IGNORE_FLING = 1000
private val STATUSBAR_OFFSET_SYSTEMS = listOf(
"VolvoCars/ihu_emulator_volvo_car/ihu_emulator:11",
"Google/sdk_gcar_x86_64/generic_64bitonly_x86_64:11"
)
private val prefs = PreferenceDataSource(ctx)
private lateinit var virtualDisplay: VirtualDisplay
lateinit var presentation: Presentation
private lateinit var mapView: MapContainerView
private var width: Int = 0
private var height: Int = 0
private var visibleArea: Rect? = null
private var map: AnyMap? = null
private val mapCallbacks = mutableListOf<OnMapReadyCallback>()
private var flingAnimator: ValueAnimator? = null
private var idle = true
private var idleDelay: Job? = null
var cameraMoveStartedListener: (() -> Unit)? = null
var cameraIdleListener: (() -> Unit)? = null
override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
if (surfaceContainer.surface == null || surfaceContainer.dpi == 0 || surfaceContainer.height == 0 || surfaceContainer.width == 0) {
return
}
if (Build.FINGERPRINT.contains("emulator") || Build.FINGERPRINT.contains("sdk_gcar")) {
// fix for MapLibre in Android Automotive Emulators
System.setProperty("ro.kernel.qemu", "1")
}
width = surfaceContainer.width
height = surfaceContainer.height
virtualDisplay = ContextCompat
.getSystemService(ctx, DisplayManager::class.java)!!
.createVirtualDisplay(
VIRTUAL_DISPLAY_NAME,
width,
height,
(surfaceContainer.dpi * when (getMapProvider()) {
"mapbox" -> 1.6
"google" -> 1.0
else -> 1.0
}).roundToInt(),
surfaceContainer.surface,
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY
)
presentation = Presentation(ctx, virtualDisplay.display, R.style.AppTheme)
mapView = createMap(presentation.context)
mapView.onCreate(null)
mapView.onResume()
presentation.setContentView(mapView)
presentation.show()
mapView.getMapAsync(this)
}
private fun getMapProvider(): String = if (BuildConfig.FLAVOR_automotive == "automotive") {
// Google Maps SDK is not available on AAOS (not even AAOS with GAS, so far)
"mapbox"
} else prefs.mapProvider
override fun onVisibleAreaChanged(visibleArea: Rect) {
Log.d("MapSurfaceCallback", "visible area: $visibleArea")
this.visibleArea = visibleArea
updateVisibleArea()
}
override fun onStableAreaChanged(stableArea: Rect) {
Log.d("MapSurfaceCallback", "stable area: $stableArea")
}
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
mapView.onPause()
mapView.onStop()
mapView.onDestroy()
map = null
presentation.dismiss()
virtualDisplay.release()
}
@RequiresCarApi(2)
override fun onScroll(distanceX: Float, distanceY: Float) {
flingAnimator?.cancel()
val map = map ?: return
map.moveCamera(map.cameraUpdateFactory.scrollBy(distanceX, distanceY))
dispatchCameraMoveStarted()
}
@RequiresCarApi(2)
override fun onFling(velocityX: Float, velocityY: Float) {
val map = map ?: return
val screenDensity: Float = presentation.resources.displayMetrics.density
// calculate velocity vector for xy dimensions, independent from screen size
val velocityXY =
hypot((velocityX / screenDensity).toDouble(), (velocityY / screenDensity).toDouble())
if (velocityXY < VELOCITY_THRESHOLD_IGNORE_FLING) {
// ignore short flings, these can occur when other gestures just have finished executing
return
}
idleDelay?.cancel()
val offsetX = velocityX / 10
val offsetY = velocityY / 10
val animationTime = (velocityXY / 10).roundToLong()
flingAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = animationTime
interpolator = LinearOutSlowInInterpolator()
var last = 0f
addUpdateListener {
val current = it.animatedFraction
val diff = last - current
map.moveCamera(map.cameraUpdateFactory.scrollBy(diff * offsetX, diff * offsetY))
last = current
}
start()
doOnEnd { dispatchCameraIdle() }
}
}
@RequiresCarApi(2)
override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) {
flingAnimator?.cancel()
val map = map ?: return
if (scaleFactor == 2f) return
val offsetX = (focusX - mapView.width / 2) * (scaleFactor - 1f)
val offsetY = (offsetY(focusY) - mapView.height / 2) * (scaleFactor - 1f)
Log.i("MapSurfaceCallback", "focus: $focusX, $focusY, scaleFactor: $scaleFactor")
map.moveCamera(map.cameraUpdateFactory.zoomBy(scaleFactor - 1))
map.moveCamera(map.cameraUpdateFactory.scrollBy(offsetX, offsetY))
dispatchCameraMoveStarted()
}
fun animateCamera(update: CameraUpdate) {
val map = map ?: return
map.animateCamera(update, object : CancelableCallback {
override fun onFinish() {
dispatchCameraIdle()
}
override fun onCancel() {
}
})
}
private fun dispatchCameraMoveStarted() {
if (idle) {
idle = false
cameraMoveStartedListener?.invoke()
}
idleDelay?.cancel()
idleDelay = lifecycleScope.launch {
delay(500)
dispatchCameraIdle()
}
}
private fun dispatchCameraIdle() {
idle = true
cameraIdleListener?.invoke()
}
@RequiresCarApi(5)
override fun onClick(x: Float, y: Float) {
flingAnimator?.cancel()
val downTime: Long = SystemClock.uptimeMillis()
val eventTime: Long = downTime + 100
val yOffset = offsetY(y)
val downEvent = MotionEvent.obtain(
downTime,
downTime,
MotionEvent.ACTION_DOWN,
x,
yOffset,
0
)
mapView.dispatchTouchEvent(downEvent)
downEvent.recycle()
val upEvent = MotionEvent.obtain(
downTime,
eventTime,
MotionEvent.ACTION_UP,
x,
yOffset,
0
)
mapView.dispatchTouchEvent(upEvent)
upEvent.recycle()
}
private fun offsetY(y: Float): Float {
if (!STATUSBAR_OFFSET_SYSTEMS.any { Build.FINGERPRINT.startsWith(it) }) return y
// In some emulators, touch locations are offset by the status bar height
// related: https://issuetracker.google.com/issues/256905247
val resId = ctx.resources.getIdentifier("status_bar_height", "dimen", "android")
val offset = resId.takeIf { it > 0 }?.let { ctx.resources.getDimensionPixelSize(it) } ?: 0
return y + offset
}
private fun createMap(ctx: Context): MapContainerView {
val priority = arrayOf(
when (getMapProvider()) {
"mapbox" -> MapFactory.MAPLIBRE
"google" -> MapFactory.GOOGLE
else -> null
},
MapFactory.GOOGLE,
MapFactory.MAPLIBRE
)
return MapFactory.createMap(ctx, priority).view
}
override fun onMapReady(anyMap: AnyMap) {
this.map = anyMap
updateVisibleArea()
mapCallbacks.forEach { it.onMapReady(anyMap) }
mapCallbacks.clear()
}
private fun updateVisibleArea() {
visibleArea?.let {
map?.setPadding(it.left, it.top, width - it.right, height - it.bottom)
}
}
fun getMapAsync(callback: OnMapReadyCallback) {
mapCallbacks.add(callback)
}
}

View File

@@ -1,25 +1,18 @@
package net.vonforst.evmap.auto
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.add
import androidx.fragment.app.commit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import net.vonforst.evmap.R
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
class OAuthLoginActivity : AppCompatActivity(R.layout.activity_oauth_login) {
companion object {
private val resultRegistry: MutableMap<String, MutableSharedFlow<String>> = mutableMapOf()
fun registerForResult(url: String): Flow<String> {
val flow = MutableSharedFlow<String>(replay = 1)
resultRegistry[url] = flow
return flow
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
@@ -29,14 +22,10 @@ class OAuthLoginActivity : AppCompatActivity(R.layout.activity_oauth_login) {
}
}
val url = intent.getStringExtra(OAuthLoginFragment.EXTRA_URL)!!
supportFragmentManager.setFragmentResultListener(url, this) { _, result ->
val resultUrl = result.getString(OAuthLoginFragment.EXTRA_URL) ?: return@setFragmentResultListener
resultRegistry[url]?.let { flow ->
flow.tryEmit(resultUrl)
resultRegistry.remove(url)
LocalBroadcastManager.getInstance(this).registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
finish()
}
finish()
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
}
}

View File

@@ -11,34 +11,19 @@ import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.DistanceSpan
import androidx.car.app.model.ItemList
import androidx.car.app.model.Row
import androidx.car.app.model.SearchTemplate
import androidx.car.app.model.Template
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.iconForPlaceType
import net.vonforst.evmap.adapter.isSpecialPlace
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.AutocompletePlace
import net.vonforst.evmap.autocomplete.AutocompleteProvider
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.autocomplete.getAutocompleteProviders
import net.vonforst.evmap.autocomplete.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.RecentAutocompletePlace
@@ -132,7 +117,7 @@ class PlaceSearchScreen(
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id) ?: return@launch
prefs.placeSearchResultAndroidAuto = placeDetails
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
screenManager.popTo(MapScreen.MARKER)

View File

@@ -29,13 +29,9 @@ import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.single
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.launch
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.EXTRA_DONATE
@@ -118,25 +114,22 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
}
.build()
)
if (carContext.carAppApiLevel < 7 || !carContext.isAppDrivenRefreshSupported) {
// this option is only supported in LegacyMapScreen
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_ahead))
.setToggle(Toggle.Builder {
prefs.showChargersAheadAndroidAuto = it
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).setTint(CarColor.DEFAULT).build()
)
.build()
)
}
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_ahead))
.setToggle(Toggle.Builder {
prefs.showChargersAheadAndroidAuto = it
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).setTint(CarColor.DEFAULT).build()
)
.build()
)
}
addItem(
Row.Builder()
@@ -161,7 +154,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
}
@ExperimentalCarApi
class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), DefaultLifecycleObserver {
class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
val encryptedPrefs = EncryptedPreferenceDataStore(ctx)
val db = AppDatabase.getInstance(ctx)
@@ -172,22 +165,14 @@ class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ct
carContext.resources.getStringArray(R.array.pref_search_provider_names)
val searchProviderValues =
carContext.resources.getStringArray(R.array.pref_search_provider_values)
val mapProviderNames =
carContext.resources.getStringArray(R.array.pref_map_provider_names)
val mapProviderValues =
carContext.resources.getStringArray(R.array.pref_map_provider_values)
var teslaLoggingIn = false
init {
lifecycle.addObserver(this)
}
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_data_sources))
setHeaderAction(Action.BACK)
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_data_source))
setBrowsable(true)
@@ -203,6 +188,35 @@ class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ct
)
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_provider))
setBrowsable(true)
val searchProviderId = prefs.searchProvider
val searchProviderDesc =
searchProviderNames[searchProviderValues.indexOf(searchProviderId)]
addText(searchProviderDesc)
setOnClickListener {
screenManager.push(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.SEARCH_PROVIDER
)
)
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_delete_recent))
setOnClickListener {
lifecycleScope.launch {
db.recentAutocompletePlaceDao().deleteAll()
CarToast.makeText(
carContext,
R.string.deleted_recent_search_results,
CarToast.LENGTH_SHORT
).show()
}
}
}.build())
/*addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.pref_prediction_enabled))
@@ -236,104 +250,10 @@ class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ct
})
}
}.build())
}.build(), carContext.getString(R.string.settings_charger_data)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_provider))
setBrowsable(true)
val searchProviderId = prefs.searchProvider
val searchProviderDesc =
searchProviderNames[searchProviderValues.indexOf(searchProviderId)]
addText(searchProviderDesc)
setOnClickListener {
screenManager.push(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.SEARCH_PROVIDER
)
)
}
}.build())
if (supportsNewMapScreen(carContext) && BuildConfig.FLAVOR_automotive != "automotive") {
// Google Maps SDK is not available on AAOS (not even AAOS with GAS, so far)
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_map_provider))
setBrowsable(true)
val mapProviderId = prefs.mapProvider
val mapProviderDesc =
mapProviderNames[mapProviderValues.indexOf(mapProviderId)]
addText(mapProviderDesc)
setOnClickListener {
screenManager.push(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.MAP_PROVIDER
)
)
}
}.build())
}
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_delete_recent))
setOnClickListener {
lifecycleScope.launch {
db.recentAutocompletePlaceDao().deleteAll()
CarToast.makeText(
carContext,
R.string.deleted_recent_search_results,
CarToast.LENGTH_SHORT
).show()
}
}
}.build())
}.build(), carContext.getString(R.string.settings_map)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_cache_count))
cacheCount?.let { count ->
cacheSize?.let { size ->
val sizeMb = size.toFloat() / 1024 / 1024
addText(
carContext.getString(
R.string.settings_cache_count_summary,
count,
sizeMb
)
)
}
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_cache_clear))
addText(carContext.getString(R.string.settings_cache_clear_summary))
setOnClickListener {
lifecycleScope.launch {
db.savedRegionDao().deleteAll()
db.chargeLocationsDao().deleteAllIfNotFavorite()
loadCacheSize()
}
}
}.build())
}.build(), carContext.getString(R.string.settings_caching)))
}.build())
}.build()
}
var cacheCount: Long? = null
var cacheSize: Long? = null
private suspend fun loadCacheSize() {
cacheCount = db.chargeLocationsDao().getCountAsync()
cacheSize = db.chargeLocationsDao().getSize()
invalidate()
}
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
lifecycleScope.launch {
loadCacheSize()
}
}
private fun teslaLogin() {
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
@@ -342,18 +262,23 @@ class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ct
val args = OAuthLoginFragmentArgs(
uri.toString(),
TeslaAuthenticationApi.resultUrlPrefix,
"#FFFFFF"
"#000000"
).toBundle()
val intent = Intent(carContext, OAuthLoginActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtras(args)
val resultFlow = OAuthLoginActivity.registerForResult(uri.toString())
lifecycleScope.launch {
resultFlow.collect { resultUrl ->
teslaGetAccessToken(resultUrl.toUri(), codeVerifier)
}
}
LocalBroadcastManager.getInstance(carContext)
.registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val url = IntentCompat.getParcelableExtra(
intent,
OAuthLoginFragment.EXTRA_URL,
Uri::class.java
)
teslaGetAccessToken(url!!, codeVerifier)
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
session.cas.startActivity(intent)
@@ -417,42 +342,32 @@ class ChooseDataSourceScreen(
@StringRes val extraDesc: Int? = null
) : Screen(ctx) {
enum class Type {
CHARGER_DATA_SOURCE, SEARCH_PROVIDER, MAP_PROVIDER
CHARGER_DATA_SOURCE, SEARCH_PROVIDER
}
val prefs = PreferenceDataSource(carContext)
val title = when (type) {
Type.CHARGER_DATA_SOURCE -> R.string.pref_data_source
Type.SEARCH_PROVIDER -> R.string.pref_search_provider
Type.MAP_PROVIDER -> R.string.pref_map_provider
}
val names = carContext.resources.getStringArray(
when (type) {
Type.CHARGER_DATA_SOURCE -> R.array.pref_data_source_names
Type.SEARCH_PROVIDER -> R.array.pref_search_provider_names
Type.MAP_PROVIDER -> R.array.pref_map_provider_names
}
)
val values = carContext.resources.getStringArray(
when (type) {
Type.CHARGER_DATA_SOURCE -> R.array.pref_data_source_values
Type.SEARCH_PROVIDER -> R.array.pref_search_provider_values
Type.MAP_PROVIDER -> R.array.pref_map_provider_values
}
)
val names = when (type) {
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_names)
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_names)
}
val values = when (type) {
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_values)
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_values)
}
val currentValue: String = when (type) {
Type.CHARGER_DATA_SOURCE -> prefs.dataSource
Type.SEARCH_PROVIDER -> prefs.searchProvider
Type.MAP_PROVIDER -> prefs.mapProvider
}
val descriptions = when (type) {
Type.CHARGER_DATA_SOURCE -> listOf(
carContext.getString(R.string.data_source_goingelectric_desc),
carContext.getString(R.string.data_source_openchargemap_desc),
carContext.getString(R.string.data_source_openstreetmap_desc)
carContext.getString(R.string.data_source_openchargemap_desc)
)
Type.SEARCH_PROVIDER -> null
Type.MAP_PROVIDER -> null
}
val callback: (String) -> Unit = when (type) {
Type.CHARGER_DATA_SOURCE -> { it ->
@@ -462,9 +377,6 @@ class ChooseDataSourceScreen(
Type.SEARCH_PROVIDER -> { it ->
prefs.searchProvider = it
}
Type.MAP_PROVIDER -> { it ->
prefs.mapProvider = it
}
}
override fun onGetTemplate(): Template {

View File

@@ -4,46 +4,32 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint
import android.util.Log
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.HostException
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarIconSpan
import androidx.car.app.model.Distance
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Template
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.ftPerMile
import net.vonforst.evmap.getPackageInfoCompat
import net.vonforst.evmap.kmPerMile
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ydPerMile
import java.util.Locale
import kotlin.math.roundToInt
@@ -236,9 +222,6 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
return true
}
fun supportsNewMapScreen(ctx: CarContext) =
ctx.carAppApiLevel >= 7 && ctx.isAppDrivenRefreshSupported
@ExperimentalCarApi
fun openUrl(carContext: CarContext, cas: CarAppService, url: String) {
val intent = CustomTabsIntent.Builder()
@@ -274,64 +257,6 @@ fun openUrl(carContext: CarContext, cas: CarAppService, url: String) {
}
}
@ExperimentalCarApi
fun navigateToCharger(ctx: CarContext, cas: CarAppService, charger: ChargeLocation) {
var success = navigateCarApp(ctx, charger)
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
// on AAOS, some OEMs' navigation apps might not support
success = navigateRegularApp(ctx, cas, charger)
}
if (!success) {
CarToast.makeText(ctx, R.string.no_maps_app_found, CarToast.LENGTH_SHORT).show()
}
}
private fun navigateCarApp(ctx: CarContext, charger: ChargeLocation): Boolean {
val coord = charger.coordinates
val intent =
Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:${coord.lat},${coord.lng}")
)
try {
ctx.startCarApp(intent)
return true
} catch (e: HostException) {
Log.w("navigateToCharger", "Could not start navigation using car app intent")
Log.w("navigateToCharger", intent.toString())
e.printStackTrace()
} catch (e: SecurityException) {
Log.w("navigateToCharger", "Could not start navigation using car app intent")
Log.w("navigateToCharger", intent.toString())
e.printStackTrace()
}
return false
}
@ExperimentalCarApi
private fun navigateRegularApp(
ctx: CarContext,
cas: CarAppService,
charger: ChargeLocation
): Boolean {
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(
"geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
Uri.encode(charger.name)
})"
)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(ctx.packageManager) != null) {
cas.startActivity(intent)
return true
} else {
Log.w("navigateToCharger", "Could not start navigation using regular intent")
Log.w("navigateToCharger", intent.toString())
}
return false
}
class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
/*
Dummy screen to get around template refresh limitations.
@@ -356,49 +281,4 @@ class TextMeasurer(ctx: CarContext) {
fun measureText(text: CharSequence): Float {
return textPaint.measureText(text, 0, text.length)
}
}
fun generateChargepointsText(
charger: ChargeLocation,
availability: ChargeLocationStatus?,
ctx: Context
): SpannableStringBuilder {
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
chargepointsText.apply {
if (i > 0) append(" · ")
append("${cp.count}× ")
val plugIcon = iconForPlugType(cp.type)
if (plugIcon != 0) {
append(
nameForPlugType(ctx.stringProvider(), cp.type),
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
ctx,
plugIcon
)
).setTint(
CarColor.createCustom(Color.WHITE, Color.BLACK)
).build()
),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
} else {
append(nameForPlugType(ctx.stringProvider(), cp.type))
}
cp.formatPower(ctx.currentOrDefaultLocale)?.let {
append(" ")
append(it)
}
}
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
return chargepointsText
}

View File

@@ -6,9 +6,6 @@ import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -111,11 +108,6 @@ class ChargepriceFragment : Fragment() {
binding.toolbar.inflateMenu(R.menu.chargeprice)
binding.toolbar.setTitle(R.string.chargeprice_title)
ViewCompat.setOnApplyWindowInsetsListener(binding.chargePricesList) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}

View File

@@ -54,7 +54,6 @@ class DataSourceSelectDialog : MaterialDialogFragment() {
when (prefs.dataSource) {
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
"openstreetmap" -> binding.rgDataSource.rbOpenStreetMap.isChecked = true
}
}
@@ -66,8 +65,6 @@ class DataSourceSelectDialog : MaterialDialogFragment() {
"goingelectric"
} else if (binding.rgDataSource.rbOpenChargeMap.isChecked) {
"openchargemap"
} else if (binding.rgDataSource.rbOpenStreetMap.isChecked) {
"openstreetmap"
} else {
return@setOnClickListener
}

View File

@@ -4,9 +4,6 @@ import android.content.Intent
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
@@ -25,10 +22,5 @@ abstract class DonateFragmentBase : Fragment() {
return true
}
}
ViewCompat.setOnApplyWindowInsetsListener(referrals.root) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
}
}

View File

@@ -7,9 +7,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -71,13 +68,6 @@ class FavoritesFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
ViewCompat.setOnApplyWindowInsetsListener(
binding.favsList
) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}

View File

@@ -9,9 +9,6 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -52,13 +49,6 @@ class FilterFragment : Fragment(), MenuProvider {
binding.vm = vm
vm.filterProfile.observe(viewLifecycleOwner) {}
ViewCompat.setOnApplyWindowInsetsListener(
binding.filtersList
) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}

View File

@@ -8,9 +8,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
@@ -63,13 +60,6 @@ class FilterProfilesFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
ViewCompat.setOnApplyWindowInsetsListener(
binding.filterProfilesList
) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}

View File

@@ -24,6 +24,7 @@ import android.widget.AdapterView
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresPermission
@@ -35,7 +36,6 @@ import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnLayout
import androidx.core.view.doOnNextLayout
import androidx.core.view.updateLayoutParams
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
@@ -58,7 +58,15 @@ import com.car2go.maps.AnyMap
import com.car2go.maps.MapFactory
import com.car2go.maps.MapFragment
import com.car2go.maps.OnMapReadyCallback
import com.car2go.maps.model.BitmapDescriptor
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.Marker
import com.car2go.maps.model.MarkerOptions
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_SETTLING
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
@@ -66,24 +74,20 @@ import com.google.android.material.transition.MaterialContainerTransform
import com.google.android.material.transition.MaterialContainerTransform.FADE_MODE_CROSS
import com.google.android.material.transition.MaterialFadeThrough
import com.google.android.material.transition.MaterialSharedAxis
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.from
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
import com.stfalcon.imageviewer.StfalconImageViewer
import io.michaelrocks.bimap.HashBiMap
import io.michaelrocks.bimap.MutableBiMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
@@ -93,6 +97,7 @@ import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargeLocationCluster
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.model.FILTERS_CUSTOM
@@ -101,8 +106,14 @@ import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.HideOnScrollFabBehavior
import net.vonforst.evmap.ui.MarkerManager
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.chargerZ
import net.vonforst.evmap.ui.clusterZ
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.ui.placeSearchZ
import net.vonforst.evmap.ui.setTouchModal
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.checkAnyLocationPermission
@@ -117,6 +128,10 @@ import net.vonforst.evmap.viewmodel.Status
import java.io.IOException
import java.time.Duration
import java.time.Instant
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.contains
import kotlin.collections.set
import kotlin.math.min
@@ -127,19 +142,54 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
private val galleryVm: GalleryViewModel by activityViewModels()
private var mapFragment: MapFragment? = null
private var map: AnyMap? = null
private var markerManager: MarkerManager? = null
private lateinit var locationEngine: LocationEngine
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var detailsDialog: ConnectorDetailsDialog
private lateinit var prefs: PreferenceDataSource
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
private var searchResultMarker: Marker? = null
private var searchResultIcon: BitmapDescriptor? = null
private var connectionErrorSnackbar: Snackbar? = null
private var zoomInSnackbar: Snackbar? = null
private var previousChargepointIds: Set<Long>? = null
private var mapTopPadding: Int = 0
private var mapBottomPadding: Int = 0
private var popupMenu: PopupMenu? = null
private var insetBottom: Int = 0
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
private lateinit var animator: MarkerAnimator
private lateinit var favToggle: MenuItem
private val bottomSheetBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val state = bottomSheetBehavior.state
when (state) {
STATE_COLLAPSED -> vm.chargerSparse.value = null
STATE_HIDDEN -> return
else -> if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
}
bottomSheetBehavior.cancelBackProgress()
}
override fun handleOnBackStarted(backEvent: BackEventCompat) {
bottomSheetBehavior.startBackProgress(backEvent)
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
bottomSheetBehavior.updateBackProgress(backEvent)
}
override fun handleOnBackCancelled() {
bottomSheetBehavior.cancelBackProgress()
}
}
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val value = vm.layersMenuOpen.value
@@ -156,18 +206,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
if (binding.search.hasFocus()) {
removeSearchFocus()
return
}
val state = bottomSheetBehavior.state
when (state) {
STATE_COLLAPSED -> vm.chargerSparse.value = null
STATE_HIDDEN -> vm.searchResult.value = null
else -> if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
}
vm.searchResult.value = null
}
}
@@ -177,6 +219,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
prefs = PreferenceDataSource(requireContext())
locationEngine = FusionEngine(requireContext())
clusterIconGenerator = ClusterIconGenerator(requireContext())
enterTransition = MaterialFadeThrough()
exitTransition = MaterialFadeThrough()
@@ -211,32 +254,39 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
.replace(R.id.map, mapFragment!!, mapFragmentTag)
.commit()
// reset map-related stuff (map provider may have changed)
map = null
markerManager = null
markers.clear()
clusterMarkers = emptyList()
searchResultMarker = null
searchResultIcon = null
}
binding.detailAppBar.toolbar.popupTheme =
com.google.android.material.R.style.Theme_Material3_DayNight
binding.detailView.toolbar.popupTheme =
com.google.android.material.R.style.ThemeOverlay_AppCompat_DayNight
val density = resources.displayMetrics.density
ViewCompat.setOnApplyWindowInsetsListener(binding.detailAppBar.toolbar) { v, insets ->
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
ViewCompat.setOnApplyWindowInsetsListener(
binding.root
) { _, insets ->
ViewCompat.onApplyWindowInsets(binding.root, insets)
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
/*binding.detailView.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemWindowInsetTop
}
WindowInsetsCompat.CONSUMED
}
}*/
val insetsBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom + insetsBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.fabLayers) { v, insets ->
// margin of layers button: status bar height + toolbar height + margin
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
val density = resources.displayMetrics.density
val margin =
if (binding.toolbarContainer.layoutParams.width == ViewGroup.LayoutParams.MATCH_PARENT) {
systemWindowInsetTop + (48 * density).toInt() + (28 * density).toInt()
} else {
systemWindowInsetTop + (12 * density).toInt()
}
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
binding.fabLayers.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = margin
}
binding.layersSheet.updateLayoutParams<ViewGroup.MarginLayoutParams> {
@@ -245,37 +295,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
// set map padding so that compass is not obstructed by toolbar
mapTopPadding = systemWindowInsetTop + (48 * density).toInt() + (16 * density).toInt()
mapBottomPadding = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
// if we actually use map.setPadding here, MapLibre will re-trigger onApplyWindowInsets
// and cause an infinite loop. So we rely on onMapReady being called later than
// onApplyWindowInsets.
WindowInsetsCompat.CONSUMED
}
ViewCompat.setOnApplyWindowInsetsListener(binding.fabLocate) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin =
systemBars + resources.getDimensionPixelSize(com.mahc.custombottomsheetbehavior.R.dimen.fab_margin)
}
WindowInsetsCompat.CONSUMED
}
ViewCompat.setOnApplyWindowInsetsListener(binding.navBarScrim) { v, insets ->
insetBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
v.layoutParams.height = insetBottom
updatePeekHeight()
WindowInsetsCompat.CONSUMED
}
ViewCompat.setOnApplyWindowInsetsListener(binding.galleryContainer) { v, insets ->
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
val newHeight =
resources.getDimensionPixelSize(R.dimen.gallery_height_with_margin) + systemWindowInsetTop
v.layoutParams.height = newHeight
bottomSheetBehavior.anchorPoint = newHeight
WindowInsetsCompat.CONSUMED
insets
}
exitTransition = TransitionInflater.from(requireContext())
@@ -285,14 +309,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
viewLifecycleOwner,
backPressedCallback
)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
bottomSheetBackPressedCallback
)
return binding.root
}
private fun updatePeekHeight() {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom + insetBottom
}
private fun getMapProvider(provider: String) = when (provider) {
"mapbox" -> MapFactory.MAPLIBRE
"google" -> MapFactory.GOOGLE
@@ -310,22 +334,20 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
mapFragment!!.getMapAsync(this)
bottomSheetBehavior = from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
bottomSheetBehavior = BottomSheetBehavior.from(binding.detailView.root)
//detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
favToggle = binding.detailAppBar.toolbar.menu.findItem(R.id.menu_fav)
binding.detailView.toolbar.inflateMenu(R.menu.detail)
favToggle = binding.detailView.toolbar.menu.findItem(R.id.menu_fav)
vm.apiName.observe(viewLifecycleOwner) {
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
binding.detailView.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, it)
}
binding.detailView.topPart.doOnNextLayout {
updatePeekHeight()
vm.bottomSheetState.value?.let { bottomSheetBehavior.state = it }
}
bottomSheetBehavior.isCollapsible = bottomSheetCollapsible
vm.bottomSheetState.value?.let { bottomSheetBehavior.state = it }
bottomSheetBehavior.skipCollapsed = !bottomSheetCollapsible
bottomSheetBehavior.state = STATE_HIDDEN
binding.detailView.connectorDetails
setupObservers()
@@ -478,7 +500,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
.show()
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = STATE_ANCHOR_POINT
bottomSheetBehavior.state = STATE_HALF_EXPANDED
}
binding.detailView.topPart.setOnLongClickListener {
val charger = vm.charger.value?.data ?: return@setOnLongClickListener false
@@ -486,14 +508,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
return@setOnLongClickListener true
}
setupSearchAutocomplete()
binding.detailAppBar.toolbar.setNavigationOnClickListener {
binding.detailView.toolbar.setNavigationOnClickListener {
if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
}
binding.detailAppBar.toolbar.setOnMenuItemClickListener {
binding.detailView.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_fav -> {
toggleFavorite()
@@ -640,36 +662,38 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
} else {
vm.insertFavorite(charger)
}
markers.inverse[charger]?.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = true,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = fav == null,
mini = vm.useMiniMarkers.value == true
)
)
}
private fun setupObservers() {
bottomSheetBehavior.addBottomSheetCallback(object :
BottomSheetCallback() {
BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (bottomSheetBehavior.state == STATE_HIDDEN) {
map?.setPadding(0, mapTopPadding, 0, mapBottomPadding)
map?.setPadding(0, mapTopPadding, 0, 0)
} else {
val height = binding.root.height - bottomSheet.top
map?.setPadding(
0,
mapTopPadding,
0,
mapBottomPadding + min(bottomSheetBehavior.peekHeight, height)
min(bottomSheetBehavior.peekHeight, height)
)
}
println(slideOffset)
if (bottomSheetBehavior.state != STATE_HIDDEN) {
binding.navBarScrim.visibility = View.VISIBLE
binding.navBarScrim.translationY =
(if (slideOffset < 0f) -slideOffset else 2 * slideOffset) * binding.navBarScrim.height
} else {
binding.navBarScrim.visibility = View.INVISIBLE
}
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
vm.bottomSheetState.value = newState
updateBackPressedCallback()
bottomSheetBackPressedCallback.isEnabled = newState != STATE_HIDDEN
if (vm.layersMenuOpen.value!! && newState !in listOf(
STATE_SETTLING,
@@ -681,7 +705,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
if (vm.selectedChargepoint.value != null && newState in listOf(
STATE_ANCHOR_POINT, STATE_COLLAPSED
STATE_HALF_EXPANDED, STATE_COLLAPSED
)
) {
closeConnectorDetailsDialog()
@@ -691,30 +715,29 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
})
vm.chargerSparse.observe(viewLifecycleOwner) {
if (it != null) {
if (vm.bottomSheetState.value != STATE_ANCHOR_POINT) {
if (vm.bottomSheetState.value != STATE_HALF_EXPANDED) {
bottomSheetBehavior.state =
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_ANCHOR_POINT
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_HALF_EXPANDED
}
removeSearchFocus()
binding.fabDirections.show()
detailAppBarBehavior.setToolbarTitle(it.name)
//detailAppBarBehavior.setToolbarTitle(it.name)
updateFavoriteToggle()
markerManager?.highlighedCharger = it
markerManager?.animateBounce(it)
highlightMarker(it)
} else {
bottomSheetBehavior.state = STATE_HIDDEN
markerManager?.highlighedCharger = null
unhighlightAllMarkers()
}
}
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
val chargepoints = res.data
if (chargepoints != null) {
markerManager?.chargepoints = chargepoints
updateMap(chargepoints.items)
}
val view = view ?: return@Observer
when (res.status) {
Status.ERROR -> {
val view = view ?: return@Observer
zoomInSnackbar?.dismiss()
connectionErrorSnackbar?.dismiss()
connectionErrorSnackbar = Snackbar
.make(view, R.string.connection_error, Snackbar.LENGTH_INDEFINITE)
@@ -726,20 +749,23 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
Status.SUCCESS -> {
connectionErrorSnackbar?.dismiss()
if (res.data != null && !res.data.isComplete) {
zoomInSnackbar?.dismiss()
zoomInSnackbar = Snackbar
.make(view, R.string.zoom_in_to_see_more, Snackbar.LENGTH_INDEFINITE)
zoomInSnackbar!!.show()
}
}
Status.LOADING -> {
zoomInSnackbar?.dismiss()
}
}
})
vm.useMiniMarkers.observe(viewLifecycleOwner) {
markerManager?.mini = it
}
vm.filteredConnectors.observe(viewLifecycleOwner) {
markerManager?.filteredConnectors = it
vm.chargepoints.value?.data?.let { updateMap(it.items) }
}
vm.favorites.observe(viewLifecycleOwner) {
updateFavoriteToggle()
markerManager?.favorites = it.map { it.favorite.chargerId }.toSet()
}
vm.searchResult.observe(viewLifecycleOwner) { place ->
displaySearchResult(place, moveCamera = true)
@@ -762,7 +788,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
if (it != null) {
detailsDialog.setData(it, vm.availability.value?.data)
}
updateBackPressedCallback()
}
updateBackPressedCallback()
@@ -770,7 +795,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
private fun displaySearchResult(place: PlaceWithBounds?, moveCamera: Boolean) {
val map = this.map ?: return
markerManager?.searchResult = place
searchResultMarker?.remove()
searchResultMarker = null
if (place != null) {
// disable location following when search result is shown
@@ -782,6 +808,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
}
if (searchResultIcon == null) {
searchResultIcon =
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
}
searchResultMarker = map.addMarker(
MarkerOptions()
.z(placeSearchZ)
.position(place.latLng)
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
} else {
binding.search.setText("")
}
@@ -790,12 +828,56 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
private fun updateBackPressedCallback() {
backPressedCallback.isEnabled =
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|| vm.searchResult.value != null
backPressedCallback.isEnabled = vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
|| binding.search.hasFocus()
|| vm.selectedChargepoint.value != null
}
private fun unhighlightAllMarkers() {
markers.forEach { (m, c) ->
m.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(c, vm.filteredConnectors.value),
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
}
}
private fun highlightMarker(charger: ChargeLocation) {
val marker = markers.inverse[charger] ?: return
// highlight this marker
marker.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = true,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
animator.animateMarkerBounce(marker, vm.useMiniMarkers.value == true)
// un-highlight all other markers
markers.forEach { (m, c) ->
if (m != marker) {
m.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(c, vm.filteredConnectors.value),
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
}
}
}
private fun updateFavoriteToggle() {
@@ -1015,30 +1097,31 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
this.map = map
val context = this.context ?: return
view ?: return
markerManager = MarkerManager(context, map, this).apply {
onChargerClick = {
vm.chargerSparse.value = it
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
vm.mapTrafficSupported.value =
mapFragment?.let { AnyMap.Feature.TRAFFIC_LAYER in it.supportedFeatures } ?: false
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFactory.GOOGLE) {
// Google Maps: icons can be generated in background thread
lifecycleScope.launch {
withContext(Dispatchers.Default) {
chargerIconGenerator.preloadCache()
}
}
onClusterClick = {
val newZoom = map.cameraPosition.zoom + 2
map.animateCamera(
map.cameraUpdateFactory.newLatLngZoom(
LatLng(it.coordinates.lat, it.coordinates.lng),
newZoom
)
)
}
chargepoints = vm.chargepoints.value?.data ?: emptyList()
highlighedCharger = vm.chargerSparse.value
searchResult = vm.searchResult.value
favorites = vm.favorites.value?.map { it.favorite.chargerId }?.toSet() ?: emptySet()
} else {
// MapLibre: needs to be run on main thread
chargerIconGenerator.preloadCache()
}
animator = MarkerAnimator(chargerIconGenerator)
map.uiSettings.setTiltGesturesEnabled(false)
map.uiSettings.setRotateGesturesEnabled(prefs.mapRotateGesturesEnabled)
map.setIndoorEnabled(false)
map.uiSettings.setIndoorLevelPickerEnabled(false)
map.uiSettings.setMapToolbarEnabled(false)
map.setOnCameraIdleListener {
vm.mapPosition.value = MapPosition(
@@ -1086,16 +1169,40 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
}
}
map.setOnMarkerClickListener { marker ->
val map = this@MapFragment.map ?: return@setOnMarkerClickListener false
when (marker) {
in markers -> {
vm.chargerSparse.value = markers[marker]
true
}
in clusterMarkers -> {
val newZoom = map.cameraPosition.zoom + 2
map.animateCamera(
map.cameraUpdateFactory.newLatLngZoom(
marker.position,
newZoom
)
)
true
}
searchResultMarker -> true
else -> false
}
}
map.setOnMapClickListener {
if (backPressedCallback.isEnabled) {
backPressedCallback.handleOnBackPressed()
} else if (bottomSheetBackPressedCallback.isEnabled) {
bottomSheetBackPressedCallback.handleOnBackPressed()
}
}
map.setMapType(vm.mapType.value)
map.setTrafficEnabled(vm.mapTrafficEnabled.value ?: false)
// set padding so that compass is not obstructed by toolbar
map.setPadding(0, mapTopPadding, 0, mapBottomPadding)
map.setPadding(0, mapTopPadding, 0, 0)
val mode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
map.setMapStyle(
@@ -1143,10 +1250,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
// show charger detail after chargers were loaded
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(value: Resource<List<ChargepointListItem>>) {
object : Observer<Resource<ChargepointList>> {
override fun onChanged(value: Resource<ChargepointList>) {
if (value.data == null) return
for (item in value.data) {
for (item in value.data.items) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
@@ -1221,6 +1328,111 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
}
@Synchronized
private fun updateMap(chargepoints: List<ChargepointListItem>) {
val map = this.map ?: return
clusterMarkers.forEach { it.remove() }
val clusters = chargepoints.filterIsInstance<ChargeLocationCluster>()
val chargers = chargepoints.filterIsInstance<ChargeLocation>()
val chargepointIds = chargers.map { it.id }.toSet()
// update icons of existing markers (connector filter may have changed)
for ((marker, charger) in markers) {
val highlight = charger.id == vm.chargerSparse.value?.id
marker.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = highlight,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
mini = vm.useMiniMarkers.value == true
)
)
marker.setAnchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
}
if (chargers.toSet() != markers.values) {
// remove markers that disappeared
val bounds = map.projection.visibleRegion.latLngBounds
markers.entries.toList().forEach {
val marker = it.key
val charger = it.value
if (!chargepointIds.contains(charger.id)) {
// animate marker if it is visible, otherwise remove immediately
if (bounds.contains(marker.position)) {
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
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())
animator.animateMarkerDisappear(
marker, tint, highlight, fault, multi, fav,
vm.useMiniMarkers.value == true
)
} else {
animator.deleteMarker(marker)
}
markers.remove(marker)
}
}
// add new markers
val map1 = markers.values.map { it.id }
for (charger in chargers) {
if (!map1.contains(charger.id)) {
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
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 marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
.z(chargerZ)
.icon(
chargerIconGenerator.getBitmapDescriptor(
tint,
0f,
255,
highlight,
fault,
multi,
fav,
vm.useMiniMarkers.value == true
)
)
.anchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
)
animator.animateMarkerAppear(
marker, tint, highlight, fault, multi, fav,
vm.useMiniMarkers.value == true
)
markers[marker] = charger
}
}
previousChargepointIds = chargepointIds
}
clusterMarkers = clusters.map { cluster ->
map.addMarker(
MarkerOptions()
.position(LatLng(cluster.coordinates.lat, cluster.coordinates.lng))
.z(clusterZ)
.icon(
map.bitmapDescriptorFactory.fromBitmap(
clusterIconGenerator.makeIcon(
cluster.clusterCount.toString()
)
)
)
.anchor(0.5f, 0.5f)
)
}
}
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.map, menu)
@@ -1428,7 +1640,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
map = null
mapFragment = null
_binding = null
markerManager = null
markers.clear()
clusterMarkers = emptyList()
searchResultMarker = null
searchResultIcon = null
/* if we don't dismiss the popup menu, it will be recreated in some cases
(split-screen mode) and then have references to a destroyed fragment. */
popupMenu?.dismiss()

View File

@@ -218,8 +218,6 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
binding.rgDataSource.textView27,
binding.rgDataSource.rbOpenChargeMap,
binding.rgDataSource.textView28,
binding.rgDataSource.rbOpenStreetMap,
binding.rgDataSource.textView29,
binding.dataSourceHint,
binding.cbAcceptPrivacy
)
@@ -248,8 +246,7 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
for (rb in listOf(
binding.rgDataSource.rbGoingElectric,
binding.rgDataSource.rbOpenChargeMap,
binding.rgDataSource.rbOpenStreetMap
binding.rgDataSource.rbOpenChargeMap
)) {
rb.setOnCheckedChangeListener { _, _ ->
if (binding.btnGetStarted.visibility == View.INVISIBLE) {
@@ -264,7 +261,6 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
when (prefs.dataSource) {
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
"openstreetmap" -> binding.rgDataSource.rbOpenStreetMap.isChecked = true
}
}
@@ -283,8 +279,6 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
"goingelectric"
} else if (binding.rgDataSource.rbOpenChargeMap.isChecked) {
"openchargemap"
} else if (binding.rgDataSource.rbOpenStreetMap.isChecked) {
"openstreetmap"
} else {
return@setOnClickListener
}

View File

@@ -1,34 +1,35 @@
package net.vonforst.evmap.fragment.oauth
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.core.graphics.toColorInt
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import java.lang.IllegalStateException
class OAuthLoginFragment : Fragment() {
companion object {
val ACTION_OAUTH_RESULT = "oauth_result"
val EXTRA_URL = "url"
}
@@ -71,11 +72,11 @@ class OAuthLoginFragment : Fragment() {
}
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
val uri = args.url.toUri()
val uri = Uri.parse(args.url)
webView = view.findViewById(R.id.webView)
args.color?.let { webView.setBackgroundColor(it.toColorInt()) }
args.color?.let { webView.setBackgroundColor(Color.parseColor(it)) }
val progress = view.findViewById<LinearProgressIndicator>(R.id.progress_indicator)
CookieManager.getInstance().removeAllCookies(null)
@@ -88,8 +89,13 @@ class OAuthLoginFragment : Fragment() {
if (url.toString().startsWith(args.resultUrlPrefix)) {
val result = Bundle()
result.putString(EXTRA_URL, url.toString())
result.putString("url", url.toString())
setFragmentResult(args.url, result)
context?.let {
LocalBroadcastManager.getInstance(it).sendBroadcast(
Intent(ACTION_OAUTH_RESULT).putExtra(EXTRA_URL, url)
)
}
navController?.popBackStack()
}
@@ -98,9 +104,6 @@ class OAuthLoginFragment : Fragment() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
if (BuildConfig.DEBUG) {
Log.w("WebViewClient", url)
}
progress.show()
}
@@ -109,24 +112,6 @@ class OAuthLoginFragment : Fragment() {
progress.hide()
webView.background = null
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError
) {
super.onReceivedError(view, request, error)
Log.w("WebViewClient", error.toString())
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse
) {
super.onReceivedHttpError(view, request, errorResponse)
Log.w("WebViewClient", "HTTP Error ${errorResponse.statusCode}")
}
}
webView.settings.javaScriptEnabled = true
webView.loadUrl(args.url)

View File

@@ -1,14 +1,9 @@
package net.vonforst.evmap.fragment.preference
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.preference.Preference
@@ -35,19 +30,6 @@ class AboutFragment : PreferenceFragmentCompat() {
exitTransition = MaterialFadeThrough()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = super.onCreateView(inflater, container, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(listView) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View File

@@ -2,13 +2,8 @@ package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceFragmentCompat
@@ -40,19 +35,6 @@ abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = super.onCreateView(inflater, container, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(listView) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View File

@@ -17,14 +17,12 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.TeslaOwnerApi
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
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
import androidx.core.net.toUri
class DataSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
@@ -148,7 +146,7 @@ class DataSettingsFragment : BaseSettingsFragment() {
val args = OAuthLoginFragmentArgs(
uri.toString(),
TeslaAuthenticationApi.resultUrlPrefix,
"#FFFFFF"
"#000000"
).toBundle()
setFragmentResultListener(uri.toString()) { _, result ->
@@ -161,7 +159,7 @@ class DataSettingsFragment : BaseSettingsFragment() {
private fun teslaGetAccessToken(result: Bundle, codeVerifier: String) {
teslaAccountPreference.summary = getString(R.string.logging_in)
val url = result.getString(OAuthLoginFragment.EXTRA_URL)!!.toUri()
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)

View File

@@ -3,19 +3,15 @@ package net.vonforst.evmap.model
import android.content.Context
import android.os.Parcelable
import androidx.core.text.HtmlCompat
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import co.anbora.labs.spatia.geometry.Point
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.utils.SphericalMercatorProjection
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
@@ -136,8 +132,8 @@ data class ChargeLocation(
.filter { it.type == variant.type && it.power == variant.power }
val count = filtered.sumOf { it.count }
Chargepoint(variant.type, variant.power, count,
filtered.map { it.current }.distinct().singleOrNull(),
filtered.map { it.voltage }.distinct().singleOrNull()
filtered.map { it.voltage }.distinct().singleOrNull(),
filtered.map { it.current }.distinct().singleOrNull()
)
}
}
@@ -147,16 +143,9 @@ data class ChargeLocation(
fun formatChargepoints(sp: StringProvider, locale: Locale): String {
return chargepointsMerged.joinToString(" · ") {
"${it.count} × ${nameForPlugType(sp, it.type)}${
it.formatPower(locale)?.let { " $it" } ?: ""
}"
"${it.count} × ${nameForPlugType(sp, it.type)} ${it.formatPower(locale)}"
}
}
// used to store projected coordinates in DB
@IgnoredOnParcel
var coordinatesProjected: Point =
SphericalMercatorProjection.project(Point(coordinates.lng, coordinates.lat))
}
/**
@@ -365,8 +354,8 @@ abstract class ChargerPhoto(open val id: String) : Parcelable {
}
data class ChargeLocationCluster(
@ColumnInfo("clusterCount") val clusterCount: Int,
@ColumnInfo("coordinates") val coordinates: Coordinate,
val clusterCount: Int,
val coordinates: Coordinate,
val items: List<ChargeLocation>? = null
) : ChargepointListItem()
@@ -383,15 +372,14 @@ data class Address(
override fun toString(): String {
// TODO: the order here follows a German-style format (i.e. street, postcode city).
// in principle this should be country-dependent (e.g. UK has postcode after city)
// maybe use https://github.com/bettermile/address-formatter-kotlin
return buildString {
street?.let {
append(it)
if (postcode != null || city != null) append(", ")
append(", ")
}
postcode?.let {
append(it)
if (city != null) append(" ")
append(" ")
}
city?.let {
append(it)
@@ -447,8 +435,7 @@ data class Chargepoint(
const val TYPE_2_UNKNOWN = "Type 2 (either plug or socket)"
const val TYPE_2_SOCKET = "Type 2 socket"
const val TYPE_2_PLUG = "Type 2 plug"
const val TYPE_3A = "Type 3A"
const val TYPE_3C = "Type 3C"
const val TYPE_3 = "Type 3"
const val CCS_TYPE_2 = "CCS Type 2"
const val CCS_TYPE_1 = "CCS Type 1"
const val CCS_UNKNOWN = "CCS (either Type 1 or Type 2)"

View File

@@ -3,11 +3,6 @@ package net.vonforst.evmap.storage
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
@@ -21,15 +16,14 @@ import java.time.Instant
* successful.
*/
class CacheLiveData<T>(
cache: LiveData<Resource<T>>,
cache: LiveData<T>,
api: LiveData<Resource<T>>,
skipApi: LiveData<Boolean>? = null
) :
MediatorLiveData<Resource<T>>() {
private var cacheResult: Resource<T>? = null
private var cacheResult: T? = null
private var apiResult: Resource<T>? = null
private var skipApiResult: Boolean = false
private val apiLiveData = api
init {
updateValue()
@@ -70,21 +64,9 @@ class CacheLiveData<T>(
Log.d("CacheLiveData", "cache has finished loading before API")
// cache has finished loading before API
if (skipApiResult) {
value = when (cache.status) {
Status.SUCCESS -> cache
Status.ERROR -> {
Log.d("CacheLiveData", "Cache returned an error, querying API")
addSource(apiLiveData) {
apiResult = it
updateValue()
}
Resource.loading(null)
}
Status.LOADING -> cache
}
value = Resource.success(cache)
} else {
value = Resource.loading(cache.data)
value = Resource.loading(cache)
}
} else if (cache == null && api != null) {
Log.d("CacheLiveData", "API has finished loading before cache")
@@ -99,7 +81,7 @@ class CacheLiveData<T>(
// Both cache and API have finished loading
value = when (api.status) {
Status.SUCCESS -> api
Status.ERROR -> Resource.error(api.message, cache.data)
Status.ERROR -> Resource.error(api.message, cache)
Status.LOADING -> api // should not occur
}
}
@@ -113,7 +95,7 @@ class CacheLiveData<T>(
* reload from the API.
*/
class PreferCacheLiveData(
cache: LiveData<ChargeLocation?>,
cache: LiveData<ChargeLocation>,
val api: LiveData<Resource<ChargeLocation>>,
cacheSoftLimit: Duration
) :
@@ -146,44 +128,4 @@ class PreferCacheLiveData(
}
}
}
}
/**
* Flow-based 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.
*/
fun preferCacheFlow(
cache: Flow<ChargeLocation?>,
api: Flow<Resource<ChargeLocation>>,
cacheSoftLimit: Duration
): Flow<Resource<ChargeLocation>> = flow {
emit(Resource.loading(null)) // initial state
val cacheRes = cache.firstOrNull() // read cache once
if (cacheRes != null) {
if (cacheRes.isDetailed && cacheRes.timeRetrieved > Instant.now() - cacheSoftLimit) {
emit(Resource.success(cacheRes))
return@flow
} else {
emit(Resource.loading(cacheRes))
emitAll(api.map { apiRes ->
when (apiRes.status) {
Status.SUCCESS -> apiRes
Status.ERROR -> Resource.error(apiRes.message, cacheRes)
Status.LOADING -> Resource.loading(cacheRes)
}
})
}
} else {
// No cache → straight to API
emitAll(api.map { apiRes ->
when (apiRes.status) {
Status.SUCCESS -> apiRes
Status.ERROR -> Resource.error(apiRes.message, null)
Status.LOADING -> Resource.loading(null)
}
})
}
}

View File

@@ -1,51 +1,34 @@
package net.vonforst.evmap.storage
import android.util.Log
import androidx.lifecycle.*
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.Point
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 kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.ChargepointList
import net.vonforst.evmap.api.FiltersSQLQuery
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.goingelectric.GEReferenceData
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.openstreetmap.OSMReferenceData
import net.vonforst.evmap.api.openstreetmap.OpenStreetMapApiWrapper
import net.vonforst.evmap.model.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.ui.getClusterPrecision
import net.vonforst.evmap.utils.crossesAntimeridian
import net.vonforst.evmap.utils.splitAtAntimeridian
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.await
import net.vonforst.evmap.viewmodel.getClusterDistance
import net.vonforst.evmap.viewmodel.singleSwitchMap
import java.time.Duration
import java.time.Instant
import kotlin.time.TimeSource
const val CLUSTER_MAX_ZOOM_LEVEL = 11f
import kotlin.math.sqrt
@Dao
abstract class ChargeLocationsDao {
@@ -76,110 +59,53 @@ abstract class ChargeLocationsDao {
abstract suspend fun deleteAllIfNotFavorite()
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND id == :id AND isDetailed == 1 AND timeRetrieved > :after")
abstract suspend fun getChargeLocationById(
abstract fun getChargeLocationById(
id: Long,
dataSource: String,
after: Long
): ChargeLocation?
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND id IN (:ids) AND timeRetrieved > :after")
abstract suspend fun getChargeLocationsById(
ids: List<Long>,
dataSource: String,
after: Long
): List<ChargeLocation>
): LiveData<ChargeLocation>
@SkipQueryVerification
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND timeRetrieved > :after AND ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND search_frame = BuildMbr(:lng1, :lat1, :lng2, :lat2))")
abstract suspend fun getChargeLocationsInBounds(
@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
): List<ChargeLocation>
): LiveData<List<ChargeLocation>>
@SkipQueryVerification
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND PtDistWithin(coordinates, MakePoint(:lng, :lat, 4326), :radius) AND timeRetrieved > :after AND ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND search_frame = BuildCircleMbr(:lng, :lat, :radius)) ORDER BY Distance(coordinates, MakePoint(:lng, :lat, 4326))")
abstract suspend fun getChargeLocationsRadius(
@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
): List<ChargeLocation>
): LiveData<List<ChargeLocation>>
@RawQuery(observedEntities = [ChargeLocation::class])
abstract suspend fun getChargeLocationsCustom(query: SupportSQLiteQuery): List<ChargeLocation>
@SkipQueryVerification
@Query("SELECT SUM(1) AS clusterCount, Transform(MakePoint(AVG(X(coordinatesProjected)), AVG(Y(coordinatesProjected)), 3857), 4326) as center, SnapToGrid(coordinatesProjected, :precision) AS snapped, GROUP_CONCAT(id, ',') as ids FROM chargelocation WHERE dataSource == :dataSource AND timeRetrieved > :after AND ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND f_geometry_column = 'coordinates' AND search_frame = BuildMbr(:lng1, :lat1, :lng2, :lat2)) GROUP BY snapped")
abstract suspend fun getChargeLocationClusters(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double,
dataSource: String,
after: Long,
precision: Double
): List<DBChargeLocationCluster>
@RawQuery(observedEntities = [ChargeLocation::class])
abstract suspend fun getChargeLocationClustersCustom(query: SupportSQLiteQuery): List<DBChargeLocationCluster>
suspend fun getChargeLocationsClustered(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double,
dataSource: String,
after: Long,
zoom: Float
): List<ChargepointListItem> {
if (zoom > CLUSTER_MAX_ZOOM_LEVEL) {
return getChargeLocationsInBounds(lat1, lat2, lng1, lng2, dataSource, after)
}
val precision = getClusterPrecision(zoom)
val clusters =
getChargeLocationClusters(lat1, lat2, lng1, lng2, dataSource, after, precision)
val singleChargers =
getChargeLocationsById(clusters.filter { it.clusterCount == 1 }.map { it.ids }
.flatten(), dataSource, after)
return clusters.filter { it.clusterCount > 1 }.map { it.convert() } + singleChargers
}
abstract fun getChargeLocationsCustom(query: SupportSQLiteQuery): LiveData<List<ChargeLocation>>
@Query("SELECT COUNT(*) FROM chargelocation")
abstract fun getCount(): LiveData<Long>
@Query("SELECT COUNT(*) FROM chargelocation")
abstract suspend fun getCountAsync(): Long
@SkipQueryVerification
@Query("SELECT SUM(pgsize) FROM dbstat WHERE name == \"ChargeLocation\"")
abstract suspend fun getSize(): Long
}
data class DBChargeLocationCluster(
@ColumnInfo("clusterCount") val clusterCount: Int,
@ColumnInfo("center") val center: Coordinate,
@ColumnInfo("snapped") val snapped: Point,
@ColumnInfo("ids") val ids: List<Long>
) {
fun convert() = ChargeLocationCluster(clusterCount, center, null)
}
private const val TAG = "ChargeLocationsDao"
/**
* The ChargeLocationsRepository wraps the ChargepointApi and the DB to provide caching
* and clustering functionality.
*/
class ChargeLocationsRepository(
private val api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
private val db: AppDatabase, private val prefs: PreferenceDataSource
) {
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
@@ -188,73 +114,61 @@ class ChargeLocationsRepository(
// if cached data is available and more recent than this duration, API will not be queried
private val cacheSoftLimit = Duration.ofDays(1)
val referenceData = when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
scope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
val referenceData = this.api.switchMap { api ->
when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
scope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
scope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
scope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenStreetMapApiWrapper -> {
OSMReferenceDataRepository(db.osmReferenceDataDao()).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}.shareIn(scope, SharingStarted.Lazily, 1)
}
private val chargeLocationsDao = db.chargeLocationsDao()
private val savedRegionDao = db.savedRegionDao()
private var fullDownloadJob: Job? = null
private var fullDownloadProgress: MutableStateFlow<Float?> = MutableStateFlow(null)
fun getChargepoints(
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues?,
overrideCache: Boolean = false,
): Flow<List<ChargepointListItem>> {
overrideCache: Boolean = false
): LiveData<Resource<ChargepointList>> {
if (bounds.crossesAntimeridian()) {
val (a, b) = bounds.splitAtAntimeridian()
val flowA = getChargepoints(a, zoom, filters, overrideCache)
val flowB = getChargepoints(b, zoom, filters, overrideCache)
return flowA.combine(flowB) { a, b -> a + b }
}
val dbResult = flow {
val t1 = TimeSource.Monotonic.markNow()
val result = if (filters.isNullOrEmpty()) {
chargeLocationsDao.getChargeLocationsClustered(
bounds.southwest.latitude,
bounds.northeast.latitude,
bounds.southwest.longitude,
bounds.northeast.longitude,
api.id,
cacheLimitDate(api),
zoom
)
} else {
queryWithFiltersClustered(api, filters, bounds, zoom)
}
val t2 = TimeSource.Monotonic.markNow()
Log.d(TAG, "DB loading time: ${t2 - t1}")
Log.d(TAG, "number of chargers: ${result.size}")
emit(result)
val liveDataA = getChargepoints(a, zoom, filters, overrideCache)
val liveDataB = getChargepoints(b, zoom, filters, overrideCache)
return combineLiveData(liveDataA, liveDataB)
}
val api = api.value!!
val dbResult = if (filters == null) {
chargeLocationsDao.getChargeLocationsInBounds(
bounds.southwest.latitude,
bounds.northeast.latitude,
bounds.southwest.longitude,
bounds.northeast.longitude,
api.id,
cacheLimitDate(api)
)
} else {
queryWithFilters(api, filters, bounds)
}.map { ChargepointList(applyLocalClustering(it, zoom), true) }
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
@@ -270,59 +184,65 @@ class ChargeLocationsRepository(
requiresDetail
)
val useClustering = shouldUseServerSideClustering(zoom)
if (api.supportsOnlineQueries) {
val apiResult = flow {
val refData = referenceData.first()
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
)
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 if (overrideCache) {
apiResult
} else {
CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged()
}
}
return if (overrideCache) {
apiResult
} else {
return liveData {
if (fullDownloadJob != null) {
fullDownloadProgress.value?.let { emit(Resource.loading(null, it)) }
CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged()
}
}
private fun combineLiveData(
liveDataA: LiveData<Resource<ChargepointList>>,
liveDataB: LiveData<Resource<ChargepointList>>
) = MediatorLiveData<Resource<ChargepointList>>().apply {
listOf(liveDataA, liveDataB).forEach {
addSource(it) {
val valA = liveDataA.value
val valB = liveDataB.value
val combinedList = if (valA?.data != null && valB?.data != null) {
ChargepointList(
valA.data.items + valB.data.items,
valA.data.isComplete && valB.data.isComplete
)
} else if (valA?.data != null) {
ChargepointList(valA.data.items, false)
} else if (valB?.data != null) {
ChargepointList(valB.data.items, false)
} else null
if (valA?.status == Status.SUCCESS && valB?.status == Status.SUCCESS) {
Resource.success(combinedList)
} else if (valA?.status == Status.ERROR || valB?.status == Status.ERROR) {
Resource.error(valA?.message ?: valB?.message, combinedList)
} else {
Resource.loading(combinedList)
}
if (!savedRegionResult.await()) {
val job = fullDownloadJob ?: scope.launch {
withContext(Dispatchers.IO) {
fullDownload()
}
}.also { fullDownloadJob = it }
val progressJob = scope.launch {
fullDownloadProgress.collect {
emit(Resource.loading(null, it))
}
}
job.join()
progressJob.cancelAndJoin()
}
emit(dbResult.await())
}
}
}
@@ -330,26 +250,23 @@ class ChargeLocationsRepository(
fun getChargepointsRadius(
location: LatLng,
radius: Int,
zoom: Float,
filters: FilterValues?
): LiveData<Resource<List<ChargeLocation>>> {
): LiveData<Resource<ChargepointList>> {
val api = api.value!!
val radiusMeters = radius.toDouble() * 1000
val dbResult = if (filters.isNullOrEmpty()) {
liveData {
emit(
Resource.success(
chargeLocationsDao.getChargeLocationsRadius(
location.latitude,
location.longitude,
radiusMeters,
api.id,
cacheLimitDate(api)
)
)
)
}
val dbResult = if (filters == null) {
chargeLocationsDao.getChargeLocationsRadius(
location.latitude,
location.longitude,
radiusMeters,
api.id,
cacheLimitDate(api)
)
} else {
queryWithFilters(api, filters, location, radiusMeters)
}
}.map { ChargepointList(applyLocalClustering(it, zoom), true) }
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
@@ -363,98 +280,69 @@ class ChargeLocationsRepository(
filtersSerialized,
requiresDetail
)
if (api.supportsOnlineQueries) {
val apiResult = liveData {
val refData = referenceData.first()
val time = Instant.now()
val result =
api.getChargepointsRadius(
refData,
location,
radius,
16f,
false,
filters
)
emit(
Resource(
result.status,
result.data?.items?.filterIsInstance<ChargeLocation>(),
result.message
)
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 (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
)
)
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
)
savedRegionDao.insert(
SavedRegion(
region, api.id, time,
filtersSerialized,
false
)
)
}
)
}
}
return CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged()
} else {
return liveData {
if (!savedRegionResult.await()) {
val job = fullDownloadJob ?: scope.launch {
fullDownload()
}.also { fullDownloadJob = it }
val progressJob = scope.launch {
fullDownloadProgress.collect {
emit(Resource.loading(null, it))
}
}
job.join()
progressJob.cancelAndJoin()
}
emit(dbResult.await())
}
}
return CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged()
}
private suspend fun applyLocalClustering(
private fun applyLocalClustering(
result: Resource<ChargepointList>,
zoom: Float
): Resource<List<ChargepointListItem>> {
): Resource<ChargepointList> {
val list = result.data ?: return Resource(result.status, null, result.message)
val chargers = list.items.filterIsInstance<ChargeLocation>()
if (chargers.size != list.items.size) return Resource(
result.status,
list.items,
list,
result.message
) // list already contains clusters
val clustered = applyLocalClustering(chargers, zoom)
return Resource(result.status, clustered, result.message)
return Resource(result.status, ChargepointList(clustered, list.isComplete), result.message)
}
private suspend fun applyLocalClustering(
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 <= CLUSTER_MAX_ZOOM_LEVEL
val useClustering = chargers.size > 500 || zoom <= 11f
val clusterDistance = getClusterDistance(zoom)
val chargersClustered = if (useClustering) {
withContext(Dispatchers.Default) {
cluster(chargers, zoom)
val chargersClustered = if (useClustering && clusterDistance != null) {
Dispatchers.Default.run {
cluster(chargers, zoom, clusterDistance)
}
} else chargers
return chargersClustered
@@ -463,37 +351,37 @@ class ChargeLocationsRepository(
fun getChargepointDetail(
id: Long,
overrideCache: Boolean = false
): Flow<Resource<ChargeLocation>> {
val dbResult = flow {
emit(
chargeLocationsDao.getChargeLocationById(
id,
prefs.dataSource,
cacheLimitDate(api)
)
)
): LiveData<Resource<ChargeLocation>> {
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!!)
}
}
if (api.supportsOnlineQueries) {
val apiResult = flow {
val refData = referenceData.first()
val result = api.getChargepointDetail(refData, id)
emit(result)
if (result.status == Status.SUCCESS) {
chargeLocationsDao.insert(result.data!!)
}
}
return if (overrideCache) {
apiResult
} else {
preferCacheFlow(dbResult, apiResult, cacheSoftLimit)
}
return if (overrideCache) {
apiResult
} else {
return dbResult.map { Resource.success(it) }
PreferCacheLiveData(dbResult, apiResult, cacheSoftLimit)
}
}
fun getFilters(sp: StringProvider) = referenceData.map {
api.getFilters(it, sp)
fun getFilters(sp: StringProvider) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { refData: ReferenceData? ->
refData?.let { value = api.value!!.getFilters(refData, sp) }
}
}
suspend fun getFiltersAsync(sp: StringProvider): List<Filter<FilterValue>> {
val refData = referenceData.await()
return api.value!!.getFilters(refData, sp)
}
val chargeCardMap by lazy {
@@ -508,164 +396,67 @@ class ChargeLocationsRepository(
}
}
private suspend fun queryWithFilters(
private fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
bounds: LatLngBounds
): List<ChargeLocation> {
return queryWithFilters(api, filters, boundsSpatialIndexQuery(bounds))
): 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 suspend fun queryWithFiltersClustered(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
bounds: LatLngBounds,
zoom: Float
): List<ChargepointListItem> {
return queryWithFiltersClustered(api, filters, boundsSpatialIndexQuery(bounds), zoom)
}
private suspend fun queryWithFilters(
private fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
location: LatLng,
radius: Double
): List<ChargeLocation> {
): LiveData<List<ChargeLocation>> {
val region =
radiusSpatialIndexQuery(location, radius)
"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 boundsSpatialIndexQuery(bounds: LatLngBounds) =
"ChargeLocation.ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND search_frame = BuildMbr(${bounds.southwest.longitude}, ${bounds.southwest.latitude}, ${bounds.northeast.longitude}, ${bounds.northeast.latitude}))"
private fun radiusSpatialIndexQuery(location: LatLng, radius: Double) =
"PtDistWithin(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326), ${radius}) AND ChargeLocation.ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND search_frame = BuildCircleMbr(${location.longitude}, ${location.latitude}, $radius))"
private suspend fun queryWithFilters(
private fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
regionSql: String,
orderSql: String? = null
): List<ChargeLocation> {
val query = api.convertFiltersToSQL(filters, referenceData.first())
val after = cacheLimitDate(api)
val sql = buildFilteredQuery(query, regionSql, after, orderSql)
return chargeLocationsDao.getChargeLocationsCustom(
SimpleSQLiteQuery(
sql,
null
)
)
}
private suspend fun queryWithFiltersClustered(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
regionSql: String,
zoom: Float,
orderSql: String? = null
): List<ChargepointListItem> = if (zoom > CLUSTER_MAX_ZOOM_LEVEL) {
queryWithFilters(api, filters, regionSql, orderSql).map { it }
} else {
val query = api.convertFiltersToSQL(filters, referenceData.first())
val after = cacheLimitDate(api)
val clusterPrecision = getClusterPrecision(zoom)
val sql = buildFilteredQuery(query, regionSql, after, orderSql, clusterPrecision)
val clusters = chargeLocationsDao.getChargeLocationClustersCustom(
SimpleSQLiteQuery(
sql,
null
)
)
val singleChargers =
chargeLocationsDao.getChargeLocationsById(clusters.filter { it.clusterCount == 1 }
.map { it.ids }
.flatten(), prefs.dataSource, after)
clusters.filter { it.clusterCount > 1 }
.map { it.convert() } + singleChargers
}
private fun buildFilteredQuery(
query: FiltersSQLQuery,
regionSql: String,
after: Long,
orderSql: String? = null,
clusterPrecision: Double? = null
) = StringBuilder().apply {
append("SELECT")
if (clusterPrecision != null) {
append(" SUM(1) AS clusterCount, Transform(MakePoint(AVG(X(query.coordinatesProjected)), AVG(Y(query.coordinatesProjected)), 3857), 4326) as center, SnapToGrid(query.coordinatesProjected, $clusterPrecision) AS snapped, GROUP_CONCAT(query.id, ',') as ids FROM (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) }
if (clusterPrecision != null) {
append(") AS query GROUP BY snapped")
}
}.toString()
private suspend fun fullDownload() {
if (!api.supportsFullDownload) return
val time = Instant.now()
val result = api.fullDownload()
): LiveData<List<ChargeLocation>> = referenceData.singleSwitchMap { refData ->
try {
var insertJob: Job? = null
result.chargers.chunked(1024).forEach {
insertJob?.join()
insertJob = withContext(Dispatchers.IO) {
scope.launch {
chargeLocationsDao.insert(*it.toTypedArray())
}
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(" *")
}
fullDownloadProgress.value = result.progress
}
val region = Mbr(
-180.0,
-90.0,
180.0,
90.0, 4326
).asPolygon()
savedRegionDao.insert(
SavedRegion(
region, api.id, time,
null,
true
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
)
)
when (api) {
is OpenStreetMapApiWrapper -> {
val refData = result.referenceData
OSMReferenceDataRepository(db.osmReferenceDataDao()).updateReferenceData(refData as OSMReferenceData)
}
}
} finally {
fullDownloadProgress.value = null
fullDownloadJob = null
} catch (e: NotImplementedError) {
MutableLiveData() // in this case we cannot get a DB result
}
}

View File

@@ -15,7 +15,7 @@ class CleanupCacheWorker(appContext: Context, workerParams: WorkerParameters) :
val savedRegionDao = db.savedRegionDao()
val now = Instant.now()
val dataSources = listOf("openchargemap", "openstreetmap", "goingelectric")
val dataSources = listOf("openchargemap", "goingelectric")
for (dataSource in dataSources) {
val api = createApi(dataSource, applicationContext)
val limit = now.minus(api.cacheLimit).toEpochMilli()

View File

@@ -16,12 +16,7 @@ import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.openchargemap.OCMConnectionType
import net.vonforst.evmap.api.openchargemap.OCMCountry
import net.vonforst.evmap.api.openchargemap.OCMOperator
import net.vonforst.evmap.model.BooleanFilterValue
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.model.MultipleChoiceFilterValue
import net.vonforst.evmap.model.SliderFilterValue
import net.vonforst.evmap.model.*
@Database(
entities = [
@@ -38,9 +33,8 @@ import net.vonforst.evmap.model.SliderFilterValue
OCMConnectionType::class,
OCMCountry::class,
OCMOperator::class,
OSMNetwork::class,
SavedRegion::class
], version = 24
], version = 22
)
@TypeConverters(Converters::class, GeometryConverters::class)
abstract class AppDatabase : RoomDatabase() {
@@ -57,9 +51,6 @@ abstract class AppDatabase : RoomDatabase() {
// OpenChargeMap API specific
abstract fun ocmReferenceDataDao(): OCMReferenceDataDao
// OpenStreetMap API specific
abstract fun osmReferenceDataDao(): OSMReferenceDataDao
companion object {
private lateinit var context: Context
private val database: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
@@ -84,29 +75,22 @@ abstract class AppDatabase : RoomDatabase() {
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,
MIGRATION_22, MIGRATION_23, MIGRATION_24
MIGRATION_22
)
.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)")
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openstreetmap', '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('ChargeLocation', 'coordinatesProjected', 3857, 'POINT', 'XY');")
.moveToNext()
db.query("SELECT CreateSpatialIndex('ChargeLocation', 'coordinatesProjected');")
.moveToNext()
db.query("SELECT RecoverGeometryColumn('SavedRegion', 'region', 4326, 'POLYGON', 'XY');")
.moveToNext()
db.query("SELECT CreateSpatialIndex('SavedRegion', 'region');")
.moveToNext()
db.query("SELECT RecoverGeometryColumn('ChargeLocation', 'coordinatesProjected', 3857, 'POINT', 'XY');")
.moveToNext()
}
}).build()
}
@@ -475,32 +459,6 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("DELETE FROM savedregion")
}
}
private val MIGRATION_23 = object : Migration(22, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
// API openstreetmap added
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openstreetmap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
db.execSQL("CREATE TABLE IF NOT EXISTS `OSMNetwork` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))")
}
}
private val MIGRATION_24 = object : Migration(23, 24) {
override fun migrate(db: SupportSQLiteDatabase) {
try {
db.beginTransaction()
// add column with projected location for fast clustering in DB
db.execSQL("ALTER TABLE `ChargeLocation` ADD `coordinatesProjected` BLOB NOT NULL DEFAULT x'00'")
db.query("SELECT RecoverGeometryColumn('ChargeLocation', 'coordinatesProjected', 3857, 'POINT', 'XY');")
.moveToNext()
db.query("SELECT CreateSpatialIndex('ChargeLocation', 'coordinatesProjected');")
.moveToNext()
db.execSQL("UPDATE `ChargeLocation` SET coordinatesProjected = Transform(coordinates, 3857);")
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
}
/**

View File

@@ -1,15 +1,9 @@
package net.vonforst.evmap.storage
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Transaction
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GEChargeCard
import net.vonforst.evmap.api.goingelectric.GEReferenceData
@@ -42,7 +36,7 @@ abstract class GEReferenceDataDao {
}
@Query("SELECT * FROM genetwork")
abstract fun getAllNetworks(): Flow<List<GENetwork>>
abstract fun getAllNetworks(): LiveData<List<GENetwork>>
// PLUGS
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -60,7 +54,7 @@ abstract class GEReferenceDataDao {
}
@Query("SELECT * FROM geplug")
abstract fun getAllPlugs(): Flow<List<GEPlug>>
abstract fun getAllPlugs(): LiveData<List<GEPlug>>
// CHARGE CARDS
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -78,21 +72,31 @@ abstract class GEReferenceDataDao {
}
@Query("SELECT * FROM gechargecard")
abstract fun getAllChargeCards(): Flow<List<GEChargeCard>>
abstract fun getAllChargeCards(): LiveData<List<GEChargeCard>>
}
class GEReferenceDataRepository(
private val api: GoingElectricApiWrapper, private val scope: CoroutineScope,
private val dao: GEReferenceDataDao, private val prefs: PreferenceDataSource
) {
fun getReferenceData(): Flow<GEReferenceData> {
fun getReferenceData(): LiveData<GEReferenceData> {
scope.launch {
updateData()
}
val plugs = dao.getAllPlugs()
val networks = dao.getAllNetworks()
val chargeCards = dao.getAllChargeCards()
return combine(plugs, networks, chargeCards) { p, n, c -> GEReferenceData(p.map { it.name }, n.map { it.name }, c) }
return MediatorLiveData<GEReferenceData>().apply {
value = null
listOf(chargeCards, networks, plugs).map { source ->
addSource(source) { _ ->
val p = plugs.value ?: return@addSource
val n = networks.value ?: return@addSource
val cc = chargeCards.value ?: return@addSource
value = GEReferenceData(p.map { it.name }, n.map { it.name }, cc)
}
}
}
}
private suspend fun updateData() {

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