Compare commits
290 Commits
1.4.8
...
screenshot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e1e22a44b | ||
|
|
d638a88f4d | ||
|
|
b9c08a8e75 | ||
|
|
ab2a77d25a | ||
|
|
e0ddc9f734 | ||
|
|
168fa244d3 | ||
|
|
76388ccc6e | ||
|
|
76aac7dae4 | ||
|
|
ee8b586079 | ||
|
|
360e7767bd | ||
|
|
5f0c9fd31d | ||
|
|
536c884f23 | ||
|
|
7daf5a0adb | ||
|
|
862f2b06d8 | ||
|
|
198a9ecc48 | ||
|
|
2762a32105 | ||
|
|
8a83a80e75 | ||
|
|
75e8569964 | ||
|
|
00b26d224f | ||
|
|
836f42b299 | ||
|
|
3de994f09d | ||
|
|
d78eda9d97 | ||
|
|
ed4be05aed | ||
|
|
45de4c8ff0 | ||
|
|
b5b785be07 | ||
|
|
0b8589d599 | ||
|
|
ba757831f3 | ||
|
|
1990152836 | ||
|
|
50fd433439 | ||
|
|
381e6f3d98 | ||
|
|
9c5582f19c | ||
|
|
1c16d8cbb6 | ||
|
|
1734f1c09e | ||
|
|
ebf0f82597 | ||
|
|
85a38a6da1 | ||
|
|
60ca97179c | ||
|
|
4413cba9fa | ||
|
|
e2e6a3060b | ||
|
|
7e507cad70 | ||
|
|
3b8aca3eff | ||
|
|
7a525d3f28 | ||
|
|
b0d7f465bc | ||
|
|
5bf839d4e9 | ||
|
|
73247b5a23 | ||
|
|
526addd010 | ||
|
|
287c5f60bc | ||
|
|
b68ab8f960 | ||
|
|
17d8bfc46a | ||
|
|
640f98c90f | ||
|
|
b1cf9809f6 | ||
|
|
c171c859af | ||
|
|
75c8114676 | ||
|
|
1d10ffeb52 | ||
|
|
0e278bfedb | ||
|
|
c6395feaa3 | ||
|
|
4b38c0de2d | ||
|
|
22d24f3bd0 | ||
|
|
d8e8475666 | ||
|
|
c3148796d4 | ||
|
|
8551966348 | ||
|
|
5f8be9dc0c | ||
|
|
0dc1a0270e | ||
|
|
609d984df1 | ||
|
|
5830965d3a | ||
|
|
b613b4d626 | ||
|
|
2e96ebbcd1 | ||
|
|
579ce088dc | ||
|
|
be15be00bd | ||
|
|
3266c623eb | ||
|
|
f6feb2cf8c | ||
|
|
ac1a0e01e3 | ||
|
|
7b038ad850 | ||
|
|
d02c9cc005 | ||
|
|
c83ecf1e5a | ||
|
|
8287084818 | ||
|
|
8f433a02a0 | ||
|
|
de6890e27e | ||
|
|
55b3a10919 | ||
|
|
08b6902020 | ||
|
|
7183475f31 | ||
|
|
ca6ff94c1f | ||
|
|
c02c259162 | ||
|
|
a44718ded2 | ||
|
|
4f268f5e83 | ||
|
|
99b4841545 | ||
|
|
7ad7d7da30 | ||
|
|
0e80f2bf82 | ||
|
|
b5a6ceb5f9 | ||
|
|
c655cae405 | ||
|
|
fb2e510220 | ||
|
|
c170270557 | ||
|
|
e7b42e2c19 | ||
|
|
fc85e631c9 | ||
|
|
7192c9ebfa | ||
|
|
c652265ea1 | ||
|
|
0320238dc9 | ||
|
|
e814c088bf | ||
|
|
b60b2d70b9 | ||
|
|
3905656ea7 | ||
|
|
1f88e5fbdd | ||
|
|
646469e9ea | ||
|
|
cce7c69d74 | ||
|
|
bc91c0571b | ||
|
|
a83102a97e | ||
|
|
f52a98540c | ||
|
|
e0d97e7219 | ||
|
|
3bbd20a57e | ||
|
|
3279c5eceb | ||
|
|
03d958ac2c | ||
|
|
b1fd370101 | ||
|
|
bdc96fcd57 | ||
|
|
0d54e17eb4 | ||
|
|
b1d0081fb7 | ||
|
|
1134499532 | ||
|
|
0417ade802 | ||
|
|
8fafabf6a8 | ||
|
|
1b3c35e94f | ||
|
|
23a3adc500 | ||
|
|
16c2dcc938 | ||
|
|
f322974e52 | ||
|
|
50ae2123e9 | ||
|
|
72894399f6 | ||
|
|
77014d754f | ||
|
|
66dbd6426f | ||
|
|
e4127f4a56 | ||
|
|
f9bf8b80f7 | ||
|
|
67eeb47d5f | ||
|
|
3c6a7cd536 | ||
|
|
31e3509369 | ||
|
|
b03f765216 | ||
|
|
9222dec613 | ||
|
|
71c36fbc8f | ||
|
|
830477e664 | ||
|
|
3ce91a9c50 | ||
|
|
a3b2b94b25 | ||
|
|
a7770e1c1b | ||
|
|
fcd51307cb | ||
|
|
ba4a9c29b2 | ||
|
|
3463177ad2 | ||
|
|
09deaf5080 | ||
|
|
23f429bbea | ||
|
|
1184d3b6cc | ||
|
|
c95a60807b | ||
|
|
4b8cf82843 | ||
|
|
f33b9e8117 | ||
|
|
cbc3040807 | ||
|
|
92619ea95e | ||
|
|
a7007284ff | ||
|
|
7fce566052 | ||
|
|
0c44b4b074 | ||
|
|
a652d96f74 | ||
|
|
8a9b3ad948 | ||
|
|
c48f33e265 | ||
|
|
8ba4897026 | ||
|
|
42916d71ca | ||
|
|
5ca7524e8b | ||
|
|
c37f72a26b | ||
|
|
6f0113c50d | ||
|
|
f99ea7ca9e | ||
|
|
788db0c10f | ||
|
|
db2213a50f | ||
|
|
ace4126035 | ||
|
|
d5d6e4f314 | ||
|
|
55999d15e6 | ||
|
|
b61ca609d3 | ||
|
|
b0afad2144 | ||
|
|
94842954e3 | ||
|
|
1bee5f7e13 | ||
|
|
d636cde70e | ||
|
|
2a4497fe7a | ||
|
|
10e3287d82 | ||
|
|
a0f7a389c8 | ||
|
|
1cabb8dccf | ||
|
|
85079bb888 | ||
|
|
10bfc21f54 | ||
|
|
ae33fce637 | ||
|
|
773d57b9a9 | ||
|
|
022f570322 | ||
|
|
a6c2b30325 | ||
|
|
2210e65e5c | ||
|
|
024e3cef35 | ||
|
|
687ef2ec0f | ||
|
|
9e61dce7be | ||
|
|
aad7a320d0 | ||
|
|
96df684b80 | ||
|
|
7903c027c7 | ||
|
|
06801c1898 | ||
|
|
c946b0fcd3 | ||
|
|
dd4fcc7550 | ||
|
|
2ce82b961b | ||
|
|
1be519b1ee | ||
|
|
01737f21d2 | ||
|
|
17ce9f420b | ||
|
|
6eb90498eb | ||
|
|
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 |
2
.github/FUNDING.yml
vendored
@@ -1,2 +1,2 @@
|
||||
github: johan12345
|
||||
custom: 'https://paypal.me/johan98'
|
||||
custom: ['https://paypal.me/johan98', 'https://ev-map.app/donate/']
|
||||
|
||||
1
.github/workflows/apikeys-ci.xml
vendored
@@ -5,4 +5,5 @@
|
||||
<string name="chargeprice_key" translatable="false">ci</string>
|
||||
<string name="openchargemap_key" translatable="false">ci</string>
|
||||
<string name="fronyx_key" translatable="false">ci</string>
|
||||
<string name="acra_credentials" translatable="false">ci:ci</string>
|
||||
</resources>
|
||||
|
||||
18
.github/workflows/release.yml
vendored
@@ -11,18 +11,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java environment
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
distribution: 'zulu'
|
||||
cache: 'gradle'
|
||||
- name: Decrypt keystore
|
||||
run: openssl aes-256-cbc -K ${{ secrets.encrypted_53968681344a_key }} -iv ${{ secrets.encrypted_53968681344a_iv }} -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
|
||||
- name: Extract version code
|
||||
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s\+[0-9]*" app/build.gradle | awk '{ print $2 }' | tr -d \''"\\')" >> $GITHUB_ENV
|
||||
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s*=\s*[0-9]\+" app/build.gradle.kts | awk '{ print $3 }' | tr -d \''"\\')" >> $GITHUB_ENV
|
||||
|
||||
- name: Build app release
|
||||
env:
|
||||
@@ -32,6 +32,7 @@ jobs:
|
||||
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
|
||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
|
||||
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
|
||||
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
|
||||
@@ -76,3 +77,12 @@ jobs:
|
||||
asset_path: app/build/outputs/apk/googleAutomotive/release/app-google-automotive-release.apk
|
||||
asset_name: app-google-automotive-release.apk
|
||||
asset_content_type: application/vnd.android.package-archive
|
||||
- name: upload Foss Automotive artifact
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fossAutomotive/release/app-foss-automotive-release.apk
|
||||
asset_name: app-foss-automotive-release.apk
|
||||
asset_content_type: application/vnd.android.package-archive
|
||||
|
||||
115
.github/workflows/screenshots.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
name: Generate Screenshots
|
||||
|
||||
jobs:
|
||||
|
||||
screenshot:
|
||||
name: Generate screenshots
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
device:
|
||||
- profile: pixel_6
|
||||
api-level: 34
|
||||
display_size: 1080x2336 # subtracted the 64px bottom navigation bar
|
||||
type: phone
|
||||
- profile: pixel_tablet
|
||||
api-level: 34
|
||||
display_size: 2560x1488 # subtracted the 64px navigation bar and 48px status bar
|
||||
type: tenInch
|
||||
env:
|
||||
ANDROID_USER_HOME: /home/runner/.android
|
||||
steps:
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Retrieve debug keystore
|
||||
env:
|
||||
DEBUG_KEYSTORE_BASE64: ${{ secrets.DEBUG_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
mkdir ~/.config/.android
|
||||
echo $DEBUG_KEYSTORE_BASE64 | base64 --decode > ~/.config/.android/debug.keystore
|
||||
|
||||
- name: Set up Java environment
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'zulu'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Setup Android SDK
|
||||
run: |
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "cmdline-tools;latest"
|
||||
rm -r $ANDROID_HOME/cmdline-tools/latest
|
||||
mv $ANDROID_HOME/cmdline-tools/latest-2 $ANDROID_HOME/cmdline-tools/latest
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --version
|
||||
|
||||
- name: AVD cache
|
||||
uses: actions/cache@v3
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/*
|
||||
~/.android/adb*
|
||||
key: ${{ runner.os }}-avd-api${{ matrix.device.api-level }}-${{ matrix.device.profile }}
|
||||
|
||||
- name: create AVD and generate snapshot for caching
|
||||
if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: ${{ matrix.device.api-level }}
|
||||
target: google_atd
|
||||
arch: x86_64
|
||||
profile: ${{ matrix.device.profile }}
|
||||
force-avd-creation: false
|
||||
ram-size: 2048M
|
||||
disk-size: 4096M
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
|
||||
disable-animations: true
|
||||
script: echo "Generated AVD snapshot for caching."
|
||||
|
||||
- name: Build app
|
||||
run: ./gradlew assembleGoogleNormalDebug assembleGoogleNormalAndroidTest
|
||||
env:
|
||||
GOINGELECTRIC_API_KEY: ${{ secrets.GOINGELECTRIC_API_KEY }}
|
||||
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
|
||||
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
|
||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
|
||||
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
|
||||
|
||||
- name: Run emulator and generate screenshots
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: ${{ matrix.device.api-level }}
|
||||
target: google_atd
|
||||
arch: x86_64
|
||||
profile: ${{ matrix.device.profile }}
|
||||
force-avd-creation: false
|
||||
ram-size: 2048M
|
||||
disk-size: 4096M
|
||||
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
|
||||
disable-animations: true
|
||||
script: |
|
||||
adb shell pm clear net.vonforst.evmap.debug || true
|
||||
adb shell wm size ${{ matrix.device.display_size }}
|
||||
adb logcat -c
|
||||
|
||||
adb logcat *:E &
|
||||
fastlane screengrab --app_apk_path app/build/outputs/apk/googleNormal/debug/app-google-normal-debug.apk --test_apk_path app/build/outputs/apk/androidTest/googleNormal/debug/app-google-normal-debug-androidTest.apk --tests_package_name=net.vonforst.evmap.debug.test --app_package_name net.vonforst.evmap.debug -p net.vonforst.evmap.screenshot --use_timestamp_suffix false --clear_previous_screenshots true --device_type=${{ matrix.device.type }} -q en-US,de-DE,fr-FR,nb-rNO,nl-NL,pt-PT,ro-RO
|
||||
|
||||
- name: Upload screenshots as artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: screenshots-${{ matrix.device.profile }}-${{ matrix.device.api-level }}
|
||||
path: fastlane/metadata/android
|
||||
8
.github/workflows/tests.yml
vendored
@@ -13,15 +13,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
buildvariant: [ FossNormal, GoogleNormal, GoogleAutomotive ]
|
||||
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java environment
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v4
|
||||
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-2024 Johan von Forstner and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
25
README.md
@@ -1,7 +1,8 @@
|
||||
EVMap [](https://github.com/ev-map/EVMap/actions)
|
||||
=====
|
||||
|
||||
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
|
||||
<a href="https://ev-map.app" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/></a>
|
||||
|
||||
Android app to find electric vehicle charging stations.
|
||||
|
||||
@@ -36,19 +37,33 @@ Development setup
|
||||
The App is developed using Android Studio and should pretty much work out-of-the-box when you clone
|
||||
the Git repository and open the project with Android Studio.
|
||||
|
||||
The only exception is that you need to obtain some free API keys for the different data sources that
|
||||
The only exception is that you need to obtain some API keys for the different data sources that
|
||||
EVMap uses and put them into the app in the form of a resource file called `apikeys.xml` under
|
||||
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
|
||||
features and how they can be obtained in our [documentation page](doc/api_keys.md).
|
||||
|
||||
There are three different build flavors, `googleNormal`, `fossNormal` and `googleAutomotive`.
|
||||
- The `foss` variant only uses Mapbox data and should run on most Android devices, even without
|
||||
- The `foss` variants only use Mapbox data and should run on most Android devices, even without
|
||||
Google Play Services.
|
||||
- `fossNormal` is intended to run on smartphones and tablets, and also includes the Android
|
||||
Auto app for use on the car display (however for that to work, the Android Auto app is
|
||||
necessary, which in turn does require Google Play Services).
|
||||
- `fossAutomotive` can be installed directly on
|
||||
[Android Automotive OS (AAOS)](https://source.android.com/docs/automotive/start/what_automotive)
|
||||
headunits without Google services.
|
||||
It does not provide the usual smartphone UI, and requires an implementation of the
|
||||
[AOSP template app host](https://source.android.com/docs/automotive/hmi/aosp_host)
|
||||
to be installed. If you are an OEM and would like to distribute EVMap to your AAOS vehicles,
|
||||
please [get in touch](mailto:evmap@vonforst.net).
|
||||
- The `google` variants also include access to Google Maps data.
|
||||
- `googleNormal` is intended to run on smartphones and tablets, and also includes the Android
|
||||
Auto app for use on the car display.
|
||||
- `googleAutomotive` variant is intended to be installed directly on car infotainment systems
|
||||
using the Google-flavored Android Automotive OS. It does not provide the usual smartphone UI.
|
||||
- `googleAutomotive` can be installed directly on car infotainment systems running the
|
||||
Google-flavored Android Automotive OS (Google Automotive Services /
|
||||
["Google built-in"](https://built-in.google/cars/)).
|
||||
It does not provide the usual smartphone UI, and requires the
|
||||
[Google Automotive App Host](https://play.google.com/store/apps/details?id=com.google.android.apps.automotive.templates.host)
|
||||
to run, which should be preinstalled on those cars and can be updated through the Play Store.
|
||||
|
||||
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
|
||||
app.
|
||||
|
||||
@@ -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 |
276
app/build.gradle
@@ -1,276 +0,0 @@
|
||||
plugins {
|
||||
id 'com.adarshr.test-logger' version '3.1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'androidx.navigation.safeargs.kotlin'
|
||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
|
||||
def supportedLocales = "en,de,fr,nb-rNO,nl"
|
||||
|
||||
android {
|
||||
compileSdkVersion 33
|
||||
buildToolsVersion "30.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "net.vonforst.evmap"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 33
|
||||
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||
versionCode 164
|
||||
versionName "1.4.8"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
resConfigs supportedLocales.split(',')
|
||||
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
def isRunningOnCI = System.getenv("CI") == "true"
|
||||
if (isRunningOnCI) {
|
||||
// configure keystore
|
||||
storeFile = file("../_ci/keystore.jks")
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("KEYSTORE_ALIAS")
|
||||
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
debuggable true
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "dependencies", "automotive"
|
||||
productFlavors {
|
||||
foss {
|
||||
dimension "dependencies"
|
||||
}
|
||||
google {
|
||||
dimension "dependencies"
|
||||
versionNameSuffix "-google"
|
||||
}
|
||||
normal {
|
||||
dimension "automotive"
|
||||
}
|
||||
automotive {
|
||||
dimension "automotive"
|
||||
versionNameSuffix "-automotive"
|
||||
versionCode defaultConfig.versionCode + 1
|
||||
minSdkVersion 29
|
||||
}
|
||||
}
|
||||
variantFilter { variant ->
|
||||
def names = variant.flavors*.name
|
||||
// Android Automotive OS app is always based on Google variant
|
||||
if (names.contains("automotive") && !names.contains("google")) {
|
||||
setIgnore(true)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
viewBinding true
|
||||
}
|
||||
lint {
|
||||
disable 'NullSafeMutableLiveData'
|
||||
warning 'MissingTranslation'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources true
|
||||
}
|
||||
|
||||
namespace 'net.vonforst.evmap'
|
||||
|
||||
// add API keys from environment variable if not set in apikeys.xml
|
||||
applicationVariants.all { variant ->
|
||||
ext.env = System.getenv()
|
||||
def goingelectricKey = env.GOINGELECTRIC_API_KEY ?: project.findProperty("GOINGELECTRIC_API_KEY")
|
||||
if (goingelectricKey != null) {
|
||||
variant.resValue "string", "goingelectric_key", goingelectricKey
|
||||
}
|
||||
def openchargemapKey = env.OPENCHARGEMAP_API_KEY ?: project.findProperty("OPENCHARGEMAP_API_KEY")
|
||||
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
|
||||
openchargemapKey = decode(project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
||||
}
|
||||
if (openchargemapKey != null) {
|
||||
variant.resValue "string", "openchargemap_key", openchargemapKey
|
||||
}
|
||||
def googleMapsKey = env.GOOGLE_MAPS_API_KEY ?: project.findProperty("GOOGLE_MAPS_API_KEY")
|
||||
if (googleMapsKey != null && variant.flavorName.startsWith('google')) {
|
||||
variant.resValue "string", "google_maps_key", googleMapsKey
|
||||
}
|
||||
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
|
||||
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
|
||||
mapboxKey = decode(project.findProperty("MAPBOX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
||||
}
|
||||
if (mapboxKey != null) {
|
||||
variant.resValue "string", "mapbox_key", mapboxKey
|
||||
}
|
||||
def chargepriceKey = env.CHARGEPRICE_API_KEY ?: project.findProperty("CHARGEPRICE_API_KEY")
|
||||
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
|
||||
chargepriceKey = decode(project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
||||
}
|
||||
if (chargepriceKey != null) {
|
||||
variant.resValue "string", "chargeprice_key", chargepriceKey
|
||||
}
|
||||
def fronyxKey = env.FRONYX_API_KEY ?: project.findProperty("FRONYX_API_KEY")
|
||||
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
|
||||
fronyxKey = decode(project.findProperty("FRONYX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
||||
}
|
||||
if (fronyxKey != null) {
|
||||
variant.resValue "string", "fronyx_key", fronyxKey
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
configurations {
|
||||
googleNormalImplementation {}
|
||||
googleAutomotiveImplementation {}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-ktx:1.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.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'com.google.android.material:material:1.8.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.browser:browser:1.5.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
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.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
|
||||
implementation 'io.coil-kt:coil:1.1.0'
|
||||
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
|
||||
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
|
||||
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
|
||||
implementation 'com.airbnb.android:lottie:4.1.0'
|
||||
implementation 'io.michaelrocks.bimap:bimap:1.1.0'
|
||||
implementation 'com.google.guava:guava:29.0-android'
|
||||
implementation 'com.github.pengrad:mapscaleview:1.6.0'
|
||||
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
|
||||
|
||||
// Android Auto
|
||||
def carAppVersion = '1.3.0-rc01'
|
||||
googleImplementation "androidx.car.app:app:$carAppVersion"
|
||||
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
|
||||
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
|
||||
|
||||
// AnyMaps
|
||||
def anyMapsVersion = '7fdcf50fc4'
|
||||
implementation "com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion"
|
||||
googleImplementation "com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion"
|
||||
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
|
||||
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
|
||||
exclude group: 'com.google.android.gms', module: 'play-services-location'
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-core'
|
||||
}
|
||||
// original version of mapbox-android-core
|
||||
googleImplementation 'com.mapbox.mapboxsdk:mapbox-android-core:2.0.1'
|
||||
// patched version that removes build-time dependency on GMS (-> no Google location services)
|
||||
fossImplementation 'com.github.ev-map:mapbox-events-android:a21c324501'
|
||||
|
||||
// Google Places
|
||||
googleImplementation 'com.google.android.libraries.places:places:3.0.0'
|
||||
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4'
|
||||
|
||||
// Mapbox Geocoding
|
||||
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
|
||||
|
||||
// navigation library
|
||||
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
|
||||
// viewmodel library
|
||||
def lifecycle_version = "2.5.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"
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
|
||||
// billing library
|
||||
def billing_version = "5.1.0"
|
||||
googleImplementation "com.android.billingclient:billing:$billing_version"
|
||||
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
|
||||
|
||||
// ACRA (crash reporting)
|
||||
def acraVersion = "5.8.4"
|
||||
implementation("ch.acra:acra-mail:$acraVersion")
|
||||
implementation("ch.acra:acra-dialog:$acraVersion")
|
||||
implementation("ch.acra:acra-limiter:$acraVersion")
|
||||
|
||||
// debug tools
|
||||
implementation 'com.facebook.stetho:stetho:1.6.0'
|
||||
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
|
||||
|
||||
// testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
|
||||
//noinspection GradleDependency
|
||||
testImplementation 'org.json:json:20080701'
|
||||
|
||||
// testing for car app
|
||||
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
|
||||
testGoogleImplementation 'org.robolectric:robolectric:4.9'
|
||||
testGoogleImplementation 'androidx.test:core:1.5.0'
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
|
||||
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2'
|
||||
}
|
||||
|
||||
private static String decode(String s, String key) {
|
||||
return new String(xorWithKey(s.decodeBase64(), key.getBytes()), "UTF-8");
|
||||
}
|
||||
|
||||
private static byte[] xorWithKey(byte[] a, byte[] key) {
|
||||
byte[] out = new byte[a.length];
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
out[i] = (byte) (a[i] ^ key[i%key.length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
363
app/build.gradle.kts
Normal file
@@ -0,0 +1,363 @@
|
||||
import java.util.Base64
|
||||
|
||||
plugins {
|
||||
id("com.adarshr.test-logger") version "3.1.0"
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("kotlin-parcelize")
|
||||
id("kotlin-kapt")
|
||||
id("androidx.navigation.safeargs.kotlin")
|
||||
id("com.mikepenz.aboutlibraries.plugin")
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
defaultConfig {
|
||||
applicationId = "net.vonforst.evmap"
|
||||
compileSdk = 34
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||
versionCode = 212
|
||||
versionName = "1.8.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val isRunningOnCI = System.getenv("CI") == "true"
|
||||
if (isRunningOnCI) {
|
||||
// configure keystore
|
||||
storeFile = file("../_ci/keystore.jks")
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("KEYSTORE_ALIAS")
|
||||
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
isDebuggable = true
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += listOf("dependencies", "automotive")
|
||||
productFlavors {
|
||||
create("foss") {
|
||||
dimension = "dependencies"
|
||||
}
|
||||
create("google") {
|
||||
dimension = "dependencies"
|
||||
versionNameSuffix = "-google"
|
||||
}
|
||||
create("normal") {
|
||||
dimension = "automotive"
|
||||
}
|
||||
create("automotive") {
|
||||
dimension = "automotive"
|
||||
versionNameSuffix = "-automotive"
|
||||
versionCode = defaultConfig.versionCode!! + 1
|
||||
minSdk = 29
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs>().configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
lint {
|
||||
disable += listOf("NullSafeMutableLiveData")
|
||||
warning += listOf("MissingTranslation")
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
namespace = "net.vonforst.evmap"
|
||||
|
||||
// add API keys from environment variable if not set in apikeys.xml
|
||||
applicationVariants.all {
|
||||
val goingelectricKey =
|
||||
System.getenv("GOINGELECTRIC_API_KEY") ?: project.findProperty("GOINGELECTRIC_API_KEY")
|
||||
?.toString()
|
||||
if (goingelectricKey != null) {
|
||||
resValue("string", "goingelectric_key", goingelectricKey)
|
||||
}
|
||||
var openchargemapKey =
|
||||
System.getenv("OPENCHARGEMAP_API_KEY") ?: project.findProperty("OPENCHARGEMAP_API_KEY")
|
||||
?.toString()
|
||||
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
|
||||
openchargemapKey = decode(
|
||||
project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED").toString(),
|
||||
"FmK.d,-f*p+rD+WK!eds"
|
||||
)
|
||||
}
|
||||
if (openchargemapKey != null) {
|
||||
resValue("string", "openchargemap_key", openchargemapKey)
|
||||
}
|
||||
val googleMapsKey =
|
||||
System.getenv("GOOGLE_MAPS_API_KEY") ?: project.findProperty("GOOGLE_MAPS_API_KEY")
|
||||
?.toString()
|
||||
if (googleMapsKey != null && flavorName.startsWith("google")) {
|
||||
resValue("string", "google_maps_key", googleMapsKey)
|
||||
}
|
||||
var mapboxKey =
|
||||
System.getenv("MAPBOX_API_KEY") ?: project.findProperty("MAPBOX_API_KEY")?.toString()
|
||||
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
|
||||
mapboxKey = decode(
|
||||
project.findProperty("MAPBOX_API_KEY_ENCRYPTED").toString(),
|
||||
"FmK.d,-f*p+rD+WK!eds"
|
||||
)
|
||||
}
|
||||
if (mapboxKey != null) {
|
||||
resValue("string", "mapbox_key", mapboxKey)
|
||||
}
|
||||
var chargepriceKey =
|
||||
System.getenv("CHARGEPRICE_API_KEY") ?: project.findProperty("CHARGEPRICE_API_KEY")
|
||||
?.toString()
|
||||
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
|
||||
chargepriceKey = decode(
|
||||
project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED").toString(),
|
||||
"FmK.d,-f*p+rD+WK!eds"
|
||||
)
|
||||
}
|
||||
if (chargepriceKey != null) {
|
||||
resValue("string", "chargeprice_key", chargepriceKey)
|
||||
}
|
||||
var fronyxKey =
|
||||
System.getenv("FRONYX_API_KEY") ?: project.findProperty("FRONYX_API_KEY")?.toString()
|
||||
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
|
||||
fronyxKey = decode(
|
||||
project.findProperty("FRONYX_API_KEY_ENCRYPTED").toString(),
|
||||
"FmK.d,-f*p+rD+WK!eds"
|
||||
)
|
||||
}
|
||||
if (fronyxKey != null) {
|
||||
resValue("string", "fronyx_key", fronyxKey)
|
||||
}
|
||||
var acraKey = System.getenv("ACRA_CRASHREPORT_CREDENTIALS")
|
||||
?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")?.toString()
|
||||
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
|
||||
acraKey = decode(
|
||||
project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED").toString(),
|
||||
"FmK.d,-f*p+rD+WK!eds"
|
||||
)
|
||||
}
|
||||
if (acraKey != null) {
|
||||
resValue("string", "acra_credentials", acraKey)
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
pickFirsts.addAll(
|
||||
listOf(
|
||||
"lib/x86/libc++_shared.so",
|
||||
"lib/arm64-v8a/libc++_shared.so",
|
||||
"lib/x86_64/libc++_shared.so",
|
||||
"lib/armeabi-v7a/libc++_shared.so"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
create("googleNormalImplementation") {}
|
||||
create("googleAutomotiveImplementation") {}
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
allowedLicenses = arrayOf(
|
||||
"Apache-2.0", "mit", "BSD-2-Clause",
|
||||
"asdkl", // Android SDK
|
||||
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
|
||||
"Google Maps Platform Terms of Service" // Google Maps SDK
|
||||
)
|
||||
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val kotlinVersion: String by rootProject.extra
|
||||
val aboutLibsVersion: String by rootProject.extra
|
||||
val navVersion: String by rootProject.extra
|
||||
val normalImplementation by configurations
|
||||
val googleImplementation by configurations
|
||||
val automotiveImplementation by configurations
|
||||
val fossImplementation by configurations
|
||||
val testGoogleImplementation by configurations
|
||||
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation("androidx.activity:activity-ktx:1.8.2")
|
||||
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
||||
implementation("androidx.cardview:cardview:1.0.0")
|
||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("androidx.browser:browser:1.7.0")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
implementation("com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b")
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.11.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
|
||||
implementation("com.markomilos.jsonapi:jsonapi-retrofit:1.1.0")
|
||||
implementation("io.coil-kt:coil:2.4.0")
|
||||
implementation("com.github.ev-map:StfalconImageViewer:5082ebd392")
|
||||
implementation("com.mikepenz:aboutlibraries-core:$aboutLibsVersion")
|
||||
implementation("com.mikepenz:aboutlibraries:$aboutLibsVersion")
|
||||
implementation("com.airbnb.android:lottie:4.1.0")
|
||||
implementation("io.michaelrocks.bimap:bimap:1.1.0")
|
||||
implementation("com.google.guava:guava:29.0-android")
|
||||
implementation("com.github.pengrad:mapscaleview:1.6.0")
|
||||
implementation("com.github.romandanylyk:PageIndicatorView:b1bad589b5")
|
||||
|
||||
// Android Auto
|
||||
val carAppVersion = "1.4.0-rc02"
|
||||
implementation("androidx.car.app:app:$carAppVersion")
|
||||
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
|
||||
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
|
||||
|
||||
// AnyMaps
|
||||
val anyMapsVersion = "4854581f72"
|
||||
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.2.0")
|
||||
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
|
||||
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-accounts")
|
||||
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-telemetry")
|
||||
exclude(group = "com.google.android.gms", module = "play-services-location")
|
||||
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-core")
|
||||
}
|
||||
// original version of mapbox-android-core
|
||||
googleImplementation("com.mapbox.mapboxsdk:mapbox-android-core:2.0.1")
|
||||
// patched version that removes build-time dependency on GMS (-> no Google location services)
|
||||
fossImplementation("com.github.ev-map:mapbox-events-android:a21c324501")
|
||||
|
||||
implementation("com.mapbox.mapboxsdk:mapbox-android-sdk") {
|
||||
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-accounts")
|
||||
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-telemetry")
|
||||
version {
|
||||
strictly("9.1.0-SNAPSHOT")
|
||||
}
|
||||
}
|
||||
|
||||
// Google Places
|
||||
googleImplementation("com.google.android.libraries.places:places:3.3.0")
|
||||
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||
|
||||
// Mapbox Geocoding
|
||||
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0")
|
||||
|
||||
// navigation library
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
|
||||
|
||||
// viewmodel library
|
||||
val lifecycle_version = "2.6.2"
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
|
||||
|
||||
// room library
|
||||
val room_version = "2.6.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.9") {
|
||||
exclude(group = "com.github.dalgarins", module = "android-spatialite")
|
||||
}
|
||||
implementation("com.github.EV-map:android-spatialite:e5495c83ad") // version with minSdk increased to 21 & FORTIFY_SOURCE enabled
|
||||
|
||||
// billing library
|
||||
val billing_version = "6.1.0"
|
||||
googleImplementation("com.android.billingclient:billing:$billing_version")
|
||||
googleImplementation("com.android.billingclient:billing-ktx:$billing_version")
|
||||
|
||||
// ACRA (crash reporting)
|
||||
val acraVersion = "5.11.1"
|
||||
implementation("ch.acra:acra-http:$acraVersion")
|
||||
implementation("ch.acra:acra-dialog:$acraVersion")
|
||||
implementation("ch.acra:acra-limiter:$acraVersion")
|
||||
|
||||
// debug tools
|
||||
debugImplementation("com.facebook.flipper:flipper:0.238.0")
|
||||
debugImplementation("com.facebook.soloader:soloader:0.10.5")
|
||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:0.238.0")
|
||||
debugImplementation("androidx.test.espresso:espresso-idling-resource:3.5.1")
|
||||
|
||||
// testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.11.0")
|
||||
//noinspection GradleDependency
|
||||
testImplementation("org.json:json:20080701")
|
||||
testImplementation("org.robolectric:robolectric:4.11.1")
|
||||
testImplementation("androidx.test:core:1.5.0")
|
||||
testImplementation("androidx.arch.core:core-testing:2.2.0")
|
||||
testImplementation("androidx.car.app:app-testing:$carAppVersion")
|
||||
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1")
|
||||
androidTestImplementation("androidx.test:rules:1.5.0")
|
||||
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
|
||||
androidTestImplementation("tools.fastlane:screengrab:2.1.1")
|
||||
|
||||
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
|
||||
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
}
|
||||
|
||||
fun decode(s: String, key: String): String {
|
||||
return String(xorWithKey(Base64.getDecoder().decode(s), key.toByteArray()), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
fun xorWithKey(a: ByteArray, key: ByteArray): ByteArray {
|
||||
val out = ByteArray(a.size)
|
||||
for (i in a.indices) {
|
||||
out[i] = (a[i].toInt() xor key[i % key.size].toInt()).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
6
app/lint.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lint>
|
||||
<issue id="MissingQuantity">
|
||||
<ignore regexp=".*?Czech.*?many" />
|
||||
</issue>
|
||||
</lint>
|
||||
2
app/proguard-rules.pro
vendored
@@ -1,6 +1,6 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
@@ -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,112 @@
|
||||
/*
|
||||
* Copyright (C) 2019 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package net.vonforst.evmap.screenshot
|
||||
|
||||
import android.view.View
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.IdlingResource
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* An espresso idling resource implementation that reports idle status for all data binding
|
||||
* layouts. Data Binding uses a mechanism to post messages which Espresso doesn't track yet.
|
||||
*
|
||||
* Since this application only uses fragments, the resource only checks the fragments and their
|
||||
* children instead of the whole view tree.
|
||||
*
|
||||
* Tracking bug: https://github.com/android/android-test/issues/317
|
||||
*/
|
||||
class DataBindingIdlingResource(
|
||||
activityScenarioRule: ActivityScenarioRule<out FragmentActivity>
|
||||
) : IdlingResource {
|
||||
// list of registered callbacks
|
||||
private val idlingCallbacks = mutableListOf<IdlingResource.ResourceCallback>()
|
||||
|
||||
// give it a unique id to workaround an espresso bug where you cannot register/unregister
|
||||
// an idling resource w/ the same name.
|
||||
private val id = UUID.randomUUID().toString()
|
||||
|
||||
// holds whether isIdle is called and the result was false. We track this to avoid calling
|
||||
// onTransitionToIdle callbacks if Espresso never thought we were idle in the first place.
|
||||
private var wasNotIdle = false
|
||||
|
||||
lateinit var activity: FragmentActivity
|
||||
|
||||
override fun getName() = "DataBinding $id"
|
||||
|
||||
init {
|
||||
monitorActivity(activityScenarioRule.scenario)
|
||||
}
|
||||
|
||||
override fun isIdleNow(): Boolean {
|
||||
val idle = !getBindings().any { it.hasPendingBindings() }
|
||||
@Suppress("LiftReturnOrAssignment")
|
||||
if (idle) {
|
||||
if (wasNotIdle) {
|
||||
// notify observers to avoid espresso race detector
|
||||
idlingCallbacks.forEach { it.onTransitionToIdle() }
|
||||
}
|
||||
wasNotIdle = false
|
||||
} else {
|
||||
wasNotIdle = true
|
||||
// check next frame
|
||||
activity.findViewById<View>(android.R.id.content).postDelayed({
|
||||
isIdleNow
|
||||
}, 16)
|
||||
}
|
||||
return idle
|
||||
}
|
||||
|
||||
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
|
||||
idlingCallbacks.add(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the activity from an [ActivityScenario] to be used from [DataBindingIdlingResource].
|
||||
*/
|
||||
private fun monitorActivity(
|
||||
activityScenario: ActivityScenario<out FragmentActivity>
|
||||
) {
|
||||
activityScenario.onActivity {
|
||||
this.activity = it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all binding classes in all currently available fragments.
|
||||
*/
|
||||
private fun getBindings(): List<ViewDataBinding> {
|
||||
val fragments = (activity as? FragmentActivity)
|
||||
?.supportFragmentManager
|
||||
?.fragments
|
||||
|
||||
val bindings =
|
||||
fragments?.mapNotNull {
|
||||
it.view?.getBinding()
|
||||
} ?: emptyList()
|
||||
val childrenBindings = fragments?.flatMap { it.childFragmentManager.fragments }
|
||||
?.mapNotNull { it.view?.getBinding() } ?: emptyList()
|
||||
|
||||
return bindings + childrenBindings
|
||||
}
|
||||
}
|
||||
|
||||
private fun View.getBinding(): ViewDataBinding? = DataBindingUtil.getBinding(this)
|
||||
@@ -0,0 +1,155 @@
|
||||
package net.vonforst.evmap.screenshot
|
||||
|
||||
import android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
import android.content.Intent
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.IdlingRegistry
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.action.ViewActions.pressBack
|
||||
import androidx.test.espresso.contrib.DrawerActions
|
||||
import androidx.test.espresso.contrib.NavigationViewActions
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isRoot
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import androidx.test.rule.GrantPermissionRule.grant
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.vonforst.evmap.EXTRA_CHARGER_ID
|
||||
import net.vonforst.evmap.EXTRA_LAT
|
||||
import net.vonforst.evmap.EXTRA_LON
|
||||
import net.vonforst.evmap.EspressoIdlingResource
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.goingelectric.GEReferenceData
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.model.Favorite
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import tools.fastlane.screengrab.Screengrab
|
||||
import tools.fastlane.screengrab.locale.LocaleTestRule
|
||||
import java.time.Instant
|
||||
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ScreenshotTest {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@BeforeClass
|
||||
fun beforeAll() {
|
||||
IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource)
|
||||
|
||||
Screengrab.setDefaultScreenshotStrategy { screenshotName, screenshotCallback ->
|
||||
screenshotCallback.screenshotCaptured(
|
||||
screenshotName,
|
||||
androidx.test.core.app.takeScreenshot()
|
||||
)
|
||||
}
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val prefs = PreferenceDataSource(context)
|
||||
prefs.dataSourceSet = true
|
||||
prefs.welcomeDialogShown = true
|
||||
prefs.privacyAccepted = true
|
||||
prefs.opensourceDonationsDialogLastShown = Instant.now()
|
||||
prefs.chargepriceMyVehicles = setOf("b58bc94d-d929-ad71-d95b-08b877bf76ba")
|
||||
prefs.appStartCounter = 0
|
||||
prefs.mapProvider = "google"
|
||||
|
||||
// insert favorites
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val api = GoingElectricApiWrapper(
|
||||
context.getString(R.string.goingelectric_key),
|
||||
context = context
|
||||
)
|
||||
val ids = listOf(70774L to true, 40315L to true, 65330L to true, 62489L to false)
|
||||
runBlocking {
|
||||
val refData = api.getReferenceData().data as GEReferenceData
|
||||
ids.forEachIndexed { i, (id, favorite) ->
|
||||
val detail = api.getChargepointDetail(refData, id).data!!
|
||||
db.chargeLocationsDao().insert(detail)
|
||||
if (db.favoritesDao().findFavorite(id, "goingelectric") == null && favorite) {
|
||||
db.favoritesDao().insert(
|
||||
Favorite(
|
||||
chargerId = id,
|
||||
chargerDataSource = "goingelectric"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val localeTestRule = LocaleTestRule()
|
||||
|
||||
@get:Rule
|
||||
val activityRule: ActivityScenarioRule<MapsActivity> = ActivityScenarioRule(
|
||||
Intent(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
MapsActivity::class.java
|
||||
).apply {
|
||||
putExtra(EXTRA_CHARGER_ID, 62489L)
|
||||
putExtra(EXTRA_LAT, 53.099512)
|
||||
putExtra(EXTRA_LON, 9.981884)
|
||||
})
|
||||
|
||||
@get:Rule
|
||||
val permissionRule: GrantPermissionRule = grant(ACCESS_FINE_LOCATION)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
IdlingRegistry.getInstance().register(DataBindingIdlingResource(activityRule))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTakeScreenshot() {
|
||||
Thread.sleep(15000L)
|
||||
Screengrab.screenshot("01_map_google")
|
||||
|
||||
onView(withId(R.id.topPart)).perform(click())
|
||||
|
||||
Thread.sleep(1000L)
|
||||
Screengrab.screenshot("02_detail")
|
||||
|
||||
onView(withId(R.id.btnChargeprice)).perform(click())
|
||||
Thread.sleep(5000L)
|
||||
Screengrab.screenshot("03_prices")
|
||||
|
||||
onView(isRoot()).perform(pressBack())
|
||||
Thread.sleep(500L)
|
||||
onView(isRoot()).perform(pressBack())
|
||||
|
||||
Thread.sleep(2000L)
|
||||
|
||||
onView(withId(R.id.menu_filter)).perform(click())
|
||||
Thread.sleep(1000L)
|
||||
onView(withText(R.string.menu_edit_filters)).perform(click())
|
||||
|
||||
Thread.sleep(1000L)
|
||||
|
||||
Screengrab.screenshot("05_filters")
|
||||
onView(isRoot()).perform(pressBack())
|
||||
|
||||
onView(withId(R.id.drawer_layout)).perform(DrawerActions.open())
|
||||
onView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.favs))
|
||||
|
||||
Thread.sleep(10000L)
|
||||
Screengrab.screenshot("04_favorites")
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
PreferenceDataSource(context).mapProvider = "mapbox"
|
||||
onView(isRoot()).perform(pressBack())
|
||||
|
||||
Thread.sleep(5000L)
|
||||
Screengrab.screenshot("01_map_mapbox")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/automotive/res/values-cs/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="grant_on_phone">Povolit</string>
|
||||
<string name="auto_location_permission_needed">Pro spuštění aplikace EVMap ve vašem autě musíte povolit přístup k vaší poloze.</string>
|
||||
</resources>
|
||||
5
app/src/automotive/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/automotive/res/values-ro/strings.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
29
app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Permissions needed for fastlane screengrab -->
|
||||
|
||||
<!-- Allows unlocking your device and activating its screen so UI tests can succeed -->
|
||||
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Allows for storing and retrieving screenshots -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<!-- Allows changing locales -->
|
||||
<uses-permission
|
||||
android:name="android.permission.CHANGE_CONFIGURATION"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!-- Allows changing SystemUI demo mode -->
|
||||
<uses-permission
|
||||
android:name="android.permission.DUMP"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="com.facebook.flipper.android.diagnostics.FlipperDiagnosticActivity"
|
||||
android:exported="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
64
app/src/debug/java/net/vonforst/evmap/DebugInits.kt
Normal file
@@ -0,0 +1,64 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.test.espresso.IdlingRegistry
|
||||
import androidx.test.espresso.idling.CountingIdlingResource
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains a static reference to [IdlingResource], only available in the 'debug' build type.
|
||||
*/
|
||||
object EspressoIdlingResource {
|
||||
private const val RESOURCE = "GLOBAL"
|
||||
|
||||
@JvmField
|
||||
val countingIdlingResource = CountingIdlingResource(RESOURCE)
|
||||
|
||||
fun increment() {
|
||||
countingIdlingResource.increment()
|
||||
}
|
||||
|
||||
fun decrement() {
|
||||
if (!countingIdlingResource.isIdleNow) {
|
||||
countingIdlingResource.decrement()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package net.vonforst.evmap
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun init(context: Context) {
|
||||
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun checkPlayServices(activity: Activity): Boolean {
|
||||
return true
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import com.google.android.material.transition.MaterialSharedAxis
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.databinding.FragmentDonateBinding
|
||||
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
|
||||
|
||||
class DonateFragment : Fragment() {
|
||||
class DonateFragment : DonateFragmentBase() {
|
||||
private lateinit var binding: FragmentDonateBinding
|
||||
private lateinit var referrals: FragmentDonateReferralBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -28,6 +30,7 @@ class DonateFragment : Fragment() {
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentDonateBinding.inflate(inflater, container, false)
|
||||
referrals = binding.referrals
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@@ -42,5 +45,7 @@ class DonateFragment : Fragment() {
|
||||
binding.btnDonate.setOnClickListener {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
|
||||
}
|
||||
|
||||
setupReferrals(referrals)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package net.vonforst.evmap.fragment
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
||||
class OnboardingViewPagerAdapter(fragment: Fragment) :
|
||||
FragmentStateAdapter(fragment) {
|
||||
override fun getItemCount(): Int = 3
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> WelcomeFragment()
|
||||
1 -> IconsFragment()
|
||||
2 -> DataSourceSelectFragment()
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/linearLayout2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/toolbar_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
@@ -21,31 +19,55 @@
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnDonate"
|
||||
style="@style/Widget.Material3.Button.Icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/donate_paypal"
|
||||
app:icon="@drawable/ic_paypal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView20" />
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView20"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/donations_info"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnDonate"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/toolbar_container"
|
||||
app:layout_constraintVertical_chainStyle="packed" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_width="match_parent">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnDonate"
|
||||
style="@style/Widget.Material3.Button.Icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:text="@string/donate_paypal"
|
||||
app:icon="@drawable/ic_paypal"
|
||||
app:layout_constraintBottom_toTopOf="@id/referrals"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView20" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView20"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/donations_info"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnDonate"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed" />
|
||||
|
||||
<include
|
||||
android:id="@+id/referrals"
|
||||
layout="@layout/fragment_donate_referral"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="36dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/btnDonate" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
6
app/src/foss/res/values-cs/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Pomohla vám EVMap? Podpořte její vývoj zasláním finančního daru vývojáři.</string>
|
||||
<string name="donate_paypal">Přispět pomocí PayPalu</string>
|
||||
<string name="data_sources_hint">Mapová data v aplikaci poskytuje služba OpenStreetMap (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>
|
||||
@@ -1,46 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
|
||||
|
||||
<uses-sdk tools:overrideLibrary="androidx.car.app,androidx.car.app.projected" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
<package android:name="com.google.android.apps.automotive.templates.host" />
|
||||
</queries>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="@string/google_maps_key" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.car.app.theme"
|
||||
android:resource="@style/CarAppTheme" />
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="1" />
|
||||
|
||||
<service
|
||||
android:name=".auto.CarAppService"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action
|
||||
android:name="androidx.car.app.CarAppService"
|
||||
android:category="androidx.car.app.category.POI" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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
|
||||
|
||||
@@ -6,11 +6,10 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
@@ -18,12 +17,17 @@ import com.google.android.material.transition.MaterialSharedAxis
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.DonationAdapter
|
||||
import net.vonforst.evmap.adapter.SingleViewAdapter
|
||||
import net.vonforst.evmap.databinding.FragmentDonateBinding
|
||||
import net.vonforst.evmap.databinding.FragmentDonateHeaderBinding
|
||||
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
|
||||
import net.vonforst.evmap.viewmodel.DonateViewModel
|
||||
|
||||
class DonateFragment : Fragment() {
|
||||
class DonateFragment : DonateFragmentBase() {
|
||||
private lateinit var binding: FragmentDonateBinding
|
||||
private val vm: DonateViewModel by viewModels()
|
||||
private lateinit var header: FragmentDonateHeaderBinding
|
||||
private lateinit var referrals: FragmentDonateReferralBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -40,6 +44,9 @@ class DonateFragment : Fragment() {
|
||||
binding.lifecycleOwner = this
|
||||
binding.vm = vm
|
||||
|
||||
header = FragmentDonateHeaderBinding.inflate(inflater, container, false)
|
||||
referrals = FragmentDonateReferralBinding.inflate(inflater, container, false)
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@@ -51,25 +58,33 @@ class DonateFragment : Fragment() {
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
binding.productsList.apply {
|
||||
adapter = DonationAdapter().apply {
|
||||
onClickListener = {
|
||||
vm.startPurchase(it, requireActivity())
|
||||
}
|
||||
val donationAdapter = DonationAdapter().apply {
|
||||
onClickListener = {
|
||||
vm.startPurchase(it, requireActivity())
|
||||
}
|
||||
}
|
||||
binding.productsList.apply {
|
||||
val joinedAdapter = ConcatAdapter(
|
||||
SingleViewAdapter(header.root),
|
||||
donationAdapter,
|
||||
SingleViewAdapter(referrals.root)
|
||||
)
|
||||
adapter = joinedAdapter
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
}
|
||||
|
||||
vm.products.observe(viewLifecycleOwner) {
|
||||
print(it)
|
||||
donationAdapter.submitList(it.data)
|
||||
}
|
||||
|
||||
vm.purchaseSuccessful.observe(viewLifecycleOwner, Observer {
|
||||
vm.purchaseSuccessful.observe(viewLifecycleOwner) {
|
||||
Snackbar.make(view, R.string.donation_successful, Snackbar.LENGTH_LONG).show()
|
||||
})
|
||||
vm.purchaseFailed.observe(viewLifecycleOwner, Observer {
|
||||
}
|
||||
vm.purchaseFailed.observe(viewLifecycleOwner) {
|
||||
Snackbar.make(view, R.string.donation_failed, Snackbar.LENGTH_LONG).show()
|
||||
})
|
||||
}
|
||||
|
||||
setupReferrals(referrals)
|
||||
|
||||
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
|
||||
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package net.vonforst.evmap.fragment
|
||||
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import net.vonforst.evmap.databinding.FragmentOnboardingAndroidAutoBinding
|
||||
|
||||
class OnboardingViewPagerAdapter(fragment: Fragment) :
|
||||
FragmentStateAdapter(fragment) {
|
||||
override fun getItemCount(): Int = 4
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> WelcomeFragment()
|
||||
1 -> IconsFragment()
|
||||
2 -> AndroidAutoFragment()
|
||||
3 -> DataSourceSelectFragment()
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
class AndroidAutoFragment : OnboardingPageFragment() {
|
||||
private lateinit var binding: FragmentOnboardingAndroidAutoBinding
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentOnboardingAndroidAutoBinding.inflate(inflater, container, false)
|
||||
|
||||
binding.btnGetStarted.setOnClickListener {
|
||||
parent.goToNext()
|
||||
}
|
||||
binding.imgAndroidAuto.alpha = 0f
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
val animators =
|
||||
listOf(
|
||||
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "translationY", -20f, 0f).apply {
|
||||
interpolator = DecelerateInterpolator()
|
||||
},
|
||||
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "alpha", 0f, 1f).apply {
|
||||
interpolator = DecelerateInterpolator()
|
||||
}
|
||||
)
|
||||
AnimatorSet().apply {
|
||||
playTogether(animators)
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
binding.imgAndroidAuto.alpha = 0f
|
||||
}
|
||||
}
|
||||
@@ -35,29 +35,16 @@
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView20"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/donations_info"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/toolbar_container" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/products_list"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="16dp"
|
||||
app:data="@{vm.products.data}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView20"
|
||||
tools:listitem="@layout/item_donation" />
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar_container"
|
||||
tools:itemCount="1"
|
||||
tools:listitem="@layout/fragment_donate_preview" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar3"
|
||||
|
||||
10
app/src/google/res/layout/fragment_donate_header.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView android:id="@+id/textView20"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/donations_info"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
16
app/src/google/res/layout/fragment_donate_preview.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<include layout="@layout/fragment_donate_header" />
|
||||
|
||||
<include layout="@layout/item_donation" />
|
||||
|
||||
<include layout="@layout/item_donation" />
|
||||
|
||||
<include layout="@layout/item_donation" />
|
||||
|
||||
<include layout="@layout/fragment_donate_referral" />
|
||||
</LinearLayout>
|
||||
7
app/src/google/res/values-cs/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Pomohla vám EVMap? Podpořte její vývoj posláním finančního daru vývojáři.
|
||||
\n
|
||||
\nGoogle si z každého daru strhne 15 %.</string>
|
||||
<string name="data_sources_hint">V nastavení můžete také pro mapová data přepínat mezi službami Mapy Google a OpenStreetMap (Mapbox).</string>
|
||||
</resources>
|
||||
@@ -1,39 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
|
||||
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
|
||||
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
|
||||
<string name="auto_no_favorites_found">Keine Favoriten gefunden</string>
|
||||
<string name="open_in_app">In App öffnen</string>
|
||||
<string name="opened_on_phone">Auf dem Telefon geöffnet</string>
|
||||
<string name="auto_location_permission_needed">Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
|
||||
<string name="auto_vehicle_data_permission_needed">Für diese Funktion benötigt EVMap Zugriff auf Daten deines Fahrzeugs.</string>
|
||||
<string name="grant_on_phone">Auf Telefon zulassen</string>
|
||||
<string name="auto_chargers_closeby">In der Nähe</string>
|
||||
<string name="auto_favorites">Favoriten</string>
|
||||
<string name="auto_chargers_near_location">Nahe %s</string>
|
||||
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
|
||||
<string name="auto_prices">Preise</string>
|
||||
<string name="auto_vehicle_data">Fahrzeugdaten</string>
|
||||
<string name="auto_charging_level">Ladezustand</string>
|
||||
<string name="auto_no_data">Nicht verfügbar</string>
|
||||
<string name="auto_range">Reichweite</string>
|
||||
<string name="auto_speed">Geschwindigkeit</string>
|
||||
<string name="auto_heading">Fahrtrichtung</string>
|
||||
<string name="auto_settings">Einstellungen</string>
|
||||
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
|
||||
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
|
||||
<string name="sounds_cool">klingt cool</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%1$s %2$s).</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
|
||||
<string name="auto_chargers_ahead">Nur Ladestationen in Fahrtrichtung</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Ladebereich für Preisvergleich</string>
|
||||
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap (Mapbox) für die Kartendaten wechseln.</string>
|
||||
<string name="selecting_all">alle Einträge ausgewählt</string>
|
||||
<string name="selecting_none">alle Einträge abgewählt</string>
|
||||
<string name="loading">Lade…</string>
|
||||
<string name="auto_multipage_goto">Seite %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
</resources>
|
||||
@@ -3,36 +3,5 @@
|
||||
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.
|
||||
\n
|
||||
\nGoogle prend 15% sur chaque don.</string>
|
||||
<string name="auto_location_service">EVMap fonctionne sur Android Auto et utilise votre position.</string>
|
||||
<string name="open_in_app">Ouvrir dans l\'application</string>
|
||||
<string name="opened_on_phone">Ouvert sur le téléphone</string>
|
||||
<string name="auto_location_permission_needed">Pour exécuter EVMap sur Android Auto, vous devez autoriser l\'accès à votre emplacement.</string>
|
||||
<string name="grant_on_phone">Grant au téléphone</string>
|
||||
<string name="auto_prices">Prix</string>
|
||||
<string name="auto_vehicle_data">Données sur le véhicule</string>
|
||||
<string name="auto_range">Autonomie</string>
|
||||
<string name="auto_speed">Vitesse</string>
|
||||
<string name="welcome_android_auto">Prise en charge d’Android Auto</string>
|
||||
<string name="sounds_cool">ça a l\'air cool</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">Aucun des véhicules sélectionnés dans l\'application ne correspond à ce véhicule (%1$s %2$s).</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Plusieurs véhicules sélectionnés dans l\'application correspondent à ce véhicule (%1$s %2$s).</string>
|
||||
<string name="selecting_all">tous les éléments sélectionnés</string>
|
||||
<string name="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap (Mapbox) pour les données cartographiques.</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap n\'a pas pu déterminer le modèle de votre véhicule.</string>
|
||||
<string name="auto_no_chargers_found">Aucun chargeur à proximité n\'a été trouvé</string>
|
||||
<string name="auto_no_favorites_found">Pas de favoris trouvés</string>
|
||||
<string name="auto_charging_level">Niveau de charge</string>
|
||||
<string name="auto_chargers_closeby">Chargeurs à proximité</string>
|
||||
<string name="auto_chargers_near_location">Près de %s</string>
|
||||
<string name="auto_fault_report_date">⚠️ Rapport d\'anomalie (%s)</string>
|
||||
<string name="auto_no_data">Indisponible</string>
|
||||
<string name="auto_settings">Paramètres</string>
|
||||
<string name="selecting_none">désélectionner tous les éléments</string>
|
||||
<string name="auto_vehicle_data_permission_needed">Pour cette fonction, EVMap doit avoir accès aux données de votre véhicule.</string>
|
||||
<string name="auto_heading">Direction</string>
|
||||
<string name="auto_favorites">Favoris</string>
|
||||
<string name="auto_no_refresh_possible">D\'autres mises à jour ne sont pas possibles. Veuillez revenir en arrière et redémarrer.</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Plage de charge pour la comparaison des prix</string>
|
||||
<string name="welcome_android_auto_detail">Vous pouvez également utiliser EVMap à partir d\'Android Auto sur les voitures prises en charge. Il suffit de sélectionner l\'application EVMap dans le menu Android Auto.</string>
|
||||
<string name="loading">Chargement…</string>
|
||||
</resources>
|
||||
@@ -3,39 +3,5 @@
|
||||
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende penger til utvikleren.
|
||||
\n
|
||||
\nGoogle tar 15% av alle donasjoner.</string>
|
||||
<string name="auto_favorites">Favoritter</string>
|
||||
<string name="auto_charging_level">Ladingsnivå</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap kunne ikke fastsette kjøretøymodellen.</string>
|
||||
<string name="selecting_none">fravalgte alle elementer</string>
|
||||
<string name="grant_on_phone">Innvilg på mobilenheten</string>
|
||||
<string name="auto_chargers_closeby">Ladere i nærheten</string>
|
||||
<string name="auto_prices">Pris</string>
|
||||
<string name="auto_no_chargers_found">Ingen ladere i nærheten</string>
|
||||
<string name="auto_no_favorites_found">Fant ikke noen favoritter</string>
|
||||
<string name="open_in_app">Åpne i programmet</string>
|
||||
<string name="auto_location_service">EVMap kjører på Android Auto og bruker posisjonen din.</string>
|
||||
<string name="auto_heading">Fartsretning</string>
|
||||
<string name="opened_on_phone">Åpnet på mobilenheten</string>
|
||||
<string name="auto_location_permission_needed">Innvilg posisjonstilgang for å bruke EVMap på Android Auto.</string>
|
||||
<string name="auto_chargers_near_location">Nær %s</string>
|
||||
<string name="auto_fault_report_date">⚠️ Feilrapport (%s)</string>
|
||||
<string name="auto_vehicle_data">Kjøretøydata</string>
|
||||
<string name="auto_no_data">Utilgjengelig</string>
|
||||
<string name="auto_speed">Hastighet</string>
|
||||
<string name="auto_settings">Innstillinger</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">Ingen av kjøretøyene valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
|
||||
<string name="welcome_android_auto">Android Auto-støtte</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Flere kjøretøy valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
|
||||
<string name="auto_vehicle_data_permission_needed">EvMap trenger tilgang til kjøretøydata for å bruke denne funksjonen.</string>
|
||||
<string name="auto_no_refresh_possible">Videre oppdateringer er ikke mulig. Gå tilbake og start på ny.</string>
|
||||
<string name="auto_range">Rekkevidde</string>
|
||||
<string name="welcome_android_auto_detail">Du kan også bruke EVMap inne i Android Auto på bilder som støtter dette ved å velge det i Android Auto-menyen.</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Prissammenligning for laderekkevidde fordelt på pris</string>
|
||||
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap (Mapbox) for kartdata.</string>
|
||||
<string name="selecting_all">valgte alle elementene</string>
|
||||
<string name="sounds_cool">den er grei</string>
|
||||
<string name="auto_chargers_ahead">Kun ladere i kjøreretningen</string>
|
||||
<string name="loading">Laster inn …</string>
|
||||
<string name="auto_multipage_goto">Side %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
</resources>
|
||||
@@ -1,41 +1,7 @@
|
||||
<?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>
|
||||
7
app/src/google/res/values-pt/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Acha que o EVMap é útil\? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.
|
||||
\n
|
||||
\nA Google cobra 15% de cada doação.</string>
|
||||
<string name="data_sources_hint">Também pode mudar entre o Google Maps e OpenStreetMap (Mapbox) nas definições da app.</string>
|
||||
</resources>
|
||||
2
app/src/google/res/values-ro/strings.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="gauge_active">#00e676</color>
|
||||
<color name="gauge_middle">#087f23</color>
|
||||
<color name="gauge_inactive">#9e9e9e</color>
|
||||
<color name="charger_100kw_dark">#FBC02D</color>
|
||||
</resources>
|
||||
@@ -1,39 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
|
||||
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
|
||||
<string name="auto_no_chargers_found">No nearby chargers found</string>
|
||||
<string name="auto_no_favorites_found">No favorites found</string>
|
||||
<string name="open_in_app">Open in app</string>
|
||||
<string name="opened_on_phone">Opened on phone</string>
|
||||
<string name="auto_location_permission_needed">To run EVMap on Android Auto, you need to grant access to your location.</string>
|
||||
<string name="auto_vehicle_data_permission_needed">For this feature, EVMap needs access to your vehicle data.</string>
|
||||
<string name="grant_on_phone">Grant on phone</string>
|
||||
<string name="auto_chargers_closeby">Nearby chargers</string>
|
||||
<string name="auto_favorites">Favorites</string>
|
||||
<string name="auto_chargers_near_location">Near %s</string>
|
||||
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
|
||||
<string name="auto_prices">Pricing</string>
|
||||
<string name="auto_vehicle_data">Vehicle data</string>
|
||||
<string name="auto_charging_level">Charging level</string>
|
||||
<string name="auto_no_data">Unavailable</string>
|
||||
<string name="auto_range">Range</string>
|
||||
<string name="auto_speed">Speed</string>
|
||||
<string name="auto_heading">Heading</string>
|
||||
<string name="auto_settings">Settings</string>
|
||||
<string name="welcome_android_auto">Android Auto support</string>
|
||||
<string name="welcome_android_auto_detail">You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
|
||||
<string name="sounds_cool">sounds cool</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap could not determine your vehicle model.</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">None of the vehicles selected in the app matches this vehicle (%1$s %2$s).</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Multiple vehicles selected in the app match this vehicle (%1$s %2$s).</string>
|
||||
<string name="auto_chargers_ahead">Only chargers along driving direction</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Charging range for price comparison</string>
|
||||
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap (Mapbox) for the map data.</string>
|
||||
<string name="selecting_all">selected all items</string>
|
||||
<string name="selecting_none">deselected all items</string>
|
||||
<string name="loading">Loading…</string>
|
||||
<string name="auto_multipage_goto">Page %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
</resources>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="CarAppTheme">
|
||||
<item name="carColorPrimary">@color/colorPrimary</item>
|
||||
<item name="carColorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="carColorSecondary">@color/colorSecondary</item>
|
||||
<item name="carColorSecondaryDark">@color/colorSecondaryDark</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,7 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<Preference
|
||||
android:fragment="net.vonforst.evmap.fragment.preference.AndroidAutoSettingsFragment"
|
||||
android:title="@string/settings_android_auto"
|
||||
android:icon="@drawable/ic_android_auto" />
|
||||
<PreferenceScreen>
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -1,10 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
@@ -18,17 +24,24 @@
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
<package android:name="com.google.android.apps.automotive.templates.host" />
|
||||
<package android:name="com.google.android.apps.maps" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".EvMapApplication"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:dataExtractionRules="@xml/backup_rules_api31"
|
||||
android:fullBackupOnly="true"
|
||||
android:backupAgent=".storage.BackupAgent"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:localeConfig="@xml/locales_config">
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<meta-data
|
||||
android:name="com.mapbox.ACCESS_TOKEN"
|
||||
@@ -260,6 +273,17 @@
|
||||
android:host="openchargemap.org"
|
||||
android:pathPattern="/site/poi/details/..*"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="map.openchargemap.io"
|
||||
android:path="/"
|
||||
android:scheme="https" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</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" />
|
||||
@@ -270,6 +294,10 @@
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<activity android:name=".auto.OAuthLoginActivity">
|
||||
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
@@ -278,6 +306,51 @@
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<!-- Remove WorkManagerInitializer as we implement getWorkManagerConfiguration in application class -->
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<!-- Configuration for Android Auto app -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.car.app.theme"
|
||||
android:resource="@style/CarAppTheme" />
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="1" />
|
||||
|
||||
<service
|
||||
android:name=".auto.CarAppService"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="location">
|
||||
<intent-filter>
|
||||
<action
|
||||
android:name="androidx.car.app.CarAppService"
|
||||
android:category="androidx.car.app.category.POI" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="net.vonforst.evmap" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,17 +1,22 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import com.facebook.stetho.Stetho
|
||||
import android.os.Build
|
||||
import androidx.work.*
|
||||
import net.vonforst.evmap.storage.CleanupCacheWorker
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.updateAppLocale
|
||||
import net.vonforst.evmap.ui.updateNightMode
|
||||
import org.acra.config.dialog
|
||||
import org.acra.config.httpSender
|
||||
import org.acra.config.limiter
|
||||
import org.acra.config.mailSender
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.HttpSender
|
||||
import java.time.Duration
|
||||
|
||||
class EvMapApplication : Application() {
|
||||
class EvMapApplication : Application(), Configuration.Provider {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val prefs = PreferenceDataSource(this)
|
||||
@@ -24,16 +29,21 @@ class EvMapApplication : Application() {
|
||||
prefs.language = null
|
||||
}
|
||||
|
||||
Stetho.initializeWithDefaults(this);
|
||||
init(applicationContext)
|
||||
addDebugInterceptors(applicationContext)
|
||||
|
||||
if (!BuildConfig.DEBUG) {
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.KEY_VALUE_LIST
|
||||
|
||||
mailSender {
|
||||
mailTo = "evmap+crashreport@vonforst.net"
|
||||
// Vehicles often don't have an email app, so use HTTP to send instead
|
||||
reportFormat = StringFormat.JSON
|
||||
httpSender {
|
||||
uri = getString(R.string.acra_backend_url)
|
||||
val creds = getString(R.string.acra_credentials).split(":")
|
||||
basicAuthLogin = creds[0]
|
||||
basicAuthPassword = creds[1]
|
||||
httpMethod = HttpSender.Method.POST
|
||||
}
|
||||
|
||||
dialog {
|
||||
@@ -42,6 +52,10 @@ class EvMapApplication : Application() {
|
||||
commentPrompt = getString(R.string.crash_report_comment_prompt)
|
||||
resIcon = R.drawable.ic_launcher_foreground
|
||||
resTheme = R.style.AppTheme
|
||||
if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
reportDialogClass =
|
||||
Class.forName("androidx.car.app.activity.CarAppActivity") as Class<out Activity>?
|
||||
}
|
||||
}
|
||||
|
||||
limiter {
|
||||
@@ -49,5 +63,18 @@ 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.UPDATE, cleanupCacheRequest
|
||||
)
|
||||
}
|
||||
|
||||
override val workManagerConfiguration = Configuration.Builder().build()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.app.PendingIntent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
@@ -55,8 +56,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)
|
||||
|
||||
@@ -107,6 +108,10 @@ class MapsActivity : AppCompatActivity(),
|
||||
navGraph.setStartDestination(R.id.onboarding)
|
||||
navController.graph = navGraph
|
||||
return
|
||||
} else if (!prefs.privacyAccepted) {
|
||||
navGraph.setStartDestination(R.id.onboarding)
|
||||
navController.graph = navGraph
|
||||
return
|
||||
} else {
|
||||
navGraph.setStartDestination(R.id.map)
|
||||
navController.setGraph(navGraph, MapFragmentArgs(appStart = true).toBundle())
|
||||
@@ -124,7 +129,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)
|
||||
@@ -151,8 +156,12 @@ class MapsActivity : AppCompatActivity(),
|
||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host == "openchargemap.org") {
|
||||
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host in listOf("openchargemap.org", "map.openchargemap.io")) {
|
||||
val id = when (intent.data?.host) {
|
||||
"openchargemap.org" -> intent.data?.pathSegments?.last()?.toLongOrNull()
|
||||
"map.openchargemap.io" -> intent.data?.getQueryParameter("id")?.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (id != null) {
|
||||
if (prefs.dataSource != "openchargemap") {
|
||||
prefs.dataSource = "openchargemap"
|
||||
@@ -171,19 +180,42 @@ 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)
|
||||
.setArguments(
|
||||
MapFragmentArgs(
|
||||
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
|
||||
latLng = LatLng(
|
||||
intent.getDoubleExtra(EXTRA_LAT, 0.0),
|
||||
intent.getDoubleExtra(EXTRA_LON, 0.0)
|
||||
)
|
||||
).toBundle()
|
||||
)
|
||||
.createPendingIntent()
|
||||
navController.navigate(
|
||||
R.id.map, MapFragmentArgs(
|
||||
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
|
||||
latLng = LatLng(
|
||||
intent.getDoubleExtra(EXTRA_LAT, 0.0),
|
||||
intent.getDoubleExtra(EXTRA_LON, 0.0)
|
||||
)
|
||||
).toBundle()
|
||||
)
|
||||
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(navGraph)
|
||||
@@ -207,7 +239,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 +255,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
})"
|
||||
)
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
startActivity(intent);
|
||||
startActivity(intent)
|
||||
} else {
|
||||
val cb = fragmentCallback ?: return
|
||||
Snackbar.make(
|
||||
@@ -234,7 +266,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
}
|
||||
}
|
||||
|
||||
fun openUrl(url: String) {
|
||||
fun openUrl(url: String, preferBrowser: Boolean = true) {
|
||||
val pkg = CustomTabsClient.getPackageName(this, null)
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setDefaultColorSchemeParams(
|
||||
@@ -246,7 +278,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
pkg?.let {
|
||||
// prefer to open URL in custom tab, even if native app
|
||||
// available (such as EVMap itself)
|
||||
intent.intent.setPackage(pkg)
|
||||
if (preferBrowser) intent.intent.setPackage(pkg)
|
||||
}
|
||||
try {
|
||||
intent.launchUrl(this, Uri.parse(url))
|
||||
@@ -262,7 +294,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)
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Typeface
|
||||
import android.icu.util.LocaleData
|
||||
import android.icu.util.ULocale
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.*
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.SpannedString
|
||||
import android.text.TextUtils
|
||||
import android.text.style.StyleSpan
|
||||
import java.util.*
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import java.util.Locale
|
||||
|
||||
fun Bundle.optDouble(name: String): Double? {
|
||||
if (!this.containsKey(name)) return null
|
||||
@@ -88,9 +96,25 @@ fun Context.isDarkMode() =
|
||||
|
||||
const val kmPerMile = 1.609344
|
||||
const val meterPerFt = 0.3048
|
||||
const val ftPerMile = 5280
|
||||
const val ydPerMile = 1760
|
||||
|
||||
fun shouldUseImperialUnits(ctx: Context): Boolean {
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
return when (prefs.units) {
|
||||
"metric" -> false
|
||||
"imperial" -> true
|
||||
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
when (LocaleData.getMeasurementSystem(ULocale.getDefault())) {
|
||||
LocaleData.MeasurementSystem.US, LocaleData.MeasurementSystem.UK -> true
|
||||
LocaleData.MeasurementSystem.SI -> false
|
||||
else -> false
|
||||
}
|
||||
} else {
|
||||
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldUseImperialUnits(): Boolean {
|
||||
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
|
||||
}
|
||||
|
||||
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
|
||||
@@ -98,4 +122,21 @@ fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): Pa
|
||||
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int = 0): ApplicationInfo =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(flags.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION") getApplicationInfo(packageName, flags)
|
||||
}
|
||||
|
||||
|
||||
fun PackageManager.isAppInstalled(packageName: String): Boolean {
|
||||
return try {
|
||||
getApplicationInfoCompat(packageName, 0).enabled
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.vonforst.evmap.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.ViewDataBinding
|
||||
@@ -21,6 +22,7 @@ import net.vonforst.evmap.databinding.ItemChargepriceVehicleChipBinding
|
||||
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.ui.CheckableConstraintLayout
|
||||
import java.time.Instant
|
||||
|
||||
interface Equatable {
|
||||
override fun equals(other: Any?): Boolean
|
||||
@@ -30,6 +32,7 @@ abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
|
||||
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback(getKey)) {
|
||||
|
||||
var onClickListener: ((T) -> Unit)? = null
|
||||
var onLongClickListener: ((T) -> Boolean)? = null
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> {
|
||||
val layoutInflater = LayoutInflater.from(parent.context)
|
||||
@@ -54,6 +57,12 @@ abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
|
||||
listener(item)
|
||||
}
|
||||
}
|
||||
if (onLongClickListener != null) {
|
||||
holder.binding.root.setOnLongClickListener {
|
||||
val listener = onLongClickListener ?: return@setOnLongClickListener false
|
||||
return@setOnLongClickListener listener(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DiffCallback<T : Equatable>(val getKey: ((T) -> Any)?) : DiffUtil.ItemCallback<T>() {
|
||||
@@ -87,10 +96,22 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
|
||||
override fun getItemViewType(position: Int): Int = R.layout.item_connector
|
||||
}
|
||||
|
||||
class ChargepriceAdapter() :
|
||||
class ConnectorDetailsAdapter : DataBindingAdapter<ConnectorDetailsAdapter.ConnectorDetails>() {
|
||||
data class ConnectorDetails(
|
||||
val status: ChargepointStatus?,
|
||||
val evseId: String?,
|
||||
val label: String?,
|
||||
val lastChange: Instant?
|
||||
) :
|
||||
Equatable
|
||||
|
||||
override fun getItemViewType(position: Int): Int = R.layout.dialog_connector_details_item
|
||||
}
|
||||
|
||||
class ChargepriceAdapter :
|
||||
DataBindingAdapter<ChargePrice>() {
|
||||
|
||||
val viewPool = RecyclerView.RecycledViewPool();
|
||||
val viewPool = RecyclerView.RecycledViewPool()
|
||||
var meta: ChargepriceChargepointMeta? = null
|
||||
set(value) {
|
||||
field = value
|
||||
@@ -209,8 +230,8 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
|
||||
checkedItem = item
|
||||
root.post {
|
||||
notifyDataSetChanged()
|
||||
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
|
||||
}
|
||||
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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.tesla.Pricing
|
||||
import net.vonforst.evmap.api.availability.tesla.Rates
|
||||
import net.vonforst.evmap.api.availability.tesla.TeslaChargingOwnershipGraphQlApi
|
||||
import net.vonforst.evmap.bold
|
||||
import net.vonforst.evmap.joinToSpannedString
|
||||
import net.vonforst.evmap.model.ChargeCard
|
||||
@@ -10,6 +17,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 +49,18 @@ fun buildDetails(
|
||||
loc: ChargeLocation?,
|
||||
chargeCards: Map<Long, ChargeCard>?,
|
||||
filteredChargeCards: Set<Long>?,
|
||||
teslaPricing: 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 +76,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 +141,128 @@ fun buildDetails(
|
||||
)
|
||||
}
|
||||
|
||||
fun formatTeslaParkingFee(teslaPricing: Pricing, ctx: Context) =
|
||||
teslaPricing.memberRates?.activePricebook?.parking?.let { parkingFee ->
|
||||
ctx.getString(
|
||||
R.string.tesla_pricing_blocking_fee,
|
||||
formatTeslaPricingRate(parkingFee.rates, parkingFee.currencyCode, parkingFee.uom, ctx)
|
||||
)
|
||||
}
|
||||
|
||||
fun formatTeslaPricing(teslaPricing: 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: 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,15 +1,21 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.RateLimitInterceptor
|
||||
import net.vonforst.evmap.api.await
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.cartesianProduct
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import okhttp3.Cache
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -17,6 +23,7 @@ import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface AvailabilityDetector {
|
||||
@@ -133,7 +140,11 @@ 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 labels: Map<Chargepoint, List<String?>>? = null,
|
||||
val congestionHistogram: List<Double>? = null,
|
||||
val lastChange: Map<Chargepoint, List<Instant?>>? = null,
|
||||
val extraData: Any? = null // API-specific data
|
||||
) {
|
||||
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
|
||||
val statusFiltered = status.filterKeys {
|
||||
@@ -148,48 +159,73 @@ data class ChargeLocationStatus(
|
||||
val totalChargepoints = status.map { it.key.count }.sum()
|
||||
}
|
||||
|
||||
enum class ChargepointStatus {
|
||||
@Parcelize
|
||||
enum class ChargepointStatus : Parcelable {
|
||||
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED
|
||||
}
|
||||
|
||||
class AvailabilityDetectorException(message: String) : Exception(message)
|
||||
|
||||
class NotSignedInException : IOException("not signed in")
|
||||
|
||||
private val cookieManager = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
|
||||
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 cacheSize = 5L * 1024 * 1024 // 5MB
|
||||
private val okhttp = OkHttpClient.Builder()
|
||||
.addInterceptor(RateLimitInterceptor())
|
||||
.addDebugInterceptors()
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||
.cache(Cache(context.cacheDir, cacheSize))
|
||||
.build()
|
||||
private val teslaOwnerAvailabilityDetector =
|
||||
TeslaOwnerAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context))
|
||||
private val availabilityDetectors = listOf(
|
||||
RheinenergieAvailabilityDetector(okhttp),
|
||||
teslaOwnerAvailabilityDetector,
|
||||
TeslaGuestAvailabilityDetector(okhttp),
|
||||
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 result: ChargeLocationStatus? = null
|
||||
var exception: Throwable? = null
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
for (ad in availabilityDetectors) {
|
||||
if (!ad.isChargerSupported(charger)) continue
|
||||
try {
|
||||
result = ad.getAvailability(charger)
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
exception = exception.takeIf { it is NotSignedInException } ?: e
|
||||
e.printStackTrace()
|
||||
} catch (e: HttpException) {
|
||||
exception = exception.takeIf { it is NotSignedInException } ?: e
|
||||
e.printStackTrace()
|
||||
} catch (e: AvailabilityDetectorException) {
|
||||
exception = exception.takeIf { it is NotSignedInException } ?: e
|
||||
e.printStackTrace()
|
||||
} catch (e: NotSignedInException) {
|
||||
exception = e
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
result?.let {
|
||||
return Resource.success(it)
|
||||
}
|
||||
return Resource.error(exception?.message, null)
|
||||
}
|
||||
return value ?: Resource.error(null, null)
|
||||
|
||||
fun isSupercharger(charger: ChargeLocation) =
|
||||
teslaOwnerAvailabilityDetector.isChargerSupported(charger)
|
||||
|
||||
fun isTeslaSupported(charger: ChargeLocation) =
|
||||
teslaOwnerAvailabilityDetector.isChargerSupported(charger) && teslaOwnerAvailabilityDetector.isSignedIn()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.ToJson
|
||||
import net.vonforst.evmap.api.availability.tesla.LocalTimeAdapter
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
@@ -10,9 +14,11 @@ import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import java.time.Instant
|
||||
import java.time.LocalTime
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
private const val maxDistance = 40 // max distance between reported positions in meters
|
||||
private const val maxDistance = 60 // max distance between reported positions in meters
|
||||
|
||||
interface EnBwApi {
|
||||
@GET("chargestations?grouping=false")
|
||||
@@ -53,7 +59,8 @@ interface EnBwApi {
|
||||
data class EnBwChargePoint(
|
||||
val evseId: String?,
|
||||
val status: String,
|
||||
val connectors: List<EnBwConnector>
|
||||
val connectors: List<EnBwConnector>,
|
||||
val state: EnBwState?
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@@ -70,6 +77,11 @@ interface EnBwApi {
|
||||
val upperRightLon: Double
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class EnBwState(
|
||||
val updatedAt: Instant?
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, baseUrl: String? = null): EnBwApi {
|
||||
val clientWithInterceptor = client.newBuilder()
|
||||
@@ -85,7 +97,11 @@ interface EnBwApi {
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://enbw-emp.azure-api.net/emobility-public-api/api/v1/")
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
.addConverterFactory(
|
||||
MoshiConverterFactory.create(
|
||||
Moshi.Builder().add(InstantAdapter()).build()
|
||||
)
|
||||
)
|
||||
.client(clientWithInterceptor)
|
||||
.build()
|
||||
return retrofit.create(EnBwApi::class.java)
|
||||
@@ -93,6 +109,23 @@ interface EnBwApi {
|
||||
}
|
||||
}
|
||||
|
||||
internal class InstantAdapter {
|
||||
@FromJson
|
||||
fun fromJson(value: Long?): Instant? = value?.let {
|
||||
Instant.ofEpochMilli(it)
|
||||
}
|
||||
|
||||
@ToJson
|
||||
fun toJson(value: Instant?): Long? = value?.toEpochMilli()
|
||||
}
|
||||
|
||||
data class EnBwStatus(
|
||||
val conn: EnBwApi.EnBwConnector,
|
||||
val status: String,
|
||||
val evseId: String?,
|
||||
val lastChange: Instant?
|
||||
)
|
||||
|
||||
class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
BaseAvailabilityDetector(client) {
|
||||
val api = EnBwApi.create(client, baseUrl)
|
||||
@@ -117,6 +150,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
listOf(it)
|
||||
}
|
||||
}
|
||||
if (markers.any { it.grouped }) throw AvailabilityDetectorException("markers still grouped")
|
||||
|
||||
val nearest = markers.minByOrNull { marker ->
|
||||
distanceBetween(marker.lat, marker.lon, lat, lng)
|
||||
@@ -132,34 +166,39 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
throw AvailabilityDetectorException("no candidates found")
|
||||
}
|
||||
|
||||
// combine related stations
|
||||
markers = markers.filter { marker ->
|
||||
distanceBetween(
|
||||
marker.lat,
|
||||
marker.lon,
|
||||
nearest.lat,
|
||||
nearest.lon
|
||||
) < maxDistance
|
||||
if (nearest.numberOfChargePoints < location.totalChargepoints) {
|
||||
// combine related stations
|
||||
markers = markers.filter { marker ->
|
||||
distanceBetween(
|
||||
marker.lat,
|
||||
marker.lon,
|
||||
nearest.lat,
|
||||
nearest.lon
|
||||
) < maxDistance
|
||||
}.filter {
|
||||
// only include stations from same operator
|
||||
it.operator == nearest.operator && it.stationId != null
|
||||
}
|
||||
} else {
|
||||
markers = listOf(nearest)
|
||||
}
|
||||
|
||||
var details = markers.filter {
|
||||
// only include stations from same operator
|
||||
it.operator == nearest.operator && it.stationId != null
|
||||
}.map {
|
||||
val details = markers.mapNotNull { it.stationId }.map {
|
||||
// load details
|
||||
api.getLocation(it.stationId!!)
|
||||
api.getLocation(it)
|
||||
}
|
||||
|
||||
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
|
||||
cp.connectors.map { connector ->
|
||||
Triple(connector, cp.status, cp.evseId)
|
||||
EnBwStatus(connector, cp.status, cp.evseId, cp.state?.updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
val enbwConnectors = mutableMapOf<Long, Pair<Double, String>>()
|
||||
val enbwStatus = mutableMapOf<Long, ChargepointStatus>()
|
||||
val enbwEvseId = mutableMapOf<Long, String>()
|
||||
connectorStatus.forEachIndexed { index, (connector, statusStr, evseId) ->
|
||||
val enbwLastChange = mutableMapOf<Long, Instant?>()
|
||||
connectorStatus.forEachIndexed { index, (connector, statusStr, evseId, updatedAt) ->
|
||||
val id = index.toLong()
|
||||
val power = connector.maxPowerInKw ?: 0.0
|
||||
val type = when (connector.plugTypeName) {
|
||||
@@ -182,6 +221,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
}
|
||||
enbwConnectors[id] = power to type
|
||||
enbwStatus[id] = status
|
||||
enbwLastChange[id] = updatedAt
|
||||
evseId?.let { enbwEvseId[id] = it }
|
||||
}
|
||||
|
||||
@@ -192,10 +232,13 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
val evseIds = if (enbwEvseId.size == enbwStatus.size) match.mapValues { entry ->
|
||||
entry.value.map { enbwEvseId[it]!! }
|
||||
} else null
|
||||
val lastChange =
|
||||
if (enbwLastChange.size == enbwStatus.size) match.mapValues { entry -> entry.value.map { enbwLastChange[it] } } else null
|
||||
return ChargeLocationStatus(
|
||||
chargepointStatus,
|
||||
"EnBW",
|
||||
evseIds
|
||||
evseIds,
|
||||
lastChange = lastChange
|
||||
)
|
||||
}
|
||||
|
||||
@@ -203,30 +246,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import androidx.car.app.model.DateTimeWithZone
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.ToJson
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
@@ -9,25 +13,31 @@ import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.util.*
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
private const val maxDistance = 40 // max distance between reported positions in meters
|
||||
private const val maxDistance = 60 // max distance between reported positions in meters
|
||||
|
||||
interface NewMotionApi {
|
||||
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}")
|
||||
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}/{zoom}")
|
||||
suspend fun getMarkers(
|
||||
@Path("lngMin") lngMin: Double,
|
||||
@Path("lngMax") lngMax: Double,
|
||||
@Path("latMin") latMin: Double,
|
||||
@Path("latMax") latMax: Double
|
||||
@Path("latMax") latMax: Double,
|
||||
@Path("zoom") zoom: Int = 22
|
||||
): List<NMMarker>
|
||||
|
||||
@GET("locations/{id}")
|
||||
suspend fun getLocation(@Path("id") id: Long): NMLocation
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NMMarker(val coordinates: NMCoordinates, val locationUid: Long)
|
||||
data class NMMarker(val coordinates: NMCoordinates, val locationUid: Long, val evseCount: Int)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NMCoordinates(val latitude: Double, val longitude: Double)
|
||||
@@ -41,7 +51,12 @@ interface NewMotionApi {
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NMEvse(val evseId: String?, val status: String, val connectors: List<NMConnector>)
|
||||
data class NMEvse(
|
||||
val evseId: String?,
|
||||
val status: String,
|
||||
val connectors: List<NMConnector>,
|
||||
val updated: ZonedDateTime?
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NMConnector(
|
||||
@@ -76,8 +91,12 @@ 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/")
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
|
||||
.addConverterFactory(
|
||||
MoshiConverterFactory.create(
|
||||
Moshi.Builder().add(ZonedDateTimeAdapter()).build()
|
||||
)
|
||||
)
|
||||
.client(client)
|
||||
.build()
|
||||
return retrofit.create(NewMotionApi::class.java)
|
||||
@@ -85,6 +104,21 @@ interface NewMotionApi {
|
||||
}
|
||||
}
|
||||
|
||||
internal class ZonedDateTimeAdapter {
|
||||
@FromJson
|
||||
fun fromJson(value: String): ZonedDateTime? = ZonedDateTime.parse(value)
|
||||
|
||||
@ToJson
|
||||
fun toJson(value: ZonedDateTime): String = value.toString()
|
||||
}
|
||||
|
||||
data class NmStatus(
|
||||
val conn: NewMotionApi.NMConnector,
|
||||
val status: String,
|
||||
val evseId: String?,
|
||||
val updated: ZonedDateTime?
|
||||
)
|
||||
|
||||
class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||
BaseAvailabilityDetector(client) {
|
||||
val api = NewMotionApi.create(client, baseUrl)
|
||||
@@ -110,14 +144,18 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
throw AvailabilityDetectorException("no candidates found")
|
||||
}
|
||||
|
||||
// combine related stations
|
||||
markers = markers.filter { marker ->
|
||||
distanceBetween(
|
||||
marker.coordinates.latitude,
|
||||
marker.coordinates.longitude,
|
||||
nearest.coordinates.latitude,
|
||||
nearest.coordinates.longitude
|
||||
) < maxDistance
|
||||
markers = if (nearest.evseCount < location.totalChargepoints) {
|
||||
// combine related stations
|
||||
markers.filter { marker ->
|
||||
distanceBetween(
|
||||
marker.coordinates.latitude,
|
||||
marker.coordinates.longitude,
|
||||
nearest.coordinates.latitude,
|
||||
nearest.coordinates.longitude
|
||||
) < maxDistance
|
||||
}
|
||||
} else {
|
||||
listOf(nearest)
|
||||
}
|
||||
|
||||
// load details
|
||||
@@ -130,14 +168,15 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
}
|
||||
val connectorStatus = details.flatMap { it.evses }.flatMap { evse ->
|
||||
evse.connectors.map { connector ->
|
||||
Triple(connector, evse.status, evse.evseId)
|
||||
NmStatus(connector, evse.status, evse.evseId, evse.updated)
|
||||
}
|
||||
}
|
||||
|
||||
val nmConnectors = mutableMapOf<Long, Pair<Double, String>>()
|
||||
val nmStatus = mutableMapOf<Long, ChargepointStatus>()
|
||||
val nmEvseId = mutableMapOf<Long, String>()
|
||||
connectorStatus.forEach { (connector, statusStr, evseId) ->
|
||||
val nmUpdated = mutableMapOf<Long, ZonedDateTime>()
|
||||
connectorStatus.forEach { (connector, statusStr, evseId, updated) ->
|
||||
val id = connector.uid
|
||||
val power = connector.electricalProperties.getPower()
|
||||
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
|
||||
@@ -163,6 +202,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
nmConnectors.put(id, power to type)
|
||||
nmStatus.put(id, status)
|
||||
evseId?.let { nmEvseId[id] = it }
|
||||
updated?.let { nmUpdated[id] = it }
|
||||
}
|
||||
|
||||
val match = matchChargepoints(nmConnectors, location.chargepointsMerged)
|
||||
@@ -172,16 +212,22 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
val evseIds = if (nmEvseId.size == nmStatus.size) match.mapValues { entry ->
|
||||
entry.value.map { nmEvseId[it]!! }
|
||||
} else null
|
||||
val updated = match.mapValues { entry -> entry.value.map { nmUpdated[it]?.toInstant() } }
|
||||
return ChargeLocationStatus(
|
||||
chargepointStatus,
|
||||
"NewMotion",
|
||||
evseIds
|
||||
evseIds,
|
||||
lastChange = updated
|
||||
)
|
||||
}
|
||||
|
||||
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,173 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import com.squareup.moshi.JsonDataException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import net.vonforst.evmap.api.availability.tesla.ChargerAvailability
|
||||
import net.vonforst.evmap.api.availability.tesla.TeslaChargingGuestGraphQlApi
|
||||
import net.vonforst.evmap.api.availability.tesla.TeslaCuaApi
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
|
||||
class TeslaGuestAvailabilityDetector(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String? = null
|
||||
) :
|
||||
BaseAvailabilityDetector(client) {
|
||||
|
||||
private var cuaApi = TeslaCuaApi.create(client, baseUrl)
|
||||
private var api = TeslaChargingGuestGraphQlApi.create(client, baseUrl)
|
||||
|
||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||
val results = cuaApi.getTeslaLocations()
|
||||
|
||||
val result =
|
||||
results.minByOrNull {
|
||||
if (it.latitude != null && it.longitude != null) {
|
||||
distanceBetween(
|
||||
it.latitude,
|
||||
it.longitude,
|
||||
location.coordinates.lat,
|
||||
location.coordinates.lng
|
||||
)
|
||||
} else Double.POSITIVE_INFINITY
|
||||
} ?: throw AvailabilityDetectorException("no candidates found.")
|
||||
|
||||
val resultDetails = try {
|
||||
cuaApi.getTeslaLocation(result.locationId)
|
||||
} catch (e: JsonDataException) {
|
||||
// instead of a single location, this may also return an empty JSON list []. This is hard to fix with Moshi
|
||||
if (e.message == "Expected BEGIN_OBJECT but was BEGIN_ARRAY at path \$") {
|
||||
throw AvailabilityDetectorException("no candidates found.")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
val trtId = resultDetails.trtId?.toLongOrNull()
|
||||
?: throw AvailabilityDetectorException("charger data not available through guest API")
|
||||
|
||||
val (detailsA, guestPricing) = coroutineScope {
|
||||
val details = async {
|
||||
api.getChargingSiteDetails(
|
||||
TeslaChargingGuestGraphQlApi.GetChargingSiteDetailsRequest(
|
||||
TeslaChargingGuestGraphQlApi.GetChargingSiteInformationVariables(
|
||||
TeslaChargingGuestGraphQlApi.Identifier(
|
||||
TeslaChargingGuestGraphQlApi.ChargingSiteIdentifier(
|
||||
trtId
|
||||
)
|
||||
),
|
||||
TeslaChargingGuestGraphQlApi.Experience.ADHOC
|
||||
)
|
||||
)
|
||||
).data.site ?: throw AvailabilityDetectorException("no candidates found.")
|
||||
}
|
||||
val guestPricing = async {
|
||||
api.getChargingSiteDetails(
|
||||
TeslaChargingGuestGraphQlApi.GetChargingSiteDetailsRequest(
|
||||
TeslaChargingGuestGraphQlApi.GetChargingSiteInformationVariables(
|
||||
TeslaChargingGuestGraphQlApi.Identifier(
|
||||
TeslaChargingGuestGraphQlApi.ChargingSiteIdentifier(
|
||||
trtId
|
||||
)
|
||||
),
|
||||
TeslaChargingGuestGraphQlApi.Experience.GUEST
|
||||
)
|
||||
)
|
||||
).data.site?.pricing
|
||||
}
|
||||
details to guestPricing
|
||||
}
|
||||
val details = detailsA.await()
|
||||
|
||||
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
|
||||
val scV2CCSConnectors = location.chargepoints.filter {
|
||||
it.type in listOf(
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CCS_UNKNOWN
|
||||
) && it.power != null && it.power <= 150
|
||||
}
|
||||
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
|
||||
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
|
||||
}
|
||||
val scV3Connectors = location.chargepoints.filter {
|
||||
it.type in listOf(
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CCS_UNKNOWN
|
||||
) && it.power != null && it.power > 150
|
||||
}
|
||||
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
|
||||
"charger has unknown connectors"
|
||||
)
|
||||
|
||||
val chargerDetails = details.chargersAvailable.chargerDetails
|
||||
val chargers = details.chargers.associateBy { it.id }
|
||||
var detailsSorted = chargerDetails
|
||||
.sortedBy { chargers[it.id]?.labelLetter }
|
||||
.sortedBy { chargers[it.id]?.labelNumber }
|
||||
|
||||
|
||||
if (detailsSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
|
||||
// apparently some connectors are missing in Tesla data
|
||||
// If we have just one type of charger, we can still match
|
||||
val numMissing =
|
||||
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - detailsSorted.size
|
||||
if ((scV2Connectors.isEmpty() || scV3Connectors.isEmpty()) && numMissing > 0) {
|
||||
detailsSorted =
|
||||
detailsSorted + List(numMissing) {
|
||||
TeslaChargingGuestGraphQlApi.ChargerDetail(
|
||||
ChargerAvailability.UNKNOWN,
|
||||
""
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
|
||||
}
|
||||
}
|
||||
|
||||
val detailsMap =
|
||||
mutableMapOf<Chargepoint, List<TeslaChargingGuestGraphQlApi.ChargerDetail>>()
|
||||
var i = 0
|
||||
for (connector in scV2Connectors) {
|
||||
detailsMap[connector] =
|
||||
detailsSorted.subList(i, i + connector.count)
|
||||
i += connector.count
|
||||
}
|
||||
if (scV2CCSConnectors.isNotEmpty()) {
|
||||
i = 0
|
||||
for (connector in scV2CCSConnectors) {
|
||||
detailsMap[connector] =
|
||||
detailsSorted.subList(i, i + connector.count)
|
||||
i += connector.count
|
||||
}
|
||||
}
|
||||
for (connector in scV3Connectors) {
|
||||
detailsMap[connector] =
|
||||
detailsSorted.subList(i, i + connector.count)
|
||||
i += connector.count
|
||||
}
|
||||
|
||||
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
|
||||
val labelsMap = detailsMap.mapValues { it.value.map { chargers[it.id]?.label } }
|
||||
|
||||
val pricing = details.pricing.copy(memberRates = guestPricing.await()?.userRates)
|
||||
|
||||
return ChargeLocationStatus(
|
||||
statusMap,
|
||||
"Tesla",
|
||||
labels = labelsMap,
|
||||
extraData = pricing
|
||||
)
|
||||
}
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
return when (charger.dataSource) {
|
||||
"goingelectric" -> charger.network == "Tesla Supercharger"
|
||||
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import net.vonforst.evmap.api.availability.tesla.ChargerAvailability
|
||||
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
|
||||
import net.vonforst.evmap.api.availability.tesla.TeslaChargingGuestGraphQlApi
|
||||
import net.vonforst.evmap.api.availability.tesla.TeslaChargingOwnershipGraphQlApi
|
||||
import net.vonforst.evmap.api.availability.tesla.asTeslaCoord
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import okhttp3.OkHttpClient
|
||||
import java.time.Instant
|
||||
import java.util.Collections
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
|
||||
class TeslaOwnerAvailabilityDetector(
|
||||
private val client: OkHttpClient,
|
||||
private val tokenStore: TokenStore,
|
||||
private val baseUrl: String? = null
|
||||
) :
|
||||
BaseAvailabilityDetector(client) {
|
||||
|
||||
private val authApi = TeslaAuthenticationApi.create(client, null)
|
||||
private var api: TeslaChargingOwnershipGraphQlApi? = null
|
||||
|
||||
interface TokenStore {
|
||||
var teslaRefreshToken: String?
|
||||
var teslaAccessToken: String?
|
||||
var teslaAccessTokenExpiry: Long
|
||||
}
|
||||
|
||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||
val api = initApi()
|
||||
val req = TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesRequest(
|
||||
TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesVariables(
|
||||
TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesArgs(
|
||||
location.coordinates.asTeslaCoord(),
|
||||
TeslaChargingOwnershipGraphQlApi.Coordinate(
|
||||
location.coordinates.lat + coordRange,
|
||||
location.coordinates.lng - coordRange
|
||||
),
|
||||
TeslaChargingOwnershipGraphQlApi.Coordinate(
|
||||
location.coordinates.lat - coordRange,
|
||||
location.coordinates.lng + coordRange
|
||||
),
|
||||
TeslaChargingOwnershipGraphQlApi.OpenToNonTeslasFilterValue(false)
|
||||
)
|
||||
)
|
||||
)
|
||||
val results = api.getNearbyChargingSites(
|
||||
req,
|
||||
req.operationName
|
||||
).data.charging?.nearbySites?.sitesAndDistances
|
||||
?: throw AvailabilityDetectorException("no candidates found.")
|
||||
val result =
|
||||
results.minByOrNull { it.haversineDistanceMiles.value }
|
||||
?: throw AvailabilityDetectorException("no candidates found.")
|
||||
|
||||
val details = api.getChargingSiteInformation(
|
||||
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationRequest(
|
||||
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationVariables(
|
||||
TeslaChargingOwnershipGraphQlApi.ChargingSiteIdentifier(result.id.text),
|
||||
TeslaChargingOwnershipGraphQlApi.VehicleMakeType.NON_TESLA
|
||||
)
|
||||
)
|
||||
).data.charging.site ?: throw AvailabilityDetectorException("no candidates found.")
|
||||
|
||||
|
||||
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
|
||||
val scV2CCSConnectors = location.chargepoints.filter {
|
||||
it.type in listOf(
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CCS_UNKNOWN
|
||||
) && it.power != null && it.power <= 150
|
||||
}
|
||||
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
|
||||
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
|
||||
}
|
||||
val scV3Connectors = location.chargepoints.filter {
|
||||
it.type in listOf(
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CCS_UNKNOWN
|
||||
) && it.power != null && it.power > 150
|
||||
}
|
||||
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
|
||||
"charger has unknown connectors"
|
||||
)
|
||||
|
||||
val chargerDetails = details.siteDynamic.chargerDetails
|
||||
val chargers = details.siteStatic.chargers.associateBy { it.id }
|
||||
var detailsSorted = chargerDetails
|
||||
.sortedBy { c -> c.charger.labelLetter ?: chargers[c.charger.id]?.labelLetter }
|
||||
.sortedBy { c -> c.charger.labelNumber ?: chargers[c.charger.id]?.labelNumber }
|
||||
if (detailsSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
|
||||
// apparently some connectors are missing in Tesla data
|
||||
// If we have just one type of charger, we can still match
|
||||
val numMissing =
|
||||
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - detailsSorted.size
|
||||
if ((scV2Connectors.isEmpty() || scV3Connectors.isEmpty()) && numMissing > 0) {
|
||||
detailsSorted =
|
||||
detailsSorted + List(numMissing) {
|
||||
TeslaChargingOwnershipGraphQlApi.ChargerDetail(
|
||||
ChargerAvailability.UNKNOWN,
|
||||
TeslaChargingOwnershipGraphQlApi.ChargerId(
|
||||
TeslaChargingOwnershipGraphQlApi.Text(""),
|
||||
null,
|
||||
null
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
|
||||
}
|
||||
}
|
||||
|
||||
val detailsMap =
|
||||
emptyMap<Chargepoint, List<TeslaChargingOwnershipGraphQlApi.ChargerDetail>>().toMutableMap()
|
||||
var i = 0
|
||||
for (connector in scV2Connectors) {
|
||||
detailsMap[connector] =
|
||||
detailsSorted.subList(i, i + connector.count)
|
||||
i += connector.count
|
||||
}
|
||||
if (scV2CCSConnectors.isNotEmpty()) {
|
||||
i = 0
|
||||
for (connector in scV2CCSConnectors) {
|
||||
detailsMap[connector] =
|
||||
detailsSorted.subList(i, i + connector.count)
|
||||
i += connector.count
|
||||
}
|
||||
}
|
||||
for (connector in scV3Connectors) {
|
||||
detailsMap[connector] =
|
||||
detailsSorted.subList(i, i + connector.count)
|
||||
i += connector.count
|
||||
}
|
||||
|
||||
val congestionHistogram = details.congestionPriceHistogram?.let { cph ->
|
||||
val indexOfMidnight = cph.dataAttributes.indexOfFirst { it.label == "12AM" }
|
||||
indexOfMidnight.takeIf { it >= 0 }?.let { index ->
|
||||
val data = cph.data.toMutableList()
|
||||
Collections.rotate(data, -index)
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
|
||||
val labelsMap = detailsMap.mapValues {
|
||||
it.value.map {
|
||||
it.charger.label?.value ?: chargers[it.charger.id]?.label?.value
|
||||
}
|
||||
}
|
||||
|
||||
return ChargeLocationStatus(
|
||||
statusMap,
|
||||
"Tesla",
|
||||
labels = labelsMap,
|
||||
congestionHistogram = congestionHistogram,
|
||||
extraData = details.pricing
|
||||
)
|
||||
}
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
return when (charger.dataSource) {
|
||||
"goingelectric" -> charger.network == "Tesla Supercharger"
|
||||
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun initApi(): TeslaChargingOwnershipGraphQlApi {
|
||||
|
||||
return api ?: run {
|
||||
val newApi = TeslaChargingOwnershipGraphQlApi.create(client, baseUrl) {
|
||||
val now = Instant.now().epochSecond
|
||||
val token =
|
||||
tokenStore.teslaAccessToken.takeIf { tokenStore.teslaAccessTokenExpiry > now }
|
||||
?: run {
|
||||
val refreshToken = tokenStore.teslaRefreshToken
|
||||
?: throw NotSignedInException()
|
||||
val response =
|
||||
authApi.getToken(
|
||||
TeslaAuthenticationApi.RefreshTokenRequest(
|
||||
refreshToken
|
||||
)
|
||||
)
|
||||
tokenStore.teslaAccessToken = response.accessToken
|
||||
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
|
||||
response.accessToken
|
||||
}
|
||||
token
|
||||
}
|
||||
api = newApi
|
||||
newApi
|
||||
}
|
||||
}
|
||||
|
||||
fun isSignedIn() = tokenStore.teslaRefreshToken != null
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package net.vonforst.evmap.api.availability.tesla
|
||||
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.ToJson
|
||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||
import net.vonforst.evmap.model.Coordinate
|
||||
import java.time.LocalTime
|
||||
|
||||
sealed class GraphQlRequest {
|
||||
abstract val operationName: String
|
||||
abstract val query: String
|
||||
abstract val variables: Any?
|
||||
}
|
||||
|
||||
fun Coordinate.asTeslaCoord() =
|
||||
TeslaChargingOwnershipGraphQlApi.Coordinate(this.lat, this.lng)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Outage(val message: String /* TODO: */)
|
||||
|
||||
enum class ChargerAvailability {
|
||||
@Json(name = "CHARGER_AVAILABILITY_AVAILABLE")
|
||||
AVAILABLE,
|
||||
|
||||
@Json(name = "CHARGER_AVAILABILITY_OCCUPIED")
|
||||
OCCUPIED,
|
||||
|
||||
@Json(name = "CHARGER_AVAILABILITY_DOWN")
|
||||
DOWN,
|
||||
|
||||
@Json(name = "CHARGER_AVAILABILITY_UNKNOWN")
|
||||
UNKNOWN;
|
||||
|
||||
fun toStatus() = when (this) {
|
||||
AVAILABLE -> ChargepointStatus.AVAILABLE
|
||||
OCCUPIED -> ChargepointStatus.OCCUPIED
|
||||
DOWN -> ChargepointStatus.FAULTED
|
||||
UNKNOWN -> ChargepointStatus.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Pricing(
|
||||
val canDisplayCombinedComparison: Boolean?,
|
||||
val hasMSPPricing: Boolean?,
|
||||
val hasMembershipPricing: Boolean?,
|
||||
val memberRates: Rates?, // rates for Tesla drivers & non-Tesla drivers with subscription
|
||||
val userRates: Rates? // rates without subscription
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Rates(
|
||||
val activePricebook: Pricebook
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Pricebook(
|
||||
val charging: PricebookDetails,
|
||||
val parking: PricebookDetails,
|
||||
val priceBookID: Long?
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PricebookDetails(
|
||||
val bucketUom: String, // unit of measurement for buckets (typically "kw")
|
||||
val buckets: List<Bucket>, // buckets of charging power (used for minute-based pricing)
|
||||
val currencyCode: String,
|
||||
val programType: String,
|
||||
val rates: List<Double>,
|
||||
val touRates: TouRates,
|
||||
val uom: String, // unit of measurement ("kwh" or "min")
|
||||
val vehicleMakeType: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Bucket(
|
||||
val start: Int,
|
||||
val end: Int
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TouRates(
|
||||
val activeRatesByTime: List<ActiveRatesByTime>,
|
||||
val enabled: Boolean
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ActiveRatesByTime(
|
||||
val startTime: LocalTime,
|
||||
val endTime: LocalTime,
|
||||
val rates: List<Double>
|
||||
)
|
||||
|
||||
internal class LocalTimeAdapter {
|
||||
@FromJson
|
||||
fun fromJson(value: String?): LocalTime? = value?.let {
|
||||
if (it == "24:00") LocalTime.MAX else LocalTime.parse(it)
|
||||
}
|
||||
|
||||
@ToJson
|
||||
fun toJson(value: LocalTime?): String? = value?.toString()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package net.vonforst.evmap.api.availability.tesla
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonWriter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import java.lang.reflect.Type
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface TeslaCuaApi {
|
||||
@GET("tesla-locations")
|
||||
suspend fun getTeslaLocations(
|
||||
@Query("translate") translate: String = "en_US",
|
||||
@Query("usetrt") usetrt: Boolean = true,
|
||||
): List<TeslaLocation>
|
||||
|
||||
@GET("tesla-location")
|
||||
suspend fun getTeslaLocation(
|
||||
@Query("id") id: String,
|
||||
@Query("translate") translate: String = "en_US",
|
||||
@Query("usetrt") usetrt: Boolean = true
|
||||
): TeslaLocation
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TeslaLocation(
|
||||
val latitude: Double?,
|
||||
val longitude: Double?,
|
||||
@Json(name = "location_id") val locationId: String,
|
||||
val title: String?,
|
||||
@Json(name = "location_type") val locationType: List<String>,
|
||||
val trtId: String?
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String? = null
|
||||
): TeslaCuaApi {
|
||||
val clientWithInterceptor = client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
// increase cache duration to 24h (useful for the large getTeslaLocations request)
|
||||
val request = chain.request().newBuilder()
|
||||
.cacheControl(CacheControl.Builder().maxStale(24, TimeUnit.HOURS).build())
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://www.tesla.com/cua-api/")
|
||||
.addConverterFactory(
|
||||
MoshiConverterFactory.create(
|
||||
Moshi.Builder().add(LocalTimeAdapter()).build()
|
||||
)
|
||||
)
|
||||
.client(clientWithInterceptor)
|
||||
.build()
|
||||
return retrofit.create(TeslaCuaApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TeslaChargingGuestGraphQlApi {
|
||||
@POST("graphql")
|
||||
suspend fun getChargingSiteDetails(
|
||||
@Body request: GetChargingSiteDetailsRequest,
|
||||
@Query("operationName") operationName: String = "getGuestChargingSiteDetails"
|
||||
): GetChargingSiteDetailsResponse
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteDetailsRequest(
|
||||
override val variables: GetChargingSiteInformationVariables,
|
||||
override val operationName: String = "getGuestChargingSiteDetails",
|
||||
override val query: String =
|
||||
"\n query getGuestChargingSiteDetails(\$identifier: ChargingSiteIdentifierInput!, \$deviceLocale: String!, \$experience: ChargingExperienceEnum!) {\n site(\n identifier: \$identifier\n deviceLocale: \$deviceLocale\n experience: \$experience\n ) {\n activeOutages\n address {\n countryCode\n }\n chargers {\n id\n label\n }\n chargersAvailable {\n chargerDetails {\n id\n availability\n }\n }\n holdAmount {\n holdAmount\n currencyCode\n }\n maxPowerKw\n name\n programType\n publicStallCount\n id\n pricing(experience: \$experience) {\n userRates {\n activePricebook {\n charging {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n }\n parking {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n }\n congestion {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n }\n }\n }\n }\n }\n}\n "
|
||||
) : GraphQlRequest()
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationVariables(
|
||||
val identifier: Identifier,
|
||||
val experience: Experience,
|
||||
val deviceLocale: String = "de-DE",
|
||||
)
|
||||
|
||||
enum class Experience {
|
||||
ADHOC, GUEST
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Identifier(
|
||||
val siteId: ChargingSiteIdentifier
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteIdentifier(
|
||||
val id: Long,
|
||||
val siteType: SiteType = SiteType.SUPERCHARGER
|
||||
)
|
||||
|
||||
enum class SiteType {
|
||||
@Json(name = "SITE_TYPE_SUPERCHARGER")
|
||||
SUPERCHARGER
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteDetailsResponse(val data: GetChargingSiteDetailsResponseData)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteDetailsResponseData(val site: ChargingSiteInformation?)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteInformation(
|
||||
val activeOutages: List<Outage>?,
|
||||
val chargers: List<ChargerId>,
|
||||
val chargersAvailable: ChargersAvailable,
|
||||
val id: Long,
|
||||
val maxPowerKw: Int,
|
||||
val name: String,
|
||||
val pricing: Pricing,
|
||||
val publicStallCount: Int
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargerId(
|
||||
val id: String,
|
||||
val label: String?,
|
||||
) {
|
||||
val labelNumber
|
||||
get() = label?.replace(Regex("""\D"""), "")?.toInt()
|
||||
val labelLetter
|
||||
get() = label?.replace(Regex("""\d"""), "")
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargersAvailable(val chargerDetails: List<ChargerDetail>)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargerDetail(
|
||||
val availability: ChargerAvailability,
|
||||
val id: String
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String? = null
|
||||
): TeslaChargingGuestGraphQlApi {
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://www.tesla.com/de_DE/charging/guest/api/")
|
||||
.addConverterFactory(
|
||||
MoshiConverterFactory.create(
|
||||
Moshi.Builder().add(LocalTimeAdapter()).build()
|
||||
)
|
||||
)
|
||||
.client(client)
|
||||
.build()
|
||||
return retrofit.create(TeslaChargingGuestGraphQlApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package net.vonforst.evmap.api.availability.tesla
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.LocalTime
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
fun buildSignInUri(codeChallenge: String): 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 resultUrlPrefix = "https://auth.tesla.com/void/callback"
|
||||
}
|
||||
}
|
||||
|
||||
interface TeslaOwnerApi {
|
||||
@GET("/api/1/users/me")
|
||||
suspend fun getUserInfo(): UserInfoResponse
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserInfoResponse(
|
||||
val response: UserInfo
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserInfo(
|
||||
val email: String,
|
||||
@Json(name = "full_name") val fullName: String,
|
||||
@Json(name = "profile_image_url") val profileImageUrl: String?
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, token: String, baseUrl: String? = null): TeslaOwnerApi {
|
||||
val clientWithInterceptor = client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
// add API key to every request
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("User-Agent", "okhttp/4.9.2")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://owner-api.teslamotors.com")
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
.client(clientWithInterceptor)
|
||||
.build()
|
||||
return retrofit.create(TeslaOwnerApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TeslaChargingOwnershipGraphQlApi {
|
||||
@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
|
||||
}
|
||||
|
||||
@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 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 ChargerId(
|
||||
val id: Text,
|
||||
val label: Value<String>?,
|
||||
val name: String?
|
||||
) {
|
||||
val labelNumber
|
||||
get() = label?.value?.replace(Regex("""\D"""), "")?.toInt()
|
||||
val labelLetter
|
||||
get() = label?.value?.replace(Regex("""\d"""), "")
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargerDetail(
|
||||
val availability: ChargerAvailability,
|
||||
val charger: ChargerId
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class 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 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.
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Value<T : Any>(val value: T)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Text(val text: String)
|
||||
|
||||
enum class WaitEstimateBucket {
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_NO_WAIT")
|
||||
NO_WAIT,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_LESS_THAN_5_MINUTES")
|
||||
LESS_THAN_5_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_5_MINUTES")
|
||||
APPROXIMATELY_5_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_10_MINUTES")
|
||||
APPROXIMATELY_10_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_15_MINUTES")
|
||||
APPROXIMATELY_15_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
|
||||
APPROXIMATELY_20_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_GREATER_THAN_25_MINUTES")
|
||||
GREATER_THAN_25_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String? = null,
|
||||
token: suspend () -> String
|
||||
): TeslaChargingOwnershipGraphQlApi {
|
||||
val clientWithInterceptor = client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
val t = runBlocking { token() }
|
||||
// add API key to every request
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $t")
|
||||
.header("User-Agent", "okhttp/4.9.2")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
||||
.header("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(TeslaChargingOwnershipGraphQlApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package net.vonforst.evmap.api.fronyx
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.moshi.JsonDataException
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
data class PredictionData(
|
||||
val predictionGraph: Map<ZonedDateTime, Double>?,
|
||||
val maxValue: Double,
|
||||
val predictedChargepoints: List<Chargepoint>,
|
||||
val isPercentage: Boolean,
|
||||
val description: String?
|
||||
)
|
||||
|
||||
class PredictionRepository(private val context: Context) {
|
||||
private val predictionApi = FronyxApi(context.getString(R.string.fronyx_key))
|
||||
private val prefs = PreferenceDataSource(context)
|
||||
|
||||
suspend fun getPredictionData(
|
||||
charger: ChargeLocation,
|
||||
availability: ChargeLocationStatus?,
|
||||
filteredConnectors: Set<String>? = null
|
||||
): PredictionData {
|
||||
val fronyxPrediction = availability?.evseIds?.let { evseIds ->
|
||||
getFronyxPrediction(charger, evseIds, filteredConnectors)
|
||||
}?.data
|
||||
val graph = buildPredictionGraph(availability, fronyxPrediction)
|
||||
val predictedChargepoints = getPredictedChargepoints(charger, filteredConnectors)
|
||||
val maxValue = getPredictionMaxValue(availability, fronyxPrediction, predictedChargepoints)
|
||||
val isPercentage = predictionIsPercentage(availability, fronyxPrediction)
|
||||
val description = getDescription(charger, predictedChargepoints)
|
||||
return PredictionData(
|
||||
graph, maxValue, predictedChargepoints, isPercentage, description
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getFronyxPrediction(
|
||||
charger: ChargeLocation,
|
||||
evseIds: Map<Chargepoint, List<String>>,
|
||||
filteredConnectors: Set<String>?
|
||||
): Resource<List<FronyxEvseIdResponse>> {
|
||||
if (!prefs.predictionEnabled) return Resource.success(null)
|
||||
|
||||
val allEvseIds =
|
||||
evseIds.filterKeys {
|
||||
FronyxApi.isChargepointSupported(charger, it) &&
|
||||
filteredConnectors?.let { filtered ->
|
||||
equivalentPlugTypes(
|
||||
it.type
|
||||
).any { filtered.contains(it) }
|
||||
} ?: true
|
||||
}.flatMap { it.value }
|
||||
if (allEvseIds.isEmpty()) {
|
||||
return Resource.success(emptyList())
|
||||
}
|
||||
try {
|
||||
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
|
||||
if (result.size == allEvseIds.size) {
|
||||
return Resource.success(result)
|
||||
} else {
|
||||
return Resource.error("not all EVSEIDs found", null)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: HttpException) {
|
||||
e.printStackTrace()
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: AvailabilityDetectorException) {
|
||||
e.printStackTrace()
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: JsonDataException) {
|
||||
// malformed JSON response from fronyx API
|
||||
e.printStackTrace()
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPredictionGraph(
|
||||
availability: ChargeLocationStatus?,
|
||||
prediction: List<FronyxEvseIdResponse>?
|
||||
): Map<ZonedDateTime, Double>? {
|
||||
val congestionHistogram = availability?.congestionHistogram
|
||||
return if (congestionHistogram != null && prediction == null) {
|
||||
congestionHistogram.mapIndexed { i, value ->
|
||||
LocalTime.of(i, 0).atDate(LocalDate.now())
|
||||
.atZone(ZoneId.systemDefault()) to value
|
||||
}.toMap()
|
||||
} else {
|
||||
prediction?.let { responses ->
|
||||
if (responses.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val evseIds = responses.map { it.evseId }
|
||||
val groupByTimestamp = responses.flatMap { response ->
|
||||
response.predictions.map {
|
||||
Triple(
|
||||
it.timestamp,
|
||||
response.evseId,
|
||||
it.status
|
||||
)
|
||||
}
|
||||
}
|
||||
.groupBy { it.first } // group by timestamp
|
||||
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
|
||||
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
|
||||
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
|
||||
|
||||
groupByTimestamp.mapValues {
|
||||
it.value.count {
|
||||
it.second == FronyxStatus.UNAVAILABLE
|
||||
}.toDouble()
|
||||
}.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPredictedChargepoints(
|
||||
charger: ChargeLocation,
|
||||
filteredConnectors: Set<String>?
|
||||
) =
|
||||
charger.chargepoints.filter {
|
||||
FronyxApi.isChargepointSupported(charger, it) &&
|
||||
filteredConnectors?.let { filtered ->
|
||||
equivalentPlugTypes(it.type).any {
|
||||
filtered.contains(
|
||||
it
|
||||
)
|
||||
}
|
||||
} ?: true
|
||||
}
|
||||
|
||||
private fun getPredictionMaxValue(
|
||||
availability: ChargeLocationStatus?,
|
||||
prediction: List<FronyxEvseIdResponse>?,
|
||||
predictedChargepoints: List<Chargepoint>
|
||||
): Double = if (availability?.congestionHistogram != null && prediction == null) {
|
||||
1.0
|
||||
} else {
|
||||
predictedChargepoints.sumOf { it.count }.toDouble()
|
||||
}
|
||||
|
||||
private fun predictionIsPercentage(
|
||||
availability: ChargeLocationStatus?,
|
||||
prediction: List<FronyxEvseIdResponse>?
|
||||
) =
|
||||
availability?.congestionHistogram != null && prediction == null
|
||||
|
||||
|
||||
private fun getDescription(
|
||||
charger: ChargeLocation,
|
||||
predictedChargepoints: List<Chargepoint>
|
||||
): String? {
|
||||
val allChargepoints = charger.chargepoints
|
||||
|
||||
val predictedChargepointTypes = predictedChargepoints.map { it.type }.distinct()
|
||||
return if (allChargepoints == predictedChargepoints) {
|
||||
null
|
||||
} else if (predictedChargepointTypes.size == 1) {
|
||||
context.getString(
|
||||
R.string.prediction_only,
|
||||
nameForPlugType(context.stringProvider(), predictedChargepointTypes[0])
|
||||
)
|
||||
} else {
|
||||
context.getString(
|
||||
R.string.prediction_only,
|
||||
context.getString(R.string.prediction_dc_plugs_only)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,18 +11,44 @@ import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.*
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.ui.cluster
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.ChargepointList
|
||||
import net.vonforst.evmap.api.FiltersSQLQuery
|
||||
import net.vonforst.evmap.api.StringProvider
|
||||
import net.vonforst.evmap.api.mapPower
|
||||
import net.vonforst.evmap.api.mapPowerInverse
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.powerSteps
|
||||
import net.vonforst.evmap.model.BooleanFilter
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.model.ChargepointListItem
|
||||
import net.vonforst.evmap.model.Filter
|
||||
import net.vonforst.evmap.model.FilterValue
|
||||
import net.vonforst.evmap.model.FilterValues
|
||||
import net.vonforst.evmap.model.MultipleChoiceFilter
|
||||
import net.vonforst.evmap.model.MultipleChoiceFilterValue
|
||||
import net.vonforst.evmap.model.ReferenceData
|
||||
import net.vonforst.evmap.model.SliderFilter
|
||||
import net.vonforst.evmap.model.getBooleanValue
|
||||
import net.vonforst.evmap.model.getMultipleChoiceValue
|
||||
import net.vonforst.evmap.model.getSliderValue
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import net.vonforst.evmap.viewmodel.getClusterDistance
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.*
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
|
||||
interface GoingElectricApi {
|
||||
@FormUrlEncoded
|
||||
@@ -104,10 +130,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()
|
||||
|
||||
@@ -121,23 +147,26 @@ interface GoingElectricApi {
|
||||
}
|
||||
}
|
||||
|
||||
private const val STATUS_OK = "ok"
|
||||
|
||||
class GoingElectricApiWrapper(
|
||||
val apikey: String,
|
||||
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 +175,39 @@ class GoingElectricApiWrapper(
|
||||
val minPower = filters?.getSliderValue("min_power")
|
||||
val minConnectors = filters?.getSliderValue("min_connectors")
|
||||
|
||||
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
|
||||
// no connectors chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
if (connectorsVal != null && connectorsVal.values.contains("CCS")) {
|
||||
// see note about Tesla Supercharger CCS filter in getFilters below
|
||||
connectorsVal.values.add("Tesla Supercharger CCS")
|
||||
}
|
||||
val connectors = formatMultipleChoice(connectorsVal)
|
||||
|
||||
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
|
||||
if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
|
||||
// no chargeCards chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val chargeCards = formatMultipleChoice(chargeCardsVal)
|
||||
|
||||
val networksVal = filters?.getMultipleChoiceValue("networks")
|
||||
if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) {
|
||||
// no networks chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val networks = formatMultipleChoice(networksVal)
|
||||
|
||||
val categoriesVal = filters?.getMultipleChoiceValue("categories")
|
||||
if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) {
|
||||
// no categories chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val categories = formatMultipleChoice(categoriesVal)
|
||||
|
||||
// do not use clustering if filters need to be applied locally.
|
||||
val useClustering = zoom < clusterThreshold
|
||||
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
|
||||
val useGeClustering = useClustering && geClusteringAvailable
|
||||
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
|
||||
@@ -205,21 +237,23 @@ class GoingElectricApiWrapper(
|
||||
categories = categories,
|
||||
startkey = startkey
|
||||
)
|
||||
if (!response.isSuccessful || response.body()!!.status != "ok") {
|
||||
if (!response.isSuccessful || response.body()!!.status != STATUS_OK) {
|
||||
return Resource.error(response.message(), null)
|
||||
} else {
|
||||
val body = response.body()!!
|
||||
data.addAll(body.chargelocations)
|
||||
data.addAll(body.chargelocations!!)
|
||||
startkey = body.startkey
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: HttpException) {
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
} while (startkey != null && startkey < 10000)
|
||||
|
||||
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 +264,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 +275,39 @@ class GoingElectricApiWrapper(
|
||||
val minPower = filters?.getSliderValue("min_power")
|
||||
val minConnectors = filters?.getSliderValue("min_connectors")
|
||||
|
||||
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
|
||||
// no connectors chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
if (connectorsVal != null && connectorsVal.values.contains("CCS")) {
|
||||
// see note about Tesla Supercharger CCS filter in getFilters below
|
||||
connectorsVal.values.add("Tesla Supercharger CCS")
|
||||
}
|
||||
val connectors = formatMultipleChoice(connectorsVal)
|
||||
|
||||
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
|
||||
if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
|
||||
// no chargeCards chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val chargeCards = formatMultipleChoice(chargeCardsVal)
|
||||
|
||||
val networksVal = filters?.getMultipleChoiceValue("networks")
|
||||
if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) {
|
||||
// no networks chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val networks = formatMultipleChoice(networksVal)
|
||||
|
||||
val categoriesVal = filters?.getMultipleChoiceValue("categories")
|
||||
if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) {
|
||||
// no categories chosen
|
||||
return Resource.success(emptyList())
|
||||
return Resource.success(ChargepointList.empty())
|
||||
}
|
||||
val categories = formatMultipleChoice(categoriesVal)
|
||||
|
||||
// do not use clustering if filters need to be applied locally.
|
||||
val useClustering = zoom < clusterThreshold
|
||||
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
|
||||
val useGeClustering = useClustering && geClusteringAvailable
|
||||
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
|
||||
@@ -296,31 +334,40 @@ class GoingElectricApiWrapper(
|
||||
categories = categories,
|
||||
startkey = startkey
|
||||
)
|
||||
if (!response.isSuccessful || response.body()!!.status != "ok") {
|
||||
if (!response.isSuccessful || response.body()!!.status != STATUS_OK) {
|
||||
return Resource.error(response.message(), null)
|
||||
} else {
|
||||
val body = response.body()!!
|
||||
data.addAll(body.chargelocations)
|
||||
data.addAll(body.chargelocations!!)
|
||||
startkey = body.startkey
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: HttpException) {
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
} while (startkey != null && startkey < 10000)
|
||||
|
||||
val result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
|
||||
return Resource.success(result)
|
||||
val result = postprocessResult(data, filters)
|
||||
return Resource.success(ChargepointList(result, startkey == null))
|
||||
}
|
||||
|
||||
private fun postprocessResult(
|
||||
chargers: List<GEChargepointListItem>,
|
||||
minPower: Int?,
|
||||
connectorsVal: MultipleChoiceFilterValue?,
|
||||
minConnectors: Int?,
|
||||
zoom: Float
|
||||
filters: FilterValues?
|
||||
): List<ChargepointListItem> {
|
||||
// apply filters which GoingElectric does not support natively
|
||||
var result = chargers.filter { it ->
|
||||
val minPower = filters?.getSliderValue("min_power")
|
||||
val minConnectors = filters?.getSliderValue("min_connectors")
|
||||
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
|
||||
val freecharging = filters?.getBooleanValue("freecharging")
|
||||
val freeparking = filters?.getBooleanValue("freeparking")
|
||||
val open247 = filters?.getBooleanValue("open_247")
|
||||
val barrierfree = filters?.getBooleanValue("barrierfree")
|
||||
val networks = filters?.getMultipleChoiceValue("networks")
|
||||
val chargecards = filters?.getMultipleChoiceValue("chargecards")
|
||||
|
||||
return chargers.filter { it ->
|
||||
// apply filters which GoingElectric does not support natively
|
||||
if (it is GEChargeLocation) {
|
||||
it.chargepoints
|
||||
.filter { it.power >= (minPower ?: 0) }
|
||||
@@ -329,19 +376,41 @@ class GoingElectricApiWrapper(
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}.map { it.convert(apikey, false) }
|
||||
|
||||
// apply clustering
|
||||
val useClustering = zoom < clusterThreshold
|
||||
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
|
||||
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
|
||||
if (!geClusteringAvailable && useClustering) {
|
||||
// apply local clustering if server side clustering is not available
|
||||
Dispatchers.IO.run {
|
||||
result = cluster(result, zoom, clusterDistance!!)
|
||||
}.map {
|
||||
// infer some properties based on applied filters
|
||||
if (it is GEChargeLocation) {
|
||||
var inferred = it
|
||||
if (freecharging == true) {
|
||||
inferred = inferred.copy(
|
||||
cost = inferred.cost?.copy(freecharging = true)
|
||||
?: GECost(freecharging = true)
|
||||
)
|
||||
}
|
||||
if (freeparking == true) {
|
||||
inferred = inferred.copy(
|
||||
cost = inferred.cost?.copy(freeparking = true) ?: GECost(freeparking = true)
|
||||
)
|
||||
}
|
||||
if (open247 == true) {
|
||||
inferred = inferred.copy(
|
||||
openinghours = inferred.openinghours?.copy(twentyfourSeven = true)
|
||||
?: GEOpeningHours(twentyfourSeven = true)
|
||||
)
|
||||
}
|
||||
if (barrierfree == true
|
||||
&& (networks == null || networks.all || it.network !in networks.values)
|
||||
&& (chargecards == null || chargecards.all)
|
||||
) {
|
||||
/* barrierfree, networks and chargecards are combined with OR - so we can only
|
||||
* be sure that the charger is barrierFree if the other filters are not active
|
||||
* or the charger does not match the other filters */
|
||||
inferred = inferred.copy(barrierFree = true)
|
||||
}
|
||||
inferred
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
return result
|
||||
}.map { it.convert(apikey, false) }
|
||||
}
|
||||
|
||||
override suspend fun getChargepointDetail(
|
||||
@@ -350,9 +419,9 @@ class GoingElectricApiWrapper(
|
||||
): Resource<ChargeLocation> {
|
||||
try {
|
||||
val response = api.getChargepointDetail(id)
|
||||
return if (response.isSuccessful && response.body()!!.status == "ok" && response.body()!!.chargelocations.size == 1) {
|
||||
return if (response.isSuccessful && response.body()!!.status == STATUS_OK && response.body()!!.chargelocations!!.size == 1) {
|
||||
Resource.success(
|
||||
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
|
||||
(response.body()!!.chargelocations!![0] as GEChargeLocation).convert(
|
||||
apikey, true
|
||||
)
|
||||
)
|
||||
@@ -361,6 +430,8 @@ class GoingElectricApiWrapper(
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: HttpException) {
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,19 +449,24 @@ class GoingElectricApiWrapper(
|
||||
|
||||
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
|
||||
|
||||
if (responses.map { it.isSuccessful }.all { it }) {
|
||||
if (responses.map { it.isSuccessful }.all { it }
|
||||
&& plugsResponse.body()!!.status == STATUS_OK
|
||||
&& chargeCardsResponse.body()!!.status == STATUS_OK
|
||||
&& networksResponse.body()!!.status == STATUS_OK) {
|
||||
Resource.success(
|
||||
GEReferenceData(
|
||||
plugsResponse.body()!!.result,
|
||||
networksResponse.body()!!.result,
|
||||
chargeCardsResponse.body()!!.result
|
||||
plugsResponse.body()!!.result!!,
|
||||
networksResponse.body()!!.result!!,
|
||||
chargeCardsResponse.body()!!.result!!
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
|
||||
Resource.error(responses.find { !it.isSuccessful }?.message(), null)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Resource.error(e.message, null)
|
||||
} catch (e: HttpException) {
|
||||
Resource.error(e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,11 +480,20 @@ 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()
|
||||
/*
|
||||
"Tesla Supercharger CCS" is a bit peculiar - it is available as a filter, but the API
|
||||
just returns "CCS" in the charging station details. So we cannot use it for filtering as
|
||||
it won't work in the local database. So we join them into a single filter option.
|
||||
If you want to find Tesla Superchargers with CCS, you can still do that using the network
|
||||
filter.
|
||||
*/
|
||||
val plugMap = plugs
|
||||
.filter { it != "Tesla Supercharger CCS" }
|
||||
.associateWith { plug ->
|
||||
nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
|
||||
}
|
||||
val networkMap = networks.associateWith { it }
|
||||
val chargecardMap = chargeCards.associate { it.id.toString() to it.name }
|
||||
val categoryMap = mapOf(
|
||||
"Autohaus" to sp.getString(R.string.category_car_dealership),
|
||||
"Autobahnraststätte" to sp.getString(R.string.category_service_on_motorway),
|
||||
@@ -481,5 +566,104 @@ class GoingElectricApiWrapper(
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun convertFiltersToSQL(
|
||||
filters: FilterValues,
|
||||
referenceData: ReferenceData
|
||||
): FiltersSQLQuery {
|
||||
if (filters.isEmpty()) return FiltersSQLQuery("", false, false)
|
||||
var requiresChargepointQuery = false
|
||||
var requiresChargeCardQuery = false
|
||||
|
||||
val result = StringBuilder()
|
||||
if (filters.getBooleanValue("freecharging") == true) {
|
||||
result.append(" AND freecharging IS 1")
|
||||
}
|
||||
if (filters.getBooleanValue("freeparking") == true) {
|
||||
result.append(" AND freeparking IS 1")
|
||||
}
|
||||
if (filters.getBooleanValue("open_247") == true) {
|
||||
result.append(" AND twentyfourSeven IS 1")
|
||||
}
|
||||
if (filters.getBooleanValue("exclude_faults") == true) {
|
||||
result.append(" AND fault_report_description IS NULL AND fault_report_created IS NULL")
|
||||
}
|
||||
|
||||
val minPower = filters.getSliderValue("min_power")
|
||||
if (minPower != null && minPower > 0) {
|
||||
result.append(" AND json_extract(cp.value, '$.power') >= ${minPower}")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
val connectors = filters.getMultipleChoiceValue("connectors")
|
||||
if (connectors != null && !connectors.all) {
|
||||
val connectorsList = if (connectors.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
connectors.values.joinToString(",") {
|
||||
DatabaseUtils.sqlEscapeString(
|
||||
GEChargepoint.convertTypeFromGE(
|
||||
it
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
// networks, chargecards and barrierFree filters are combined with OR in the GE API
|
||||
val networks = filters.getMultipleChoiceValue("networks")
|
||||
val chargecards = filters.getMultipleChoiceValue("chargecards")
|
||||
val barrierFree = filters.getBooleanValue("barrierfree")
|
||||
|
||||
if ((networks != null && !networks.all) || barrierFree == true || (chargecards != null && !chargecards.all)) {
|
||||
val queries = mutableListOf<String>()
|
||||
if (networks != null && !networks.all) {
|
||||
val networksList = if (networks.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
networks.values.joinToString(",") { DatabaseUtils.sqlEscapeString(it) }
|
||||
}
|
||||
queries.add("network IN (${networksList})")
|
||||
}
|
||||
if (barrierFree == true) {
|
||||
queries.add("barrierFree IS 1")
|
||||
}
|
||||
if (chargecards != null && !chargecards.all) {
|
||||
val chargecardsList = if (chargecards.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
chargecards.values.joinToString(",")
|
||||
}
|
||||
queries.add("json_extract(cc.value, '$.id') IN (${chargecardsList})")
|
||||
requiresChargeCardQuery = true
|
||||
}
|
||||
result.append(" AND (${queries.joinToString(" OR ")})")
|
||||
}
|
||||
|
||||
val categories = filters.getMultipleChoiceValue("categories")
|
||||
if (categories != null && !categories.all) {
|
||||
throw NotImplementedError() // category cannot be determined in SQL
|
||||
}
|
||||
|
||||
|
||||
val minConnectors = filters.getSliderValue("min_connectors")
|
||||
if (minConnectors != null && minConnectors > 1) {
|
||||
result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= ${minConnectors}")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
return FiltersSQLQuery(result.toString(), requiresChargepointQuery, requiresChargeCardQuery)
|
||||
}
|
||||
|
||||
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
|
||||
val chargecards = filters.getMultipleChoiceValue("chargecards")
|
||||
return filters.getBooleanValue("freecharging") == true
|
||||
|| filters.getBooleanValue("freeparking") == true
|
||||
|| filters.getBooleanValue("open_247") == true
|
||||
|| filters.getBooleanValue("barrierfree") == true
|
||||
|| (chargecards != null && !chargecards.all)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,27 +5,42 @@ import androidx.room.PrimaryKey
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.model.Address
|
||||
import net.vonforst.evmap.model.ChargeCard
|
||||
import net.vonforst.evmap.model.ChargeCardId
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ChargeLocationCluster
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.model.ChargepointListItem
|
||||
import net.vonforst.evmap.model.ChargepriceData
|
||||
import net.vonforst.evmap.model.ChargerPhoto
|
||||
import net.vonforst.evmap.model.Coordinate
|
||||
import net.vonforst.evmap.model.Cost
|
||||
import net.vonforst.evmap.model.FaultReport
|
||||
import net.vonforst.evmap.model.Hours
|
||||
import net.vonforst.evmap.model.OpeningHours
|
||||
import net.vonforst.evmap.model.OpeningHoursDays
|
||||
import net.vonforst.evmap.model.ReferenceData
|
||||
import java.time.Instant
|
||||
import java.time.LocalTime
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GEChargepointList(
|
||||
val status: String,
|
||||
val chargelocations: List<GEChargepointListItem>,
|
||||
val chargelocations: List<GEChargepointListItem>?,
|
||||
@JsonObjectOrFalse val startkey: Int?
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GEStringList(
|
||||
val status: String,
|
||||
val result: List<String>
|
||||
val result: List<String>?
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GEChargeCardList(
|
||||
val status: String,
|
||||
val result: List<GEChargeCard>
|
||||
val result: List<GEChargeCard>?
|
||||
)
|
||||
|
||||
sealed class GEChargepointListItem {
|
||||
@@ -35,7 +50,7 @@ sealed class GEChargepointListItem {
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GEChargeLocation(
|
||||
@Json(name = "ge_id") val id: Long,
|
||||
val name: String,
|
||||
val name: String?,
|
||||
val coordinates: GECoordinate,
|
||||
val address: GEAddress,
|
||||
val chargepoints: List<GEChargepoint>,
|
||||
@@ -57,7 +72,7 @@ data class GEChargeLocation(
|
||||
override fun convert(apikey: String, isDetailed: Boolean) = ChargeLocation(
|
||||
id,
|
||||
"goingelectric",
|
||||
name,
|
||||
name ?: "Charging station",
|
||||
coordinates.convert(),
|
||||
address.convert(),
|
||||
chargepoints.map { it.convert() },
|
||||
@@ -77,6 +92,8 @@ data class GEChargeLocation(
|
||||
cost?.convert(),
|
||||
null,
|
||||
ChargepriceData(address.country, network, chargepoints.map { it.type }),
|
||||
null,
|
||||
null,
|
||||
Instant.now(),
|
||||
isDetailed
|
||||
)
|
||||
@@ -84,10 +101,10 @@ data class GEChargeLocation(
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GECost(
|
||||
val freecharging: Boolean,
|
||||
val freeparking: Boolean,
|
||||
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
|
||||
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
|
||||
val freecharging: Boolean = false,
|
||||
val freeparking: Boolean = false,
|
||||
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String? = null,
|
||||
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String? = null
|
||||
) {
|
||||
fun convert() = Cost(
|
||||
// In GE, freecharging = false can either mean "paid charging" or "no information
|
||||
@@ -102,8 +119,8 @@ data class GECost(
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GEOpeningHours(
|
||||
@Json(name = "24/7") val twentyfourSeven: Boolean,
|
||||
@JsonObjectOrFalse val description: String?,
|
||||
val days: GEOpeningHoursDays?
|
||||
@JsonObjectOrFalse val description: String? = null,
|
||||
val days: GEOpeningHoursDays? = null
|
||||
) {
|
||||
fun convert() = OpeningHours(twentyfourSeven, description, days?.convert())
|
||||
}
|
||||
@@ -147,7 +164,7 @@ data class GEChargerPhoto(val id: String) {
|
||||
@JsonClass(generateAdapter = true)
|
||||
class GEChargerPhotoAdapter(override val id: String, val apikey: String) :
|
||||
ChargerPhoto(id) {
|
||||
override fun getUrl(height: Int?, width: Int?, size: Int?): String {
|
||||
override fun getUrl(height: Int?, width: Int?, size: Int?, allowOriginal: Boolean): String {
|
||||
return "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}&id=$id" +
|
||||
when {
|
||||
size != null -> "&size=$size"
|
||||
@@ -209,6 +226,7 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) {
|
||||
"Typ1" -> Chargepoint.TYPE_1
|
||||
"Typ2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
"Typ3" -> Chargepoint.TYPE_3
|
||||
"Tesla Supercharger CCS" -> Chargepoint.CCS_UNKNOWN
|
||||
"CCS" -> Chargepoint.CCS_UNKNOWN
|
||||
"Schuko" -> Chargepoint.SCHUKO
|
||||
"CHAdeMO" -> Chargepoint.CHADEMO
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
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.api.*
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.ui.cluster
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.ChargepointList
|
||||
import net.vonforst.evmap.api.FiltersSQLQuery
|
||||
import net.vonforst.evmap.api.StringProvider
|
||||
import net.vonforst.evmap.api.mapPower
|
||||
import net.vonforst.evmap.api.mapPowerInverse
|
||||
import net.vonforst.evmap.api.powerSteps
|
||||
import net.vonforst.evmap.model.BooleanFilter
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ChargepointListItem
|
||||
import net.vonforst.evmap.model.Filter
|
||||
import net.vonforst.evmap.model.FilterValue
|
||||
import net.vonforst.evmap.model.FilterValues
|
||||
import net.vonforst.evmap.model.MultipleChoiceFilter
|
||||
import net.vonforst.evmap.model.MultipleChoiceFilterValue
|
||||
import net.vonforst.evmap.model.ReferenceData
|
||||
import net.vonforst.evmap.model.SliderFilter
|
||||
import net.vonforst.evmap.model.getBooleanValue
|
||||
import net.vonforst.evmap.model.getMultipleChoiceValue
|
||||
import net.vonforst.evmap.model.getSliderValue
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import net.vonforst.evmap.viewmodel.getClusterDistance
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
|
||||
private const val maxResults = 3000
|
||||
|
||||
interface OpenChargeMapApi {
|
||||
@GET("poi/")
|
||||
@@ -30,7 +50,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 +65,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 +103,7 @@ interface OpenChargeMapApi {
|
||||
chain.proceed(new)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(StethoInterceptor())
|
||||
addDebugInterceptors()
|
||||
}
|
||||
if (context != null) {
|
||||
cache(Cache(context.cacheDir, cacheSize))
|
||||
@@ -105,11 +125,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 +138,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 +150,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,24 +169,26 @@ 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)
|
||||
} catch (e: HttpException) {
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,8 +197,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 +209,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 +226,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 +252,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(
|
||||
@@ -264,6 +280,8 @@ class OpenChargeMapApiWrapper(
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: HttpException) {
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +295,8 @@ class OpenChargeMapApiWrapper(
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: HttpException) {
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +306,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 +347,68 @@ class OpenChargeMapApiWrapper(
|
||||
)
|
||||
}
|
||||
|
||||
override fun convertFiltersToSQL(
|
||||
filters: FilterValues,
|
||||
referenceData: ReferenceData
|
||||
): FiltersSQLQuery {
|
||||
if (filters.isEmpty()) return FiltersSQLQuery("", false, false)
|
||||
|
||||
val refData = referenceData as OCMReferenceData
|
||||
var requiresChargepointQuery = false
|
||||
|
||||
val result = StringBuilder()
|
||||
|
||||
if (filters.getBooleanValue("exclude_faults") == true) {
|
||||
result.append(" AND fault_report_description IS NULL AND fault_report_created IS NULL")
|
||||
}
|
||||
|
||||
val minPower = filters.getSliderValue("min_power")
|
||||
if (minPower != null && minPower > 0) {
|
||||
result.append(" AND json_extract(cp.value, '$.power') >= ${minPower}")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
val connectors = filters.getMultipleChoiceValue("connectors")
|
||||
if (connectors != null && !connectors.all) {
|
||||
val connectorsList = if (connectors.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
connectors.values.joinToString(",") {
|
||||
DatabaseUtils.sqlEscapeString(
|
||||
OCMConnection.convertConnectionTypeFromOCM(
|
||||
it.toLong(),
|
||||
refData
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
val operators = filters.getMultipleChoiceValue("operators")
|
||||
if (operators != null && !operators.all) {
|
||||
val networksList = if (operators.values.size == 0) {
|
||||
""
|
||||
} else {
|
||||
operators.values.joinToString(",") { opId ->
|
||||
DatabaseUtils.sqlEscapeString(refData.operators.find { it.id == opId.toLong() }?.title.orEmpty())
|
||||
}
|
||||
}
|
||||
result.append(" AND network IN (${networksList})")
|
||||
}
|
||||
|
||||
val minConnectors = filters.getSliderValue("min_connectors")
|
||||
if (minConnectors != null && minConnectors > 1) {
|
||||
result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= ${minConnectors}")
|
||||
requiresChargepointQuery = true
|
||||
}
|
||||
|
||||
return FiltersSQLQuery(result.toString(), requiresChargepointQuery, false)
|
||||
}
|
||||
|
||||
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,17 +6,28 @@ import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import net.vonforst.evmap.max
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.model.Address
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.model.ChargepriceData
|
||||
import net.vonforst.evmap.model.ChargerPhoto
|
||||
import net.vonforst.evmap.model.Coordinate
|
||||
import net.vonforst.evmap.model.Cost
|
||||
import net.vonforst.evmap.model.FaultReport
|
||||
import net.vonforst.evmap.model.ReferenceData
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
// Unknown, Currently Available, Currently In Use, Operational
|
||||
val noFaultStatuses = listOf(0, 10, 20, 50)
|
||||
val noFaultStatuses = listOf(0L, 10L, 20L, 50L)
|
||||
|
||||
// Temporarily Unavailable, Partly Operational, Not Operational, Planned For Future Date, Removed (Decommissioned)
|
||||
val faultStatuses = listOf(30L, 75L, 100L, 150L, 200L)
|
||||
// Temporarily Unavailable, Partly Operational, Not Operational, Planned For Future Date
|
||||
val faultStatuses = listOf(30L, 75L, 100L, 150L)
|
||||
val faultReportCommentType = 1000L
|
||||
|
||||
// Removed (Decommissioned), Removed (Duplicate Listing)
|
||||
val removedStatuses = listOf(200L, 210L)
|
||||
|
||||
data class OCMBoundingBox(
|
||||
val sw_lat: Double, val sw_lng: Double,
|
||||
val ne_lat: Double, val ne_lng: Double
|
||||
@@ -52,9 +63,9 @@ data class OCMChargepoint(
|
||||
Coordinate(addressInfo.latitude, addressInfo.longitude),
|
||||
addressInfo.toAddress(refData),
|
||||
connections.map { it.convert(refData) },
|
||||
operatorInfo?.title,
|
||||
"https://openchargemap.org/site/poi/details/$id",
|
||||
"https://openchargemap.org/site/poi/edit/$id",
|
||||
operatorInfo?.title ?: refData.operators.find { it.id == operatorId }?.title,
|
||||
"https://map.openchargemap.io/?id=$id",
|
||||
"https://map.openchargemap.io/?id=$id",
|
||||
convertFaultReport(),
|
||||
recentlyVerified,
|
||||
null,
|
||||
@@ -71,10 +82,16 @@ data class OCMChargepoint(
|
||||
addressInfo.countryISOCode(refData),
|
||||
operatorId?.toString(),
|
||||
connections.map { "${it.connectionTypeId},${it.currentTypeId}" }),
|
||||
operatorInfo?.websiteUrl,
|
||||
if (operatorInfo?.websiteUrl?.withoutTrailingSlash() != addressInfo.relatedUrl?.withoutTrailingSlash()) addressInfo.relatedUrl else null,
|
||||
Instant.now(),
|
||||
isDetailed
|
||||
)
|
||||
|
||||
private fun String.withoutTrailingSlash(): String {
|
||||
return this.replace(Regex("/$"), "")
|
||||
}
|
||||
|
||||
private fun convertFaultReport(): FaultReport? {
|
||||
if (statusTypeId in faultStatuses || connections.any { it.statusTypeId in faultStatuses }) {
|
||||
if (userComments != null) {
|
||||
@@ -92,7 +109,7 @@ data class OCMChargepoint(
|
||||
connections.first { it.statusType != null && it.statusTypeId in faultStatuses }.statusType!!.title
|
||||
)
|
||||
}
|
||||
return FaultReport(null, null)
|
||||
return FaultReport(null, "")
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
@@ -142,7 +159,9 @@ data class OCMConnection(
|
||||
fun convert(refData: OCMReferenceData) = Chargepoint(
|
||||
convertConnectionTypeFromOCM(connectionTypeId, refData),
|
||||
power,
|
||||
quantity ?: 1
|
||||
quantity ?: 1,
|
||||
voltage?.toDouble(),
|
||||
amps?.toDouble()
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -156,7 +175,8 @@ data class OCMConnection(
|
||||
17L -> Chargepoint.CEE_ROT
|
||||
28L -> Chargepoint.SCHUKO
|
||||
8L -> Chargepoint.TESLA_ROADSTER_HPC
|
||||
27L -> Chargepoint.SUPERCHARGER
|
||||
27L -> Chargepoint.SUPERCHARGER // Tesla North American plug (NACS)
|
||||
30L -> Chargepoint.SUPERCHARGER // European Tesla Model S/X Supercharger plug (DC on Type 2)
|
||||
25L -> Chargepoint.TYPE_2_SOCKET
|
||||
1036L -> Chargepoint.TYPE_2_PLUG
|
||||
1L -> Chargepoint.TYPE_1
|
||||
@@ -251,14 +271,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
|
||||
|
||||
@@ -27,12 +27,14 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.car2go.maps.model.LatLng
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.location.Priority
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.utils.checkFineLocationPermission
|
||||
import org.acra.interaction.DialogInteraction
|
||||
|
||||
|
||||
interface LocationAwareScreen {
|
||||
@@ -43,13 +45,15 @@ interface LocationAwareScreen {
|
||||
class CarAppService : androidx.car.app.CarAppService() {
|
||||
private val CHANNEL_ID = "car_location"
|
||||
private val NOTIFICATION_ID = 1000
|
||||
private var foregroundStarted = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
fun ensureForegroundService() {
|
||||
// we want to run as a foreground service to make sure we can use location
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, getNotification())
|
||||
if (!foregroundStarted) {
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, getNotification())
|
||||
foregroundStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
@@ -121,9 +125,13 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
}
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
|
||||
val mapScreen = MapScreen(carContext, this)
|
||||
val screens = mutableListOf<Screen>(mapScreen)
|
||||
|
||||
handleActionsIntent(intent)?.let {
|
||||
screens.add(it)
|
||||
}
|
||||
if (!prefs.dataSourceSet) {
|
||||
screens.add(
|
||||
ChooseDataSourceScreen(
|
||||
@@ -146,6 +154,14 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
)
|
||||
)
|
||||
}
|
||||
if (!prefs.privacyAccepted) {
|
||||
screens.add(
|
||||
AcceptPrivacyScreen(carContext)
|
||||
)
|
||||
}
|
||||
handleACRAIntent(intent)?.let {
|
||||
screens.add(it)
|
||||
}
|
||||
|
||||
if (screens.size > 1) {
|
||||
val screenManager = carContext.getCarService(ScreenManager::class.java)
|
||||
@@ -157,6 +173,35 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
return screens.last()
|
||||
}
|
||||
|
||||
private fun handleACRAIntent(intent: Intent): Screen? {
|
||||
return if (intent.hasExtra(DialogInteraction.EXTRA_REPORT_CONFIG)) {
|
||||
CrashReportScreen(carContext, intent)
|
||||
} else null
|
||||
}
|
||||
|
||||
private fun handleActionsIntent(intent: Intent): Screen? {
|
||||
intent.data?.let {
|
||||
if (it.host == "find_charger") {
|
||||
val lat = it.getQueryParameter("latitude")?.toDouble()
|
||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||
val name = it.getQueryParameter("name")
|
||||
if (lat != null && lon != null) {
|
||||
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
|
||||
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
|
||||
return null
|
||||
} else if (name != null) {
|
||||
val screen = PlaceSearchScreen(carContext, this, name)
|
||||
return screen
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
handleActionsIntent(intent)
|
||||
}
|
||||
|
||||
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
|
||||
|
||||
private fun updateLocation(location: Location?) {
|
||||
@@ -179,6 +224,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
@SuppressLint("MissingPermission")
|
||||
fun requestLocationUpdates() {
|
||||
if (!locationPermissionGranted()) return
|
||||
cas.ensureForegroundService()
|
||||
Log.i(TAG, "Requesting location updates")
|
||||
requestCarHardwareLocationUpdates()
|
||||
requestPhoneLocationUpdates()
|
||||