Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
074e0bf904 | ||
|
|
41ac223e97 | ||
|
|
f7196bcce0 | ||
|
|
4f6092e5dc | ||
|
|
dfd42e1ffd | ||
|
|
895b24d406 | ||
|
|
3dea7993f3 | ||
|
|
ca90f1b37f | ||
|
|
fe0843e653 | ||
|
|
0f42ae84de | ||
|
|
2748b0a3db | ||
|
|
14798dee6a | ||
|
|
1cb48f7e0e | ||
|
|
dc0f4d3eab | ||
|
|
8ae954f37b | ||
|
|
1ed3b73285 | ||
|
|
2ba6a86b34 | ||
|
|
463ff61420 | ||
|
|
81b4e77a66 | ||
|
|
d16d48bf8f | ||
|
|
edfce541f6 | ||
|
|
26136dc482 | ||
|
|
0d11e450ac | ||
|
|
265b530936 | ||
|
|
8c5c7aeb58 | ||
|
|
23873dccdb | ||
|
|
6006790ffb | ||
|
|
f5fc32f420 | ||
|
|
90c6357093 | ||
|
|
69ca8723a5 | ||
|
|
20400b630a | ||
|
|
b22ca736cb | ||
|
|
ea906ec969 | ||
|
|
ec2b6d4f28 | ||
|
|
e7c2683ee2 | ||
|
|
d76051ec3a | ||
|
|
975ba2bcce | ||
|
|
dc067fd86b | ||
|
|
226ca3a60e | ||
|
|
af63ee350b | ||
|
|
21d4060ac9 | ||
|
|
3b9efa0302 | ||
|
|
95d93af0d6 | ||
|
|
17a6a253d4 | ||
|
|
f73545c01e | ||
|
|
e4fa1f2c78 | ||
|
|
b2b5cc63e8 | ||
|
|
84ba62f755 | ||
|
|
b29653049a | ||
|
|
4159491589 | ||
|
|
4e67f434cd | ||
|
|
5e58d52a0d | ||
|
|
eddc1f9b61 | ||
|
|
b5054b4dc9 | ||
|
|
926799bb1d | ||
|
|
f038138620 | ||
|
|
1c44e5ae3d | ||
|
|
c58543fe3f | ||
|
|
a5db42322f | ||
|
|
bb0d2e35d4 | ||
|
|
38c8c5510f | ||
|
|
8d1d15ad68 | ||
|
|
954203bf18 | ||
|
|
524e9fcfc0 | ||
|
|
ae2041d26b | ||
|
|
698c832518 | ||
|
|
17c1a11675 | ||
|
|
d04661e925 | ||
|
|
02316fceb9 | ||
|
|
9bf7a90302 | ||
|
|
2697389b49 | ||
|
|
cd0e381707 | ||
|
|
e5ed5eeafe | ||
|
|
b25c61fbea | ||
|
|
d472be1676 | ||
|
|
24fa85929e | ||
|
|
4a67ffd956 | ||
|
|
fab66d1f84 | ||
|
|
0783c6c272 | ||
|
|
c5714c8592 | ||
|
|
cb4b571721 | ||
|
|
0bfa80bbe0 | ||
|
|
d77f13682d | ||
|
|
0c19eb5833 | ||
|
|
a5abedae55 | ||
|
|
8405f4f4fa | ||
|
|
f435180c03 | ||
|
|
c2c3e96e97 | ||
|
|
9100a6f442 | ||
|
|
5403549e0a | ||
|
|
c95f1e7c24 | ||
|
|
f8d5b78112 | ||
|
|
246d456851 | ||
|
|
3d303b6535 | ||
|
|
135fce43c3 | ||
|
|
ee354d2cd1 | ||
|
|
350f18df8e | ||
|
|
dda151abf5 | ||
|
|
a86f1397f4 | ||
|
|
086cc51dd3 | ||
|
|
0de91bc107 | ||
|
|
3436bcd870 | ||
|
|
22c150d557 | ||
|
|
675abb5011 | ||
|
|
af2a2cfcae | ||
|
|
f74526fdd6 | ||
|
|
c5bbca0428 | ||
|
|
6167079c0e | ||
|
|
c3836a92ad | ||
|
|
dccce1a0a0 | ||
|
|
74d79640a8 | ||
|
|
0eb6ece780 | ||
|
|
ae15b13591 | ||
|
|
4962eb7268 | ||
|
|
abe360d7c2 |
2
.github/workflows/release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Set up Java environment
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
distribution: 'zulu'
|
||||
cache: 'gradle'
|
||||
- name: Decrypt keystore
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Java environment
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
distribution: 'zulu'
|
||||
cache: 'gradle'
|
||||
|
||||
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2022 Johan von Forstner
|
||||
Copyright (c) 2020-2023 Johan von Forstner and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
EVMap [](https://github.com/johan12345/EVMap/actions)
|
||||
EVMap [](https://github.com/ev-map/EVMap/actions)
|
||||
=====
|
||||
|
||||
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
|
||||
<a href="https://ev-map.app" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/></a>
|
||||
|
||||
Android app to find electric vehicle charging stations.
|
||||
|
||||
@@ -28,7 +29,7 @@ Features
|
||||
Screenshots
|
||||
-----------
|
||||
|
||||
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
|
||||
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
|
||||
|
||||
Development setup
|
||||
-----------------
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
<svg id="Ebene_5" data-name="Ebene 5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>.cls-1,.cls-2{fill:none;}.cls-2{stroke:#000;stroke-miterlimit:10;stroke-width:2px;}
|
||||
</style>
|
||||
</defs>
|
||||
<title>connector_supercharger</title>
|
||||
<path class="cls-1" d="M12,12H36V36H12Z" />
|
||||
<path class="cls-2"
|
||||
d="M13.45,17.08a8.24,8.24,0,0,1-3.11.6,8.34,8.34,0,0,1-6-14.18H16.3a8.35,8.35,0,0,1,1.07,10.33" />
|
||||
<circle cx="10.34" cy="9.34" r="1.67" />
|
||||
<circle cx="15.35" cy="9.34" r="1.67" />
|
||||
<circle cx="12.84" cy="13.51" r="1.67" />
|
||||
<circle cx="7.84" cy="13.51" r="1.67" />
|
||||
<circle cx="5.34" cy="9.34" r="1.67" />
|
||||
<circle cx="7.84" cy="5.59" r="1" />
|
||||
<circle cx="12.84" cy="5.59" r="1.04" />
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Ebene_5" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24"
|
||||
style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{display:none;fill:none;}
|
||||
.st1{fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<path class="st0" d="M12,12h24v24H12V12z" />
|
||||
<path class="st1"
|
||||
d="M6.2,13.8C4.1,10.6,4.6,6.3,7.3,3.5h12c1.5,1.6,2.4,3.7,2.4,5.9c0,4.6-3.8,8.3-8.4,8.3c-1.1,0-2.1-0.2-3.1-0.6" />
|
||||
<circle cx="13.3" cy="9.3" r="1.7" />
|
||||
<circle cx="8.3" cy="9.3" r="1.7" />
|
||||
<circle cx="10.8" cy="13.5" r="1.7" />
|
||||
<circle cx="15.8" cy="13.5" r="1.7" />
|
||||
<circle cx="18.3" cy="9.3" r="1.7" />
|
||||
<circle cx="15.8" cy="5.6" r="1" />
|
||||
<circle cx="10.8" cy="5.6" r="1" />
|
||||
<g id="T">
|
||||
<path id="path35"
|
||||
d="M18.18,22.23l1-5.48c.93,0,1.22.1,1.27.52a2.15,2.15,0,0,0,.93-.7,6.91,6.91,0,0,0-2.46-.6l-.71.88h0L17.46,16a7,7,0,0,0-2.46.6,2.22,2.22,0,0,0,.94.7c0-.42.33-.52,1.26-.52l1,5.48" />
|
||||
<path id="path37"
|
||||
d="M18.18,15.72a7.9,7.9,0,0,1,3.28.66,2.65,2.65,0,0,0,.2-.4,9.24,9.24,0,0,0-7,0,2.61,2.61,0,0,0,.19.4,7.94,7.94,0,0,1,3.29-.66h0" />
|
||||
</g>
|
||||
</svg>
|
||||
<path id="path35" d="M5.4,22.3l1-5.5c0.9,0,1.3,0.1,1.3,0.5c0.4-0.1,0.7-0.4,0.9-0.7C7.8,16.3,7,16,6.1,16l-0.8,0.8l0,0L4.7,16
|
||||
c-0.8,0-1.7,0.3-2.5,0.6c0.2,0.3,0.6,0.6,0.9,0.7c0.1-0.4,0.3-0.5,1.3-0.5L5.4,22.3" />
|
||||
<path id="path37" d="M5.5,15.7L5.5,15.7c1.1,0,2.3,0.2,3.3,0.7c0.1-0.1,0.1-0.3,0.2-0.4c-2.2-0.9-4.8-0.9-7,0
|
||||
c0.1,0.1,0.1,0.3,0.2,0.4C3.2,15.9,4.3,15.7,5.5,15.7" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 844 KiB After Width: | Height: | Size: 872 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 875 KiB After Width: | Height: | Size: 886 KiB |
|
Before Width: | Height: | Size: 844 KiB After Width: | Height: | Size: 872 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1005 KiB |
|
Before Width: | Height: | Size: 841 KiB After Width: | Height: | Size: 848 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 864 KiB After Width: | Height: | Size: 884 KiB |
|
Before Width: | Height: | Size: 841 KiB After Width: | Height: | Size: 848 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 114 KiB |
@@ -8,8 +8,9 @@ apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'androidx.navigation.safeargs.kotlin'
|
||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
apply plugin: 'pt.jcosta.resourceplaceholders'
|
||||
|
||||
def supportedLocales = "en,de,fr,nb-rNO"
|
||||
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
|
||||
|
||||
android {
|
||||
compileSdkVersion 33
|
||||
@@ -20,8 +21,8 @@ android {
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 33
|
||||
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||
versionCode 160
|
||||
versionName "1.4.6"
|
||||
versionCode 184
|
||||
versionName "1.6.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
resConfigs supportedLocales.split(',')
|
||||
@@ -90,6 +91,12 @@ android {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs).configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
viewBinding true
|
||||
@@ -103,6 +110,9 @@ android {
|
||||
unitTests.includeAndroidResources true
|
||||
}
|
||||
|
||||
resourcePlaceholders {
|
||||
files = ['xml/shortcuts.xml']
|
||||
}
|
||||
namespace 'net.vonforst.evmap'
|
||||
|
||||
// add API keys from environment variable if not set in apikeys.xml
|
||||
@@ -146,6 +156,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
pickFirst 'lib/x86/libc++_shared.so'
|
||||
pickFirst 'lib/arm64-v8a/libc++_shared.so'
|
||||
pickFirst 'lib/x86_64/libc++_shared.so'
|
||||
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
@@ -155,28 +171,30 @@ configurations {
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.6.0'
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0'
|
||||
implementation "androidx.activity:activity-ktx:1.6.1"
|
||||
implementation "androidx.fragment:fragment-ktx:1.5.5"
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||
implementation "androidx.activity:activity-ktx:1.7.2"
|
||||
implementation "androidx.fragment:fragment-ktx:1.5.7"
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'com.google.android.material:material:1.8.0'
|
||||
implementation 'com.google.android.material:material:1.9.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.browser:browser:1.4.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.0'
|
||||
implementation 'androidx.browser:browser:1.5.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f4f641aab5'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
implementation "androidx.work:work-runtime-ktx:2.8.1"
|
||||
implementation 'com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
|
||||
implementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
|
||||
implementation 'com.squareup.moshi:moshi-adapters:1.13.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.11.0'
|
||||
implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
||||
implementation 'com.squareup.moshi:moshi-adapters:1.15.0'
|
||||
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
|
||||
implementation 'io.coil-kt:coil:1.1.0'
|
||||
implementation 'com.github.johan12345:StfalconImageViewer:5082ebd392'
|
||||
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
|
||||
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
|
||||
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
|
||||
implementation 'com.airbnb.android:lottie:4.1.0'
|
||||
@@ -192,21 +210,23 @@ dependencies {
|
||||
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
|
||||
|
||||
// AnyMaps
|
||||
def anyMapsVersion = '7fdcf50fc4'
|
||||
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
|
||||
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
|
||||
def anyMapsVersion = '8f1226e1c5'
|
||||
implementation "com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion"
|
||||
googleImplementation "com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion"
|
||||
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
|
||||
implementation("com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
|
||||
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
|
||||
exclude group: 'com.google.android.gms', module: 'play-services-location'
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-core'
|
||||
}
|
||||
// patched version of mapbox-android-core that removes build-time dependency on GMS
|
||||
implementation 'com.github.johan12345:mapbox-events-android:a21c324501'
|
||||
// original version of mapbox-android-core
|
||||
googleImplementation 'com.mapbox.mapboxsdk:mapbox-android-core:2.0.1'
|
||||
// patched version that removes build-time dependency on GMS (-> no Google location services)
|
||||
fossImplementation 'com.github.ev-map:mapbox-events-android:a21c324501'
|
||||
|
||||
// Google Places
|
||||
googleImplementation 'com.google.android.libraries.places:places:3.0.0'
|
||||
googleImplementation 'com.google.android.libraries.places:places:3.1.0'
|
||||
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4'
|
||||
|
||||
// Mapbox Geocoding
|
||||
@@ -217,18 +237,19 @@ dependencies {
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
|
||||
// viewmodel library
|
||||
def lifecycle_version = "2.5.1"
|
||||
def lifecycle_version = "2.6.1"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
// room library
|
||||
def room_version = "2.5.0"
|
||||
def room_version = "2.5.1"
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
implementation 'com.github.anboralabs:spatia-room:0.2.7'
|
||||
|
||||
// billing library
|
||||
def billing_version = "5.1.0"
|
||||
def billing_version = "6.0.0"
|
||||
googleImplementation "com.android.billingclient:billing:$billing_version"
|
||||
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
|
||||
|
||||
@@ -239,26 +260,31 @@ dependencies {
|
||||
implementation("ch.acra:acra-limiter:$acraVersion")
|
||||
|
||||
// debug tools
|
||||
implementation 'com.facebook.stetho:stetho:1.6.0'
|
||||
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
|
||||
debugImplementation 'com.facebook.flipper:flipper:0.190.0'
|
||||
debugImplementation 'com.facebook.soloader:soloader:0.10.5'
|
||||
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.190.0'
|
||||
|
||||
// testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
|
||||
//noinspection GradleDependency
|
||||
testImplementation 'org.json:json:20080701'
|
||||
testImplementation 'org.robolectric:robolectric:4.9.2'
|
||||
testImplementation 'androidx.test:core:1.5.0'
|
||||
testImplementation 'androidx.arch.core:core-testing:2.2.0'
|
||||
|
||||
// testing for car app
|
||||
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
|
||||
testGoogleImplementation 'org.robolectric:robolectric:4.9'
|
||||
testGoogleImplementation 'org.robolectric:robolectric:4.9.2'
|
||||
testGoogleImplementation 'androidx.test:core:1.5.0'
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation 'androidx.arch.core:core-testing:2.2.0'
|
||||
|
||||
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
|
||||
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0"
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.0'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
||||
}
|
||||
|
||||
private static String decode(String s, String key) {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.johan.evmap
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.johan.evmap", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.johan.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import co.anbora.labs.spatia.geometry.Mbr
|
||||
import co.anbora.labs.spatia.geometry.MultiPolygon
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.SavedRegion
|
||||
import net.vonforst.evmap.storage.SavedRegionDao
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.viewmodel.await
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SavedRegionDaoTest {
|
||||
private lateinit var database: AppDatabase
|
||||
private lateinit var dao: SavedRegionDao
|
||||
|
||||
@get:Rule
|
||||
var instantExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = AppDatabase.createInMemory(context)
|
||||
dao = database.savedRegionDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetSavedRegion() {
|
||||
val ds = "test"
|
||||
|
||||
val ts1 = ZonedDateTime.of(2023, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC).toInstant()
|
||||
val region1 = Mbr(9.0, 53.0, 10.0, 54.0, 4326).asPolygon()
|
||||
runBlocking {
|
||||
dao.insert(
|
||||
SavedRegion(
|
||||
region1,
|
||||
ds, ts1, null, false
|
||||
)
|
||||
)
|
||||
}
|
||||
assertEquals(region1, dao.getSavedRegion(ds, 0))
|
||||
runBlocking {
|
||||
assertTrue(dao.savedRegionCovers(53.1, 53.2, 9.1, 9.2, ds, 0).await())
|
||||
assertTrue(dao.savedRegionCoversRadius(53.05, 9.15, 10.0, ds, 0).await())
|
||||
assertFalse(dao.savedRegionCovers(52.1, 52.2, 9.1, 9.2, ds, 0).await())
|
||||
}
|
||||
|
||||
val ts2 = ZonedDateTime.of(2023, 1, 1, 1, 0, 0, 0, ZoneOffset.UTC).toInstant()
|
||||
val region2 = Mbr(9.0, 55.0, 10.0, 56.0, 4326).asPolygon()
|
||||
runBlocking {
|
||||
dao.insert(
|
||||
SavedRegion(
|
||||
region2,
|
||||
ds, ts2, null, false
|
||||
)
|
||||
)
|
||||
}
|
||||
assertEquals(MultiPolygon(listOf(region1, region2)), dao.getSavedRegion(ds, 0))
|
||||
assertEquals(region2, dao.getSavedRegion(ds, ts1.toEpochMilli()))
|
||||
|
||||
runBlocking {
|
||||
assertTrue(dao.savedRegionCovers(53.1, 53.2, 9.1, 9.2, ds, 0).await())
|
||||
assertTrue(dao.savedRegionCoversRadius(53.05, 9.15, 10.0, ds, 0).await())
|
||||
assertFalse(dao.savedRegionCovers(53.1, 55.2, 9.1, 9.2, ds, 0).await())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMakeCircle() {
|
||||
val lat = 53.0
|
||||
val lng = 10.0
|
||||
val radius = 10000.0
|
||||
val circle = runBlocking { dao.makeCircle(lat, lng, radius) }
|
||||
for (point in circle.points) {
|
||||
assertEquals(radius, distanceBetween(lat, lng, point.y, point.x), 10.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
42
app/src/debug/java/net/vonforst/evmap/DebugInits.kt
Normal file
@@ -0,0 +1,42 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.facebook.flipper.android.AndroidFlipperClient
|
||||
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin
|
||||
import com.facebook.flipper.plugins.inspector.DescriptorMapping
|
||||
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin
|
||||
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor
|
||||
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin
|
||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
|
||||
import com.facebook.soloader.SoLoader
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
private val networkFlipperPlugin = NetworkFlipperPlugin()
|
||||
|
||||
fun addDebugInterceptors(context: Context) {
|
||||
if (Build.FINGERPRINT == "robolectric") return
|
||||
|
||||
SoLoader.init(context, false)
|
||||
val client = AndroidFlipperClient.getInstance(context)
|
||||
client.addPlugin(InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()))
|
||||
client.addPlugin(networkFlipperPlugin)
|
||||
client.addPlugin(DatabasesFlipperPlugin(context))
|
||||
client.addPlugin(SharedPreferencesFlipperPlugin(context))
|
||||
client.start()
|
||||
}
|
||||
|
||||
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder {
|
||||
// Flipper does not work during unit tests - so check whether we are running tests first
|
||||
var isRunningTest = true
|
||||
try {
|
||||
Class.forName("org.junit.Test")
|
||||
} catch (e: ClassNotFoundException) {
|
||||
isRunningTest = false
|
||||
}
|
||||
|
||||
if (!isRunningTest) {
|
||||
this.addNetworkInterceptor(FlipperOkhttpInterceptor(networkFlipperPlugin))
|
||||
}
|
||||
return this
|
||||
}
|
||||
6
app/src/foss/res/values-nl/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Vond je EVMap nuttig\? Je kan de ontwikkeling ondersteunen door een donatie te sturen naar de ontwikkelaar.</string>
|
||||
<string name="donate_paypal">Doneer via PayPal</string>
|
||||
<string name="data_sources_hint">De kaartgegevens zijn afkomstig van OpenStreetMap (Mapbox).</string>
|
||||
</resources>
|
||||
6
app/src/foss/res/values-pt/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="data_sources_hint">Os dados do mapa são fornecidos pelo OpenStreetMap (Mapbox).</string>
|
||||
<string name="donate_paypal">Doar com o PayPal</string>
|
||||
<string name="donations_info" formatted="false">Acha que o EVMap é útil\? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.</string>
|
||||
</resources>
|
||||
6
app/src/foss/res/values-ro/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Crezi ca EVMap este util? Sprijina dezvoltarea printr-o donatie pentru dezvoltator.</string>
|
||||
<string name="donate_paypal">Doneaza cu PayPal</string>
|
||||
<string name="data_sources_hint">Hartile din aplicatie sunt furnizate de OpenStreetMap (Mapbox).</string>
|
||||
</resources>
|
||||
@@ -41,6 +41,13 @@
|
||||
android:name="androidx.car.app.CarAppService"
|
||||
android:category="androidx.car.app.category.POI" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="net.vonforst.evmap" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -27,6 +27,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.car2go.maps.model.LatLng
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
@@ -121,6 +122,8 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
}
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
handleActionsIntent(intent)
|
||||
|
||||
val mapScreen = MapScreen(carContext, this)
|
||||
val screens = mutableListOf<Screen>(mapScreen)
|
||||
|
||||
@@ -157,6 +160,30 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
return screens.last()
|
||||
}
|
||||
|
||||
private fun handleActionsIntent(intent: Intent): Boolean {
|
||||
intent.data?.let {
|
||||
if (it.host == "find_charger") {
|
||||
val lat = it.getQueryParameter("latitude")?.toDouble()
|
||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||
val name = it.getQueryParameter("name")
|
||||
if (lat != null && lon != null) {
|
||||
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
|
||||
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
|
||||
return true
|
||||
} else if (name != null) {
|
||||
val screenManager = carContext.getCarService(ScreenManager::class.java)
|
||||
screenManager.push(PlaceSearchScreen(carContext, this, name))
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
handleActionsIntent(intent)
|
||||
}
|
||||
|
||||
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
|
||||
|
||||
private fun updateLocation(location: Location?) {
|
||||
|
||||
@@ -8,6 +8,9 @@ package net.vonforst.evmap.auto
|
||||
private val models = mapOf(
|
||||
"Audi" to mapOf(
|
||||
"516 (G4x)" to "e-tron"
|
||||
),
|
||||
"Renault" to mapOf(
|
||||
"BCB" to "Megane E-Tech"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
|
||||
meta = metaMapped.chargePoints.maxByOrNull { it.power }
|
||||
|
||||
prices = result.data!!.map { cp ->
|
||||
prices = result.data!!.mapNotNull { cp ->
|
||||
val filteredPrices =
|
||||
cp.chargepointPrices.filter {
|
||||
it.plug == chargepoint.plug && it.power == chargepoint.power
|
||||
@@ -287,7 +287,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
chargepointPrices = filteredPrices
|
||||
)
|
||||
}
|
||||
}.filterNotNull()
|
||||
}
|
||||
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
|
||||
.sortedByDescending {
|
||||
prefs.chargepriceMyTariffsAll ||
|
||||
@@ -352,7 +352,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
} else if (vehicles.size > 1) {
|
||||
if (modelName != null) {
|
||||
vehicles = vehicles.filter {
|
||||
it.name.startsWith(modelName)
|
||||
it.name.lowercase().startsWith(modelName.lowercase())
|
||||
}
|
||||
if (vehicles.isEmpty()) {
|
||||
throw VehicleUnknownException()
|
||||
|
||||
@@ -23,8 +23,8 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.getAvailability
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.iconForPlugType
|
||||
@@ -57,6 +57,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||
|
||||
private val imageSize = 128 // images should be 128dp according to docs
|
||||
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
|
||||
@@ -465,7 +466,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
|
||||
invalidate()
|
||||
|
||||
availability = getAvailability(charger).data
|
||||
availability = availabilityRepo.getAvailability(charger).data
|
||||
|
||||
invalidate()
|
||||
} else {
|
||||
|
||||
@@ -24,8 +24,8 @@ import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.*
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.getAvailability
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
@@ -79,6 +79,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||
private val searchRadius = 5 // kilometers
|
||||
private val distanceUpdateThreshold = Duration.ofSeconds(15)
|
||||
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
|
||||
@@ -325,7 +326,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
}
|
||||
|
||||
// power
|
||||
val power = charger.maxPower;
|
||||
val power = charger.maxPower
|
||||
if (power != null) {
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append("${power.roundToInt()} kW")
|
||||
@@ -427,14 +428,15 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
} else {
|
||||
// try multiple search radii until we have enough chargers
|
||||
var chargers: List<ChargeLocation>? = null
|
||||
for (radius in listOf(searchRadius, searchRadius * 10, searchRadius * 50)) {
|
||||
val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50)
|
||||
for (radius in radiusValues) {
|
||||
val response = repo.getChargepointsRadius(
|
||||
searchLocation,
|
||||
radius,
|
||||
zoom = 16f,
|
||||
filtersWithValue
|
||||
).awaitFinished()
|
||||
if (response.status == Status.ERROR) {
|
||||
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
|
||||
loadingError = true
|
||||
this@MapScreen.chargers = null
|
||||
invalidate()
|
||||
@@ -577,7 +579,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
|
||||
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
|
||||
// when the list is scrolled, load corresponding availabilities
|
||||
if (startIndex == visibleStart && endIndex == visibleEnd && !availabilities.isEmpty()) return
|
||||
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
|
||||
if (startIndex == -1 || endIndex == -1) return
|
||||
if (availabilityUpdateCoroutine != null) return
|
||||
|
||||
@@ -606,7 +608,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
// update only if not yet stored
|
||||
if (!availabilities.containsKey(it.id)) {
|
||||
lifecycleScope.async {
|
||||
val availability = getAvailability(it).data
|
||||
val availability = availabilityRepo.getAvailability(it).data
|
||||
val date = ZonedDateTime.now()
|
||||
availabilities[it.id] = date to availability
|
||||
}
|
||||
|
||||
@@ -31,7 +31,11 @@ import java.io.IOException
|
||||
import java.time.Instant
|
||||
|
||||
@ExperimentalCarApi
|
||||
class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
|
||||
class PlaceSearchScreen(
|
||||
ctx: CarContext,
|
||||
val session: EVMapSession,
|
||||
val initialSearch: String = ""
|
||||
) : Screen(ctx),
|
||||
SearchTemplate.SearchCallback, LocationAwareScreen,
|
||||
DefaultLifecycleObserver {
|
||||
private val hardwareMan: CarHardwareManager by lazy {
|
||||
@@ -64,13 +68,15 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
update("")
|
||||
update(initialSearch)
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return SearchTemplate.Builder(this).apply {
|
||||
setHeaderAction(Action.BACK)
|
||||
setSearchHint(carContext.getString(R.string.search))
|
||||
setInitialSearchText(initialSearch)
|
||||
setShowKeyboardByDefault(initialSearch == "")
|
||||
resultList?.let {
|
||||
setItemList(buildItemList(it))
|
||||
} ?: setLoading(true)
|
||||
|
||||
@@ -86,6 +86,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(getLabel(item))
|
||||
.apply { getDetails(item)?.let { addText(it) } }
|
||||
.setImage(if (isSelected(item)) checkedIcon else uncheckedIcon)
|
||||
.setOnClickListener {
|
||||
toggleSelected(item)
|
||||
@@ -130,5 +131,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
||||
|
||||
abstract fun getLabel(it: T): String
|
||||
|
||||
open fun getDetails(it: T): String? = null
|
||||
|
||||
abstract suspend fun loadData(): List<T>
|
||||
}
|
||||
@@ -390,6 +390,8 @@ class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<Chargepric
|
||||
|
||||
override fun getLabel(it: ChargepriceCar) = "${it.brand} ${it.name}"
|
||||
|
||||
override fun getDetails(it: ChargepriceCar) = it.formatSpecs()
|
||||
|
||||
override suspend fun loadData(): List<ChargepriceCar> {
|
||||
return api.getVehicles()
|
||||
}
|
||||
|
||||
@@ -38,10 +38,10 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
|
||||
): List<AutocompletePlace> {
|
||||
val request = FindAutocompletePredictionsRequest.builder().apply {
|
||||
if (location != null) {
|
||||
setLocationBias(calcLocationBias(location))
|
||||
setOrigin(LatLng(location.latitude, location.longitude))
|
||||
locationBias = calcLocationBias(location)
|
||||
origin = LatLng(location.latitude, location.longitude)
|
||||
}
|
||||
setSessionToken(token)
|
||||
sessionToken = token
|
||||
setQuery(query)
|
||||
}.build()
|
||||
try {
|
||||
@@ -92,10 +92,11 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAttributionString(): Int = R.string.places_powered_by_google
|
||||
override fun getAttributionString(): Int =
|
||||
com.google.android.libraries.places.R.string.places_powered_by_google
|
||||
|
||||
override fun getAttributionImage(dark: Boolean): Int =
|
||||
if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light
|
||||
if (dark) com.google.android.libraries.places.R.drawable.places_powered_by_google_dark else com.google.android.libraries.places.R.drawable.places_powered_by_google_light
|
||||
|
||||
private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds {
|
||||
val radius = 100e3 // meters
|
||||
|
||||
@@ -3,12 +3,8 @@ package net.vonforst.evmap.fragment.preference
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.ui.RangeSliderPreference
|
||||
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
import java.text.NumberFormat
|
||||
|
||||
class AndroidAutoSettingsFragment : BaseSettingsFragment() {
|
||||
|
||||
@@ -15,6 +15,12 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
|
||||
.setListener(this)
|
||||
.enablePendingPurchases()
|
||||
.build()
|
||||
|
||||
val products: MutableLiveData<Resource<List<DonationItem>>> by lazy {
|
||||
MutableLiveData<Resource<List<DonationItem>>>().apply {
|
||||
value = Resource.loading(null)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
billingClient.startConnection(object : BillingClientStateListener {
|
||||
@@ -70,12 +76,6 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
|
||||
}
|
||||
}
|
||||
|
||||
val products: MutableLiveData<Resource<List<DonationItem>>> by lazy {
|
||||
MutableLiveData<Resource<List<DonationItem>>>().apply {
|
||||
value = Resource.loading(null)
|
||||
}
|
||||
}
|
||||
|
||||
val purchaseSuccessful = SingleLiveEvent<Nothing>()
|
||||
val purchaseFailed = SingleLiveEvent<Nothing>()
|
||||
|
||||
|
||||
@@ -35,4 +35,6 @@
|
||||
<string name="settings_android_auto_chargeprice_range">Plage de charge pour la comparaison des prix</string>
|
||||
<string name="welcome_android_auto_detail">Vous pouvez également utiliser EVMap à partir d\'Android Auto sur les voitures prises en charge. Il suffit de sélectionner l\'application EVMap dans le menu Android Auto.</string>
|
||||
<string name="loading">Chargement…</string>
|
||||
<string name="auto_multipage_goto">Page %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
</resources>
|
||||
41
app/src/google/res/values-nl/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Meerdere voertuigen geselecteerd in de app komen overeen met dit voertuig (%1$s %2$s).</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="auto_location_service">EVMap draait op Android Auto en gebruikt jouw locatie.</string>
|
||||
<string name="auto_no_chargers_found">Geen laadpunten gevonden in de omgeving</string>
|
||||
<string name="auto_no_favorites_found">Geen favorieten gevonden</string>
|
||||
<string name="open_in_app">Open in de app</string>
|
||||
<string name="opened_on_phone">Geopend op de telefoon</string>
|
||||
<string name="auto_location_permission_needed">Om EVMap op Android Auto te gebruiken, moet je toegang geven tot je locatie.</string>
|
||||
<string name="auto_vehicle_data_permission_needed">Voor deze functie heeft EVMap toegang nodig tot de gegevens van je voertuig.</string>
|
||||
<string name="grant_on_phone">Geef toestemming op telefoon</string>
|
||||
<string name="auto_chargers_closeby">Oplaadpunten in de buurt</string>
|
||||
<string name="auto_favorites">Favorieten</string>
|
||||
<string name="auto_chargers_near_location">Nabij %s</string>
|
||||
<string name="auto_fault_report_date">⚠️ Foutrapport (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Verdere updates zijn niet mogelijk. Ga terug en herbegin.</string>
|
||||
<string name="auto_prices">Prijzen</string>
|
||||
<string name="auto_vehicle_data">Voertuiggegevens</string>
|
||||
<string name="auto_charging_level">Laadniveau (SoC)</string>
|
||||
<string name="auto_no_data">Niet beschikbaar</string>
|
||||
<string name="auto_range">Reikwijdte</string>
|
||||
<string name="auto_speed">Snelheid</string>
|
||||
<string name="auto_heading">Richting</string>
|
||||
<string name="auto_settings">Instellingen</string>
|
||||
<string name="welcome_android_auto">Android Auto support</string>
|
||||
<string name="welcome_android_auto_detail">Je kan EVMap ook gebruiken in Android Auto op ondersteunde voertuigen. Selecteer gewoon de EVMap app in het Android Auto menu.</string>
|
||||
<string name="sounds_cool">klinkt cool</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap kon je voertuigtype niet bepalen.</string>
|
||||
<string name="auto_chargers_ahead">Alleen laadpunten in rijrichting</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Laadbereik voor prijsvergelijking</string>
|
||||
<string name="data_sources_hint">In de instellingen kan je ook wisselen tussen Google Maps en OpenStreetMap (Mapbox) voor de kaartgegevens.</string>
|
||||
<string name="selecting_all">alle items geselecteerd</string>
|
||||
<string name="selecting_none">alle items gedeselecteerd</string>
|
||||
<string name="loading">Laden…</string>
|
||||
<string name="auto_multipage_goto">Pagina %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">Geen enkel voertuig geselecteerd in de app komt overeen met dit voertuig (%1$s %2$s).</string>
|
||||
</resources>
|
||||
41
app/src/google/res/values-pt/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="auto_no_chargers_found">Não foram encontrados carregadores próximo de si</string>
|
||||
<string name="auto_no_favorites_found">Nenhum favorito encontrado</string>
|
||||
<string name="opened_on_phone">Aberto no telefone</string>
|
||||
<string name="auto_location_permission_needed">Para usar o EVMap no Android Auto, permita o acesso à sua localização.</string>
|
||||
<string name="open_in_app">Abrir na app</string>
|
||||
<string name="auto_vehicle_data_permission_needed">Para esta funcionalidade, o EVMap precisa de aceder aos dados do seu veículo.</string>
|
||||
<string name="auto_chargers_closeby">Carregadores próximos</string>
|
||||
<string name="grant_on_phone">Conceda permissões no telefone</string>
|
||||
<string name="auto_chargers_near_location">Perto de %s</string>
|
||||
<string name="auto_favorites">Favoritos</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Vários veículos selecionados na app correspondem a este veículo (%1$s %2$s).</string>
|
||||
<string name="selecting_none">todos os items desmarcados</string>
|
||||
<string name="data_sources_hint">Também pode mudar entre o Google Maps e OpenStreetMap (Mapbox) nas definições da app.</string>
|
||||
<string name="selecting_all">todos os items selecionados</string>
|
||||
<string name="loading">Carregando…</string>
|
||||
<string name="auto_multipage_goto">Página %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Escala de carregamento para comparação de preços</string>
|
||||
<string name="donations_info" formatted="false">Acha que o EVMap é útil\? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.
|
||||
\n
|
||||
\nA Google cobra 15% de cada doação.</string>
|
||||
<string name="auto_location_service">O EVMap está a funcionar no Android Auto e usando a sua localização.</string>
|
||||
<string name="auto_fault_report_date">⚠️ Problemas (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Não é possível atualizar. Por favor volte atrás e reinicie.</string>
|
||||
<string name="auto_prices">Preços</string>
|
||||
<string name="auto_vehicle_data">Dados do veículo</string>
|
||||
<string name="auto_charging_level">Nível de carregamento</string>
|
||||
<string name="auto_no_data">Não disponível</string>
|
||||
<string name="auto_speed">Velocidade</string>
|
||||
<string name="auto_heading">Direção</string>
|
||||
<string name="auto_settings">Definições</string>
|
||||
<string name="welcome_android_auto">Suporte para Android Auto</string>
|
||||
<string name="auto_range">Alcance</string>
|
||||
<string name="welcome_android_auto_detail">Também pode usar o EVMap no Android Auto em carros compatíveis. Basta selecionar a app EVMap no menu do Android Auto.</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">O EVMap não pôde determinar o modelo do seu veículo.</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">Nenhum dos veículos selecionados na app corresponde a este veículo (%1$s %2$s).</string>
|
||||
<string name="auto_chargers_ahead">Apenas carregadores na direção do destino</string>
|
||||
<string name="sounds_cool">Continuar</string>
|
||||
</resources>
|
||||
2
app/src/google/res/values-ro/strings.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
@@ -54,6 +54,13 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="net.vonforst.evmap" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="distractionOptimized"
|
||||
|
||||
5
app/src/googleAutomotive/res/values-nl/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="grant_on_phone">Toestaan</string>
|
||||
<string name="auto_location_permission_needed">Om EVmap te gebruiken in je wagen, moet je toegang geven tot je locatie.</string>
|
||||
</resources>
|
||||
5
app/src/googleAutomotive/res/values-pt/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="grant_on_phone">Permitir</string>
|
||||
<string name="auto_location_permission_needed">Para usar o EVMap no seu carro, permita o acesso à sua localização.</string>
|
||||
</resources>
|
||||
2
app/src/googleAutomotive/res/values-ro/strings.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
@@ -23,6 +24,10 @@
|
||||
<application
|
||||
android:name=".EvMapApplication"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:dataExtractionRules="@xml/backup_rules_api31"
|
||||
android:fullBackupOnly="true"
|
||||
android:backupAgent=".storage.BackupAgent"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -264,6 +269,13 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="net.vonforst.evmap" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
@@ -278,6 +290,18 @@
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<!-- Remove WorkManagerInitializer as we implement getWorkManagerConfiguration in application class -->
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,7 +1,9 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.app.Application
|
||||
import com.facebook.stetho.Stetho
|
||||
import android.os.Build
|
||||
import androidx.work.*
|
||||
import net.vonforst.evmap.storage.CleanupCacheWorker
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.updateAppLocale
|
||||
import net.vonforst.evmap.ui.updateNightMode
|
||||
@@ -10,8 +12,9 @@ import org.acra.config.limiter
|
||||
import org.acra.config.mailSender
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import java.time.Duration
|
||||
|
||||
class EvMapApplication : Application() {
|
||||
class EvMapApplication : Application(), Configuration.Provider {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val prefs = PreferenceDataSource(this)
|
||||
@@ -24,8 +27,8 @@ class EvMapApplication : Application() {
|
||||
prefs.language = null
|
||||
}
|
||||
|
||||
Stetho.initializeWithDefaults(this);
|
||||
init(applicationContext)
|
||||
addDebugInterceptors(applicationContext)
|
||||
|
||||
if (!BuildConfig.DEBUG) {
|
||||
initAcra {
|
||||
@@ -49,5 +52,20 @@ class EvMapApplication : Application() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cleanupCacheRequest = PeriodicWorkRequestBuilder<CleanupCacheWorker>(Duration.ofDays(1))
|
||||
.setConstraints(Constraints.Builder().apply {
|
||||
setRequiresBatteryNotLow(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setRequiresDeviceIdle(true)
|
||||
}
|
||||
}.build()).build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.REPLACE, cleanupCacheRequest
|
||||
)
|
||||
}
|
||||
|
||||
override fun getWorkManagerConfiguration(): Configuration {
|
||||
return Configuration.Builder().build()
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,8 @@ class MapsActivity : AppCompatActivity(),
|
||||
private lateinit var prefs: PreferenceDataSource
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val splashScreen = installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_maps)
|
||||
|
||||
@@ -124,7 +124,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
|
||||
.createPendingIntent()
|
||||
} else if (query != null && query.isNotEmpty()) {
|
||||
} else if (!query.isNullOrEmpty()) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
@@ -171,6 +171,32 @@ class MapsActivity : AppCompatActivity(),
|
||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent.scheme == "net.vonforst.evmap") {
|
||||
intent.data?.let {
|
||||
if (it.host == "find_charger") {
|
||||
val lat = it.getQueryParameter("latitude")?.toDouble()
|
||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||
val name = it.getQueryParameter("name")
|
||||
if (lat != null && lon != null) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(
|
||||
MapFragmentArgs(
|
||||
latLng = LatLng(lat, lon),
|
||||
locationName = name
|
||||
).toBundle()
|
||||
)
|
||||
.createPendingIntent()
|
||||
} else if (name != null) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(locationName = name).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setDestination(R.id.map)
|
||||
@@ -207,7 +233,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
intent.data = Uri.parse("google.navigation:q=${coord.lat},${coord.lng}")
|
||||
intent.`package` = "com.google.android.apps.maps"
|
||||
if (prefs.navigateUseMaps && intent.resolveActivity(packageManager) != null) {
|
||||
startActivity(intent);
|
||||
startActivity(intent)
|
||||
} else {
|
||||
// fallback: generic geo intent
|
||||
showLocation(charger)
|
||||
@@ -223,7 +249,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
})"
|
||||
)
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
startActivity(intent);
|
||||
startActivity(intent)
|
||||
} else {
|
||||
val cb = fragmentCallback ?: return
|
||||
Snackbar.make(
|
||||
@@ -262,7 +288,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
|
||||
fun shareUrl(url: String) {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
setType("text/plain")
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, url)
|
||||
}
|
||||
startActivity(intent)
|
||||
|
||||
@@ -90,7 +90,7 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
|
||||
class ChargepriceAdapter() :
|
||||
DataBindingAdapter<ChargePrice>() {
|
||||
|
||||
val viewPool = RecyclerView.RecycledViewPool();
|
||||
val viewPool = RecyclerView.RecycledViewPool()
|
||||
var meta: ChargepriceChargepointMeta? = null
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package net.vonforst.evmap.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.text.Spannable
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
|
||||
import net.vonforst.evmap.bold
|
||||
import net.vonforst.evmap.joinToSpannedString
|
||||
import net.vonforst.evmap.model.ChargeCard
|
||||
@@ -10,6 +15,7 @@ import net.vonforst.evmap.model.ChargeCardId
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.OpeningHoursDays
|
||||
import net.vonforst.evmap.plus
|
||||
import net.vonforst.evmap.ui.currency
|
||||
import net.vonforst.evmap.utils.formatDMS
|
||||
import net.vonforst.evmap.utils.formatDecimal
|
||||
import java.time.ZoneId
|
||||
@@ -41,11 +47,18 @@ fun buildDetails(
|
||||
loc: ChargeLocation?,
|
||||
chargeCards: Map<Long, ChargeCard>?,
|
||||
filteredChargeCards: Set<Long>?,
|
||||
teslaPricing: TeslaGraphQlApi.Pricing?,
|
||||
ctx: Context
|
||||
): List<DetailsAdapter.Detail> {
|
||||
if (loc == null) return emptyList()
|
||||
|
||||
return listOfNotNull(
|
||||
if (teslaPricing != null) DetailsAdapter.Detail(
|
||||
R.drawable.ic_tesla,
|
||||
R.string.cost,
|
||||
formatTeslaPricing(teslaPricing, ctx),
|
||||
formatTeslaParkingFee(teslaPricing, ctx)
|
||||
) else null,
|
||||
if (loc.address != null) DetailsAdapter.Detail(
|
||||
R.drawable.ic_address,
|
||||
R.string.address,
|
||||
@@ -61,7 +74,8 @@ fun buildDetails(
|
||||
if (loc.network != null) DetailsAdapter.Detail(
|
||||
R.drawable.ic_network,
|
||||
R.string.network,
|
||||
loc.network
|
||||
loc.network,
|
||||
clickable = loc.networkUrl != null
|
||||
) else null,
|
||||
if (loc.faultReport != null) DetailsAdapter.Detail(
|
||||
R.drawable.ic_fault_report,
|
||||
@@ -125,6 +139,128 @@ fun buildDetails(
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
|
||||
teslaPricing.memberRates?.activePricebook?.parking?.let { parkingFee ->
|
||||
ctx.getString(
|
||||
R.string.tesla_pricing_blocking_fee,
|
||||
formatTeslaPricingRate(parkingFee.rates, parkingFee.currencyCode, parkingFee.uom, ctx)
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
|
||||
buildSpannedString {
|
||||
teslaPricing.memberRates?.let { memberRates ->
|
||||
append(
|
||||
ctx.getString(if (teslaPricing.userRates != null) R.string.tesla_pricing_members else R.string.tesla_pricing_owners),
|
||||
StyleSpan(Typeface.BOLD),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
append(formatTeslaPricingRates(memberRates, ctx))
|
||||
}
|
||||
teslaPricing.userRates?.let { userRates ->
|
||||
append("\n\n")
|
||||
append(
|
||||
ctx.getString(R.string.tesla_pricing_others),
|
||||
StyleSpan(Typeface.BOLD),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
append(formatTeslaPricingRates(userRates, ctx))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTeslaPricingRates(rates: TeslaGraphQlApi.Rates, ctx: Context) =
|
||||
buildSpannedString {
|
||||
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
if (rates.activePricebook.charging.touRates.enabled) {
|
||||
// time-of-day-based rates
|
||||
val ratesByTime = rates.activePricebook.charging.touRates.activeRatesByTime
|
||||
val distinctRates =
|
||||
ratesByTime.map { it.rates }.distinct().sortedByDescending { it.max() }
|
||||
if (distinctRates.size == 2) {
|
||||
// special case: only list periods with higher price
|
||||
val highPriceTimes = ratesByTime.filter { it.rates == distinctRates[0] }
|
||||
append("\n")
|
||||
append(highPriceTimes.joinToString(", ") {
|
||||
timeFmt.format(it.startTime) + " - " + timeFmt.format(it.endTime)
|
||||
} + ": ", StyleSpan(Typeface.ITALIC), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
append(
|
||||
formatTeslaPricingRate(
|
||||
distinctRates[0],
|
||||
rates.activePricebook.charging.currencyCode,
|
||||
rates.activePricebook.charging.uom,
|
||||
ctx
|
||||
)
|
||||
)
|
||||
append("\n")
|
||||
append(
|
||||
ctx.getString(R.string.tesla_pricing_other_times),
|
||||
StyleSpan(Typeface.ITALIC),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
append(" ")
|
||||
append(
|
||||
formatTeslaPricingRate(
|
||||
distinctRates[1],
|
||||
rates.activePricebook.charging.currencyCode,
|
||||
rates.activePricebook.charging.uom,
|
||||
ctx
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// general case
|
||||
ratesByTime.forEach { rate ->
|
||||
append("\n")
|
||||
append(
|
||||
timeFmt.format(rate.startTime) + " - " + timeFmt.format(rate.endTime) + ": ",
|
||||
StyleSpan(Typeface.ITALIC),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
append(
|
||||
formatTeslaPricingRate(
|
||||
rate.rates,
|
||||
rates.activePricebook.charging.currencyCode,
|
||||
rates.activePricebook.charging.uom,
|
||||
ctx
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// fixed rates
|
||||
append(" ")
|
||||
append(
|
||||
formatTeslaPricingRate(
|
||||
rates.activePricebook.charging.rates,
|
||||
rates.activePricebook.charging.currencyCode,
|
||||
rates.activePricebook.charging.uom,
|
||||
ctx
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTeslaPricingRate(
|
||||
rates: List<Double>,
|
||||
currencyCode: String,
|
||||
uom: String,
|
||||
ctx: Context
|
||||
): String {
|
||||
if (rates.isEmpty()) return ""
|
||||
val rate = rates.max()
|
||||
val value = ctx.getString(
|
||||
when (uom) {
|
||||
"kwh" -> R.string.charge_price_kwh_format
|
||||
"min" -> R.string.charge_price_minute_format
|
||||
else -> return ""
|
||||
}, rate, currency(currencyCode)
|
||||
)
|
||||
return if (rates.size > 1) {
|
||||
ctx.getString(R.string.pricing_up_to, value)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fun formatChargeCards(
|
||||
chargecards: List<ChargeCardId>,
|
||||
chargecardData: Map<Long, ChargeCard>?,
|
||||
|
||||
@@ -5,14 +5,14 @@ import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver.OnGlobalLayoutListener
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.load
|
||||
import coil.memory.MemoryCache
|
||||
import coil.size.OriginalSize
|
||||
import coil.size.SizeResolver
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.model.ChargerPhoto
|
||||
|
||||
@@ -37,27 +37,43 @@ class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener?
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val id = getItem(position).id
|
||||
val url = getItem(position).getUrl(height = holder.view.height)
|
||||
val item = getItem(position)
|
||||
|
||||
holder.view.load(
|
||||
url
|
||||
) {
|
||||
size(SizeResolver(OriginalSize))
|
||||
allowHardware(false)
|
||||
listener(
|
||||
onSuccess = { _, metadata ->
|
||||
memoryKeys[id] = metadata.memoryCacheKey
|
||||
if (holder.view.height == 0) {
|
||||
holder.view.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
holder.view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
loadImage(item, holder)
|
||||
}
|
||||
)
|
||||
})
|
||||
} else {
|
||||
loadImage(item, holder)
|
||||
}
|
||||
|
||||
if (itemClickListener != null) {
|
||||
holder.view.setOnClickListener {
|
||||
itemClickListener.onItemClick(holder.view, position, memoryKeys[id])
|
||||
itemClickListener.onItemClick(holder.view, position, memoryKeys[item.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadImage(
|
||||
item: ChargerPhoto,
|
||||
holder: ViewHolder
|
||||
) {
|
||||
val url = item.getUrl(height = holder.view.height)
|
||||
|
||||
holder.view.load(
|
||||
url
|
||||
) {
|
||||
listener(
|
||||
onSuccess = { _, metadata ->
|
||||
memoryKeys[item.id] = metadata.memoryCacheKey
|
||||
}
|
||||
)
|
||||
allowHardware(!BuildConfig.DEBUG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ChargerPhotoDiffCallback : DiffUtil.ItemCallback<ChargerPhoto>() {
|
||||
|
||||
@@ -8,23 +8,36 @@ import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
interface ChargepointApi<out T : ReferenceData> {
|
||||
/**
|
||||
* Query for chargepoints within certain geographic bounds
|
||||
*/
|
||||
suspend fun getChargepoints(
|
||||
referenceData: ReferenceData,
|
||||
bounds: LatLngBounds,
|
||||
zoom: Float,
|
||||
useClustering: Boolean,
|
||||
filters: FilterValues?
|
||||
): Resource<List<ChargepointListItem>>
|
||||
): Resource<ChargepointList>
|
||||
|
||||
/**
|
||||
* Query for chargepoints within a given radius in kilometers
|
||||
*/
|
||||
suspend fun getChargepointsRadius(
|
||||
referenceData: ReferenceData,
|
||||
location: LatLng,
|
||||
radius: Int,
|
||||
zoom: Float,
|
||||
useClustering: Boolean,
|
||||
filters: FilterValues?
|
||||
): Resource<List<ChargepointListItem>>
|
||||
): Resource<ChargepointList>
|
||||
|
||||
/**
|
||||
* Fetches detailed data for a specific charging site
|
||||
*/
|
||||
suspend fun getChargepointDetail(
|
||||
referenceData: ReferenceData,
|
||||
id: Long
|
||||
@@ -34,8 +47,17 @@ interface ChargepointApi<out T : ReferenceData> {
|
||||
|
||||
fun getFilters(referenceData: ReferenceData, sp: StringProvider): List<Filter<FilterValue>>
|
||||
|
||||
fun convertFiltersToSQL(filters: FilterValues, referenceData: ReferenceData): FiltersSQLQuery
|
||||
|
||||
fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean
|
||||
|
||||
val name: String
|
||||
val id: String
|
||||
|
||||
/**
|
||||
* Duration we are limited to if there is a required API local cache time limit.
|
||||
*/
|
||||
val cacheLimit: Duration
|
||||
}
|
||||
|
||||
interface StringProvider {
|
||||
@@ -66,4 +88,16 @@ fun createApi(type: String, ctx: Context): ChargepointApi<ReferenceData> {
|
||||
}
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
data class FiltersSQLQuery(
|
||||
val query: String,
|
||||
val requiresChargepointQuery: Boolean,
|
||||
val requiresChargeCardQuery: Boolean
|
||||
)
|
||||
|
||||
data class ChargepointList(val items: List<ChargepointListItem>, val isComplete: Boolean) {
|
||||
companion object {
|
||||
fun empty() = ChargepointList(emptyList(), true)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ class RateLimitInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (request.url.host == "my.newmotion.com") {
|
||||
if (request.url.host == "ui-map.shellrecharge.com") {
|
||||
// limit requests sent to NewMotion to 3 per second
|
||||
rateLimiter.acquire(1)
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.RateLimitInterceptor
|
||||
import net.vonforst.evmap.api.await
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.cartesianProduct
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -133,7 +135,9 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
data class ChargeLocationStatus(
|
||||
val status: Map<Chargepoint, List<ChargepointStatus>>,
|
||||
val source: String,
|
||||
val evseIds: Map<Chargepoint, List<String>>? = null
|
||||
val evseIds: Map<Chargepoint, List<String>>? = null,
|
||||
val congestionHistogram: List<Double>? = null,
|
||||
val extraData: Any? = null // API-specific data
|
||||
) {
|
||||
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
|
||||
val statusFiltered = status.filterKeys {
|
||||
@@ -158,38 +162,41 @@ private val cookieManager = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
|
||||
private val okhttp = OkHttpClient.Builder()
|
||||
.addInterceptor(RateLimitInterceptor())
|
||||
.addNetworkInterceptor(StethoInterceptor())
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||
.build()
|
||||
val availabilityDetectors = listOf(
|
||||
RheinenergieAvailabilityDetector(okhttp),
|
||||
EnBwAvailabilityDetector(okhttp),
|
||||
NewMotionAvailabilityDetector(okhttp)
|
||||
)
|
||||
class AvailabilityRepository(context: Context) {
|
||||
private val okhttp = OkHttpClient.Builder()
|
||||
.addInterceptor(RateLimitInterceptor())
|
||||
.addDebugInterceptors()
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||
.build()
|
||||
private val availabilityDetectors = listOf(
|
||||
RheinenergieAvailabilityDetector(okhttp),
|
||||
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
|
||||
EnBwAvailabilityDetector(okhttp),
|
||||
NewMotionAvailabilityDetector(okhttp)
|
||||
)
|
||||
|
||||
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
|
||||
var value: Resource<ChargeLocationStatus>? = null
|
||||
withContext(Dispatchers.IO) {
|
||||
for (ad in availabilityDetectors) {
|
||||
if (!ad.isChargerSupported(charger)) continue
|
||||
try {
|
||||
value = Resource.success(ad.getAvailability(charger))
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
} catch (e: HttpException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
} catch (e: AvailabilityDetectorException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
|
||||
var value: Resource<ChargeLocationStatus>? = null
|
||||
withContext(Dispatchers.IO) {
|
||||
for (ad in availabilityDetectors) {
|
||||
if (!ad.isChargerSupported(charger)) continue
|
||||
try {
|
||||
value = Resource.success(ad.getAvailability(charger))
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
} catch (e: HttpException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
} catch (e: AvailabilityDetectorException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return value ?: Resource.error(null, null)
|
||||
}
|
||||
return value ?: Resource.error(null, null)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
) < maxDistance
|
||||
}
|
||||
|
||||
var details = markers.filter {
|
||||
val details = markers.filter {
|
||||
// only include stations from same operator
|
||||
it.operator == nearest.operator && it.stationId != null
|
||||
}.map {
|
||||
@@ -203,30 +203,46 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
val country = charger.chargepriceData?.country
|
||||
?: charger.address?.country ?: return false
|
||||
return when (charger.dataSource) {
|
||||
// list of countries as of 2021/06/30, according to
|
||||
// https://www.electrive.net/2021/06/30/enbw-expandiert-mit-ladenetz-in-drei-weitere-laender/
|
||||
// list of countries as of 2023/04/14, according to
|
||||
// https://www.enbw.com/elektromobilitaet/produkte/ladetarife
|
||||
"goingelectric" -> country in listOf(
|
||||
"Deutschland",
|
||||
"Österreich",
|
||||
"Schweiz",
|
||||
"Frankreich",
|
||||
"Belgien",
|
||||
"Niederlande",
|
||||
"Luxemburg",
|
||||
"Liechtenstein",
|
||||
"Dänemark",
|
||||
"Frankreich",
|
||||
"Italien",
|
||||
)
|
||||
"Kroatien",
|
||||
"Liechtenstein",
|
||||
"Luxemburg",
|
||||
"Niederlande",
|
||||
"Polen",
|
||||
"Schweden",
|
||||
"Slowakei",
|
||||
"Slowenien",
|
||||
"Spanien",
|
||||
"Tschechien"
|
||||
) && charger.network != "Tesla Supercharger"
|
||||
"openchargemap" -> country in listOf(
|
||||
"DE",
|
||||
"AT",
|
||||
"CH",
|
||||
"FR",
|
||||
"BE",
|
||||
"NE",
|
||||
"LU",
|
||||
"DK",
|
||||
"FR",
|
||||
"IT",
|
||||
"HR",
|
||||
"LI",
|
||||
"IT"
|
||||
)
|
||||
"LU",
|
||||
"NE",
|
||||
"PL",
|
||||
"SE",
|
||||
"SK",
|
||||
"SI",
|
||||
"ES",
|
||||
"CZ"
|
||||
) && charger.chargepriceData?.network !in listOf("23", "3534")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ private const val coordRange = 0.005 // range of latitude and longitude for loa
|
||||
private const val maxDistance = 40 // max distance between reported positions in meters
|
||||
|
||||
interface NewMotionApi {
|
||||
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}")
|
||||
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}/{zoom}")
|
||||
suspend fun getMarkers(
|
||||
@Path("lngMin") lngMin: Double,
|
||||
@Path("lngMax") lngMax: Double,
|
||||
@Path("latMin") latMin: Double,
|
||||
@Path("latMax") latMax: Double
|
||||
@Path("latMax") latMax: Double,
|
||||
@Path("zoom") zoom: Int = 22
|
||||
): List<NMMarker>
|
||||
|
||||
@GET("locations/{id}")
|
||||
@@ -76,7 +77,7 @@ interface NewMotionApi {
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, baseUrl: String? = null): NewMotionApi {
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://my.newmotion.com/api/map/v2/")
|
||||
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
.client(client)
|
||||
.build()
|
||||
@@ -181,7 +182,11 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
// NewMotion is our fallback
|
||||
return true
|
||||
return when (charger.dataSource) {
|
||||
"goingelectric" -> charger.network != "Tesla Supercharger"
|
||||
"openchargemap" -> charger.chargepriceData?.network !in listOf("23", "3534")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import android.util.Base64
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.ToJson
|
||||
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.model.Coordinate
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.Instant
|
||||
import java.time.LocalTime
|
||||
import java.util.Collections
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
|
||||
interface TeslaAuthenticationApi {
|
||||
@POST("oauth2/v3/token")
|
||||
suspend fun getToken(@Body request: OAuth2Request): OAuth2Response
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
class AuthCodeRequest(
|
||||
val code: String,
|
||||
@Json(name = "code_verifier") val codeVerifier: String,
|
||||
@Json(name = "redirect_uri") val redirectUri: String = "https://auth.tesla.com/void/callback",
|
||||
scope: String = "openid email offline_access",
|
||||
@Json(name = "client_id") clientId: String = "ownerapi"
|
||||
) : OAuth2Request(scope, clientId)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
class RefreshTokenRequest(
|
||||
@Json(name = "refresh_token") val refreshToken: String,
|
||||
scope: String = "openid email offline_access",
|
||||
@Json(name = "client_id") clientId: String = "ownerapi"
|
||||
) : OAuth2Request(scope, clientId)
|
||||
|
||||
sealed class OAuth2Request(
|
||||
val scope: String,
|
||||
val clientId: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OAuth2Response(
|
||||
@Json(name = "access_token") val accessToken: String,
|
||||
@Json(name = "token_type") val tokenType: String,
|
||||
@Json(name = "expires_in") val expiresIn: Long,
|
||||
@Json(name = "refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, baseUrl: String? = null): TeslaAuthenticationApi {
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://auth.tesla.com")
|
||||
.addConverterFactory(
|
||||
MoshiConverterFactory.create(
|
||||
Moshi.Builder()
|
||||
.add(
|
||||
PolymorphicJsonAdapterFactory.of(
|
||||
OAuth2Request::class.java,
|
||||
"grant_type"
|
||||
)
|
||||
.withSubtype(AuthCodeRequest::class.java, "authorization_code")
|
||||
.withSubtype(RefreshTokenRequest::class.java, "refresh_token")
|
||||
.withDefaultValue(null)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.client(client)
|
||||
.build()
|
||||
return retrofit.create(TeslaAuthenticationApi::class.java)
|
||||
}
|
||||
|
||||
fun generateCodeVerifier(): String {
|
||||
val code = ByteArray(64)
|
||||
SecureRandom().nextBytes(code)
|
||||
return Base64.encodeToString(
|
||||
code,
|
||||
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
|
||||
)
|
||||
}
|
||||
|
||||
fun generateCodeChallenge(codeVerifier: String): String {
|
||||
val bytes = codeVerifier.toByteArray()
|
||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
messageDigest.update(bytes, 0, bytes.size)
|
||||
return Base64.encodeToString(
|
||||
messageDigest.digest(),
|
||||
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TeslaOwnerApi {
|
||||
@GET("/api/1/users/me")
|
||||
suspend fun getUserInfo(): UserInfoResponse
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserInfoResponse(
|
||||
val response: UserInfo
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserInfo(
|
||||
val email: String,
|
||||
@Json(name = "full_name") val fullName: String,
|
||||
@Json(name = "profile_image_url") val profileImageUrl: String?
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, token: String, baseUrl: String? = null): TeslaOwnerApi {
|
||||
val clientWithInterceptor = client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
// add API key to every request
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("User-Agent", "okhttp/4.9.2")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://owner-api.teslamotors.com")
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
.client(clientWithInterceptor)
|
||||
.build()
|
||||
return retrofit.create(TeslaOwnerApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TeslaGraphQlApi {
|
||||
@POST("/graphql")
|
||||
suspend fun getNearbyChargingSites(
|
||||
@Body request: GetNearbyChargingSitesRequest,
|
||||
@Query("operationName") operationName: String = "GetNearbyChargingSites",
|
||||
@Query("deviceLanguage") deviceLanguage: String = "en",
|
||||
@Query("deviceCountry") deviceCountry: String = "US",
|
||||
@Query("ttpLocale") ttpLocale: String = "en_US",
|
||||
@Query("vin") vin: String = "",
|
||||
): GetNearbyChargingSitesResponse
|
||||
|
||||
@POST("/graphql")
|
||||
suspend fun getChargingSiteInformation(
|
||||
@Body request: GetChargingSiteInformationRequest,
|
||||
@Query("operationName") operationName: String = "getChargingSiteInformation",
|
||||
@Query("deviceLanguage") deviceLanguage: String = "en",
|
||||
@Query("deviceCountry") deviceCountry: String = "US",
|
||||
@Query("ttpLocale") ttpLocale: String = "en_US",
|
||||
@Query("vin") vin: String = "",
|
||||
): GetChargingSiteInformationResponse
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesRequest(
|
||||
override val variables: GetNearbyChargingSitesVariables,
|
||||
override val operationName: String = "GetNearbyChargingSites",
|
||||
override val query: String =
|
||||
"\n query GetNearbyChargingSites(\$args: GetNearbyChargingSitesRequestType!) {\n charging {\n nearbySites(args: \$args) {\n sitesAndDistances {\n ...ChargingNearbySitesFragment\n }\n }\n }\n}\n \n fragment ChargingNearbySitesFragment on ChargerSiteAndDistanceType {\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n availableStalls {\n value\n }\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n drivingDistanceMiles {\n value\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n haversineDistanceMiles {\n value\n }\n id {\n text\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n trtId {\n value\n }\n totalStalls {\n value\n }\n siteType\n accessType\n waitEstimateBucket\n hasHighCongestion\n}\n \n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n "
|
||||
) : GraphQlRequest()
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesVariables(val args: GetNearbyChargingSitesArgs)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesArgs(
|
||||
val userLocation: Coordinate,
|
||||
val northwestCorner: Coordinate,
|
||||
val southeastCorner: Coordinate,
|
||||
val openToNonTeslasFilter: OpenToNonTeslasFilterValue,
|
||||
val languageCode: String = "en",
|
||||
val countryCode: String = "US",
|
||||
//val vin: String = "",
|
||||
//val maxCount: Int = 100
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OpenToNonTeslasFilterValue(val value: Boolean)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Coordinate(val latitude: Double, val longitude: Double)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationRequest(
|
||||
override val variables: GetChargingSiteInformationVariables,
|
||||
override val operationName: String = "getChargingSiteInformation",
|
||||
override val query: String =
|
||||
"\n query getChargingSiteInformation(\$id: ChargingSiteIdentifierInputType!, \$vehicleMakeType: ChargingVehicleMakeTypeEnum, \$deviceCountry: String!, \$deviceLanguage: String!) {\n charging {\n site(\n id: \$id\n deviceCountry: \$deviceCountry\n deviceLanguage: \$deviceLanguage\n vehicleMakeType: \$vehicleMakeType\n ) {\n siteStatic {\n ...SiteStaticFragmentNoHoldAmt\n }\n siteDynamic {\n ...SiteDynamicFragment\n }\n pricing(vehicleMakeType: \$vehicleMakeType) {\n userRates {\n ...ChargingActiveRateFragment\n }\n memberRates {\n ...ChargingActiveRateFragment\n }\n hasMembershipPricing\n hasMSPPricing\n canDisplayCombinedComparison\n }\n holdAmount(vehicleMakeType: \$vehicleMakeType) {\n currencyCode\n holdAmount\n }\n congestionPriceHistogram(vehicleMakeType: \$vehicleMakeType) {\n ...CongestionPriceHistogramFragment\n }\n }\n }\n}\n \n fragment SiteStaticFragmentNoHoldAmt on ChargingSiteStaticType {\n address {\n ...AddressFragment\n }\n amenities\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n id {\n text\n }\n accessCode {\n value\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n name\n openToPublic\n chargers {\n id {\n text\n }\n label {\n value\n }\n }\n publicStallCount\n timeZone {\n id\n version\n }\n fastchargeSiteId {\n value\n }\n siteType\n accessType\n isMagicDockSupportedSite\n trtId {\n value\n }\n}\n \n fragment AddressFragment on EnergySvcAddressType {\n streetNumber {\n value\n }\n street {\n value\n }\n district {\n value\n }\n city {\n value\n }\n state {\n value\n }\n postalCode {\n value\n }\n country\n}\n \n\n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n \n\n fragment SiteDynamicFragment on ChargingSiteDynamicType {\n id {\n text\n }\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n chargersAvailable {\n value\n }\n chargerDetails {\n charger {\n id {\n text\n }\n label {\n value\n }\n name\n }\n availability\n }\n waitEstimateBucket\n currentCongestion\n}\n \n\n fragment ChargingActiveRateFragment on ChargingActiveRateType {\n activePricebook {\n charging {\n ...ChargingUserRateFragment\n }\n parking {\n ...ChargingUserRateFragment\n }\n priceBookID\n }\n}\n \n fragment ChargingUserRateFragment on ChargingUserRateType {\n currencyCode\n programType\n rates\n buckets {\n start\n end\n }\n bucketUom\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n uom\n vehicleMakeType\n}\n \n\n fragment CongestionPriceHistogramFragment on HistogramData {\n axisLabels {\n index\n value\n }\n regionLabels {\n index\n value {\n ...ChargingPriceFragment\n ... on HistogramRegionLabelValueString {\n value\n }\n }\n }\n chargingUom\n parkingUom\n parkingRate {\n ...ChargingPriceFragment\n }\n data\n activeBar\n maxRateIndex\n whenRateChanges\n dataAttributes {\n congestionThreshold\n label\n }\n}\n \n fragment ChargingPriceFragment on ChargingPrice {\n currencyCode\n price\n}\n"
|
||||
) : GraphQlRequest()
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationVariables(
|
||||
val id: ChargingSiteIdentifier,
|
||||
val vehicleMakeType: VehicleMakeType,
|
||||
val deviceLanguage: String = "en",
|
||||
val deviceCountry: String = "US",
|
||||
val ttpLocale: String = "en_US"
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteIdentifier(
|
||||
val id: String,
|
||||
val type: ChargingSiteIdentifierType = ChargingSiteIdentifierType.SITE_ID
|
||||
)
|
||||
|
||||
enum class ChargingSiteIdentifierType {
|
||||
SITE_ID
|
||||
}
|
||||
|
||||
enum class VehicleMakeType {
|
||||
TESLA, NON_TESLA
|
||||
}
|
||||
|
||||
sealed class GraphQlRequest {
|
||||
abstract val operationName: String
|
||||
abstract val query: String
|
||||
abstract val variables: Any?
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesResponse(val data: GetNearbyChargingSitesResponseData)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesResponseData(val charging: GetNearbyChargingSitesResponseDataCharging)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesResponseDataCharging(val nearbySites: GetNearbyChargingSitesResponseDataChargingNearbySites)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesResponseDataChargingNearbySites(val sitesAndDistances: List<ChargingSite>)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSite(
|
||||
val activeOutages: List<Outage>,
|
||||
val availableStalls: Value<Int>?,
|
||||
val centroid: Coordinate,
|
||||
val drivingDistanceMiles: Value<Double>?,
|
||||
val entryPoint: Coordinate,
|
||||
val haversineDistanceMiles: Value<Double>,
|
||||
val id: Text,
|
||||
val localizedSiteName: Value<String>,
|
||||
val maxPowerKw: Value<Int>,
|
||||
val totalStalls: Value<Int>
|
||||
// TODO: siteType, accessType
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Outage(val message: String /* TODO: */)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Value<T : Any>(val value: T)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Text(val text: String)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationResponse(val data: GetChargingSiteInformationResponseData)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteInformation(
|
||||
val siteDynamic: SiteDynamic,
|
||||
val siteStatic: SiteStatic,
|
||||
val pricing: Pricing,
|
||||
val congestionPriceHistogram: CongestionPriceHistogram,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SiteDynamic(
|
||||
val activeOutages: List<Outage>,
|
||||
val chargerDetails: List<ChargerDetail>,
|
||||
val chargersAvailable: Value<Int>?,
|
||||
val currentCongestion: Double,
|
||||
val id: Text,
|
||||
val waitEstimateBucket: WaitEstimateBucket
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargerDetail(
|
||||
val availability: ChargerAvailability,
|
||||
val charger: ChargerId
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargerId(
|
||||
val id: Text,
|
||||
val label: Value<String>,
|
||||
val name: String?
|
||||
) {
|
||||
val labelNumber
|
||||
get() = label.value.replace(Regex("""\D"""), "").toInt()
|
||||
val labelLetter
|
||||
get() = label.value.replace(Regex("""\d"""), "")
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SiteStatic(
|
||||
val accessCode: Value<String>?,
|
||||
val centroid: Coordinate,
|
||||
val chargers: List<ChargerId>,
|
||||
val entryPoint: Coordinate,
|
||||
val fastchargeSiteId: Value<Long>,
|
||||
val id: Text,
|
||||
val isMagicDockSupportedSite: Boolean,
|
||||
val localizedSiteName: Value<String>,
|
||||
val maxPowerKw: Value<Int>,
|
||||
val name: String,
|
||||
val openToPublic: Boolean,
|
||||
val publicStallCount: Int
|
||||
// TODO: siteType, accessType, address, amenities, timeZone
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Pricing(
|
||||
val canDisplayCombinedComparison: Boolean,
|
||||
val hasMSPPricing: Boolean,
|
||||
val hasMembershipPricing: Boolean,
|
||||
val memberRates: Rates?, // rates for Tesla drivers & non-Tesla drivers with subscription
|
||||
val userRates: Rates? // rates without subscription
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Rates(
|
||||
val activePricebook: Pricebook
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Pricebook(
|
||||
val charging: PricebookDetails,
|
||||
val parking: PricebookDetails,
|
||||
val priceBookID: Long
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PricebookDetails(
|
||||
val bucketUom: String, // unit of measurement for buckets (typically "kw")
|
||||
val buckets: List<Bucket>, // buckets of charging power (used for minute-based pricing)
|
||||
val currencyCode: String,
|
||||
val programType: String,
|
||||
val rates: List<Double>,
|
||||
val touRates: TouRates,
|
||||
val uom: String, // unit of measurement ("kwh" or "min")
|
||||
val vehicleMakeType: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Bucket(
|
||||
val start: Int,
|
||||
val end: Int
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TouRates(
|
||||
val activeRatesByTime: List<ActiveRatesByTime>,
|
||||
val enabled: Boolean
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ActiveRatesByTime(
|
||||
val startTime: LocalTime,
|
||||
val endTime: LocalTime,
|
||||
val rates: List<Double>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CongestionPriceHistogram(
|
||||
val data: List<Double>,
|
||||
val dataAttributes: List<CongestionHistogramDataAttributes>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CongestionHistogramDataAttributes(
|
||||
val congestionThreshold: String, // "LEVEL_1"
|
||||
val label: String // "1AM", "2AM", etc.
|
||||
)
|
||||
|
||||
enum class ChargerAvailability {
|
||||
@Json(name = "CHARGER_AVAILABILITY_AVAILABLE")
|
||||
AVAILABLE,
|
||||
|
||||
@Json(name = "CHARGER_AVAILABILITY_OCCUPIED")
|
||||
OCCUPIED,
|
||||
|
||||
@Json(name = "CHARGER_AVAILABILITY_DOWN")
|
||||
DOWN,
|
||||
@Json(name = "CHARGER_AVAILABILITY_UNKNOWN")
|
||||
UNKNOWN;
|
||||
|
||||
fun toStatus() = when (this) {
|
||||
AVAILABLE -> ChargepointStatus.AVAILABLE
|
||||
OCCUPIED -> ChargepointStatus.OCCUPIED
|
||||
DOWN -> ChargepointStatus.FAULTED
|
||||
UNKNOWN -> ChargepointStatus.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
enum class WaitEstimateBucket {
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_NO_WAIT")
|
||||
NO_WAIT,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_LESS_THAN_5_MINUTES")
|
||||
LESS_THAN_5_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_5_MINUTES")
|
||||
APPROXIMATELY_5_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_10_MINUTES")
|
||||
APPROXIMATELY_10_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_15_MINUTES")
|
||||
APPROXIMATELY_15_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
|
||||
APPROXIMATELY_20_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String? = null,
|
||||
token: suspend () -> String
|
||||
): TeslaGraphQlApi {
|
||||
val clientWithInterceptor = client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
val t = runBlocking { token() }
|
||||
// add API key to every request
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $t")
|
||||
.header("User-Agent", "okhttp/4.9.2")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://akamai-apigateway-charging-ownership.tesla.com")
|
||||
.addConverterFactory(
|
||||
MoshiConverterFactory.create(
|
||||
Moshi.Builder().add(LocalTimeAdapter()).build()
|
||||
)
|
||||
)
|
||||
.client(clientWithInterceptor)
|
||||
.build()
|
||||
return retrofit.create(TeslaGraphQlApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class LocalTimeAdapter {
|
||||
@FromJson
|
||||
fun fromJson(value: String?): LocalTime? = value?.let {
|
||||
if (it == "24:00") LocalTime.MAX else LocalTime.parse(it)
|
||||
}
|
||||
|
||||
@ToJson
|
||||
fun toJson(value: LocalTime?): String? = value?.toString()
|
||||
}
|
||||
|
||||
fun Coordinate.asTeslaCoord() =
|
||||
TeslaGraphQlApi.Coordinate(this.lat, this.lng)
|
||||
|
||||
class TeslaAvailabilityDetector(
|
||||
private val client: OkHttpClient,
|
||||
private val tokenStore: TokenStore,
|
||||
private val baseUrl: String? = null
|
||||
) :
|
||||
BaseAvailabilityDetector(client) {
|
||||
|
||||
private val authApi = TeslaAuthenticationApi.create(client, null)
|
||||
private var api: TeslaGraphQlApi? = null
|
||||
|
||||
interface TokenStore {
|
||||
var teslaRefreshToken: String?
|
||||
var teslaAccessToken: String?
|
||||
var teslaAccessTokenExpiry: Long
|
||||
}
|
||||
|
||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||
val api = initApi()
|
||||
val req = TeslaGraphQlApi.GetNearbyChargingSitesRequest(
|
||||
TeslaGraphQlApi.GetNearbyChargingSitesVariables(
|
||||
TeslaGraphQlApi.GetNearbyChargingSitesArgs(
|
||||
location.coordinates.asTeslaCoord(),
|
||||
TeslaGraphQlApi.Coordinate(
|
||||
location.coordinates.lat + coordRange,
|
||||
location.coordinates.lng - coordRange
|
||||
),
|
||||
TeslaGraphQlApi.Coordinate(
|
||||
location.coordinates.lat - coordRange,
|
||||
location.coordinates.lng + coordRange
|
||||
),
|
||||
TeslaGraphQlApi.OpenToNonTeslasFilterValue(false)
|
||||
)
|
||||
)
|
||||
)
|
||||
val results = api.getNearbyChargingSites(
|
||||
req,
|
||||
req.operationName
|
||||
).data.charging.nearbySites.sitesAndDistances
|
||||
val result =
|
||||
results.minByOrNull { it.haversineDistanceMiles.value }
|
||||
?: throw AvailabilityDetectorException("no candidates found.")
|
||||
|
||||
val details = api.getChargingSiteInformation(
|
||||
TeslaGraphQlApi.GetChargingSiteInformationRequest(
|
||||
TeslaGraphQlApi.GetChargingSiteInformationVariables(
|
||||
TeslaGraphQlApi.ChargingSiteIdentifier(result.id.text),
|
||||
TeslaGraphQlApi.VehicleMakeType.NON_TESLA
|
||||
)
|
||||
)
|
||||
).data.charging.site
|
||||
|
||||
|
||||
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
|
||||
val scV2CCSConnectors = location.chargepoints.filter {
|
||||
it.type in listOf(
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CCS_UNKNOWN
|
||||
) && it.power != null && it.power <= 150
|
||||
}
|
||||
val scV3Connectors = location.chargepoints.filter {
|
||||
it.type in listOf(
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CCS_UNKNOWN
|
||||
) && it.power != null && it.power > 150
|
||||
}
|
||||
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
|
||||
"charger has unknown connectors"
|
||||
)
|
||||
|
||||
val statusSorted = details.siteDynamic.chargerDetails.sortedBy { it.charger.labelLetter }
|
||||
.sortedBy { it.charger.labelNumber }
|
||||
|
||||
val statusMap = emptyMap<Chargepoint, List<ChargepointStatus>>().toMutableMap()
|
||||
var i = 0
|
||||
for (connector in scV2Connectors) {
|
||||
statusMap[connector] =
|
||||
statusSorted.subList(i, i + connector.count).map { it.availability.toStatus() }
|
||||
i += connector.count
|
||||
}
|
||||
if (scV2CCSConnectors.isNotEmpty()) {
|
||||
i = 0
|
||||
for (connector in scV2CCSConnectors) {
|
||||
statusMap[connector] =
|
||||
statusSorted.subList(i, i + connector.count).map { it.availability.toStatus() }
|
||||
i += connector.count
|
||||
}
|
||||
}
|
||||
for (connector in scV3Connectors) {
|
||||
statusMap[connector] =
|
||||
statusSorted.subList(i, i + connector.count).map { it.availability.toStatus() }
|
||||
i += connector.count
|
||||
}
|
||||
|
||||
val indexOfMidnight =
|
||||
details.congestionPriceHistogram.dataAttributes.indexOfFirst { it.label == "12AM" }
|
||||
val congestionHistogram = indexOfMidnight.takeIf { it >= 0 }?.let { index ->
|
||||
val data = details.congestionPriceHistogram.data.toMutableList()
|
||||
Collections.rotate(data, -index)
|
||||
data
|
||||
}
|
||||
|
||||
return ChargeLocationStatus(
|
||||
statusMap,
|
||||
"Tesla",
|
||||
congestionHistogram = congestionHistogram,
|
||||
extraData = details.pricing
|
||||
)
|
||||
}
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
return when (charger.dataSource) {
|
||||
"goingelectric" -> charger.network == "Tesla Supercharger"
|
||||
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun initApi(): TeslaGraphQlApi {
|
||||
|
||||
return api ?: run {
|
||||
val newApi = TeslaGraphQlApi.create(client, baseUrl) {
|
||||
val now = Instant.now().epochSecond
|
||||
val token =
|
||||
tokenStore.teslaAccessToken.takeIf { tokenStore.teslaAccessTokenExpiry > now }
|
||||
?: run {
|
||||
val refreshToken = tokenStore.teslaRefreshToken
|
||||
?: throw IOException("not signed in")
|
||||
val response =
|
||||
authApi.getToken(
|
||||
TeslaAuthenticationApi.RefreshTokenRequest(
|
||||
refreshToken
|
||||
)
|
||||
)
|
||||
tokenStore.teslaAccessToken = response.accessToken
|
||||
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
|
||||
response.accessToken
|
||||
}
|
||||
token
|
||||
}
|
||||
api = newApi
|
||||
newApi
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package net.vonforst.evmap.api.chargeprice
|
||||
|
||||
import android.content.Context
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
|
||||
import jsonapi.Document
|
||||
import jsonapi.JsonApiFactory
|
||||
import jsonapi.retrofit.DocumentConverterFactory
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -77,10 +77,10 @@ interface ChargepriceApi {
|
||||
chain.proceed(new)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(StethoInterceptor())
|
||||
addDebugInterceptors()
|
||||
}
|
||||
if (context != null) {
|
||||
cache(Cache(context.getCacheDir(), cacheSize))
|
||||
cache(Cache(context.cacheDir, cacheSize))
|
||||
}
|
||||
}.build()
|
||||
|
||||
@@ -127,14 +127,16 @@ interface ChargepriceApi {
|
||||
charger.chargepriceData?.country?.let { isCountrySupported(it, charger.dataSource) }
|
||||
?: false
|
||||
val networkSupported = charger.chargepriceData?.network?.let {
|
||||
if (charger.dataSource == "openchargemap") {
|
||||
it !in listOf(
|
||||
when (charger.dataSource) {
|
||||
"openchargemap" -> it !in listOf(
|
||||
"1", // unknown operator
|
||||
"44", // private residence/individual
|
||||
"45" // business owner at location
|
||||
"45", // business owner at location
|
||||
"23", "3534" // Tesla
|
||||
)
|
||||
} else {
|
||||
true
|
||||
|
||||
"goingelectric" -> it != "Tesla Supercharger"
|
||||
else -> true
|
||||
}
|
||||
} ?: false
|
||||
val powerAvailable = charger.chargepoints.all { it.hasKnownPower() }
|
||||
@@ -163,7 +165,7 @@ interface ChargepriceApi {
|
||||
"Spanien",
|
||||
"Großbritannien",
|
||||
"Irland",
|
||||
// additional countries found 2022/09/17, https://github.com/johan12345/EVMap/issues/234
|
||||
// additional countries found 2022/09/17, https://github.com/ev-map/EVMap/issues/234
|
||||
"Finnland",
|
||||
"Lettland",
|
||||
"Litauen",
|
||||
@@ -202,7 +204,7 @@ interface ChargepriceApi {
|
||||
"ES",
|
||||
"GB",
|
||||
"IE",
|
||||
// additional countries found 2022/09/17, https://github.com/johan12345/EVMap/issues/234
|
||||
// additional countries found 2022/09/17, https://github.com/ev-map/EVMap/issues/234
|
||||
"FI",
|
||||
"LV",
|
||||
"LT",
|
||||
|
||||
@@ -114,8 +114,26 @@ data class ChargepriceCar(
|
||||
val brand: String,
|
||||
|
||||
@Json(name = "dc_charge_ports")
|
||||
val dcChargePorts: List<String>
|
||||
val dcChargePorts: List<String>,
|
||||
|
||||
@Json(name = "usable_battery_size")
|
||||
val usableBatterySize: Float,
|
||||
|
||||
@Json(name = "ac_max_power")
|
||||
val acMaxPower: Float,
|
||||
|
||||
@Json(name = "dc_max_power")
|
||||
val dcMaxPower: Float?
|
||||
) : Equatable, Parcelable {
|
||||
fun formatSpecs(): String = buildString {
|
||||
append("%.0f kWh".format(usableBatterySize))
|
||||
append(" | ")
|
||||
append("AC %.0f kW".format(acMaxPower))
|
||||
dcMaxPower?.let {
|
||||
append(" | ")
|
||||
append("DC %.0f kW".format(it))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val acConnectors = listOf(
|
||||
@@ -139,9 +157,9 @@ data class ChargepriceCar(
|
||||
get() = id_!!
|
||||
|
||||
val compatibleEvmapConnectors: List<String>
|
||||
get() = dcChargePorts.map {
|
||||
get() = dcChargePorts.mapNotNull {
|
||||
plugMapping[it]
|
||||
}.filterNotNull().plus(acConnectors)
|
||||
}.plus(acConnectors)
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@@ -210,7 +228,7 @@ internal object RelationshipsParceler : Parceler<Relationships?> {
|
||||
if (parcel.readInt() == 0) return null
|
||||
|
||||
val nMembers = parcel.readInt()
|
||||
val members = (0 until nMembers).map { _ ->
|
||||
val members = (0 until nMembers).associate { _ ->
|
||||
val key = parcel.readString()!!
|
||||
val value = if (parcel.readInt() == 0) {
|
||||
val type = parcel.readString()
|
||||
@@ -229,7 +247,7 @@ internal object RelationshipsParceler : Parceler<Relationships?> {
|
||||
Relationship.ToMany(ris)
|
||||
}
|
||||
key to value
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
return Relationships(members)
|
||||
}
|
||||
@@ -281,12 +299,12 @@ data class ChargepointPrice(
|
||||
}
|
||||
|
||||
fun time(value: Int): String {
|
||||
val h = floor(value.toDouble() / 60).toInt();
|
||||
val min = ceil(value.toDouble() % 60).toInt();
|
||||
if (h == 0 && min > 0) return "${min}min";
|
||||
val h = floor(value.toDouble() / 60).toInt()
|
||||
val min = ceil(value.toDouble() % 60).toInt()
|
||||
return if (h == 0 && min > 0) "${min}min";
|
||||
// be slightly sloppy (3:01 is shown as 3h) to save space
|
||||
else if (h > 0 && (min == 0 || min == 1)) return "${h}h";
|
||||
else return "%d:%02dh".format(h, min);
|
||||
else if (h > 0 && (min == 0 || min == 1)) "${h}h";
|
||||
else "%d:%02dh".format(h, min)
|
||||
}
|
||||
|
||||
// based on https://github.com/chargeprice/chargeprice-client/blob/d420bb2f216d9ad91a210a36dd0859a368a8229a/src/views/priceList.js
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package net.vonforst.evmap.api.fronyx
|
||||
|
||||
import android.content.Context
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import com.squareup.moshi.Moshi
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import okhttp3.Cache
|
||||
@@ -49,10 +49,10 @@ private interface FronyxApiRetrofit {
|
||||
chain.proceed(new)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(StethoInterceptor())
|
||||
addDebugInterceptors()
|
||||
}
|
||||
if (context != null) {
|
||||
cache(Cache(context.getCacheDir(), cacheSize))
|
||||
cache(Cache(context.cacheDir, cacheSize))
|
||||
}
|
||||
}.build()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.squareup.moshi.*
|
||||
import java.lang.reflect.Type
|
||||
import java.time.Instant
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
|
||||
internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
|
||||
@@ -13,12 +14,12 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
|
||||
annotations: MutableSet<out Annotation>,
|
||||
moshi: Moshi
|
||||
): JsonAdapter<*>? {
|
||||
if (Types.getRawType(type) == GEChargepointListItem::class.java) {
|
||||
return ChargepointListItemJsonAdapter(
|
||||
return if (Types.getRawType(type) == GEChargepointListItem::class.java) {
|
||||
ChargepointListItemJsonAdapter(
|
||||
moshi
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +96,7 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
|
||||
false -> null // Response was false
|
||||
else -> {
|
||||
if (this.clazz == GEFaultReport::class.java) {
|
||||
GEFaultReport(null, null) as T
|
||||
GEFaultReport(null, "") as T
|
||||
} else {
|
||||
throw IllegalStateException("Non-false boolean for @JsonObjectOrFalse field")
|
||||
}
|
||||
@@ -138,7 +139,12 @@ internal class HoursAdapter {
|
||||
val end = if (match.groupValues[2] == "24:00") {
|
||||
LocalTime.MAX
|
||||
} else {
|
||||
LocalTime.parse(match.groupValues[2])
|
||||
try {
|
||||
LocalTime.parse(match.groupValues[2])
|
||||
} catch (e: DateTimeParseException) {
|
||||
// got a rare bug report where the value is 24:0000
|
||||
LocalTime.MIN
|
||||
}
|
||||
}
|
||||
return GEHours(start, end)
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package net.vonforst.evmap.api.goingelectric
|
||||
|
||||
import android.content.Context
|
||||
import android.database.DatabaseUtils
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -11,9 +11,9 @@ import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.*
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.ui.cluster
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import net.vonforst.evmap.viewmodel.getClusterDistance
|
||||
import okhttp3.Cache
|
||||
@@ -23,6 +23,7 @@ import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.*
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
|
||||
interface GoingElectricApi {
|
||||
@FormUrlEncoded
|
||||
@@ -104,10 +105,10 @@ interface GoingElectricApi {
|
||||
chain.proceed(original)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(StethoInterceptor())
|
||||
addDebugInterceptors()
|
||||
}
|
||||
if (context != null) {
|
||||
cache(Cache(context.getCacheDir(), cacheSize))
|
||||
cache(Cache(context.cacheDir, cacheSize))
|
||||
}
|
||||
}.build()
|
||||
|
||||
@@ -126,18 +127,19 @@ class GoingElectricApiWrapper(
|
||||
baseurl: String = "https://api.goingelectric.de",
|
||||
context: Context? = null
|
||||
) : ChargepointApi<GEReferenceData> {
|
||||
private val clusterThreshold = 11f
|
||||
val api = GoingElectricApi.create(apikey, baseurl, context)
|
||||
|
||||
override val name = "GoingElectric.de"
|
||||
override val id = "going_electric"
|
||||
override val id = "goingelectric"
|
||||
override val cacheLimit = Duration.ofDays(1)
|
||||
|
||||
override suspend fun getChargepoints(
|
||||
referenceData: ReferenceData,
|
||||
bounds: LatLngBounds,
|
||||
zoom: Float,
|
||||
useClustering: Boolean,
|
||||
filters: FilterValues?
|
||||
): Resource<List<ChargepointListItem>> {
|
||||
): Resource<ChargepointList> {
|
||||
val freecharging = filters?.getBooleanValue("freecharging")
|
||||
val freeparking = filters?.getBooleanValue("freeparking")
|
||||
val open247 = filters?.getBooleanValue("open_247")
|
||||
@@ -146,36 +148,35 @@ class GoingElectricApiWrapper(
|
||||
val minPower = filters?.getSliderValue("min_power")
|
||||
val minConnectors = filters?.getSliderValue("min_connectors")
|
||||
|
||||
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
|
||||
// no connectors chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val connectors = formatMultipleChoice(connectorsVal)
|
||||
|
||||
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
|
||||
if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
|
||||
// no chargeCards chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val chargeCards = formatMultipleChoice(chargeCardsVal)
|
||||
|
||||
val networksVal = filters?.getMultipleChoiceValue("networks")
|
||||
if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) {
|
||||
// no networks chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val networks = formatMultipleChoice(networksVal)
|
||||
|
||||
val categoriesVal = filters?.getMultipleChoiceValue("categories")
|
||||
if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) {
|
||||
// no categories chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val categories = formatMultipleChoice(categoriesVal)
|
||||
|
||||
// do not use clustering if filters need to be applied locally.
|
||||
val useClustering = zoom < clusterThreshold
|
||||
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
|
||||
val useGeClustering = useClustering && geClusteringAvailable
|
||||
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
|
||||
@@ -217,9 +218,9 @@ class GoingElectricApiWrapper(
|
||||
}
|
||||
} while (startkey != null && startkey < 10000)
|
||||
|
||||
var result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
|
||||
val result = postprocessResult(data, filters)
|
||||
|
||||
return Resource.success(result)
|
||||
return Resource.success(ChargepointList(result, startkey == null))
|
||||
}
|
||||
|
||||
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
|
||||
@@ -230,8 +231,9 @@ class GoingElectricApiWrapper(
|
||||
location: LatLng,
|
||||
radius: Int,
|
||||
zoom: Float,
|
||||
useClustering: Boolean,
|
||||
filters: FilterValues?
|
||||
): Resource<List<ChargepointListItem>> {
|
||||
): Resource<ChargepointList> {
|
||||
val freecharging = filters?.getBooleanValue("freecharging")
|
||||
val freeparking = filters?.getBooleanValue("freeparking")
|
||||
val open247 = filters?.getBooleanValue("open_247")
|
||||
@@ -240,36 +242,35 @@ class GoingElectricApiWrapper(
|
||||
val minPower = filters?.getSliderValue("min_power")
|
||||
val minConnectors = filters?.getSliderValue("min_connectors")
|
||||
|
||||
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
|
||||
// no connectors chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val connectors = formatMultipleChoice(connectorsVal)
|
||||
|
||||
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
|
||||
if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
|
||||
// no chargeCards chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val chargeCards = formatMultipleChoice(chargeCardsVal)
|
||||
|
||||
val networksVal = filters?.getMultipleChoiceValue("networks")
|
||||
if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) {
|
||||
// no networks chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val networks = formatMultipleChoice(networksVal)
|
||||
|
||||
val categoriesVal = filters?.getMultipleChoiceValue("categories")
|
||||
if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) {
|
||||
// no categories chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val categories = formatMultipleChoice(categoriesVal)
|
||||
|
||||
// do not use clustering if filters need to be applied locally.
|
||||
val useClustering = zoom < clusterThreshold
|
||||
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
|
||||
val useGeClustering = useClustering && geClusteringAvailable
|
||||
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
|
||||
@@ -308,19 +309,24 @@ class GoingElectricApiWrapper(
|
||||
}
|
||||
} while (startkey != null && startkey < 10000)
|
||||
|
||||
val result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
|
||||
return Resource.success(result)
|
||||
val result = postprocessResult(data, filters)
|
||||
return Resource.success(ChargepointList(result, startkey == null))
|
||||
}
|
||||
|
||||
private fun postprocessResult(
|
||||
chargers: List<GEChargepointListItem>,
|
||||
minPower: Int?,
|
||||
connectorsVal: MultipleChoiceFilterValue?,
|
||||
minConnectors: Int?,
|
||||
zoom: Float
|
||||
filters: FilterValues?
|
||||
): List<ChargepointListItem> {
|
||||
// apply filters which GoingElectric does not support natively
|
||||
var result = chargers.filter { it ->
|
||||
val minPower = filters?.getSliderValue("min_power")
|
||||
val minConnectors = filters?.getSliderValue("min_connectors")
|
||||
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
val freecharging = filters?.getBooleanValue("freecharging")
|
||||
val freeparking = filters?.getBooleanValue("freeparking")
|
||||
val open247 = filters?.getBooleanValue("open_247")
|
||||
val barrierfree = filters?.getBooleanValue("barrierfree")
|
||||
|
||||
return chargers.filter { it ->
|
||||
// apply filters which GoingElectric does not support natively
|
||||
if (it is GEChargeLocation) {
|
||||
it.chargepoints
|
||||
.filter { it.power >= (minPower ?: 0) }
|
||||
@@ -329,19 +335,35 @@ class GoingElectricApiWrapper(
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}.map { it.convert(apikey, false) }
|
||||
|
||||
// apply clustering
|
||||
val useClustering = zoom < clusterThreshold
|
||||
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
|
||||
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
|
||||
if (!geClusteringAvailable && useClustering) {
|
||||
// apply local clustering if server side clustering is not available
|
||||
Dispatchers.IO.run {
|
||||
result = cluster(result, zoom, clusterDistance!!)
|
||||
}.map {
|
||||
// infer some properties based on applied filters
|
||||
if (it is GEChargeLocation) {
|
||||
var inferred = it
|
||||
if (freecharging == true) {
|
||||
inferred = inferred.copy(
|
||||
cost = inferred.cost?.copy(freecharging = true)
|
||||
?: GECost(freecharging = true)
|
||||
)
|
||||
}
|
||||
if (freeparking == true) {
|
||||
inferred = inferred.copy(
|
||||
cost = inferred.cost?.copy(freeparking = true) ?: GECost(freeparking = true)
|
||||
)
|
||||
}
|
||||
if (open247 == true) {
|
||||
inferred = inferred.copy(
|
||||
openinghours = inferred.openinghours?.copy(twentyfourSeven = true)
|
||||
?: GEOpeningHours(twentyfourSeven = true)
|
||||
)
|
||||
}
|
||||
if (barrierfree == true) {
|
||||
inferred = inferred.copy(barrierFree = true)
|
||||
}
|
||||
inferred
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
return result
|
||||
}.map { it.convert(apikey, false) }
|
||||
}
|
||||
|
||||
override suspend fun getChargepointDetail(
|
||||
@@ -404,11 +426,11 @@ class GoingElectricApiWrapper(
|
||||
val networks = refData.networks
|
||||
val chargeCards = refData.chargecards
|
||||
|
||||
val plugMap = plugs.map { plug ->
|
||||
plug to nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
|
||||
}.toMap()
|
||||
val networkMap = networks.map { it to it }.toMap()
|
||||
val chargecardMap = chargeCards.map { it.id.toString() to it.name }.toMap()
|
||||
val plugMap = plugs.associateWith { plug ->
|
||||
nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
|
||||
}
|
||||
val networkMap = networks.associateWith { it }
|
||||
val chargecardMap = chargeCards.associate { it.id.toString() to it.name }
|
||||
val categoryMap = mapOf(
|
||||
"Autohaus" to sp.getString(R.string.category_car_dealership),
|
||||
"Autobahnraststätte" to sp.getString(R.string.category_service_on_motorway),
|
||||
@@ -481,5 +503,98 @@ class GoingElectricApiWrapper(
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun convertFiltersToSQL(
|
||||
filters: FilterValues,
|
||||
referenceData: ReferenceData
|
||||
): FiltersSQLQuery {
|
||||
if (filters.isEmpty()) return FiltersSQLQuery("", false, false)
|
||||
var requiresChargepointQuery = false
|
||||
var requiresChargeCardQuery = false
|
||||
|
||||
val result = StringBuilder()
|
||||
if (filters.getBooleanValue("freecharging") == true) {
|
||||
result.append(" AND freecharging IS 1")
|
||||
}
|
||||
if (filters.getBooleanValue("freeparking") == true) {
|
||||
result.append(" AND freeparking IS 1")
|
||||
}
|
||||
if (filters.getBooleanValue("open_247") == true) {
|
||||
result.append(" AND twentyfourSeven IS 1")
|
||||
}
|
||||
if (filters.getBooleanValue("barrierfree") == true) {
|
||||
result.append(" AND barrierFree IS 1")
|
||||
}
|
||||
if (filters.getBooleanValue("exclude_faults") == true) {
|
||||
result.append(" AND fault_report_description IS NULL AND fault_report_created IS NULL")
|
||||
}
|
||||
|
||||
val minPower = filters.getSliderValue("min_power")
|
||||
if (minPower != null && minPower > 0) {
|
||||
result.append(" AND json_extract(cp.value, '$.power') >= ${minPower}")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
val connectors = filters.getMultipleChoiceValue("connectors")
|
||||
if (connectors != null && !connectors.all) {
|
||||
val connectorsList = if (connectors.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
connectors.values.joinToString(",") {
|
||||
DatabaseUtils.sqlEscapeString(
|
||||
GEChargepoint.convertTypeFromGE(
|
||||
it
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
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})")
|
||||
}
|
||||
|
||||
val chargecards = filters.getMultipleChoiceValue("chargecards")
|
||||
if (chargecards != null && !chargecards.all) {
|
||||
val chargecardsList = if (chargecards.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
chargecards.values.joinToString(",")
|
||||
}
|
||||
result.append(" AND json_extract(cc.value, '$.id') IN (${chargecardsList})")
|
||||
requiresChargeCardQuery = true
|
||||
}
|
||||
|
||||
val categories = filters.getMultipleChoiceValue("categories")
|
||||
if (categories != null && !categories.all) {
|
||||
throw NotImplementedError() // category cannot be determined in SQL
|
||||
}
|
||||
|
||||
|
||||
val minConnectors = filters.getSliderValue("min_connectors")
|
||||
if (minConnectors != null && minConnectors > 1) {
|
||||
result.append(" GROUP BY ChargeLocation.id HAVING COUNT(1) >= ${minConnectors}")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
return FiltersSQLQuery(result.toString(), requiresChargepointQuery, requiresChargeCardQuery)
|
||||
}
|
||||
|
||||
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
|
||||
val chargecards = filters.getMultipleChoiceValue("chargecards")
|
||||
return filters.getBooleanValue("freecharging") == true
|
||||
|| filters.getBooleanValue("freeparking") == true
|
||||
|| filters.getBooleanValue("open_247") == true
|
||||
|| filters.getBooleanValue("barrierfree") == true
|
||||
|| (chargecards != null && !chargecards.all)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ data class GEChargeLocation(
|
||||
cost?.convert(),
|
||||
null,
|
||||
ChargepriceData(address.country, network, chargepoints.map { it.type }),
|
||||
null,
|
||||
null,
|
||||
Instant.now(),
|
||||
isDetailed
|
||||
)
|
||||
@@ -84,10 +86,10 @@ data class GEChargeLocation(
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GECost(
|
||||
val freecharging: Boolean,
|
||||
val freeparking: Boolean,
|
||||
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
|
||||
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
|
||||
val freecharging: Boolean = false,
|
||||
val freeparking: Boolean = false,
|
||||
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String? = null,
|
||||
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String? = null
|
||||
) {
|
||||
fun convert() = Cost(
|
||||
// In GE, freecharging = false can either mean "paid charging" or "no information
|
||||
@@ -102,8 +104,8 @@ data class GECost(
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GEOpeningHours(
|
||||
@Json(name = "24/7") val twentyfourSeven: Boolean,
|
||||
@JsonObjectOrFalse val description: String?,
|
||||
val days: GEOpeningHoursDays?
|
||||
@JsonObjectOrFalse val description: String? = null,
|
||||
val days: GEOpeningHoursDays? = null
|
||||
) {
|
||||
fun convert() = OpeningHours(twentyfourSeven, description, days?.convert())
|
||||
}
|
||||
@@ -147,7 +149,7 @@ data class GEChargerPhoto(val id: String) {
|
||||
@JsonClass(generateAdapter = true)
|
||||
class GEChargerPhotoAdapter(override val id: String, val apikey: String) :
|
||||
ChargerPhoto(id) {
|
||||
override fun getUrl(height: Int?, width: Int?, size: Int?): String {
|
||||
override fun getUrl(height: Int?, width: Int?, size: Int?, allowOriginal: Boolean): String {
|
||||
return "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}&id=$id" +
|
||||
when {
|
||||
size != null -> "&size=$size"
|
||||
@@ -209,6 +211,7 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) {
|
||||
"Typ1" -> Chargepoint.TYPE_1
|
||||
"Typ2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
"Typ3" -> Chargepoint.TYPE_3
|
||||
"Tesla Supercharger CCS" -> Chargepoint.CCS_UNKNOWN
|
||||
"CCS" -> Chargepoint.CCS_UNKNOWN
|
||||
"Schuko" -> Chargepoint.SCHUKO
|
||||
"CHAdeMO" -> Chargepoint.CHADEMO
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
package net.vonforst.evmap.api.openchargemap
|
||||
|
||||
import android.content.Context
|
||||
import android.database.DatabaseUtils
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.*
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.ui.cluster
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import net.vonforst.evmap.viewmodel.getClusterDistance
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Response
|
||||
@@ -21,6 +19,9 @@ import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
|
||||
private const val maxResults = 3000
|
||||
|
||||
interface OpenChargeMapApi {
|
||||
@GET("poi/")
|
||||
@@ -30,7 +31,7 @@ interface OpenChargeMapApi {
|
||||
@Query("minpowerkw") minPower: Double? = null,
|
||||
@Query("operatorid") operators: String? = null,
|
||||
@Query("statustypeid") statusType: String? = null,
|
||||
@Query("maxresults") maxresults: Int = 500,
|
||||
@Query("maxresults") maxresults: Int = maxResults,
|
||||
@Query("compact") compact: Boolean = true,
|
||||
@Query("verbose") verbose: Boolean = false
|
||||
): Response<List<OCMChargepoint>>
|
||||
@@ -45,7 +46,7 @@ interface OpenChargeMapApi {
|
||||
@Query("minpowerkw") minPower: Double? = null,
|
||||
@Query("operatorid") operators: String? = null,
|
||||
@Query("statustypeid") statusType: String? = null,
|
||||
@Query("maxresults") maxresults: Int = 500,
|
||||
@Query("maxresults") maxresults: Int = maxResults,
|
||||
@Query("compact") compact: Boolean = true,
|
||||
@Query("verbose") verbose: Boolean = false
|
||||
): Response<List<OCMChargepoint>>
|
||||
@@ -83,7 +84,7 @@ interface OpenChargeMapApi {
|
||||
chain.proceed(new)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(StethoInterceptor())
|
||||
addDebugInterceptors()
|
||||
}
|
||||
if (context != null) {
|
||||
cache(Cache(context.cacheDir, cacheSize))
|
||||
@@ -105,11 +106,11 @@ class OpenChargeMapApiWrapper(
|
||||
baseurl: String = "https://api.openchargemap.io/v3/",
|
||||
context: Context? = null
|
||||
) : ChargepointApi<OCMReferenceData> {
|
||||
private val clusterThreshold = 11
|
||||
override val cacheLimit = Duration.ofDays(300L)
|
||||
val api = OpenChargeMapApi.create(apikey, baseurl, context)
|
||||
|
||||
override val name = "OpenChargeMap.org"
|
||||
override val id = "open_charge_map"
|
||||
override val id = "openchargemap"
|
||||
|
||||
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
|
||||
if (value == null || value.all) null else value.values.joinToString(",")
|
||||
@@ -118,8 +119,9 @@ class OpenChargeMapApiWrapper(
|
||||
referenceData: ReferenceData,
|
||||
bounds: LatLngBounds,
|
||||
zoom: Float,
|
||||
useClustering: Boolean,
|
||||
filters: FilterValues?,
|
||||
): Resource<List<ChargepointListItem>> {
|
||||
): Resource<ChargepointList> {
|
||||
val refData = referenceData as OCMReferenceData
|
||||
|
||||
val minPower = filters?.getSliderValue("min_power")?.toDouble()
|
||||
@@ -129,14 +131,14 @@ class OpenChargeMapApiWrapper(
|
||||
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
|
||||
// no connectors chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val connectors = formatMultipleChoice(connectorsVal)
|
||||
|
||||
val operatorsVal = filters?.getMultipleChoiceValue("operators")
|
||||
if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) {
|
||||
// no operators chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val operators = formatMultipleChoice(operatorsVal)
|
||||
|
||||
@@ -148,22 +150,22 @@ class OpenChargeMapApiWrapper(
|
||||
),
|
||||
minPower = minPower,
|
||||
plugs = connectors,
|
||||
operators = operators,
|
||||
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
|
||||
operators = operators
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
return Resource.error(response.message(), null)
|
||||
}
|
||||
|
||||
var result = postprocessResult(
|
||||
response.body()!!,
|
||||
val data = response.body()!!
|
||||
val result = postprocessResult(
|
||||
data,
|
||||
minPower,
|
||||
connectorsVal,
|
||||
minConnectors,
|
||||
refData,
|
||||
zoom
|
||||
excludeFaults,
|
||||
refData
|
||||
)
|
||||
return Resource.success(result)
|
||||
return Resource.success(ChargepointList(result, data.size < maxResults))
|
||||
} catch (e: IOException) {
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
@@ -174,8 +176,9 @@ class OpenChargeMapApiWrapper(
|
||||
location: LatLng,
|
||||
radius: Int,
|
||||
zoom: Float,
|
||||
useClustering: Boolean,
|
||||
filters: FilterValues?
|
||||
): Resource<List<ChargepointListItem>> {
|
||||
): Resource<ChargepointList> {
|
||||
val refData = referenceData as OCMReferenceData
|
||||
|
||||
val minPower = filters?.getSliderValue("min_power")?.toDouble()
|
||||
@@ -185,14 +188,14 @@ class OpenChargeMapApiWrapper(
|
||||
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
|
||||
// no connectors chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val connectors = formatMultipleChoice(connectorsVal)
|
||||
|
||||
val operatorsVal = filters?.getMultipleChoiceValue("operators")
|
||||
if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) {
|
||||
// no operators chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val operators = formatMultipleChoice(operatorsVal)
|
||||
|
||||
@@ -202,22 +205,22 @@ class OpenChargeMapApiWrapper(
|
||||
radius.toDouble(),
|
||||
minPower = minPower,
|
||||
plugs = connectors,
|
||||
operators = operators,
|
||||
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
|
||||
operators = operators
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
return Resource.error(response.message(), null)
|
||||
}
|
||||
|
||||
val data = response.body()!!
|
||||
val result = postprocessResult(
|
||||
response.body()!!,
|
||||
data,
|
||||
minPower,
|
||||
connectorsVal,
|
||||
minConnectors,
|
||||
refData,
|
||||
zoom
|
||||
excludeFaults,
|
||||
refData
|
||||
)
|
||||
return Resource.success(result)
|
||||
return Resource.success(ChargepointList(result, data.size < 499))
|
||||
} catch (e: IOException) {
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
@@ -228,26 +231,18 @@ class OpenChargeMapApiWrapper(
|
||||
minPower: Double?,
|
||||
connectorsVal: MultipleChoiceFilterValue?,
|
||||
minConnectors: Int?,
|
||||
referenceData: OCMReferenceData,
|
||||
zoom: Float
|
||||
excludeFaults: Boolean?,
|
||||
referenceData: OCMReferenceData
|
||||
): List<ChargepointListItem> {
|
||||
// apply filters which OCM does not support natively
|
||||
var result = chargers.filter { it ->
|
||||
return chargers.filter { it ->
|
||||
it.connections
|
||||
.filter { it.power == null || it.power >= (minPower ?: 0.0) }
|
||||
.filter { if (connectorsVal != null && !connectorsVal.all) it.connectionTypeId in connectorsVal.values.map { it.toLong() } else true }
|
||||
.sumOf { it.quantity ?: 1 } >= (minConnectors ?: 0)
|
||||
}.map { it.convert(referenceData, false) }.distinct() as List<ChargepointListItem>
|
||||
|
||||
// apply clustering
|
||||
val useClustering = zoom < clusterThreshold
|
||||
if (useClustering) {
|
||||
val clusterDistance = getClusterDistance(zoom)
|
||||
Dispatchers.IO.run {
|
||||
result = cluster(result, zoom, clusterDistance!!)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}.filter {
|
||||
it.statusTypeId == null || (it.statusTypeId !in removedStatuses && if (excludeFaults == true) it.statusTypeId !in faultStatuses else true)
|
||||
}.map { it.convert(referenceData, false) }.distinct()
|
||||
}
|
||||
|
||||
override suspend fun getChargepointDetail(
|
||||
@@ -286,8 +281,8 @@ class OpenChargeMapApiWrapper(
|
||||
): List<Filter<FilterValue>> {
|
||||
val refData = referenceData as OCMReferenceData
|
||||
|
||||
val operatorsMap = refData.operators.map { it.id.toString() to it.title }.toMap()
|
||||
val plugMap = refData.connectionTypes.map { it.id.toString() to it.title }.toMap()
|
||||
val operatorsMap = refData.operators.associate { it.id.toString() to it.title }
|
||||
val plugMap = refData.connectionTypes.associate { it.id.toString() to it.title }
|
||||
|
||||
return listOf(
|
||||
// supported by OCM API
|
||||
@@ -327,4 +322,70 @@ class OpenChargeMapApiWrapper(
|
||||
)
|
||||
}
|
||||
|
||||
override fun convertFiltersToSQL(
|
||||
filters: FilterValues,
|
||||
referenceData: ReferenceData
|
||||
): FiltersSQLQuery {
|
||||
if (filters.isEmpty()) return FiltersSQLQuery("", false, false)
|
||||
|
||||
val refData = referenceData as OCMReferenceData
|
||||
var requiresChargepointQuery = false
|
||||
|
||||
val result = StringBuilder()
|
||||
|
||||
if (filters.getBooleanValue("exclude_faults") == true) {
|
||||
result.append(" AND fault_report_description IS NULL AND fault_report_created IS NULL")
|
||||
}
|
||||
|
||||
val minPower = filters.getSliderValue("min_power")
|
||||
if (minPower != null && minPower > 0) {
|
||||
result.append(" AND json_extract(cp.value, '$.power') >= ${minPower}")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
val connectors = filters.getMultipleChoiceValue("connectors")
|
||||
if (connectors != null && !connectors.all) {
|
||||
val connectorsList = if (connectors.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
connectors.values.joinToString(",") {
|
||||
DatabaseUtils.sqlEscapeString(
|
||||
OCMConnection.convertConnectionTypeFromOCM(
|
||||
it.toLong(),
|
||||
refData
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
val operators = filters.getMultipleChoiceValue("operators")
|
||||
if (operators != null && !operators.all) {
|
||||
val networksList = if (operators.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
operators.values.joinToString(",") { opId ->
|
||||
DatabaseUtils.sqlEscapeString(refData.operators.find { it.id == opId.toLong() }?.title.orEmpty())
|
||||
}
|
||||
}
|
||||
result.append(" AND network IN (${networksList})")
|
||||
}
|
||||
|
||||
val minConnectors = filters.getSliderValue("min_connectors")
|
||||
if (minConnectors != null && minConnectors > 1) {
|
||||
result.append(" GROUP BY ChargeLocation.id HAVING COUNT(1) >= ${minConnectors}")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
return FiltersSQLQuery(result.toString(), requiresChargepointQuery, false)
|
||||
}
|
||||
|
||||
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
|
||||
val operators = filters.getMultipleChoiceValue("operators")
|
||||
return (operators != null && !operators.all)
|
||||
// TODO: it would be possible to implement this without requiring details if we extended the data structure to also save the operator ID in the DB
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,12 +11,15 @@ import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
// Unknown, Currently Available, Currently In Use, Operational
|
||||
val noFaultStatuses = listOf(0, 10, 20, 50)
|
||||
val noFaultStatuses = listOf(0L, 10L, 20L, 50L)
|
||||
|
||||
// Temporarily Unavailable, Partly Operational, Not Operational, Planned For Future Date, Removed (Decommissioned)
|
||||
val faultStatuses = listOf(30L, 75L, 100L, 150L, 200L)
|
||||
// Temporarily Unavailable, Partly Operational, Not Operational, Planned For Future Date
|
||||
val faultStatuses = listOf(30L, 75L, 100L, 150L)
|
||||
val faultReportCommentType = 1000L
|
||||
|
||||
// Removed (Decommissioned), Removed (Duplicate Listing)
|
||||
val removedStatuses = listOf(200L, 210L)
|
||||
|
||||
data class OCMBoundingBox(
|
||||
val sw_lat: Double, val sw_lng: Double,
|
||||
val ne_lat: Double, val ne_lng: Double
|
||||
@@ -71,10 +74,16 @@ data class OCMChargepoint(
|
||||
addressInfo.countryISOCode(refData),
|
||||
operatorId?.toString(),
|
||||
connections.map { "${it.connectionTypeId},${it.currentTypeId}" }),
|
||||
operatorInfo?.websiteUrl,
|
||||
if (operatorInfo?.websiteUrl?.withoutTrailingSlash() != addressInfo.relatedUrl?.withoutTrailingSlash()) addressInfo.relatedUrl else null,
|
||||
Instant.now(),
|
||||
isDetailed
|
||||
)
|
||||
|
||||
private fun String.withoutTrailingSlash(): String {
|
||||
return this.replace(Regex("/$"), "")
|
||||
}
|
||||
|
||||
private fun convertFaultReport(): FaultReport? {
|
||||
if (statusTypeId in faultStatuses || connections.any { it.statusTypeId in faultStatuses }) {
|
||||
if (userComments != null) {
|
||||
@@ -92,7 +101,7 @@ data class OCMChargepoint(
|
||||
connections.first { it.statusType != null && it.statusTypeId in faultStatuses }.statusType!!.title
|
||||
)
|
||||
}
|
||||
return FaultReport(null, null)
|
||||
return FaultReport(null, "")
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
@@ -251,14 +260,13 @@ class OCMChargerPhotoAdapter(
|
||||
val largeUrl: String,
|
||||
val thumbUrl: String
|
||||
) : ChargerPhoto(id) {
|
||||
override fun getUrl(height: Int?, width: Int?, size: Int?): String {
|
||||
override fun getUrl(height: Int?, width: Int?, size: Int?, allowOriginal: Boolean): String {
|
||||
val maxSize = size ?: max(height, width)
|
||||
val mediumUrl = thumbUrl.replace(".thmb.", ".medi.")
|
||||
return when (maxSize) {
|
||||
0 -> mediumUrl
|
||||
in 1..100 -> thumbUrl
|
||||
in 0..100 -> thumbUrl
|
||||
in 101..400 -> mediumUrl
|
||||
else -> largeUrl
|
||||
else -> if (allowOriginal) largeUrl else mediumUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ data class OSMChargingStation(
|
||||
getCost(),
|
||||
"© OpenStreetMap contributors",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
dataFetchTimestamp,
|
||||
true,
|
||||
)
|
||||
@@ -118,7 +120,7 @@ data class OSMChargingStation(
|
||||
// If that is missing as well, use a generic "Charging Station" string.
|
||||
return tags["name"]
|
||||
?: tags["operator"]
|
||||
?: "Charging Station";
|
||||
?: "Charging Station"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,7 +193,7 @@ data class OSMChargingStation(
|
||||
*/
|
||||
fun parseOutputPower(rawOutput: String?): Double? {
|
||||
if (rawOutput == null) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
val pattern = Regex("([0-9.,]+)\\s*(kW|kVA)", setOf(RegexOption.IGNORE_CASE))
|
||||
val matchResult = pattern.matchEntire(rawOutput) ?: return null
|
||||
|
||||
@@ -176,10 +176,10 @@ enum class AutocompletePlaceType {
|
||||
|
||||
companion object {
|
||||
fun valueOfOrNull(value: String): AutocompletePlaceType? {
|
||||
try {
|
||||
return valueOf(value)
|
||||
return try {
|
||||
valueOf(value)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return null
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
||||
override fun getAttributionString(): Int = R.string.powered_by_mapbox
|
||||
|
||||
override fun getAttributionImage(dark: Boolean): Int =
|
||||
if (dark) R.drawable.mapbox_logo_icon else R.drawable.mapbox_logo
|
||||
if (dark) com.mapbox.mapboxsdk.R.drawable.mapbox_logo_icon else R.drawable.mapbox_logo
|
||||
}
|
||||
|
||||
private fun BoundingBox.toLatLngBounds(): LatLngBounds {
|
||||
|
||||
@@ -123,12 +123,12 @@ class ChargepriceFragment : Fragment() {
|
||||
val charger = fragmentArgs.charger
|
||||
vm.charger.value = charger
|
||||
if (vm.chargepoint.value == null) {
|
||||
vm.chargepoint.value = charger.chargepointsMerged.get(0)
|
||||
vm.chargepoint.value = charger.chargepointsMerged[0]
|
||||
}
|
||||
|
||||
val vehicleAdapter = CheckableChargepriceCarAdapter()
|
||||
headerBinding.vehicleSelection.adapter = vehicleAdapter
|
||||
val vehicleObserver: Observer<ChargepriceCar> = Observer {
|
||||
val vehicleObserver: Observer<ChargepriceCar?> = Observer {
|
||||
vehicleAdapter.setCheckedItem(it)
|
||||
}
|
||||
vm.vehicle.observe(viewLifecycleOwner, vehicleObserver)
|
||||
@@ -172,7 +172,7 @@ class ChargepriceFragment : Fragment() {
|
||||
|
||||
val connectorsAdapter = CheckableConnectorAdapter()
|
||||
|
||||
val observer: Observer<Chargepoint> = Observer {
|
||||
val observer: Observer<Chargepoint?> = Observer {
|
||||
connectorsAdapter.setCheckedItem(it)
|
||||
}
|
||||
vm.chargepoint.observe(viewLifecycleOwner, observer)
|
||||
|
||||
@@ -80,8 +80,8 @@ class FilterProfilesFragment : Fragment() {
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val fromPos = viewHolder.bindingAdapterPosition;
|
||||
val toPos = target.bindingAdapterPosition;
|
||||
val fromPos = viewHolder.bindingAdapterPosition
|
||||
val toPos = target.bindingAdapterPosition
|
||||
|
||||
val list = vm.filterProfiles.value?.toMutableList()
|
||||
if (list != null) {
|
||||
|
||||
@@ -40,8 +40,6 @@ import androidx.transition.TransitionInflater
|
||||
import androidx.transition.TransitionManager
|
||||
import coil.load
|
||||
import coil.memory.MemoryCache
|
||||
import coil.size.OriginalSize
|
||||
import coil.size.SizeResolver
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.MapFragment
|
||||
import com.car2go.maps.OnMapReadyCallback
|
||||
@@ -75,6 +73,7 @@ import net.vonforst.evmap.autocomplete.ApiUnavailableException
|
||||
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
||||
import net.vonforst.evmap.bold
|
||||
import net.vonforst.evmap.databinding.FragmentMapBinding
|
||||
import net.vonforst.evmap.fragment.preference.DataSettingsFragmentArgs
|
||||
import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.location.Priority
|
||||
@@ -130,16 +129,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
|
||||
val state = bottomSheetBehavior.state
|
||||
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
|
||||
if (bottomSheetCollapsible) {
|
||||
when (state) {
|
||||
STATE_COLLAPSED -> vm.chargerSparse.value = null
|
||||
STATE_HIDDEN -> vm.searchResult.value = null
|
||||
else -> if (bottomSheetCollapsible) {
|
||||
bottomSheetBehavior.state = STATE_COLLAPSED
|
||||
} else {
|
||||
vm.chargerSparse.value = null
|
||||
}
|
||||
} else if (state == STATE_COLLAPSED) {
|
||||
vm.chargerSparse.value = null
|
||||
} else if (state == STATE_HIDDEN) {
|
||||
vm.searchResult.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +161,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
|
||||
println(binding.detailView.sourceButton)
|
||||
binding.lifecycleOwner = this
|
||||
binding.vm = vm
|
||||
|
||||
@@ -248,7 +246,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
mapFragment!!.getMapAsync(this)
|
||||
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
|
||||
bottomSheetBehavior = from(binding.bottomSheet)
|
||||
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
|
||||
|
||||
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
|
||||
@@ -377,6 +375,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
null, extras
|
||||
)
|
||||
}
|
||||
binding.detailView.btnChargerWebsite.setOnClickListener {
|
||||
val charger = vm.charger.value?.data ?: return@setOnClickListener
|
||||
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
|
||||
}
|
||||
binding.detailView.btnLogin.setOnClickListener {
|
||||
findNavController().navigate(
|
||||
R.id.settings_data,
|
||||
DataSettingsFragmentArgs(true).toBundle()
|
||||
)
|
||||
}
|
||||
binding.detailView.imgPredictionSource.setOnClickListener {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url))
|
||||
}
|
||||
@@ -387,7 +395,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
.show()
|
||||
}
|
||||
binding.detailView.topPart.setOnClickListener {
|
||||
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
|
||||
bottomSheetBehavior.state = STATE_ANCHOR_POINT
|
||||
}
|
||||
setupSearchAutocomplete()
|
||||
binding.detailAppBar.toolbar.setNavigationOnClickListener {
|
||||
@@ -414,7 +422,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val charger = vm.charger.value?.data
|
||||
if (charger?.editUrl != null) {
|
||||
(activity as? MapsActivity)?.openUrl(charger.editUrl)
|
||||
if (vm.apiId.value == "going_electric") {
|
||||
if (vm.apiId.value == "goingelectric") {
|
||||
// instructions specific to GoingElectric
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
@@ -552,7 +560,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
private fun setupObservers() {
|
||||
bottomSheetBehavior.addBottomSheetCallback(object :
|
||||
BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback() {
|
||||
BottomSheetCallback() {
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
if (bottomSheetBehavior.state == STATE_HIDDEN) {
|
||||
map?.setPadding(0, mapTopPadding, 0, 0)
|
||||
@@ -581,9 +589,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
})
|
||||
vm.chargerSparse.observe(viewLifecycleOwner, Observer {
|
||||
vm.chargerSparse.observe(viewLifecycleOwner) {
|
||||
if (it != null) {
|
||||
if (vm.bottomSheetState.value != BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT) {
|
||||
if (vm.bottomSheetState.value != STATE_ANCHOR_POINT) {
|
||||
bottomSheetBehavior.state =
|
||||
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_ANCHOR_POINT
|
||||
}
|
||||
@@ -596,8 +604,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
bottomSheetBehavior.state = STATE_HIDDEN
|
||||
unhighlightAllMarkers()
|
||||
}
|
||||
})
|
||||
}
|
||||
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
|
||||
val chargepoints = res.data
|
||||
if (chargepoints != null) {
|
||||
updateMap(chargepoints)
|
||||
}
|
||||
when (res.status) {
|
||||
Status.ERROR -> {
|
||||
val view = view ?: return@Observer
|
||||
@@ -617,32 +629,27 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
Status.LOADING -> {
|
||||
}
|
||||
}
|
||||
|
||||
val chargepoints = res.data
|
||||
if (chargepoints != null) {
|
||||
updateMap(chargepoints)
|
||||
}
|
||||
})
|
||||
vm.useMiniMarkers.observe(viewLifecycleOwner) {
|
||||
vm.chargepoints.value?.data?.let { updateMap(it) }
|
||||
}
|
||||
vm.favorites.observe(viewLifecycleOwner, Observer {
|
||||
vm.favorites.observe(viewLifecycleOwner) {
|
||||
updateFavoriteToggle()
|
||||
})
|
||||
vm.searchResult.observe(viewLifecycleOwner, Observer { place ->
|
||||
}
|
||||
vm.searchResult.observe(viewLifecycleOwner) { place ->
|
||||
displaySearchResult(place, moveCamera = true)
|
||||
})
|
||||
vm.layersMenuOpen.observe(viewLifecycleOwner, Observer { open ->
|
||||
}
|
||||
vm.layersMenuOpen.observe(viewLifecycleOwner) { open ->
|
||||
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
|
||||
binding.layersSheet.visibility = if (open) View.VISIBLE else View.INVISIBLE
|
||||
updateBackPressedCallback()
|
||||
})
|
||||
vm.mapType.observe(viewLifecycleOwner, Observer {
|
||||
}
|
||||
vm.mapType.observe(viewLifecycleOwner) {
|
||||
map?.setMapType(it)
|
||||
})
|
||||
vm.mapTrafficEnabled.observe(viewLifecycleOwner, Observer {
|
||||
}
|
||||
vm.mapTrafficEnabled.observe(viewLifecycleOwner) {
|
||||
map?.setTrafficEnabled(it)
|
||||
})
|
||||
}
|
||||
|
||||
updateBackPressedCallback()
|
||||
}
|
||||
@@ -697,7 +704,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = c.isMulti(vm.filteredConnectors.value),
|
||||
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
|
||||
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
@@ -713,7 +720,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = true,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
|
||||
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
@@ -728,7 +735,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = c.isMulti(vm.filteredConnectors.value),
|
||||
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
|
||||
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
@@ -753,12 +760,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val photos = vm.charger.value?.data?.photos ?: return
|
||||
|
||||
viewer = StfalconImageViewer.Builder(context, photos) { imageView, photo ->
|
||||
imageView.load(photo.getUrl(size = 1000)) {
|
||||
imageView.load(photo.getUrl(size = 1000, allowOriginal = true)) {
|
||||
if (photo == photos[position] && imageCacheKey != null) {
|
||||
placeholderMemoryCacheKey(imageCacheKey)
|
||||
}
|
||||
size(SizeResolver(OriginalSize))
|
||||
allowHardware(false)
|
||||
}
|
||||
}
|
||||
.withTransitionFrom(view as ImageView)
|
||||
@@ -810,6 +815,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
R.drawable.ic_payment -> {
|
||||
showPaymentMethodsDialog(charger)
|
||||
}
|
||||
R.drawable.ic_network -> {
|
||||
charger.networkUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -852,6 +860,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
override fun onMapReady(map: AnyMap) {
|
||||
this.map = map
|
||||
vm.mapProjection = map.projection
|
||||
val context = this.context ?: return
|
||||
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
|
||||
|
||||
@@ -876,16 +885,39 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
map.uiSettings.setIndoorLevelPickerEnabled(false)
|
||||
|
||||
map.setOnCameraIdleListener {
|
||||
vm.mapProjection = map.projection
|
||||
vm.mapPosition.value = MapPosition(
|
||||
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
||||
)
|
||||
vm.reloadChargepoints()
|
||||
}
|
||||
map.setOnCameraMoveListener {
|
||||
vm.mapProjection = map.projection
|
||||
vm.mapPosition.value = MapPosition(
|
||||
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
||||
)
|
||||
}
|
||||
|
||||
binding.scaleView.apply {
|
||||
when (prefs.mapScale) {
|
||||
"both" -> {
|
||||
visibility = View.VISIBLE
|
||||
metersAndMiles()
|
||||
}
|
||||
|
||||
"meters" -> {
|
||||
visibility = View.VISIBLE
|
||||
metersOnly()
|
||||
}
|
||||
|
||||
"miles" -> {
|
||||
visibility = View.VISIBLE
|
||||
milesOnly()
|
||||
}
|
||||
|
||||
"off" -> visibility = View.GONE
|
||||
}
|
||||
}
|
||||
vm.mapPosition.observe(viewLifecycleOwner) {
|
||||
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
|
||||
}
|
||||
@@ -958,11 +990,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
vm.loadChargerById(chargerId)
|
||||
vm.chargerSparse.observe(
|
||||
viewLifecycleOwner,
|
||||
object : Observer<ChargeLocation> {
|
||||
override fun onChanged(item: ChargeLocation?) {
|
||||
if (item?.id == chargerId) {
|
||||
object : Observer<ChargeLocation?> {
|
||||
override fun onChanged(value: ChargeLocation?) {
|
||||
if (value?.id == chargerId) {
|
||||
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(
|
||||
LatLng(item.coordinates.lat, item.coordinates.lng), 16f
|
||||
LatLng(value.coordinates.lat, value.coordinates.lng), 16f
|
||||
)
|
||||
map.moveCamera(cameraUpdate)
|
||||
vm.chargerSparse.removeObserver(this)
|
||||
@@ -981,9 +1013,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
vm.chargepoints.observe(
|
||||
viewLifecycleOwner,
|
||||
object : Observer<Resource<List<ChargepointListItem>>> {
|
||||
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
|
||||
if (res.data == null) return
|
||||
for (item in res.data) {
|
||||
override fun onChanged(value: Resource<List<ChargepointListItem>>) {
|
||||
if (value.data == null) return
|
||||
for (item in value.data) {
|
||||
if (item is ChargeLocation && item.id == chargerId) {
|
||||
vm.chargerSparse.value = item
|
||||
vm.chargepoints.removeObserver(this)
|
||||
@@ -994,44 +1026,26 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
} else {
|
||||
// mark location as search result
|
||||
vm.searchResult.value = PlaceWithBounds(latLng, boundingBox(latLng, 750.0))
|
||||
locationName?.let { binding.search.setText(it) }
|
||||
}
|
||||
|
||||
positionSet = true
|
||||
} else if (locationName != null) {
|
||||
lifecycleScope.launch {
|
||||
val address = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Geocoder(requireContext()).getFromLocationName(locationName, 1)
|
||||
?.getOrNull(0)
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
address?.let {
|
||||
val latLng = LatLng(it.latitude, it.longitude)
|
||||
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(latLng, 16f)
|
||||
map.moveCamera(cameraUpdate)
|
||||
val bboxSize = if (it.subAdminArea != null) {
|
||||
750.0 // this is a place within a city
|
||||
} else if (it.adminArea != null && it.adminArea != it.featureName) {
|
||||
4000.0 // this is a city
|
||||
} else if (it.adminArea != null) {
|
||||
100000.0 // this is a top-level administrative area (i.e. state)
|
||||
} else {
|
||||
500000.0 // this is a country
|
||||
}
|
||||
vm.searchResult.value = PlaceWithBounds(latLng, boundingBox(latLng, bboxSize))
|
||||
}
|
||||
}
|
||||
binding.search.setText(locationName)
|
||||
binding.search.requestFocus()
|
||||
binding.search.setSelection(locationName.length)
|
||||
}
|
||||
if (context.checkAnyLocationPermission()) {
|
||||
enableLocation(!positionSet, false)
|
||||
positionSet = true
|
||||
}
|
||||
if (!positionSet) {
|
||||
// center the camera on Europe
|
||||
// use position saved in preferences, fall back to default (Europe)
|
||||
val cameraUpdate =
|
||||
map.cameraUpdateFactory.newLatLngZoom(LatLng(50.113388, 9.252536), 3.5f)
|
||||
map.cameraUpdateFactory.newLatLngZoom(
|
||||
prefs.currentMapLocation,
|
||||
prefs.currentMapZoom
|
||||
)
|
||||
map.moveCamera(cameraUpdate)
|
||||
}
|
||||
|
||||
@@ -1091,7 +1105,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
highlight = highlight,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
|
||||
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
@@ -1112,7 +1126,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val fault = charger.faultReport != null
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
val fav =
|
||||
charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
|
||||
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
|
||||
animator.animateMarkerDisappear(
|
||||
marker, tint, highlight, fault, multi, fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
@@ -1131,7 +1145,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
val fault = charger.faultReport != null
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
val fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
|
||||
val fav =
|
||||
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
|
||||
val marker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
|
||||
@@ -1185,13 +1200,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val filterBadge = filterView?.findViewById<TextView>(R.id.filter_badge)
|
||||
if (filterBadge != null) {
|
||||
// set up badge showing number of active filters
|
||||
vm.filtersCount.observe(viewLifecycleOwner, Observer {
|
||||
vm.filtersCount.observe(viewLifecycleOwner) {
|
||||
filterBadge.visibility = if (it > 0) View.VISIBLE else View.GONE
|
||||
filterBadge.text = it.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
filterView?.setOnClickListener {
|
||||
var profilesMap: MutableBiMap<Long, MenuItem> = HashBiMap()
|
||||
val profilesMap: MutableBiMap<Long, MenuItem> = HashBiMap()
|
||||
|
||||
val popup = PopupMenu(
|
||||
ContextThemeWrapper(requireContext(), R.style.RoundedPopup),
|
||||
@@ -1232,7 +1247,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
|
||||
vm.filterProfiles.observe(viewLifecycleOwner, { profiles ->
|
||||
vm.filterProfiles.observe(viewLifecycleOwner) { profiles ->
|
||||
popup.menu.removeGroup(R.id.menu_group_filter_profiles)
|
||||
|
||||
val noFiltersItem = popup.menu.add(
|
||||
@@ -1262,25 +1277,28 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
profilesMap[FILTERS_CUSTOM] = customItem
|
||||
profilesMap[FILTERS_FAVORITES] = favoritesItem
|
||||
|
||||
popup.menu.setGroupCheckable(R.id.menu_group_filter_profiles, true, true);
|
||||
popup.menu.setGroupCheckable(R.id.menu_group_filter_profiles, true, true)
|
||||
|
||||
val manageFiltersItem = popup.menu.findItem(R.id.menu_manage_filter_profiles)
|
||||
manageFiltersItem.isVisible = profiles.isNotEmpty()
|
||||
|
||||
vm.filterStatus.observe(viewLifecycleOwner, Observer { id ->
|
||||
vm.filterStatus.observe(viewLifecycleOwner) { id ->
|
||||
when (id) {
|
||||
FILTERS_DISABLED -> {
|
||||
customItem.isVisible = false
|
||||
noFiltersItem.isChecked = true
|
||||
}
|
||||
|
||||
FILTERS_CUSTOM -> {
|
||||
customItem.isVisible = true
|
||||
customItem.isChecked = true
|
||||
}
|
||||
|
||||
FILTERS_FAVORITES -> {
|
||||
customItem.isVisible = false
|
||||
favoritesItem.isChecked = true
|
||||
}
|
||||
|
||||
else -> {
|
||||
customItem.isVisible = false
|
||||
val item = profilesMap[id]
|
||||
@@ -1290,8 +1308,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
// else unknown ID -> wait for filterProfiles to update
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
popup.setTouchModal(false)
|
||||
popup.show()
|
||||
}
|
||||
@@ -1365,6 +1383,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
removeLocationUpdates()
|
||||
vm.mapPosition.value?.let {
|
||||
prefs.currentMapLocation = it.bounds.center
|
||||
prefs.currentMapZoom = it.zoom
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -17,7 +17,7 @@ class MultiSelectDialog : MaterialDialogFragment() {
|
||||
companion object {
|
||||
fun getInstance(
|
||||
title: String,
|
||||
data: Map<String, String>,
|
||||
data: Map<String, CharSequence>,
|
||||
selected: Set<String>,
|
||||
commonChoices: Set<String>?,
|
||||
showAllButton: Boolean = true
|
||||
@@ -55,7 +55,7 @@ class MultiSelectDialog : MaterialDialogFragment() {
|
||||
|
||||
override fun initView(view: View, savedInstanceState: Bundle?) {
|
||||
val args = requireArguments()
|
||||
val data = args.getSerializable("data") as HashMap<String, String>
|
||||
val data = args.getSerializable("data") as HashMap<String, CharSequence>
|
||||
val selected = args.getSerializable("selected") as HashSet<String>
|
||||
val title = args.getString("title")
|
||||
val commonChoices = if (args.containsKey("commonChoices")) {
|
||||
@@ -71,7 +71,7 @@ class MultiSelectDialog : MaterialDialogFragment() {
|
||||
binding.btnAll.visibility = if (showAllButton) View.VISIBLE else View.INVISIBLE
|
||||
|
||||
items = data.entries.toList()
|
||||
.sortedBy { it.value.lowercase(Locale.getDefault()) }
|
||||
.sortedBy { it.value.toString().lowercase(Locale.getDefault()) }
|
||||
.sortedBy {
|
||||
when {
|
||||
selected.contains(it.key) && commonChoices?.contains(it.key) == true -> 0
|
||||
@@ -117,7 +117,7 @@ private fun search(
|
||||
): List<MultiSelectItem> {
|
||||
return items.filter { item ->
|
||||
// search for string within name
|
||||
text.lowercase(Locale.getDefault()) in item.name.lowercase(Locale.getDefault())
|
||||
text.lowercase(Locale.getDefault()) in item.name.toString().lowercase(Locale.getDefault())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,4 +125,5 @@ class Adapter() : DataBindingAdapter<MultiSelectItem>({ it.key }) {
|
||||
override fun getItemViewType(position: Int) = R.layout.dialog_multi_select_item
|
||||
}
|
||||
|
||||
data class MultiSelectItem(val key: String, val name: String, var selected: Boolean) : Equatable
|
||||
data class MultiSelectItem(val key: String, val name: CharSequence, var selected: Boolean) :
|
||||
Equatable
|
||||
@@ -0,0 +1,91 @@
|
||||
package net.vonforst.evmap.fragment.oauth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
class OAuthLoginFragment : Fragment() {
|
||||
private lateinit var webView: WebView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View = inflater.inflate(R.layout.fragment_oauth_login, container, false)
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||
toolbar.setupWithNavController(
|
||||
findNavController(),
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
|
||||
val uri = Uri.parse(args.url)
|
||||
|
||||
webView = view.findViewById(R.id.webView)
|
||||
|
||||
args.color?.let { webView.setBackgroundColor(Color.parseColor(it)) }
|
||||
val progress = view.findViewById<LinearProgressIndicator>(R.id.progress_indicator)
|
||||
|
||||
CookieManager.getInstance().removeAllCookies(null)
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): Boolean {
|
||||
val url = request.url
|
||||
|
||||
if (url.toString().startsWith(args.resultUrlPrefix)) {
|
||||
val result = Bundle()
|
||||
result.putString("url", url.toString())
|
||||
setFragmentResult(args.url, result)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
return url.host != uri.host
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
progress.show()
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
progress.hide()
|
||||
webView.background = null
|
||||
}
|
||||
}
|
||||
webView.settings.javaScriptEnabled = true
|
||||
webView.loadUrl(args.url)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package net.vonforst.evmap.fragment.preference
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
@@ -15,9 +16,13 @@ import com.mikepenz.aboutlibraries.LibsBuilder
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
|
||||
|
||||
class AboutFragment : PreferenceFragmentCompat() {
|
||||
private lateinit var prefs: PreferenceDataSource
|
||||
private var developerOptionsCounter = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialFadeThrough()
|
||||
@@ -33,6 +38,8 @@ class AboutFragment : PreferenceFragmentCompat() {
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
prefs = PreferenceDataSource(requireContext())
|
||||
|
||||
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
|
||||
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
||||
}
|
||||
@@ -45,6 +52,21 @@ class AboutFragment : PreferenceFragmentCompat() {
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
"version" -> {
|
||||
if (!prefs.developerModeEnabled) {
|
||||
developerOptionsCounter += 1
|
||||
if (developerOptionsCounter >= 7) {
|
||||
prefs.developerModeEnabled = true
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
getString(R.string.developer_mode_enabled),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
"contributors" -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.about_contributors)
|
||||
@@ -53,14 +75,22 @@ class AboutFragment : PreferenceFragmentCompat() {
|
||||
.show()
|
||||
true
|
||||
}
|
||||
|
||||
"website" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.website_url))
|
||||
true
|
||||
}
|
||||
|
||||
"github_link" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link))
|
||||
true
|
||||
}
|
||||
|
||||
"privacy" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.privacy_link))
|
||||
true
|
||||
}
|
||||
|
||||
"faq" -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link))
|
||||
true
|
||||
|
||||
@@ -12,14 +12,18 @@ import com.google.android.material.transition.MaterialFadeThrough
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
|
||||
abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
protected lateinit var prefs: PreferenceDataSource
|
||||
protected lateinit var encryptedPrefs: EncryptedPreferenceDataStore
|
||||
protected abstract val isTopLevel: Boolean
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
prefs = PreferenceDataSource(requireContext())
|
||||
encryptedPrefs = EncryptedPreferenceDataStore(requireContext())
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (isTopLevel) {
|
||||
@@ -40,8 +44,6 @@ abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
prefs = PreferenceDataSource(requireContext())
|
||||
|
||||
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
|
||||
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ package net.vonforst.evmap.fragment.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.ui.MultiSelectDialogPreference
|
||||
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
|
||||
|
||||
class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
||||
override val isTopLevel = false
|
||||
|
||||
@@ -22,8 +26,8 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
||||
}
|
||||
})
|
||||
|
||||
private lateinit var myVehiclePreference: MultiSelectListPreference
|
||||
private lateinit var myTariffsPreference: MultiSelectListPreference
|
||||
private lateinit var myVehiclePreference: MultiSelectDialogPreference
|
||||
private lateinit var myTariffsPreference: MultiSelectDialogPreference
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
@@ -34,8 +38,16 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
||||
res.data?.let { cars ->
|
||||
val sortedCars = cars.sortedBy { it.brand }
|
||||
myVehiclePreference.entryValues = sortedCars.map { it.id }.toTypedArray()
|
||||
myVehiclePreference.entries =
|
||||
sortedCars.map { "${it.brand} ${it.name}" }.toTypedArray()
|
||||
myVehiclePreference.entries = sortedCars.map {
|
||||
SpannableStringBuilder().apply {
|
||||
appendLine("${it.brand} ${it.name}")
|
||||
append(
|
||||
it.formatSpecs(),
|
||||
RelativeSizeSpan(0.86f),
|
||||
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}.toTypedArray()
|
||||
myVehiclePreference.isEnabled = true
|
||||
updateMyVehiclesSummary()
|
||||
}
|
||||
@@ -78,9 +90,9 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
||||
private fun updateMyVehiclesSummary() {
|
||||
vm.vehicles.value?.data?.let { cars ->
|
||||
val vehicles = cars.filter { it.id in prefs.chargepriceMyVehicles }
|
||||
val summary = vehicles.map {
|
||||
val summary = vehicles.joinToString(", ") {
|
||||
"${it.brand} ${it.name}"
|
||||
}.joinToString(", ")
|
||||
}
|
||||
myVehiclePreference.summary = summary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
package net.vonforst.evmap.fragment.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
|
||||
import net.vonforst.evmap.api.availability.TeslaOwnerApi
|
||||
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
|
||||
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.IOException
|
||||
import java.time.Instant
|
||||
|
||||
class DataSettingsFragment : BaseSettingsFragment() {
|
||||
override val isTopLevel = false
|
||||
@@ -23,8 +37,53 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
}
|
||||
})
|
||||
|
||||
private lateinit var teslaAccountPreference: Preference
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings_data, rootKey)
|
||||
teslaAccountPreference = findPreference("tesla_account")!!
|
||||
refreshTeslaAccountStatus()
|
||||
|
||||
vm.chargerCacheCount.observe(this) {
|
||||
updateCacheSizeSummary()
|
||||
}
|
||||
vm.chargerCacheSize.observe(this) {
|
||||
updateCacheSizeSummary()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCacheSizeSummary() {
|
||||
val count = vm.chargerCacheCount.value ?: return
|
||||
val size = vm.chargerCacheSize.value ?: return
|
||||
val sizeMb = size.toFloat() / 1024 / 1024
|
||||
findPreference<Preference>("cache_size")!!.summary =
|
||||
getString(R.string.settings_cache_count_summary, count, sizeMb)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
arguments?.let {
|
||||
val args = DataSettingsFragmentArgs.fromBundle(it)
|
||||
if (args.startTeslaLogin) {
|
||||
teslaLogin()
|
||||
arguments = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshTeslaAccountStatus()
|
||||
}
|
||||
|
||||
private fun refreshTeslaAccountStatus() {
|
||||
teslaAccountPreference.summary =
|
||||
if (encryptedPrefs.teslaRefreshToken != null) {
|
||||
getString(R.string.pref_tesla_account_enabled, encryptedPrefs.teslaEmail)
|
||||
} else {
|
||||
getString(R.string.pref_tesla_account_disabled)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
@@ -60,7 +119,91 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
vm.deleteRecentSearchResults()
|
||||
true
|
||||
}
|
||||
|
||||
"tesla_account" -> {
|
||||
if (encryptedPrefs.teslaRefreshToken != null) {
|
||||
teslaLogout()
|
||||
} else {
|
||||
teslaLogin()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
"cache_clear" -> {
|
||||
vm.clearChargerCache()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun teslaLogin() {
|
||||
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
|
||||
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
|
||||
val uri = Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
|
||||
.appendQueryParameter("client_id", "ownerapi")
|
||||
.appendQueryParameter("code_challenge", codeChallenge)
|
||||
.appendQueryParameter("code_challenge_method", "S256")
|
||||
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.appendQueryParameter("scope", "openid email offline_access")
|
||||
.appendQueryParameter("state", "123").build()
|
||||
|
||||
val args = OAuthLoginFragmentArgs(
|
||||
uri.toString(),
|
||||
"https://auth.tesla.com/void/callback",
|
||||
"#000000"
|
||||
).toBundle()
|
||||
|
||||
setFragmentResultListener(uri.toString()) { _, result ->
|
||||
teslaGetAccessToken(result, codeVerifier)
|
||||
}
|
||||
|
||||
findNavController().navigate(R.id.oauth_login, args)
|
||||
}
|
||||
|
||||
private fun teslaGetAccessToken(result: Bundle, codeVerifier: String) {
|
||||
teslaAccountPreference.summary = getString(R.string.logging_in)
|
||||
|
||||
val url = Uri.parse(result.getString("url"))
|
||||
val code = url.getQueryParameter("code") ?: return
|
||||
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
|
||||
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val time = Instant.now().epochSecond
|
||||
val response =
|
||||
TeslaAuthenticationApi.create(okhttp).getToken(request)
|
||||
val userResponse =
|
||||
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
|
||||
|
||||
encryptedPrefs.teslaEmail = userResponse.response.email
|
||||
encryptedPrefs.teslaAccessToken = response.accessToken
|
||||
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
|
||||
encryptedPrefs.teslaRefreshToken = response.refreshToken
|
||||
} catch (e: IOException) {
|
||||
view?.let {
|
||||
Snackbar.make(it, R.string.connection_error, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
refreshTeslaAccountStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun teslaLogout() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(getString(R.string.pref_tesla_account_enabled, encryptedPrefs.teslaEmail))
|
||||
.setPositiveButton(R.string.ok) { _, _ -> }
|
||||
.setNegativeButton(R.string.log_out) { _, _ ->
|
||||
// sign out
|
||||
encryptedPrefs.teslaRefreshToken = null
|
||||
encryptedPrefs.teslaAccessToken = null
|
||||
encryptedPrefs.teslaAccessTokenExpiry = -1
|
||||
encryptedPrefs.teslaEmail = null
|
||||
view?.let { Snackbar.make(it, R.string.logged_out, Snackbar.LENGTH_SHORT).show() }
|
||||
refreshTeslaAccountStatus()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package net.vonforst.evmap.fragment.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.Preference
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
class DeveloperSettingsFragment : BaseSettingsFragment() {
|
||||
override val isTopLevel = false
|
||||
private val locationManager: LocationManager by lazy {
|
||||
requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings_developer, rootKey)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val locationPref = findPreference<Preference>("location_status")!!
|
||||
val coarseGranted = ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val fineGranted = ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
locationPref.summary = buildString {
|
||||
append("Coarse location permission: ")
|
||||
appendLine(if (coarseGranted) "granted" else "not granted")
|
||||
append("Fine location permission: ")
|
||||
appendLine(if (fineGranted) "granted" else "not granted")
|
||||
appendLine()
|
||||
|
||||
if (coarseGranted) {
|
||||
append("Last network location: ")
|
||||
appendLine(printLocation(locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)))
|
||||
}
|
||||
if (fineGranted) {
|
||||
append("Last GPS location: ")
|
||||
appendLine(printLocation(locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)))
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.allProviders.contains(
|
||||
LocationManager.FUSED_PROVIDER
|
||||
)
|
||||
) {
|
||||
append("Last fused location: ")
|
||||
append(printLocation(locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)))
|
||||
} else {
|
||||
append("System's fused location provider not available")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
"disable_developer_mode" -> {
|
||||
prefs.developerModeEnabled = false
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
getString(R.string.developer_mode_disabled),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
findNavController().popBackStack()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun printLocation(location: Location?): String {
|
||||
if (location == null) return "not available"
|
||||
|
||||
return buildString {
|
||||
append("%.4f".format(location.latitude))
|
||||
append(",")
|
||||
append("%.4f".format(location.longitude))
|
||||
append(" (")
|
||||
append(DateUtils.getRelativeTimeSpanString(location.time))
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,22 @@ package net.vonforst.evmap.fragment.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.preference.Preference
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
|
||||
class SettingsFragment : BaseSettingsFragment() {
|
||||
override val isTopLevel = true
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.settings)
|
||||
addPreferencesFromResource(R.xml.settings_variantspecific)
|
||||
findPreference<Preference>("developer_options")?.isVisible = prefs.developerModeEnabled
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
findPreference<Preference>("developer_options")?.isVisible = prefs.developerModeEnabled
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
|
||||
@@ -31,6 +31,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private var gpsLocation: Location? = null
|
||||
private var networkLocation: Location? = null
|
||||
private var fusedLocation: Location? = null
|
||||
|
||||
private val supportsSystemFusedProvider: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.allProviders.contains(
|
||||
@@ -101,7 +102,6 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
try {
|
||||
enableFused(gpsInterval)
|
||||
checkLastKnownFused()
|
||||
return
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Permissions not granted for fused provider", e)
|
||||
}
|
||||
@@ -143,6 +143,9 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
override fun disable() {
|
||||
locationManager.removeUpdates(this)
|
||||
gpsLocation = null
|
||||
networkLocation = null
|
||||
fusedLocation = null
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
|
||||
@@ -235,15 +238,16 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
||||
|
||||
override fun onLocationChanged(location: Location) {
|
||||
if (LocationManager.FUSED_PROVIDER == location.provider) {
|
||||
fusedLocation = location
|
||||
requests.forEach { it.listener.onLocationChanged(location) }
|
||||
} else if (LocationManager.GPS_PROVIDER == location.provider) {
|
||||
gpsLocation = location
|
||||
if (gpsLocation.isBetterThan(networkLocation)) {
|
||||
if (gpsLocation.isBetterThan(networkLocation) && fusedLocation == null) {
|
||||
requests.forEach { it.listener.onLocationChanged(location) }
|
||||
}
|
||||
} else if (LocationManager.NETWORK_PROVIDER == location.provider) {
|
||||
networkLocation = location
|
||||
if (networkLocation.isBetterThan(gpsLocation)) {
|
||||
if (networkLocation.isBetterThan(gpsLocation) && fusedLocation == null) {
|
||||
requests.forEach { it.listener.onLocationChanged(location) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +58,12 @@ data class ChargeLocation(
|
||||
val id: Long,
|
||||
val dataSource: String,
|
||||
val name: String,
|
||||
@Embedded val coordinates: Coordinate,
|
||||
val coordinates: Coordinate,
|
||||
@Embedded val address: Address?,
|
||||
val chargepoints: List<Chargepoint>,
|
||||
val network: String?,
|
||||
val url: String,
|
||||
val editUrl: String?,
|
||||
val url: String, // URL of this charger at the data source
|
||||
val editUrl: String?, // URL to edit this charger at the data source
|
||||
@Embedded(prefix = "fault_report_") val faultReport: FaultReport?,
|
||||
val verified: Boolean,
|
||||
val barrierFree: Boolean?,
|
||||
@@ -78,6 +78,8 @@ data class ChargeLocation(
|
||||
@Embedded val cost: Cost?,
|
||||
val license: String?,
|
||||
@Embedded(prefix = "chargeprice") val chargepriceData: ChargepriceData?,
|
||||
val networkUrl: String?, // Website of the network
|
||||
val chargerUrl: String?, // Website for this specific charging site. Might be an ad-hoc payment page.
|
||||
val timeRetrieved: Instant,
|
||||
val isDetailed: Boolean
|
||||
) : ChargepointListItem(), Equatable, Parcelable {
|
||||
@@ -136,9 +138,9 @@ data class ChargeLocation(
|
||||
get() = chargepoints.sumOf { it.count }
|
||||
|
||||
fun formatChargepoints(sp: StringProvider): String {
|
||||
return chargepointsMerged.map {
|
||||
return chargepointsMerged.joinToString(" · ") {
|
||||
"${it.count} × ${nameForPlugType(sp, it.type)} ${it.formatPower()}"
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,12 +334,25 @@ data class Hours(
|
||||
}
|
||||
|
||||
abstract class ChargerPhoto(open val id: String) : Parcelable {
|
||||
abstract fun getUrl(height: Int? = null, width: Int? = null, size: Int? = null): String
|
||||
/**
|
||||
* Gets a URL of the image corresponding to a given size.
|
||||
*
|
||||
* If the data source supports accessing the image in its original (potentially unlimited) size,
|
||||
* this size will only be returned if allowOriginal is set to true. Otherwise, only scaled
|
||||
* versions of the images will be returned.
|
||||
*/
|
||||
abstract fun getUrl(
|
||||
height: Int? = null,
|
||||
width: Int? = null,
|
||||
size: Int? = null,
|
||||
allowOriginal: Boolean = false
|
||||
): String
|
||||
}
|
||||
|
||||
data class ChargeLocationCluster(
|
||||
val clusterCount: Int,
|
||||
val coordinates: Coordinate
|
||||
val coordinates: Coordinate,
|
||||
val items: List<ChargeLocation>? = null
|
||||
) : ChargepointListItem()
|
||||
|
||||
@Parcelize
|
||||
|
||||
@@ -23,6 +23,6 @@ data class Favorite(
|
||||
)
|
||||
|
||||
data class FavoriteWithDetail(
|
||||
@Embedded() val favorite: Favorite,
|
||||
@Embedded val favorite: Favorite,
|
||||
@Embedded val charger: ChargeLocation
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.storage.FilterProfile
|
||||
import java.net.URLEncoder
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
sealed class Filter<out T : FilterValue> : Equatable {
|
||||
@@ -51,6 +52,8 @@ sealed class FilterValue : BaseObservable(), Equatable {
|
||||
var profile: Long = FILTERS_CUSTOM
|
||||
|
||||
abstract fun hasSameValueAs(other: FilterValue): Boolean
|
||||
|
||||
abstract fun serializeValue(): String
|
||||
}
|
||||
|
||||
@Entity(
|
||||
@@ -72,6 +75,8 @@ data class BooleanFilterValue(
|
||||
override fun hasSameValueAs(other: FilterValue): Boolean {
|
||||
return other is BooleanFilterValue && other.value == this.value
|
||||
}
|
||||
|
||||
override fun serializeValue(): String = value.toString()
|
||||
}
|
||||
|
||||
@Entity(
|
||||
@@ -99,6 +104,12 @@ data class MultipleChoiceFilterValue(
|
||||
!this.all && other.values == this.values
|
||||
}
|
||||
}
|
||||
|
||||
override fun serializeValue(): String = if (all) {
|
||||
"ALL"
|
||||
} else {
|
||||
"[" + values.sorted().joinToString(",") { URLEncoder.encode(it, "UTF-8") } + "]"
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(
|
||||
@@ -120,6 +131,8 @@ data class SliderFilterValue(
|
||||
override fun hasSameValueAs(other: FilterValue): Boolean {
|
||||
return other is SliderFilterValue && other.value == this.value
|
||||
}
|
||||
|
||||
override fun serializeValue() = value.toString()
|
||||
}
|
||||
|
||||
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
|
||||
@@ -138,6 +151,9 @@ fun FilterValues.getMultipleChoiceFilter(key: String) =
|
||||
fun FilterValues.getMultipleChoiceValue(key: String) =
|
||||
this.find { it.value.key == key }?.value as MultipleChoiceFilterValue?
|
||||
|
||||
fun FilterValues.serialize() = this.sortedBy { it.value.key }
|
||||
.joinToString(",") { it.value.key + "=" + it.value.serializeValue() }
|
||||
|
||||
const val FILTERS_DISABLED = -2L
|
||||
const val FILTERS_CUSTOM = -1L
|
||||
const val FILTERS_FAVORITES = -3L
|
||||
54
app/src/main/java/net/vonforst/evmap/storage/BackupAgent.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.app.backup.BackupAgent
|
||||
import android.app.backup.BackupDataInput
|
||||
import android.app.backup.BackupDataOutput
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.time.Instant
|
||||
|
||||
private const val backupFileName = "evmap-backup.db"
|
||||
|
||||
class BackupAgent : BackupAgent() {
|
||||
override fun onBackup(
|
||||
oldState: ParcelFileDescriptor,
|
||||
data: BackupDataOutput,
|
||||
newState: ParcelFileDescriptor
|
||||
) {
|
||||
// unused on Android M+, we don't support backups on older versions
|
||||
}
|
||||
|
||||
override fun onRestore(
|
||||
data: BackupDataInput,
|
||||
appVersionCode: Int,
|
||||
newState: ParcelFileDescriptor
|
||||
) {
|
||||
// unused on Android M+, we don't support backups on older versions
|
||||
}
|
||||
|
||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||
runBlocking {
|
||||
// creates a backup of the app database to evmap-backup.db
|
||||
AppDatabase.getInstance(applicationContext).createBackup(
|
||||
applicationContext,
|
||||
backupFileName
|
||||
)
|
||||
}
|
||||
super.onFullBackup(data)
|
||||
val backupDb = applicationContext.getDatabasePath(backupFileName)
|
||||
if (backupDb.exists()) backupDb.delete()
|
||||
}
|
||||
|
||||
override fun onRestoreFinished() {
|
||||
super.onRestoreFinished()
|
||||
// rename restored backup DB as evmap.db
|
||||
val backupDb = applicationContext.getDatabasePath(backupFileName)
|
||||
if (backupDb.exists()) {
|
||||
backupDb.renameTo(applicationContext.getDatabasePath("evmap.db"))
|
||||
}
|
||||
// clear cache age
|
||||
PreferenceDataSource(applicationContext).lastGeReferenceDataUpdate = Instant.EPOCH
|
||||
PreferenceDataSource(applicationContext).lastOcmReferenceDataUpdate = Instant.EPOCH
|
||||
}
|
||||
}
|
||||
131
app/src/main/java/net/vonforst/evmap/storage/CacheLiveData.kt
Normal file
@@ -0,0 +1,131 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* LiveData implementation that allows loading data both from a cache and an API.
|
||||
*
|
||||
* It gives the cache result while loading, and then switches to the API result if the API call was
|
||||
* successful.
|
||||
*/
|
||||
class CacheLiveData<T>(
|
||||
cache: LiveData<T>,
|
||||
api: LiveData<Resource<T>>,
|
||||
skipApi: LiveData<Boolean>? = null
|
||||
) :
|
||||
MediatorLiveData<Resource<T>>() {
|
||||
private var cacheResult: T? = null
|
||||
private var apiResult: Resource<T>? = null
|
||||
private var skipApiResult: Boolean = false
|
||||
|
||||
init {
|
||||
updateValue()
|
||||
addSource(cache) {
|
||||
cacheResult = it
|
||||
removeSource(cache)
|
||||
updateValue()
|
||||
}
|
||||
if (skipApi == null) {
|
||||
addSource(api) {
|
||||
apiResult = it
|
||||
updateValue()
|
||||
}
|
||||
} else {
|
||||
addSource(skipApi) { skip ->
|
||||
removeSource(skipApi)
|
||||
skipApiResult = skip
|
||||
updateValue()
|
||||
if (!skip) {
|
||||
addSource(api) {
|
||||
apiResult = it
|
||||
updateValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateValue() {
|
||||
val api = apiResult
|
||||
val cache = cacheResult
|
||||
|
||||
if (api == null && cache == null) {
|
||||
Log.d("CacheLiveData", "both API and cache are still loading")
|
||||
// both API and cache are still loading
|
||||
value = Resource.loading(null)
|
||||
} else if (cache != null && api == null) {
|
||||
Log.d("CacheLiveData", "cache has finished loading before API")
|
||||
// cache has finished loading before API
|
||||
if (skipApiResult) {
|
||||
value = Resource.success(cache)
|
||||
} else {
|
||||
value = Resource.loading(cache)
|
||||
}
|
||||
} else if (cache == null && api != null) {
|
||||
Log.d("CacheLiveData", "API has finished loading before cache")
|
||||
// API has finished loading before cache
|
||||
value = when (api.status) {
|
||||
Status.SUCCESS -> api
|
||||
Status.ERROR -> Resource.loading(api.data)
|
||||
Status.LOADING -> api // should not occur
|
||||
}
|
||||
} else if (cache != null && api != null) {
|
||||
Log.d("CacheLiveData", "Both cache and API have finished loading")
|
||||
// Both cache and API have finished loading
|
||||
value = when (api.status) {
|
||||
Status.SUCCESS -> api
|
||||
Status.ERROR -> Resource.error(api.message, cache)
|
||||
Status.LOADING -> api // should not occur
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LiveData implementation that allows loading data both from a cache and an API.
|
||||
*
|
||||
* It first tries loading from cache, and if the result is newer than `cacheSoftLimit` it does not
|
||||
* reload from the API.
|
||||
*/
|
||||
class PreferCacheLiveData(
|
||||
cache: LiveData<ChargeLocation>,
|
||||
val api: LiveData<Resource<ChargeLocation>>,
|
||||
cacheSoftLimit: Duration
|
||||
) :
|
||||
MediatorLiveData<Resource<ChargeLocation>>() {
|
||||
init {
|
||||
value = Resource.loading(null)
|
||||
addSource(cache) { cacheRes ->
|
||||
removeSource(cache)
|
||||
if (cacheRes != null) {
|
||||
if (cacheRes.isDetailed && cacheRes.timeRetrieved > Instant.now() - cacheSoftLimit) {
|
||||
value = Resource.success(cacheRes)
|
||||
} else {
|
||||
value = Resource.loading(cacheRes)
|
||||
loadFromApi(cacheRes)
|
||||
}
|
||||
} else {
|
||||
loadFromApi(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromApi(
|
||||
cache: ChargeLocation?
|
||||
) {
|
||||
addSource(api) { apiRes ->
|
||||
value = when (apiRes.status) {
|
||||
Status.SUCCESS -> apiRes
|
||||
Status.ERROR -> Resource.error(apiRes.message, cache)
|
||||
Status.LOADING -> Resource.loading(cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,103 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import androidx.lifecycle.*
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.*
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import co.anbora.labs.spatia.geometry.Mbr
|
||||
import co.anbora.labs.spatia.geometry.Polygon
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import com.car2go.maps.util.SphericalUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.ChargepointList
|
||||
import net.vonforst.evmap.api.StringProvider
|
||||
import net.vonforst.evmap.api.goingelectric.GEReferenceData
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.ui.cluster
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.await
|
||||
import net.vonforst.evmap.viewmodel.getClusterDistance
|
||||
import net.vonforst.evmap.viewmodel.singleSwitchMap
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@Dao
|
||||
abstract class ChargeLocationsDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
abstract suspend fun insert(vararg locations: ChargeLocation)
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM chargelocation WHERE dataSource == :dataSource AND id == :id AND isDetailed == 1 AND timeRetrieved > :after )")
|
||||
abstract suspend fun checkExistsDetailed(id: Long, dataSource: String, after: Long): Boolean
|
||||
|
||||
suspend fun insertOrReplaceIfNoDetailedExists(
|
||||
afterDate: Long,
|
||||
vararg locations: ChargeLocation
|
||||
) {
|
||||
locations.forEach {
|
||||
if (it.isDetailed || !checkExistsDetailed(it.id, it.dataSource, afterDate)) {
|
||||
insert(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Delete
|
||||
abstract suspend fun delete(vararg locations: ChargeLocation)
|
||||
|
||||
@Query("DELETE FROM chargelocation WHERE dataSource == :dataSource AND timeRetrieved <= :before AND NOT EXISTS (SELECT 1 FROM favorite WHERE favorite.chargerId = chargelocation.id)")
|
||||
abstract suspend fun deleteOutdatedIfNotFavorite(dataSource: String, before: Long)
|
||||
|
||||
@Query("DELETE FROM chargelocation WHERE NOT EXISTS (SELECT 1 FROM favorite WHERE favorite.chargerId = chargelocation.id)")
|
||||
abstract suspend fun deleteAllIfNotFavorite()
|
||||
|
||||
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND id == :id AND isDetailed == 1 AND timeRetrieved > :after")
|
||||
abstract fun getChargeLocationById(
|
||||
id: Long,
|
||||
dataSource: String,
|
||||
after: Long
|
||||
): LiveData<ChargeLocation>
|
||||
|
||||
@SkipQueryVerification
|
||||
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND Within(coordinates, BuildMbr(:lng1, :lat1, :lng2, :lat2)) AND timeRetrieved > :after")
|
||||
abstract fun getChargeLocationsInBounds(
|
||||
lat1: Double,
|
||||
lat2: Double,
|
||||
lng1: Double,
|
||||
lng2: Double,
|
||||
dataSource: String,
|
||||
after: Long
|
||||
): LiveData<List<ChargeLocation>>
|
||||
|
||||
@SkipQueryVerification
|
||||
@Query("SELECT * FROM chargelocation WHERE dataSource == :dataSource AND PtDistWithin(coordinates, MakePoint(:lng, :lat, 4326), :radius) AND timeRetrieved > :after ORDER BY Distance(coordinates, MakePoint(:lng, :lat, 4326))")
|
||||
abstract fun getChargeLocationsRadius(
|
||||
lat: Double,
|
||||
lng: Double,
|
||||
radius: Double,
|
||||
dataSource: String,
|
||||
after: Long
|
||||
): LiveData<List<ChargeLocation>>
|
||||
|
||||
@RawQuery(observedEntities = [ChargeLocation::class])
|
||||
abstract fun getChargeLocationsCustom(query: SupportSQLiteQuery): LiveData<List<ChargeLocation>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM chargelocation")
|
||||
abstract fun getCount(): LiveData<Long>
|
||||
|
||||
@SkipQueryVerification
|
||||
@Query("SELECT SUM(pgsize) FROM dbstat WHERE name == \"ChargeLocation\"")
|
||||
abstract suspend fun getSize(): Long
|
||||
}
|
||||
|
||||
/**
|
||||
* The ChargeLocationsRepository wraps the ChargepointApi and the DB to provide caching
|
||||
* functionality.
|
||||
* and clustering functionality.
|
||||
*/
|
||||
class ChargeLocationsRepository(
|
||||
api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
|
||||
@@ -36,6 +105,13 @@ class ChargeLocationsRepository(
|
||||
) {
|
||||
val api = MutableLiveData<ChargepointApi<ReferenceData>>().apply { value = api }
|
||||
|
||||
// if zoom level is below this value, server-side clustering will be used (if the API provides it)
|
||||
private val serverSideClusteringThreshold = 9f
|
||||
private fun shouldUseServerSideClustering(zoom: Float) = zoom < serverSideClusteringThreshold
|
||||
|
||||
// if cached data is available and more recent than this duration, API will not be queried
|
||||
private val cacheSoftLimit = Duration.ofDays(1)
|
||||
|
||||
val referenceData = this.api.switchMap { api ->
|
||||
when (api) {
|
||||
is GoingElectricApiWrapper -> {
|
||||
@@ -61,18 +137,70 @@ class ChargeLocationsRepository(
|
||||
}
|
||||
|
||||
private val chargeLocationsDao = db.chargeLocationsDao()
|
||||
private val savedRegionDao = db.savedRegionDao()
|
||||
|
||||
fun getChargepoints(
|
||||
bounds: LatLngBounds,
|
||||
zoom: Float,
|
||||
filters: FilterValues?
|
||||
): LiveData<Resource<List<ChargepointListItem>>> {
|
||||
return liveData {
|
||||
val refData = referenceData.await()
|
||||
val result = api.value!!.getChargepoints(refData, bounds, zoom, filters)
|
||||
val api = api.value!!
|
||||
|
||||
emit(result)
|
||||
val dbResult = if (filters == null) {
|
||||
chargeLocationsDao.getChargeLocationsInBounds(
|
||||
bounds.southwest.latitude,
|
||||
bounds.northeast.latitude,
|
||||
bounds.southwest.longitude,
|
||||
bounds.northeast.longitude,
|
||||
api.id,
|
||||
cacheLimitDate(api)
|
||||
)
|
||||
} else {
|
||||
queryWithFilters(api, filters, bounds)
|
||||
}.map { applyLocalClustering(it, zoom) }
|
||||
val filtersSerialized =
|
||||
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
|
||||
?.serialize()
|
||||
val requiresDetail = filters?.let { api.filteringInSQLRequiresDetails(it) } ?: false
|
||||
val savedRegionResult = savedRegionDao.savedRegionCovers(
|
||||
bounds.southwest.latitude,
|
||||
bounds.northeast.latitude,
|
||||
bounds.southwest.longitude,
|
||||
bounds.northeast.longitude,
|
||||
api.id,
|
||||
cacheSoftLimitDate(api),
|
||||
filtersSerialized,
|
||||
requiresDetail
|
||||
)
|
||||
val useClustering = shouldUseServerSideClustering(zoom)
|
||||
val apiResult = liveData {
|
||||
val refData = referenceData.await()
|
||||
val time = Instant.now()
|
||||
val result = api.getChargepoints(refData, bounds, zoom, useClustering, filters)
|
||||
emit(applyLocalClustering(result, zoom))
|
||||
if (result.status == Status.SUCCESS) {
|
||||
val chargers = result.data!!.items.filterIsInstance<ChargeLocation>()
|
||||
chargeLocationsDao.insertOrReplaceIfNoDetailedExists(
|
||||
cacheLimitDate(api), *chargers.toTypedArray()
|
||||
)
|
||||
if (chargers.size == result.data.items.size && result.data.isComplete) {
|
||||
val region = Mbr(
|
||||
bounds.southwest.longitude,
|
||||
bounds.southwest.latitude,
|
||||
bounds.northeast.longitude,
|
||||
bounds.northeast.latitude, 4326
|
||||
).asPolygon()
|
||||
savedRegionDao.insert(
|
||||
SavedRegion(
|
||||
region, api.id, time,
|
||||
filtersSerialized,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged()
|
||||
}
|
||||
|
||||
fun getChargepointsRadius(
|
||||
@@ -81,23 +209,115 @@ class ChargeLocationsRepository(
|
||||
zoom: Float,
|
||||
filters: FilterValues?
|
||||
): LiveData<Resource<List<ChargepointListItem>>> {
|
||||
return liveData {
|
||||
val refData = referenceData.await()
|
||||
val result = api.value!!.getChargepointsRadius(refData, location, radius, zoom, filters)
|
||||
val api = api.value!!
|
||||
|
||||
emit(result)
|
||||
val radiusMeters = radius.toDouble() * 1000
|
||||
val dbResult = if (filters == null) {
|
||||
chargeLocationsDao.getChargeLocationsRadius(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
radiusMeters,
|
||||
api.id,
|
||||
cacheLimitDate(api)
|
||||
)
|
||||
} else {
|
||||
queryWithFilters(api, filters, location, radiusMeters)
|
||||
}.map { applyLocalClustering(it, zoom) }
|
||||
val filtersSerialized =
|
||||
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
|
||||
?.serialize()
|
||||
val requiresDetail = filters?.let { api.filteringInSQLRequiresDetails(it) } ?: false
|
||||
val savedRegionResult = savedRegionDao.savedRegionCoversRadius(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
radiusMeters * 0.999, // to account for float rounding errors
|
||||
api.id,
|
||||
cacheSoftLimitDate(api),
|
||||
filtersSerialized,
|
||||
requiresDetail
|
||||
)
|
||||
val useClustering = shouldUseServerSideClustering(zoom)
|
||||
val apiResult = liveData {
|
||||
val refData = referenceData.await()
|
||||
val time = Instant.now()
|
||||
val result =
|
||||
api.getChargepointsRadius(refData, location, radius, zoom, useClustering, filters)
|
||||
emit(applyLocalClustering(result, zoom))
|
||||
if (result.status == Status.SUCCESS) {
|
||||
val chargers = result.data!!.items.filterIsInstance<ChargeLocation>()
|
||||
chargeLocationsDao.insertOrReplaceIfNoDetailedExists(
|
||||
cacheLimitDate(api), *chargers.toTypedArray()
|
||||
)
|
||||
if (chargers.size == result.data.items.size && result.data.isComplete) {
|
||||
val region = Polygon(
|
||||
savedRegionDao.makeCircle(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
radiusMeters
|
||||
)
|
||||
)
|
||||
savedRegionDao.insert(
|
||||
SavedRegion(
|
||||
region, api.id, time,
|
||||
filtersSerialized,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return CacheLiveData(dbResult, apiResult, savedRegionResult).distinctUntilChanged()
|
||||
}
|
||||
|
||||
private fun applyLocalClustering(
|
||||
result: Resource<ChargepointList>,
|
||||
zoom: Float
|
||||
): Resource<List<ChargepointListItem>> {
|
||||
val list = result.data ?: return Resource(result.status, null, result.message)
|
||||
val chargers = list.items.filterIsInstance<ChargeLocation>()
|
||||
|
||||
if (chargers.size != list.items.size) return Resource(
|
||||
result.status,
|
||||
list.items,
|
||||
result.message
|
||||
) // list already contains clusters
|
||||
|
||||
val clustered = applyLocalClustering(chargers, zoom)
|
||||
return Resource(result.status, clustered, result.message)
|
||||
}
|
||||
|
||||
private fun applyLocalClustering(
|
||||
chargers: List<ChargeLocation>,
|
||||
zoom: Float
|
||||
): List<ChargepointListItem> {
|
||||
val clusterDistance = getClusterDistance(zoom)
|
||||
|
||||
val chargersClustered = if (clusterDistance != null) {
|
||||
Dispatchers.IO.run {
|
||||
cluster(chargers, zoom, clusterDistance)
|
||||
}
|
||||
} else chargers
|
||||
return chargersClustered
|
||||
}
|
||||
|
||||
fun getChargepointDetail(
|
||||
id: Long
|
||||
): LiveData<Resource<ChargeLocation>> {
|
||||
return liveData {
|
||||
val dbResult = chargeLocationsDao.getChargeLocationById(
|
||||
id,
|
||||
prefs.dataSource,
|
||||
cacheLimitDate(api.value!!)
|
||||
)
|
||||
val apiResult = liveData {
|
||||
emit(Resource.loading(null))
|
||||
val refData = referenceData.await()
|
||||
val result = api.value!!.getChargepointDetail(refData, id)
|
||||
emit(result)
|
||||
if (result.status == Status.SUCCESS) {
|
||||
chargeLocationsDao.insert(result.data!!)
|
||||
}
|
||||
}
|
||||
return PreferCacheLiveData(dbResult, apiResult, cacheSoftLimit)
|
||||
}
|
||||
|
||||
fun getFilters(sp: StringProvider) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
|
||||
@@ -122,4 +342,79 @@ class ChargeLocationsRepository(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryWithFilters(
|
||||
api: ChargepointApi<ReferenceData>,
|
||||
filters: FilterValues,
|
||||
bounds: LatLngBounds
|
||||
): LiveData<List<ChargeLocation>> {
|
||||
val region =
|
||||
"Within(coordinates, BuildMbr(${bounds.southwest.longitude}, ${bounds.southwest.latitude}, ${bounds.northeast.longitude}, ${bounds.northeast.latitude}))"
|
||||
return queryWithFilters(api, filters, region)
|
||||
}
|
||||
|
||||
private fun queryWithFilters(
|
||||
api: ChargepointApi<ReferenceData>,
|
||||
filters: FilterValues,
|
||||
location: LatLng,
|
||||
radius: Double
|
||||
): LiveData<List<ChargeLocation>> {
|
||||
val region =
|
||||
"PtDistWithin(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326), ${radius})"
|
||||
val order =
|
||||
"ORDER BY Distance(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326))"
|
||||
return queryWithFilters(api, filters, region, order)
|
||||
}
|
||||
|
||||
private fun queryWithFilters(
|
||||
api: ChargepointApi<ReferenceData>,
|
||||
filters: FilterValues,
|
||||
regionSql: String,
|
||||
orderSql: String? = null
|
||||
): LiveData<List<ChargeLocation>> = referenceData.singleSwitchMap { refData ->
|
||||
try {
|
||||
val query = api.convertFiltersToSQL(filters, refData)
|
||||
val after = cacheLimitDate(api)
|
||||
val sql = StringBuilder().apply {
|
||||
append("SELECT")
|
||||
if (query.requiresChargeCardQuery or query.requiresChargepointQuery) {
|
||||
append(" DISTINCT chargelocation.*")
|
||||
} else {
|
||||
append(" *")
|
||||
}
|
||||
append(" FROM chargelocation")
|
||||
if (query.requiresChargepointQuery) {
|
||||
append(" JOIN json_each(chargelocation.chargepoints) AS cp")
|
||||
}
|
||||
if (query.requiresChargeCardQuery) {
|
||||
append(" JOIN json_each(chargelocation.chargecards) AS cc")
|
||||
}
|
||||
append(" WHERE dataSource == '${prefs.dataSource}'")
|
||||
append(" AND $regionSql")
|
||||
append(" AND timeRetrieved > $after")
|
||||
append(query.query)
|
||||
orderSql?.let { append(" " + orderSql) }
|
||||
}.toString()
|
||||
|
||||
chargeLocationsDao.getChargeLocationsCustom(
|
||||
SimpleSQLiteQuery(
|
||||
sql,
|
||||
null
|
||||
)
|
||||
)
|
||||
} catch (e: NotImplementedError) {
|
||||
MutableLiveData() // in this case we cannot get a DB result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun cacheLimitDate(api: ChargepointApi<ReferenceData>): Long {
|
||||
val cacheLimit = api.cacheLimit
|
||||
return Instant.now().minus(cacheLimit).toEpochMilli()
|
||||
}
|
||||
|
||||
private fun cacheSoftLimitDate(api: ChargepointApi<ReferenceData>): Long {
|
||||
val cacheLimit = maxOf(api.cacheLimit, Duration.ofDays(2))
|
||||
return Instant.now().minus(cacheLimit).toEpochMilli()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import java.time.Instant
|
||||
|
||||
class CleanupCacheWorker(appContext: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(appContext, workerParams) {
|
||||
override suspend fun doWork(): Result {
|
||||
val db = AppDatabase.getInstance(applicationContext)
|
||||
|
||||
val chargeLocations = db.chargeLocationsDao()
|
||||
val savedRegionDao = db.savedRegionDao()
|
||||
val now = Instant.now()
|
||||
|
||||
val dataSources = listOf("openchargemap", "goingelectric")
|
||||
for (dataSource in dataSources) {
|
||||
val api = createApi(dataSource, applicationContext)
|
||||
val limit = now.minus(api.cacheLimit).toEpochMilli()
|
||||
chargeLocations.deleteOutdatedIfNotFavorite(dataSource, limit)
|
||||
savedRegionDao.deleteOutdated(dataSource, limit)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import co.anbora.labs.spatia.builder.SpatiaRoom
|
||||
import co.anbora.labs.spatia.geometry.GeometryConverters
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.vonforst.evmap.api.goingelectric.GEChargeCard
|
||||
import net.vonforst.evmap.api.goingelectric.GEChargepoint
|
||||
import net.vonforst.evmap.api.openchargemap.OCMConnectionType
|
||||
@@ -31,16 +33,18 @@ import net.vonforst.evmap.model.*
|
||||
GEChargeCard::class,
|
||||
OCMConnectionType::class,
|
||||
OCMCountry::class,
|
||||
OCMOperator::class
|
||||
], version = 18
|
||||
OCMOperator::class,
|
||||
SavedRegion::class
|
||||
], version = 21
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@TypeConverters(Converters::class, GeometryConverters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun chargeLocationsDao(): ChargeLocationsDao
|
||||
abstract fun favoritesDao(): FavoritesDao
|
||||
abstract fun filterValueDao(): FilterValueDao
|
||||
abstract fun filterProfileDao(): FilterProfileDao
|
||||
abstract fun recentAutocompletePlaceDao(): RecentAutocompletePlaceDao
|
||||
abstract fun savedRegionDao(): SavedRegionDao
|
||||
|
||||
// GoingElectric API specific
|
||||
abstract fun geReferenceDataDao(): GEReferenceDataDao
|
||||
@@ -51,21 +55,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
companion object {
|
||||
private lateinit var context: Context
|
||||
private val database: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, "evmap.db")
|
||||
.addMigrations(
|
||||
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
|
||||
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
|
||||
MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16,
|
||||
MIGRATION_17, MIGRATION_18
|
||||
)
|
||||
.addCallback(object : Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
// create default filter profile for each data source
|
||||
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('goingelectric', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
|
||||
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
|
||||
}
|
||||
})
|
||||
.build()
|
||||
initDb(SpatiaRoom.databaseBuilder(context, AppDatabase::class.java, "evmap.db"))
|
||||
}
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
@@ -73,12 +63,44 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
return database
|
||||
}
|
||||
|
||||
/**
|
||||
* creates an in-memory AppDatabase instance - only for testing
|
||||
*/
|
||||
fun createInMemory(context: Context): AppDatabase {
|
||||
return initDb(SpatiaRoom.inMemoryDatabaseBuilder(context, AppDatabase::class.java))
|
||||
}
|
||||
|
||||
private fun initDb(builder: SpatiaRoom.Builder<AppDatabase>): AppDatabase {
|
||||
return builder.addMigrations(
|
||||
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
|
||||
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
|
||||
MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16,
|
||||
MIGRATION_17, MIGRATION_18, MIGRATION_19, MIGRATION_20, MIGRATION_21
|
||||
)
|
||||
.addCallback(object : Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
// create default filter profile for each data source
|
||||
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('goingelectric', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
|
||||
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
|
||||
// initialize spatialite columns
|
||||
db.query("SELECT RecoverGeometryColumn('ChargeLocation', 'coordinates', 4326, 'POINT', 'XY');")
|
||||
.moveToNext()
|
||||
db.query("SELECT CreateSpatialIndex('ChargeLocation', 'coordinates');")
|
||||
.moveToNext()
|
||||
db.query("SELECT RecoverGeometryColumn('SavedRegion', 'region', 4326, 'POLYGON', 'XY');")
|
||||
.moveToNext()
|
||||
db.query("SELECT CreateSpatialIndex('SavedRegion', 'region');")
|
||||
.moveToNext()
|
||||
}
|
||||
}).build()
|
||||
}
|
||||
|
||||
private val MIGRATION_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// SQL for creating tables copied from build/generated/source/kapt/debug/net/vonforst/evmap/storage/AppDatbase_Impl
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `BooleanFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `MultipleChoiceFilterValue` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, PRIMARY KEY(`key`))")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))");
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +109,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
// recreate ChargeLocation table to make postcode nullable
|
||||
db.beginTransaction()
|
||||
try {
|
||||
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT NOT NULL, `country` TEXT NOT NULL, `postcode` TEXT, `street` TEXT NOT NULL, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, PRIMARY KEY(`id`))");
|
||||
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`");
|
||||
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT NOT NULL, `country` TEXT NOT NULL, `postcode` TEXT, `street` TEXT NOT NULL, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, PRIMARY KEY(`id`))")
|
||||
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`")
|
||||
db.execSQL("DROP TABLE `ChargeLocation`")
|
||||
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
|
||||
db.setTransactionSuccessful()
|
||||
@@ -109,8 +131,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
// recreate ChargeLocation table to make other address fields nullable
|
||||
db.beginTransaction()
|
||||
try {
|
||||
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, PRIMARY KEY(`id`))");
|
||||
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`");
|
||||
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, PRIMARY KEY(`id`))")
|
||||
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`")
|
||||
db.execSQL("DROP TABLE `ChargeLocation`")
|
||||
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
|
||||
db.setTransactionSuccessful()
|
||||
@@ -160,7 +182,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
// add profile column to existing filtervalue tables
|
||||
db.execSQL("CREATE TABLE `BooleanFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
db.execSQL("CREATE TABLE `MultipleChoiceFilterValueNew` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )");
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`), FOREIGN KEY(`profile`) REFERENCES `FilterProfile`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
|
||||
for (table in listOf(
|
||||
"BooleanFilterValue",
|
||||
@@ -202,7 +224,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
//////////////////////////////////////////
|
||||
db.execSQL("CREATE TABLE `OCMConnectionType` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))")
|
||||
db.execSQL("CREATE TABLE `OCMCountry` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))")
|
||||
db.execSQL("CREATE TABLE `OCMOperator` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))");
|
||||
db.execSQL("CREATE TABLE `OCMOperator` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))")
|
||||
|
||||
//////////////////////////////////////////
|
||||
// rename GoingElectric-specific tables //
|
||||
@@ -295,7 +317,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
// update ChargeLocation table to change primary key
|
||||
db.execSQL(
|
||||
"CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))"
|
||||
);
|
||||
)
|
||||
val columnList =
|
||||
"`id`,`dataSource`,`name`,`chargepoints`,`network`,`url`,`editUrl`,`verified`,`barrierFree`,`operator`,`generalInformation`,`amenities`,`locationDescription`,`photos`,`chargecards`,`license`,`lat`,`lng`,`city`,`country`,`postcode`,`street`,`fault_report_created`,`fault_report_description`,`twentyfourSeven`,`description`,`mostart`,`moend`,`tustart`,`tuend`,`westart`,`weend`,`thstart`,`thend`,`frstart`,`frend`,`sastart`,`saend`,`sustart`,`suend`,`hostart`,`hoend`,`freecharging`,`freeparking`,`descriptionShort`,`descriptionLong`,`chargepricecountry`,`chargepricenetwork`,`chargepriceplugTypes`"
|
||||
db.execSQL("INSERT INTO `ChargeLocationNew`($columnList) SELECT $columnList FROM `ChargeLocation`")
|
||||
@@ -311,7 +333,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
private val MIGRATION_14 = object : Migration(13, 14) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `RecentAutocompletePlace` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))");
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `RecentAutocompletePlace` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -321,7 +343,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
try {
|
||||
db.beginTransaction()
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `Favorite` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE RESTRICT )");
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `Favorite` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE RESTRICT )")
|
||||
|
||||
val cursor = db.query("SELECT * FROM `ChargeLocation`")
|
||||
while (cursor.moveToNext()) {
|
||||
@@ -361,7 +383,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
try {
|
||||
db.beginTransaction()
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `FavoriteNew` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )");
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `FavoriteNew` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )")
|
||||
val columnList =
|
||||
"`favoriteId`,`chargerId`,`chargerDataSource`"
|
||||
db.execSQL("INSERT INTO `FavoriteNew`($columnList) SELECT $columnList FROM `Favorite`")
|
||||
@@ -376,5 +398,85 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val MIGRATION_19 = object : Migration(18, 19) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `ChargeLocation` ADD `networkUrl` TEXT")
|
||||
db.execSQL("ALTER TABLE `ChargeLocation` ADD `chargerUrl` TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_20 = object : Migration(19, 20) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
try {
|
||||
db.beginTransaction()
|
||||
|
||||
// init spatialite
|
||||
db.query("SELECT InitSpatialMetaData();").moveToNext()
|
||||
|
||||
// add geometry column and set it based on lat/lng columns
|
||||
db.query("SELECT AddGeometryColumn('ChargeLocation', 'coordinates', 4326, 'POINT', 'XY');")
|
||||
.moveToNext()
|
||||
db.execSQL("UPDATE `ChargeLocation` SET `coordinates` = GeomFromText('POINT('||\"lng\"||' '||\"lat\"||')',4326);")
|
||||
|
||||
// recreate table to remove lat/lng columns
|
||||
db.execSQL(
|
||||
"CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, PRIMARY KEY(`id`, `dataSource`))"
|
||||
)
|
||||
db.query("SELECT AddGeometryColumn('ChargeLocationNew', 'coordinates', 4326, 'POINT', 'XY');")
|
||||
.moveToNext()
|
||||
db.query("SELECT CreateSpatialIndex('ChargeLocationNew', 'coordinates');")
|
||||
.moveToNext()
|
||||
|
||||
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT `id`, `dataSource`, `name`, `coordinates`, `chargepoints`, `network`, `url`, `editUrl`, `verified`, `barrierFree`, `operator`, `generalInformation`, `amenities`, `locationDescription`, `photos`, `chargecards`, `license`, `timeRetrieved`, `isDetailed`, `city`, `country`, `postcode`, `street`, `fault_report_created`, `fault_report_description`, `twentyfourSeven`, `description`, `mostart`, `moend`, `tustart`, `tuend`, `westart`, `weend`, `thstart`, `thend`, `frstart`, `frend`, `sastart`, `saend`, `sustart`, `suend`, `hostart`, `hoend`, `freecharging`, `freeparking`, `descriptionShort`, `descriptionLong`, `chargepricecountry`, `chargepricenetwork`, `chargepriceplugTypes`, `networkUrl`, `chargerUrl` FROM `ChargeLocation`")
|
||||
|
||||
db.execSQL("DROP TABLE `ChargeLocation`")
|
||||
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
|
||||
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `SavedRegion` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `SavedRegion` (`filters`, `dataSource`)");
|
||||
db.query("SELECT AddGeometryColumn('SavedRegion', 'region', 4326, 'POLYGON', 'XY');")
|
||||
.moveToNext()
|
||||
db.query("SELECT CreateSpatialIndex('SavedRegion', 'region');")
|
||||
.moveToNext()
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_21 = object : Migration(20, 21) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// clear cache with this update
|
||||
db.execSQL("DELETE FROM savedregion")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a backup of the database to evmap-backup.db.
|
||||
*
|
||||
* The backup excludes cached data which can easily be retrieved from the network on restore.
|
||||
*/
|
||||
suspend fun createBackup(context: Context, fileName: String) {
|
||||
val db = getInstance(context.applicationContext)
|
||||
val backupDb = initDb(
|
||||
SpatiaRoom.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
fileName
|
||||
)
|
||||
)
|
||||
backupDb.clearAllTables()
|
||||
|
||||
val favorites = db.favoritesDao().getAllFavoritesAsync()
|
||||
backupDb.chargeLocationsDao().insert(*favorites.map { it.charger }.toTypedArray())
|
||||
backupDb.favoritesDao().insert(*favorites.map { it.favorite }.toTypedArray())
|
||||
backupDb.filterProfileDao().insert(*db.filterProfileDao().getAllProfiles().toTypedArray())
|
||||
backupDb.filterValueDao().insert(*db.filterValueDao().getAllFilterValues().toTypedArray())
|
||||
backupDb.recentAutocompletePlaceDao()
|
||||
.insert(*db.recentAutocompletePlaceDao().getAllAsync().toTypedArray())
|
||||
backupDb.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import net.vonforst.evmap.api.availability.TeslaAvailabilityDetector
|
||||
|
||||
/**
|
||||
* Encrypted data storage for sensitive data such as API access tokens.
|
||||
* This will not be included in backups.
|
||||
*/
|
||||
class EncryptedPreferenceDataStore(context: Context) : TeslaAvailabilityDetector.TokenStore {
|
||||
val sp = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"encrypted_prefs",
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
override var teslaRefreshToken: String?
|
||||
get() = sp.getString(
|
||||
"tesla_refresh_token", null
|
||||
)
|
||||
set(value) {
|
||||
sp.edit().putString("tesla_refresh_token", value).apply()
|
||||
}
|
||||
override var teslaAccessToken: String?
|
||||
get() = sp.getString("tesla_access_token", null)
|
||||
set(value) {
|
||||
sp.edit().putString("tesla_access_token", value).apply()
|
||||
}
|
||||
override var teslaAccessTokenExpiry: Long
|
||||
get() = sp.getLong("tesla_access_token_expiry", -1)
|
||||
set(value) {
|
||||
sp.edit().putLong("tesla_access_token_expiry", value).apply()
|
||||
}
|
||||
|
||||
var teslaEmail: String?
|
||||
get() = sp.getString("tesla_email", null)
|
||||
set(value) {
|
||||
sp.edit().putString("tesla_email", value).apply()
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ interface FavoritesDao {
|
||||
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id")
|
||||
suspend fun getAllFavoritesAsync(): List<FavoriteWithDetail>
|
||||
|
||||
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE lat >= :lat1 AND lat <= :lat2 AND lng >= :lng1 AND lng <= :lng2")
|
||||
@SkipQueryVerification
|
||||
@Query("SELECT * FROM favorite LEFT JOIN chargelocation ON favorite.chargerDataSource = chargelocation.dataSource AND favorite.chargerId = chargelocation.id WHERE Within(chargelocation.coordinates, BuildMbr(:lng1, :lat1, :lng2, :lat2))")
|
||||
suspend fun getFavoritesInBoundsAsync(
|
||||
lat1: Double,
|
||||
lat2: Double,
|
||||
|
||||
@@ -19,7 +19,7 @@ data class FilterProfile(
|
||||
@Dao
|
||||
interface FilterProfileDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(profile: FilterProfile): Long
|
||||
suspend fun insert(vararg profile: FilterProfile)
|
||||
|
||||
@Update
|
||||
suspend fun update(vararg profiles: FilterProfile)
|
||||
@@ -30,6 +30,9 @@ interface FilterProfileDao {
|
||||
@Query("SELECT * FROM filterProfile WHERE dataSource = :dataSource AND id != $FILTERS_CUSTOM ORDER BY `order` ASC, `name` ASC")
|
||||
fun getProfiles(dataSource: String): LiveData<List<FilterProfile>>
|
||||
|
||||
@Query("SELECT * FROM filterProfile")
|
||||
suspend fun getAllProfiles(): List<FilterProfile>
|
||||
|
||||
@Query("SELECT * FROM filterProfile WHERE dataSource = :dataSource AND name = :name")
|
||||
suspend fun getProfileByName(name: String, dataSource: String): FilterProfile?
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ abstract class FilterValueDao {
|
||||
dataSource: String
|
||||
): List<SliderFilterValue>
|
||||
|
||||
@Query("SELECT * FROM booleanfiltervalue")
|
||||
protected abstract suspend fun getAllBooleanFilterValuesAsync(): List<BooleanFilterValue>
|
||||
|
||||
@Query("SELECT * FROM multiplechoicefiltervalue")
|
||||
protected abstract suspend fun getAllMultipleChoiceFilterValuesAsync(): List<MultipleChoiceFilterValue>
|
||||
|
||||
@Query("SELECT * FROM sliderfiltervalue")
|
||||
protected abstract suspend fun getAllSliderFilterValuesAsync(): List<SliderFilterValue>
|
||||
|
||||
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract fun getBooleanFilterValues(
|
||||
profile: Long,
|
||||
@@ -105,6 +114,11 @@ abstract class FilterValueDao {
|
||||
}
|
||||
}
|
||||
|
||||
open suspend fun getAllFilterValues(): List<FilterValue> =
|
||||
getAllBooleanFilterValuesAsync() +
|
||||
getAllMultipleChoiceFilterValuesAsync() +
|
||||
getAllSliderFilterValuesAsync()
|
||||
|
||||
@Transaction
|
||||
open suspend fun insert(vararg values: FilterValue) {
|
||||
values.forEach {
|
||||
|
||||