mirror of
https://github.com/ev-map/EVMap.git
synced 2026-01-06 14:07:47 -05:00
Compare commits
287 Commits
1.6.5
...
replace-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b99e2ea2c8 | ||
|
|
d2ae3733d1 | ||
|
|
72845da4b5 | ||
|
|
51b57433a8 | ||
|
|
3202f821d1 | ||
|
|
b7e1ff09db | ||
|
|
feabf49b8d | ||
|
|
dcbe4c6325 | ||
|
|
dcff74c125 | ||
|
|
d8f7d77a36 | ||
|
|
d03cf70499 | ||
|
|
7a6bebd143 | ||
|
|
66d68ca68e | ||
|
|
772885a8eb | ||
|
|
6b07ce012a | ||
|
|
29dbc202d8 | ||
|
|
cf8371d095 | ||
|
|
01cb551cbc | ||
|
|
45fe297616 | ||
|
|
32cabefe7d | ||
|
|
9ff8329171 | ||
|
|
e9b70a2f00 | ||
|
|
c4c3aba7c7 | ||
|
|
890af2ddef | ||
|
|
ba0b36b3ec | ||
|
|
161b48789f | ||
|
|
042b983aa3 | ||
|
|
1c21da7be0 | ||
|
|
405baed0f7 | ||
|
|
19c0d57f2b | ||
|
|
42c2a2f72a | ||
|
|
36ee3ff231 | ||
|
|
883735ef05 | ||
|
|
4c68356ae9 | ||
|
|
7fde5b50aa | ||
|
|
7c4136c66d | ||
|
|
6e56f5c3ff | ||
|
|
017be6f31a | ||
|
|
b398a5dc81 | ||
|
|
3fb0dec868 | ||
|
|
8c4de115ec | ||
|
|
334b68cf5e | ||
|
|
788c68c9dd | ||
|
|
7842a15529 | ||
|
|
e7c9432191 | ||
|
|
76b6abd3ca | ||
|
|
752c184146 | ||
|
|
5471ac5073 | ||
|
|
69ae13a199 | ||
|
|
8a2e2d9a25 | ||
|
|
fe69a78b94 | ||
|
|
2663bd7964 | ||
|
|
3b54b2799f | ||
|
|
3a24711626 | ||
|
|
c158744bc2 | ||
|
|
c01033a036 | ||
|
|
16474c3864 | ||
|
|
7ce2f8d452 | ||
|
|
28df158d94 | ||
|
|
90b3645a0b | ||
|
|
de901aa825 | ||
|
|
2ce61f2f6b | ||
|
|
398f159e27 | ||
|
|
6ab3ba2ed2 | ||
|
|
f59fd9b3aa | ||
|
|
9e18c62d9d | ||
|
|
3626c9a72f | ||
|
|
36805d8224 | ||
|
|
b1dee90068 | ||
|
|
dfc7de75ad | ||
|
|
32c7774a3a | ||
|
|
02ef25b961 | ||
|
|
e535e77b7a | ||
|
|
5b0b4e4337 | ||
|
|
a6bbf635c5 | ||
|
|
591f99dea4 | ||
|
|
0c5bd69205 | ||
|
|
72e98cf611 | ||
|
|
0fefffda2f | ||
|
|
49e555ef04 | ||
|
|
d6d1e915ee | ||
|
|
546d7a11ce | ||
|
|
4849944c23 | ||
|
|
77b38661dd | ||
|
|
3723ee161b | ||
|
|
1d3efe5295 | ||
|
|
f011944135 | ||
|
|
1d81bb5d37 | ||
|
|
e8adb759a6 | ||
|
|
f4384b4b60 | ||
|
|
1d63e37467 | ||
|
|
b5b0254bdd | ||
|
|
6514197920 | ||
|
|
4c5388350f | ||
|
|
20e9e43f0d | ||
|
|
541646dda9 | ||
|
|
b9354e77a9 | ||
|
|
65fa54ef36 | ||
|
|
6e419849b1 | ||
|
|
c4c6f09a05 | ||
|
|
3b602c03c4 | ||
|
|
012e5d7362 | ||
|
|
4f5007ca0d | ||
|
|
b40a74aa37 | ||
|
|
ce172bd4b0 | ||
|
|
b3bbca576f | ||
|
|
72ccd99c1f | ||
|
|
dd5c8659df | ||
|
|
8f7f9e5a09 | ||
|
|
8256981b8d | ||
|
|
5649ef202f | ||
|
|
ed9c729684 | ||
|
|
add66811ae | ||
|
|
2df5710910 | ||
|
|
a513a8d6d4 | ||
|
|
a55e4df62d | ||
|
|
d540faa179 | ||
|
|
3d69d3e50c | ||
|
|
fce27f0c19 | ||
|
|
da3b9643bc | ||
|
|
b690d9744d | ||
|
|
70387ec350 | ||
|
|
5a331df232 | ||
|
|
5dc5e1f43f | ||
|
|
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 |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,2 +1,2 @@
|
|||||||
github: johan12345
|
github: johan12345
|
||||||
custom: 'https://paypal.me/johan98'
|
custom: ['https://paypal.me/johan98', 'https://ev-map.app/donate/']
|
||||||
|
|||||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Java environment
|
- name: Set up Java environment
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: 17
|
java-version: 17
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- name: Decrypt keystore
|
- 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
|
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
|
- 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
|
- name: Build app release
|
||||||
env:
|
env:
|
||||||
@@ -30,8 +30,11 @@ jobs:
|
|||||||
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
|
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
|
||||||
CHARGEPRICE_API_KEY: ${{ secrets.CHARGEPRICE_API_KEY }}
|
CHARGEPRICE_API_KEY: ${{ secrets.CHARGEPRICE_API_KEY }}
|
||||||
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
|
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
|
||||||
|
JAWG_API_KEY: ${{ secrets.JAWG_API_KEY }}
|
||||||
|
ARCGIS_API_KEY: ${{ secrets.ARCGIS_API_KEY }}
|
||||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||||
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
|
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
|
||||||
|
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
|
||||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
|
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
|
||||||
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
|
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
|
||||||
@@ -82,6 +85,6 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
asset_path: app/build/outputs/apk/fossAutomotive/release/app-google-automotive-release.apk
|
asset_path: app/build/outputs/apk/fossAutomotive/release/app-foss-automotive-release.apk
|
||||||
asset_name: app-foss-automotive-release.apk
|
asset_name: app-foss-automotive-release.apk
|
||||||
asset_content_type: application/vnd.android.package-archive
|
asset_content_type: application/vnd.android.package-archive
|
||||||
|
|||||||
58
.github/workflows/tests.yml
vendored
58
.github/workflows/tests.yml
vendored
@@ -16,17 +16,17 @@ jobs:
|
|||||||
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
|
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Java environment
|
- name: Set up Java environment
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: 17
|
java-version: 17
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
cache: 'gradle'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Copy apikeys.xml
|
- name: Copy apikeys.xml
|
||||||
run: cp .github/workflows/apikeys-ci.xml app/src/main/res/values/apikeys.xml
|
run: cp _ci/apikeys-ci.xml app/src/main/res/values/apikeys.xml
|
||||||
|
|
||||||
- name: Build app
|
- name: Build app
|
||||||
run: ./gradlew assemble${{ matrix.buildvariant }}Debug --no-daemon
|
run: ./gradlew assemble${{ matrix.buildvariant }}Debug --no-daemon
|
||||||
@@ -34,3 +34,55 @@ jobs:
|
|||||||
run: ./gradlew test${{ matrix.buildvariant }}DebugUnitTest --no-daemon
|
run: ./gradlew test${{ matrix.buildvariant }}DebugUnitTest --no-daemon
|
||||||
- name: Run Android Lint
|
- name: Run Android Lint
|
||||||
run: ./gradlew lint${{ matrix.buildvariant }}Debug --no-daemon
|
run: ./gradlew lint${{ matrix.buildvariant }}Debug --no-daemon
|
||||||
|
- name: Check licenses
|
||||||
|
run: ./gradlew exportLibraryDefinitions --no-daemon
|
||||||
|
|
||||||
|
apk_check:
|
||||||
|
name: Release APK checks (${{ matrix.buildvariant }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install checksec
|
||||||
|
run: sudo apt install -y checksec
|
||||||
|
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Java environment
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: 17
|
||||||
|
distribution: 'zulu'
|
||||||
|
cache: 'gradle'
|
||||||
|
|
||||||
|
- name: Copy apikeys.xml
|
||||||
|
run: cp _ci/apikeys-ci.xml app/src/main/res/values/apikeys.xml
|
||||||
|
|
||||||
|
- name: Build app
|
||||||
|
run: ./gradlew assemble${{ matrix.buildvariant }}Release --no-daemon
|
||||||
|
|
||||||
|
- name: Unpack native libraries from APK
|
||||||
|
run: |
|
||||||
|
VARIANT_FILENAME=$(echo ${{ matrix.buildvariant }} | sed -E 's/([a-z])([A-Z])/\1-\2/g' | tr 'A-Z' 'a-z')
|
||||||
|
VARIANT_FOLDER=$(echo ${{ matrix.buildvariant }} | sed -E 's/^([A-Z])/\L\1/')
|
||||||
|
APK_FILE="app/build/outputs/apk/$VARIANT_FOLDER/release/app-$VARIANT_FILENAME-release-unsigned.apk"
|
||||||
|
unzip $APK_FILE "lib/*"
|
||||||
|
|
||||||
|
- name: Run checksec on native libraries
|
||||||
|
run: |
|
||||||
|
checksec --output=json --dir=lib > checksec_output.json
|
||||||
|
jq --argjson exceptions '[
|
||||||
|
"lib/armeabi-v7a/libc++_shared.so",
|
||||||
|
"lib/x86/libc++_shared.so"
|
||||||
|
]' '
|
||||||
|
to_entries
|
||||||
|
| map(select(.value.fortify_source == "no" and (.key as $lib | $exceptions | index($lib) | not)))
|
||||||
|
| if length > 0 then
|
||||||
|
error("The following libraries do not have fortify enabled (and are not in the exception list): " + (map(.key) | join(", ")))
|
||||||
|
else
|
||||||
|
"All libraries have fortify enabled or are in the exception list."
|
||||||
|
end
|
||||||
|
' checksec_output.json
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,4 +12,5 @@ apikeys.xml
|
|||||||
/app/**/*.apk
|
/app/**/*.apk
|
||||||
/_img/connectors/*.ai
|
/_img/connectors/*.ai
|
||||||
api-7125266970515251116-798419-8e2dda660c80.json
|
api-7125266970515251116-798419-8e2dda660c80.json
|
||||||
output-metadata.json
|
output-metadata.json
|
||||||
|
licenses_*.csv
|
||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2020-2023 Johan von Forstner and contributors
|
Copyright (c) 2020-2024 Johan von Forstner and contributors
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -24,7 +24,8 @@ Features
|
|||||||
- Android Auto & Android Automotive OS integration
|
- Android Auto & Android Automotive OS integration
|
||||||
- No ads, fully open source
|
- No ads, fully open source
|
||||||
- Compatible with Android 5.0 and above
|
- Compatible with Android 5.0 and above
|
||||||
- Can use Google Maps or Mapbox (OpenStreetMap) as map backends - the version available on F-Droid only uses Mapbox.
|
- Can use Google Maps or OpenStreetMap as map backends - the version available on F-Droid only uses
|
||||||
|
OSM.
|
||||||
|
|
||||||
Screenshots
|
Screenshots
|
||||||
-----------
|
-----------
|
||||||
@@ -37,17 +38,18 @@ Development setup
|
|||||||
The App is developed using Android Studio and should pretty much work out-of-the-box when you clone
|
The 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 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
|
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
|
`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).
|
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`.
|
There are four different build flavors, `googleNormal`, `fossNormal`, `googleAutomotive`, and
|
||||||
- The `foss` variants only use Mapbox data and should run on most Android devices, even without
|
`fossAutomotive`.
|
||||||
Google Play Services.
|
|
||||||
|
- The `foss` variants only use OSM data for the base map and place search. They should run on most Android devices, even those without Google Play Services.
|
||||||
- `fossNormal` is intended to run on smartphones and tablets, and also includes the Android
|
- `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
|
Auto app for use on the car display (however Android Auto may not work if the app is not
|
||||||
necessary, which in turn does require Google Play Services).
|
installed from Google Play, see https://github.com/ev-map/EVMap/issues/319).
|
||||||
- `fossAutomotive` can be installed directly on
|
- `fossAutomotive` can be installed directly on
|
||||||
[Android Automotive OS (AAOS)](https://source.android.com/docs/automotive/start/what_automotive)
|
[Android Automotive OS (AAOS)](https://source.android.com/docs/automotive/start/what_automotive)
|
||||||
headunits without Google services.
|
headunits without Google services.
|
||||||
@@ -75,5 +77,19 @@ You can use our [Weblate page](https://hosted.weblate.org/projects/evmap/) to he
|
|||||||
into new languages.
|
into new languages.
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/evmap/">
|
<a href="https://hosted.weblate.org/engage/evmap/">
|
||||||
<img src="https://hosted.weblate.org/widgets/evmap/-/open-graph.png" width="500" alt="Translation status" />
|
<img src="https://hosted.weblate.org/widgets/evmap/-/open-graph.png" width="400" alt="Translation status" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
Sponsors
|
||||||
|
--------
|
||||||
|
|
||||||
|
Many users currently support the development EVMap with their donations. You can find more
|
||||||
|
information on the [Donate page](https://ev-map.app/donate/) on the EVMap website.
|
||||||
|
|
||||||
|
<a href="https://www.jawg.io"><img src="https://www.jawg.io/static/Blue@10x-9cdc4596e4e59acbd9ead55e9c28613e.png" alt="JawgMaps" height="58"/></a><br>
|
||||||
|
Since May 2024, **JawgMaps** provides their OpenStreetMap vector map tiles service to EVMap for
|
||||||
|
free, i.e. the background map displayed in the app if OpenStreetMap is selected as the data source.
|
||||||
|
|
||||||
|
<a href="https://chargeprice.app"><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/powered_by_chargeprice.svg" alt="Powered by Chargeprice" height="58"/></a><br>
|
||||||
|
Since April 2021, **Chargeprice.app** provide their price comparison API at a greatly reduced
|
||||||
|
price for EVMap. This data is used in EVMap's price comparison feature.
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">ci</string>
|
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">ci</string>
|
||||||
<string name="mapbox_key" translatable="false">ci</string>
|
<string name="mapbox_key" translatable="false">ci</string>
|
||||||
|
<string name="jawg_key" translatable="false">ci</string>
|
||||||
|
<string name="arcgis_key" translatable="false">ci</string>
|
||||||
<string name="goingelectric_key" translatable="false">ci</string>
|
<string name="goingelectric_key" translatable="false">ci</string>
|
||||||
<string name="chargeprice_key" translatable="false">ci</string>
|
<string name="chargeprice_key" translatable="false">ci</string>
|
||||||
<string name="openchargemap_key" translatable="false">ci</string>
|
<string name="openchargemap_key" translatable="false">ci</string>
|
||||||
<string name="fronyx_key" translatable="false">ci</string>
|
<string name="fronyx_key" translatable="false">ci</string>
|
||||||
|
<string name="acra_credentials" translatable="false">ci:ci</string>
|
||||||
</resources>
|
</resources>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 14 KiB |
73
_img/ic_launcher-playstore.svg
Normal file
73
_img/ic_launcher-playstore.svg
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #00e676;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2, .cls-3, .cls-4, .cls-5, .cls-6, .cls-7, .cls-8 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: rgba(255, 255, 255, .2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #ffb300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #000;
|
||||||
|
isolation: isolate;
|
||||||
|
opacity: .45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #90a4ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-7 {
|
||||||
|
fill: #546e7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-8 {
|
||||||
|
fill: rgba(62, 39, 35, .2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1" data-name="Layer 1">
|
||||||
|
<g>
|
||||||
|
<rect class="cls-5" width="512" height="512" />
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3"
|
||||||
|
d="M159.42,338.98l-6.43-56.15-9.81,1.01,6.43,56.15,9.81-1.01ZM194.26,334.92l-6.43-56.15-9.81,1.01,6.43,56.15,9.81-1.01Z" />
|
||||||
|
<path class="cls-6"
|
||||||
|
d="M212.53,411.37c-3.04,3.72-5.41,6.09-5.75,6.43-8.79,7.1-15.9,9.13-21.65,6.43-10.15-5.07-9.47-24.02-9.13-26.05l7.1.34c-.34,5.41.68,16.91,5.41,19.28,2.71,1.35,7.44-.34,13.53-5.41h0s19.62-19.62,15.56-35.18c-4.74-18.6,16.91-45.33,24.02-54.46l1.01-1.01,5.75,4.4-1.01,1.35c-21.99,27.06-24.35,40.93-22.66,48.03,3.38,13.53-5.75,28.08-12.18,35.85Z" />
|
||||||
|
<path class="cls-6"
|
||||||
|
d="M137.78,338.3l2.71,23,21.31,14.21,28.75-3.04,17.59-18.6-2.71-23-67.65,7.44Z" />
|
||||||
|
<path class="cls-7"
|
||||||
|
d="M190.21,372.47l-28.75,3.04,6.09,25.37,22.66-2.71v-25.71h0ZM210.84,311.58l2.37,20.97-82.53,9.47-2.37-20.97,82.53-9.47Z" />
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1"
|
||||||
|
d="M275.45,80.22c-59.19,0-107.23,48.03-107.23,107.23,0,80.84,90.31,123.12,101.14,238.47.34,3.38,3.04,5.75,6.43,5.75s6.09-2.37,6.43-5.75c10.82-115.34,101.14-157.63,101.14-238.47-.68-59.53-48.71-107.23-107.9-107.23Z" />
|
||||||
|
<path class="cls-2"
|
||||||
|
d="M275.45,82.58c58.86,0,106.55,47.36,107.23,105.87v-1.01c0-59.19-48.03-107.23-107.23-107.23s-107.23,47.69-107.23,107.23v1.01c.68-58.52,48.37-105.87,107.23-105.87h0Z" />
|
||||||
|
<path class="cls-8"
|
||||||
|
d="M281.87,423.21c-.34,3.38-3.04,5.75-6.43,5.75s-6.09-2.37-6.43-5.75c-10.49-115.01-100.12-157.29-100.8-237.12v1.69c0,80.84,90.31,123.12,101.14,238.47.34,3.38,3.04,5.75,6.43,5.75s6.09-2.37,6.43-5.75c10.82-115.34,101.14-157.63,101.14-238.47v-1.69c-1.35,79.83-90.99,122.11-101.48,237.12h0Z" />
|
||||||
|
</g>
|
||||||
|
<path class="cls-4"
|
||||||
|
d="M250.75,135.01v64.94h17.59v53.11l41.27-71.03h-23.68l23.68-47.36c.34.34-58.86.34-58.86.34Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
23
_tools/export_licenses_faurecia.py
Normal file
23
_tools/export_licenses_faurecia.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
build_types = ["fossNormalRelease", "fossAutomotiveRelease"]
|
||||||
|
|
||||||
|
for build_type in build_types:
|
||||||
|
result = subprocess.run(["gradlew.bat", f"generateLibraryDefinitions{build_type.capitalize()}"],
|
||||||
|
capture_output=True)
|
||||||
|
|
||||||
|
data = json.load(
|
||||||
|
open(f"app/build/generated/aboutLibraries/{build_type}/res/raw/aboutlibraries.json"))
|
||||||
|
|
||||||
|
with open(f"licenses_{build_type}.csv", "w") as f:
|
||||||
|
f.write("component_name;license_title;license_url;public_repository;copyrights\n")
|
||||||
|
for lib in data["libraries"]:
|
||||||
|
license = data["licenses"][lib["licenses"][0]] if len(lib["licenses"]) > 0 else None
|
||||||
|
license_name = license["name"] if license is not None else " "
|
||||||
|
license_url = license["url"] if license is not None else " "
|
||||||
|
copyrights = ", ".join([dev["name"] for dev in lib["developers"] if "name" in dev])
|
||||||
|
if copyrights == "":
|
||||||
|
copyrights = " "
|
||||||
|
repo_url = lib['scm']['url'] if 'scm' in lib else ''
|
||||||
|
f.write(f"{lib['name']};{license_name};{license_url};\"{copyrights}\";{repo_url}\n")
|
||||||
293
app/build.gradle
293
app/build.gradle
@@ -1,293 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'com.adarshr.test-logger' version '3.1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
|
||||||
apply plugin: 'kotlin-android'
|
|
||||||
apply plugin: 'kotlin-parcelize'
|
|
||||||
apply plugin: 'kotlin-kapt'
|
|
||||||
apply plugin: 'androidx.navigation.safeargs.kotlin'
|
|
||||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
|
||||||
apply plugin: 'pt.jcosta.resourceplaceholders'
|
|
||||||
|
|
||||||
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
|
|
||||||
|
|
||||||
android {
|
|
||||||
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 190
|
|
||||||
versionName "1.6.5"
|
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
resConfigs supportedLocales.split(',')
|
|
||||||
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
release {
|
|
||||||
def isRunningOnCI = System.getenv("CI") == "true"
|
|
||||||
if (isRunningOnCI) {
|
|
||||||
// configure keystore
|
|
||||||
storeFile = file("../_ci/keystore.jks")
|
|
||||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
|
||||||
keyAlias = System.getenv("KEYSTORE_ALIAS")
|
|
||||||
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
||||||
signingConfig signingConfigs.release
|
|
||||||
}
|
|
||||||
debug {
|
|
||||||
applicationIdSuffix ".debug"
|
|
||||||
debuggable true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flavorDimensions "dependencies", "automotive"
|
|
||||||
productFlavors {
|
|
||||||
foss {
|
|
||||||
dimension "dependencies"
|
|
||||||
}
|
|
||||||
google {
|
|
||||||
dimension "dependencies"
|
|
||||||
versionNameSuffix "-google"
|
|
||||||
}
|
|
||||||
normal {
|
|
||||||
dimension "automotive"
|
|
||||||
}
|
|
||||||
automotive {
|
|
||||||
dimension "automotive"
|
|
||||||
versionNameSuffix "-automotive"
|
|
||||||
versionCode defaultConfig.versionCode + 1
|
|
||||||
minSdkVersion 29
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
coreLibraryDesugaringEnabled true
|
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs).configureEach {
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
dataBinding = true
|
|
||||||
viewBinding true
|
|
||||||
}
|
|
||||||
lint {
|
|
||||||
disable 'NullSafeMutableLiveData'
|
|
||||||
warning 'MissingTranslation'
|
|
||||||
}
|
|
||||||
|
|
||||||
testOptions {
|
|
||||||
unitTests.includeAndroidResources true
|
|
||||||
}
|
|
||||||
|
|
||||||
resourcePlaceholders {
|
|
||||||
files = ['xml/shortcuts.xml']
|
|
||||||
}
|
|
||||||
namespace 'net.vonforst.evmap'
|
|
||||||
|
|
||||||
// add API keys from environment variable if not set in apikeys.xml
|
|
||||||
applicationVariants.all { variant ->
|
|
||||||
ext.env = System.getenv()
|
|
||||||
def goingelectricKey = env.GOINGELECTRIC_API_KEY ?: project.findProperty("GOINGELECTRIC_API_KEY")
|
|
||||||
if (goingelectricKey != null) {
|
|
||||||
variant.resValue "string", "goingelectric_key", goingelectricKey
|
|
||||||
}
|
|
||||||
def openchargemapKey = env.OPENCHARGEMAP_API_KEY ?: project.findProperty("OPENCHARGEMAP_API_KEY")
|
|
||||||
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
|
|
||||||
openchargemapKey = decode(project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
|
||||||
}
|
|
||||||
if (openchargemapKey != null) {
|
|
||||||
variant.resValue "string", "openchargemap_key", openchargemapKey
|
|
||||||
}
|
|
||||||
def googleMapsKey = env.GOOGLE_MAPS_API_KEY ?: project.findProperty("GOOGLE_MAPS_API_KEY")
|
|
||||||
if (googleMapsKey != null && variant.flavorName.startsWith('google')) {
|
|
||||||
variant.resValue "string", "google_maps_key", googleMapsKey
|
|
||||||
}
|
|
||||||
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
|
|
||||||
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
|
|
||||||
mapboxKey = decode(project.findProperty("MAPBOX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
|
||||||
}
|
|
||||||
if (mapboxKey != null) {
|
|
||||||
variant.resValue "string", "mapbox_key", mapboxKey
|
|
||||||
}
|
|
||||||
def chargepriceKey = env.CHARGEPRICE_API_KEY ?: project.findProperty("CHARGEPRICE_API_KEY")
|
|
||||||
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
|
|
||||||
chargepriceKey = decode(project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
|
||||||
}
|
|
||||||
if (chargepriceKey != null) {
|
|
||||||
variant.resValue "string", "chargeprice_key", chargepriceKey
|
|
||||||
}
|
|
||||||
def fronyxKey = env.FRONYX_API_KEY ?: project.findProperty("FRONYX_API_KEY")
|
|
||||||
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
|
|
||||||
fronyxKey = decode(project.findProperty("FRONYX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
|
||||||
}
|
|
||||||
if (fronyxKey != null) {
|
|
||||||
variant.resValue "string", "fronyx_key", fronyxKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
packagingOptions {
|
|
||||||
pickFirst 'lib/x86/libc++_shared.so'
|
|
||||||
pickFirst 'lib/arm64-v8a/libc++_shared.so'
|
|
||||||
pickFirst 'lib/x86_64/libc++_shared.so'
|
|
||||||
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
configurations {
|
|
||||||
googleNormalImplementation {}
|
|
||||||
googleAutomotiveImplementation {}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
|
||||||
implementation 'androidx.core:core-ktx:1.10.1'
|
|
||||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
|
||||||
implementation "androidx.activity:activity-ktx:1.7.2"
|
|
||||||
implementation "androidx.fragment:fragment-ktx:1.5.7"
|
|
||||||
implementation 'androidx.cardview:cardview:1.0.0'
|
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
|
||||||
implementation 'com.google.android.material:material:1.9.0'
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.3.0'
|
|
||||||
implementation 'androidx.browser:browser:1.5.0'
|
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
|
||||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
|
||||||
implementation "androidx.work:work-runtime-ktx:2.8.1"
|
|
||||||
implementation 'com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b'
|
|
||||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
|
||||||
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.11.0'
|
|
||||||
implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
|
||||||
implementation 'com.squareup.moshi:moshi-adapters:1.15.0'
|
|
||||||
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
|
|
||||||
implementation 'io.coil-kt:coil:1.1.0'
|
|
||||||
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
|
|
||||||
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'
|
|
||||||
implementation "androidx.car.app:app:$carAppVersion"
|
|
||||||
normalImplementation "androidx.car.app:app-projected:$carAppVersion"
|
|
||||||
automotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
|
|
||||||
|
|
||||||
// AnyMaps
|
|
||||||
def anyMapsVersion = '8f1226e1c5'
|
|
||||||
implementation "com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion"
|
|
||||||
googleImplementation "com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion"
|
|
||||||
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
|
|
||||||
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
|
|
||||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
|
|
||||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
|
|
||||||
exclude group: 'com.google.android.gms', module: 'play-services-location'
|
|
||||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-core'
|
|
||||||
}
|
|
||||||
// original version of mapbox-android-core
|
|
||||||
googleImplementation 'com.mapbox.mapboxsdk:mapbox-android-core:2.0.1'
|
|
||||||
// patched version that removes build-time dependency on GMS (-> no Google location services)
|
|
||||||
fossImplementation 'com.github.ev-map:mapbox-events-android:a21c324501'
|
|
||||||
|
|
||||||
// Google Places
|
|
||||||
googleImplementation 'com.google.android.libraries.places:places:3.1.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.6.1"
|
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
|
||||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
|
||||||
|
|
||||||
// room library
|
|
||||||
def room_version = "2.5.1"
|
|
||||||
implementation "androidx.room:room-runtime:$room_version"
|
|
||||||
kapt "androidx.room:room-compiler:$room_version"
|
|
||||||
implementation "androidx.room:room-ktx:$room_version"
|
|
||||||
implementation 'com.github.anboralabs:spatia-room:0.2.7'
|
|
||||||
|
|
||||||
// billing library
|
|
||||||
def billing_version = "6.0.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
|
|
||||||
debugImplementation 'com.facebook.flipper:flipper:0.190.0'
|
|
||||||
debugImplementation 'com.facebook.soloader:soloader:0.10.5'
|
|
||||||
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.190.0'
|
|
||||||
|
|
||||||
// testing
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
|
||||||
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
|
|
||||||
//noinspection GradleDependency
|
|
||||||
testImplementation 'org.json:json:20080701'
|
|
||||||
testImplementation 'org.robolectric:robolectric:4.9.2'
|
|
||||||
testImplementation 'androidx.test:core:1.5.0'
|
|
||||||
testImplementation 'androidx.arch.core:core-testing:2.2.0'
|
|
||||||
|
|
||||||
// testing for car app
|
|
||||||
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
|
|
||||||
testGoogleImplementation 'org.robolectric:robolectric:4.9.2'
|
|
||||||
testGoogleImplementation 'androidx.test:core:1.5.0'
|
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
|
||||||
androidTestImplementation 'androidx.arch.core:core-testing:2.2.0'
|
|
||||||
|
|
||||||
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0"
|
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String decode(String s, String key) {
|
|
||||||
return new String(xorWithKey(s.decodeBase64(), key.getBytes()), "UTF-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] xorWithKey(byte[] a, byte[] key) {
|
|
||||||
byte[] out = new byte[a.length];
|
|
||||||
for (int i = 0; i < a.length; i++) {
|
|
||||||
out[i] = (byte) (a[i] ^ key[i%key.length]);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
412
app/build.gradle.kts
Normal file
412
app/build.gradle.kts
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.adarshr.test-logger") version "3.1.0"
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
id("kotlin-parcelize")
|
||||||
|
id("kotlin-kapt")
|
||||||
|
id("com.google.devtools.ksp").version("2.0.21-1.0.28")
|
||||||
|
id("androidx.navigation.safeargs.kotlin")
|
||||||
|
id("com.mikepenz.aboutlibraries.plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
android {
|
||||||
|
useLibrary("android.car")
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "net.vonforst.evmap"
|
||||||
|
compileSdk = 35
|
||||||
|
minSdk = 21
|
||||||
|
targetSdk = 35
|
||||||
|
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||||
|
versionCode = 256
|
||||||
|
versionName = "1.9.18"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
ksp {
|
||||||
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isRunningOnCI = System.getenv("CI") == "true"
|
||||||
|
val isCIKeystoreAvailable = System.getenv("KEYSTORE_PASSWORD") != null
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (isRunningOnCI && isCIKeystoreAvailable) {
|
||||||
|
// configure keystore
|
||||||
|
storeFile = file("../_ci/keystore.jks")
|
||||||
|
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||||
|
keyAlias = System.getenv("KEYSTORE_ALIAS")
|
||||||
|
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
signingConfig = if (isRunningOnCI && !isCIKeystoreAvailable) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
create("releaseAutomotivePackageName") {
|
||||||
|
// Faurecia Aptoide requires the automotive variant to use a separate package name
|
||||||
|
initWith(getByName("release"))
|
||||||
|
applicationIdSuffix = ".automotive"
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
isDebuggable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("releaseAutomotivePackageName").setRoot("src/release")
|
||||||
|
}
|
||||||
|
|
||||||
|
flavorDimensions += listOf("dependencies", "automotive")
|
||||||
|
productFlavors {
|
||||||
|
create("foss") {
|
||||||
|
dimension = "dependencies"
|
||||||
|
}
|
||||||
|
create("google") {
|
||||||
|
dimension = "dependencies"
|
||||||
|
versionNameSuffix = "-google"
|
||||||
|
}
|
||||||
|
create("normal") {
|
||||||
|
dimension = "automotive"
|
||||||
|
}
|
||||||
|
create("automotive") {
|
||||||
|
dimension = "automotive"
|
||||||
|
versionNameSuffix = "-automotive"
|
||||||
|
versionCode = defaultConfig.versionCode!! + 1
|
||||||
|
minSdk = 29
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
dataBinding = true
|
||||||
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
lint {
|
||||||
|
disable += listOf("NullSafeMutableLiveData")
|
||||||
|
warning += listOf("MissingTranslation")
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
generateLocaleConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace = "net.vonforst.evmap"
|
||||||
|
|
||||||
|
// add API keys from environment variable if not set in apikeys.xml
|
||||||
|
applicationVariants.all {
|
||||||
|
val goingelectricKey =
|
||||||
|
System.getenv("GOINGELECTRIC_API_KEY") ?: project.findProperty("GOINGELECTRIC_API_KEY")
|
||||||
|
?.toString()
|
||||||
|
if (goingelectricKey != null) {
|
||||||
|
resValue("string", "goingelectric_key", goingelectricKey)
|
||||||
|
}
|
||||||
|
var openchargemapKey =
|
||||||
|
System.getenv("OPENCHARGEMAP_API_KEY") ?: project.findProperty("OPENCHARGEMAP_API_KEY")
|
||||||
|
?.toString()
|
||||||
|
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
|
||||||
|
openchargemapKey = decode(
|
||||||
|
project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED").toString(),
|
||||||
|
"FmK.d,-f*p+rD+WK!eds"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (openchargemapKey != null) {
|
||||||
|
resValue("string", "openchargemap_key", openchargemapKey)
|
||||||
|
}
|
||||||
|
val googleMapsKey =
|
||||||
|
System.getenv("GOOGLE_MAPS_API_KEY") ?: project.findProperty("GOOGLE_MAPS_API_KEY")
|
||||||
|
?.toString()
|
||||||
|
if (googleMapsKey != null && flavorName.startsWith("google")) {
|
||||||
|
resValue("string", "google_maps_key", googleMapsKey)
|
||||||
|
}
|
||||||
|
var mapboxKey =
|
||||||
|
System.getenv("MAPBOX_API_KEY") ?: project.findProperty("MAPBOX_API_KEY")?.toString()
|
||||||
|
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
|
||||||
|
mapboxKey = decode(
|
||||||
|
project.findProperty("MAPBOX_API_KEY_ENCRYPTED").toString(),
|
||||||
|
"FmK.d,-f*p+rD+WK!eds"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mapboxKey != null) {
|
||||||
|
resValue("string", "mapbox_key", mapboxKey)
|
||||||
|
}
|
||||||
|
var jawgKey =
|
||||||
|
System.getenv("JAWG_API_KEY") ?: project.findProperty("JAWG_API_KEY")?.toString()
|
||||||
|
if (jawgKey == null && project.hasProperty("JAWG_API_KEY_ENCRYPTED")) {
|
||||||
|
jawgKey = decode(
|
||||||
|
project.findProperty("JAWG_API_KEY_ENCRYPTED").toString(),
|
||||||
|
"FmK.d,-f*p+rD+WK!eds"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (jawgKey != null) {
|
||||||
|
resValue("string", "jawg_key", jawgKey)
|
||||||
|
}
|
||||||
|
var arcgisKey =
|
||||||
|
System.getenv("ARCGIS_API_KEY") ?: project.findProperty("ARCGIS_API_KEY")?.toString()
|
||||||
|
if (arcgisKey == null && project.hasProperty("ARCGIS_API_KEY_ENCRYPTED")) {
|
||||||
|
arcgisKey = decode(
|
||||||
|
project.findProperty("ARCGIS_API_KEY_ENCRYPTED").toString(),
|
||||||
|
"FmK.d,-f*p+rD+WK!eds"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (arcgisKey != null) {
|
||||||
|
resValue("string", "arcgis_key", jawgKey)
|
||||||
|
}
|
||||||
|
var chargepriceKey =
|
||||||
|
System.getenv("CHARGEPRICE_API_KEY") ?: project.findProperty("CHARGEPRICE_API_KEY")
|
||||||
|
?.toString()
|
||||||
|
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
|
||||||
|
chargepriceKey = decode(
|
||||||
|
project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED").toString(),
|
||||||
|
"FmK.d,-f*p+rD+WK!eds"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (chargepriceKey != null) {
|
||||||
|
resValue("string", "chargeprice_key", chargepriceKey)
|
||||||
|
}
|
||||||
|
var fronyxKey =
|
||||||
|
System.getenv("FRONYX_API_KEY") ?: project.findProperty("FRONYX_API_KEY")?.toString()
|
||||||
|
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
|
||||||
|
fronyxKey = decode(
|
||||||
|
project.findProperty("FRONYX_API_KEY_ENCRYPTED").toString(),
|
||||||
|
"FmK.d,-f*p+rD+WK!eds"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (fronyxKey != null) {
|
||||||
|
resValue("string", "fronyx_key", fronyxKey)
|
||||||
|
}
|
||||||
|
var acraKey = System.getenv("ACRA_CRASHREPORT_CREDENTIALS")
|
||||||
|
?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")?.toString()
|
||||||
|
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
|
||||||
|
acraKey = decode(
|
||||||
|
project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED").toString(),
|
||||||
|
"FmK.d,-f*p+rD+WK!eds"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (acraKey != null) {
|
||||||
|
resValue("string", "acra_credentials", acraKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
pickFirsts.addAll(
|
||||||
|
listOf(
|
||||||
|
"lib/x86/libc++_shared.so",
|
||||||
|
"lib/arm64-v8a/libc++_shared.so",
|
||||||
|
"lib/x86_64/libc++_shared.so",
|
||||||
|
"lib/armeabi-v7a/libc++_shared.so"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
beforeVariants { variantBuilder ->
|
||||||
|
if (variantBuilder.buildType == "releaseAutomotivePackageName"
|
||||||
|
&& !variantBuilder.productFlavors.containsAll(
|
||||||
|
listOf(
|
||||||
|
"automotive" to "automotive",
|
||||||
|
"dependencies" to "foss"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// releaseAutomotivePackageName type is only needed for fossAutomotive
|
||||||
|
variantBuilder.enable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations {
|
||||||
|
create("googleNormalImplementation") {}
|
||||||
|
create("googleAutomotiveImplementation") {}
|
||||||
|
}
|
||||||
|
|
||||||
|
aboutLibraries {
|
||||||
|
allowedLicenses = arrayOf(
|
||||||
|
"Apache-2.0", "mit", "BSD-2-Clause", "BSD-3-Clause", "EPL-1.0",
|
||||||
|
"asdkl", // Android SDK
|
||||||
|
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
|
||||||
|
"Google Maps Platform Terms of Service", // Google Maps SDK
|
||||||
|
"provided without support or warranty", // org.json
|
||||||
|
"Unicode/ICU License", "Unicode-3.0", // icu4j
|
||||||
|
"Bouncy Castle Licence", // bcprov
|
||||||
|
"CDDL + GPLv2 with classpath exception", // javax.annotation-api
|
||||||
|
)
|
||||||
|
excludeFields = arrayOf("generated")
|
||||||
|
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val kotlinVersion: String by rootProject.extra
|
||||||
|
val aboutLibsVersion: String by rootProject.extra
|
||||||
|
val navVersion: String by rootProject.extra
|
||||||
|
val normalImplementation by configurations
|
||||||
|
val googleImplementation by configurations
|
||||||
|
val automotiveImplementation by configurations
|
||||||
|
val fossImplementation by configurations
|
||||||
|
val testGoogleImplementation by configurations
|
||||||
|
|
||||||
|
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.9.0")
|
||||||
|
implementation("androidx.fragment:fragment-ktx:1.7.1")
|
||||||
|
implementation("androidx.cardview:cardview:1.0.0")
|
||||||
|
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||||
|
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||||
|
implementation("androidx.browser:browser:1.8.0")
|
||||||
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
implementation("androidx.viewpager2:viewpager2:1.1.0")
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.12.0")
|
||||||
|
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
|
||||||
|
implementation("com.squareup.moshi:moshi-adapters:1.15.2")
|
||||||
|
implementation("com.markomilos.jsonapi:jsonapi-retrofit:1.1.0")
|
||||||
|
implementation("io.coil-kt:coil:2.6.0")
|
||||||
|
implementation("com.github.ev-map:StfalconImageViewer:5082ebd392")
|
||||||
|
implementation("com.mikepenz:aboutlibraries-core:$aboutLibsVersion")
|
||||||
|
implementation("com.mikepenz:aboutlibraries:$aboutLibsVersion")
|
||||||
|
implementation("com.airbnb.android:lottie:4.1.0")
|
||||||
|
implementation("io.michaelrocks.bimap:bimap:1.1.0")
|
||||||
|
implementation("com.google.guava:guava:29.0-android")
|
||||||
|
implementation("com.github.pengrad:mapscaleview:1.6.0")
|
||||||
|
implementation("com.github.romandanylyk:PageIndicatorView:b1bad589b5")
|
||||||
|
implementation("com.github.erfansn:locale-config-x:1.0.1")
|
||||||
|
|
||||||
|
// Android Auto
|
||||||
|
val carAppVersion = "1.7.0-rc01"
|
||||||
|
implementation("androidx.car.app:app:$carAppVersion")
|
||||||
|
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
|
||||||
|
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
|
||||||
|
|
||||||
|
// AnyMaps
|
||||||
|
val anyMapsVersion = "a3290b148d"
|
||||||
|
implementation("com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion")
|
||||||
|
googleImplementation("com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion")
|
||||||
|
googleImplementation("com.google.android.gms:play-services-maps:19.0.0")
|
||||||
|
implementation("com.github.ev-map.AnyMaps:anymaps-maplibre:$anyMapsVersion") {
|
||||||
|
// duplicates classes from mapbox-sdk-services
|
||||||
|
exclude("org.maplibre.gl", "android-sdk-geojson")
|
||||||
|
}
|
||||||
|
implementation("org.maplibre.gl:android-sdk:10.3.4") {
|
||||||
|
exclude("org.maplibre.gl", "android-sdk-geojson")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Places
|
||||||
|
googleImplementation("com.google.android.libraries.places:places:3.5.0")
|
||||||
|
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||||
|
|
||||||
|
// Mapbox Geocoding
|
||||||
|
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0")
|
||||||
|
|
||||||
|
// navigation library
|
||||||
|
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
|
||||||
|
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
|
||||||
|
|
||||||
|
// viewmodel library
|
||||||
|
val lifecycle_version = "2.8.1"
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
|
||||||
|
|
||||||
|
// room library
|
||||||
|
val room_version = "2.7.1"
|
||||||
|
implementation("androidx.room:room-runtime:$room_version")
|
||||||
|
ksp("androidx.room:room-compiler:$room_version")
|
||||||
|
implementation("androidx.room:room-ktx:$room_version")
|
||||||
|
implementation("com.github.anboralabs:spatia-room:0.3.0") {
|
||||||
|
exclude("com.github.dalgarins", "android-spatialite")
|
||||||
|
}
|
||||||
|
// forked version with upgraded sqlite & libxml
|
||||||
|
// https://github.com/dalgarins/android-spatialite/pull/10
|
||||||
|
implementation("com.github.ev-map:android-spatialite:31495dcd81")
|
||||||
|
|
||||||
|
// billing library
|
||||||
|
val billing_version = "7.0.0"
|
||||||
|
googleImplementation("com.android.billingclient:billing:$billing_version")
|
||||||
|
googleImplementation("com.android.billingclient:billing-ktx:$billing_version")
|
||||||
|
|
||||||
|
// ACRA (crash reporting)
|
||||||
|
val acraVersion = "5.11.1"
|
||||||
|
implementation("ch.acra:acra-http:$acraVersion")
|
||||||
|
implementation("ch.acra:acra-dialog:$acraVersion")
|
||||||
|
implementation("ch.acra:acra-limiter:$acraVersion")
|
||||||
|
|
||||||
|
// debug tools
|
||||||
|
debugImplementation("com.facebook.flipper:flipper:0.238.0")
|
||||||
|
debugImplementation("com.facebook.soloader:soloader:0.10.5")
|
||||||
|
debugImplementation("com.facebook.flipper:flipper-network-plugin:0.238.0")
|
||||||
|
debugImplementation("com.jakewharton.timber:timber:5.0.1")
|
||||||
|
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
|
||||||
|
|
||||||
|
// testing
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||||
|
//noinspection GradleDependency
|
||||||
|
testImplementation("org.json:json:20080701")
|
||||||
|
testImplementation("org.robolectric:robolectric:4.11.1")
|
||||||
|
testImplementation("androidx.test:core:1.5.0")
|
||||||
|
testImplementation("androidx.arch.core:core-testing:2.2.0")
|
||||||
|
testImplementation("androidx.car.app:app-testing:$carAppVersion")
|
||||||
|
testImplementation("androidx.test:core:1.5.0")
|
||||||
|
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
|
||||||
|
|
||||||
|
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2")
|
||||||
|
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(s: String, key: String): String {
|
||||||
|
return String(xorWithKey(Base64.getDecoder().decode(s), key.toByteArray()), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun xorWithKey(a: ByteArray, key: ByteArray): ByteArray {
|
||||||
|
val out = ByteArray(a.size)
|
||||||
|
for (i in a.indices) {
|
||||||
|
out[i] = (a[i].toInt() xor key[i % key.size].toInt()).toByte()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
6
app/lint.xml
Normal file
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
2
app/proguard-rules.pro
vendored
@@ -1,6 +1,6 @@
|
|||||||
# Add project specific ProGuard rules here.
|
# Add project specific ProGuard rules here.
|
||||||
# You can control the set of applied configuration files using the
|
# 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
|
# For more details, see
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|||||||
904
app/schemas/net.vonforst.evmap.storage.AppDatabase/22.json
Normal file
904
app/schemas/net.vonforst.evmap.storage.AppDatabase/22.json
Normal file
@@ -0,0 +1,904 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 22,
|
||||||
|
"identityHash": "5dbaaa5adf8cb9b6e8a8314bb7766447",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "ChargeLocation",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dataSource",
|
||||||
|
"columnName": "dataSource",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "coordinates",
|
||||||
|
"columnName": "coordinates",
|
||||||
|
"affinity": "BLOB",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "chargepoints",
|
||||||
|
"columnName": "chargepoints",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "network",
|
||||||
|
"columnName": "network",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "editUrl",
|
||||||
|
"columnName": "editUrl",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "verified",
|
||||||
|
"columnName": "verified",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "barrierFree",
|
||||||
|
"columnName": "barrierFree",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "operator",
|
||||||
|
"columnName": "operator",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "generalInformation",
|
||||||
|
"columnName": "generalInformation",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "amenities",
|
||||||
|
"columnName": "amenities",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "locationDescription",
|
||||||
|
"columnName": "locationDescription",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "photos",
|
||||||
|
"columnName": "photos",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "chargecards",
|
||||||
|
"columnName": "chargecards",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "license",
|
||||||
|
"columnName": "license",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "networkUrl",
|
||||||
|
"columnName": "networkUrl",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "chargerUrl",
|
||||||
|
"columnName": "chargerUrl",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timeRetrieved",
|
||||||
|
"columnName": "timeRetrieved",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDetailed",
|
||||||
|
"columnName": "isDetailed",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "address.city",
|
||||||
|
"columnName": "city",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "address.country",
|
||||||
|
"columnName": "country",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "address.postcode",
|
||||||
|
"columnName": "postcode",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "address.street",
|
||||||
|
"columnName": "street",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "faultReport.created",
|
||||||
|
"columnName": "fault_report_created",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "faultReport.description",
|
||||||
|
"columnName": "fault_report_description",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.twentyfourSeven",
|
||||||
|
"columnName": "twentyfourSeven",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.monday.start",
|
||||||
|
"columnName": "mostart",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.monday.end",
|
||||||
|
"columnName": "moend",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.tuesday.start",
|
||||||
|
"columnName": "tustart",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.tuesday.end",
|
||||||
|
"columnName": "tuend",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.wednesday.start",
|
||||||
|
"columnName": "westart",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.wednesday.end",
|
||||||
|
"columnName": "weend",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.thursday.start",
|
||||||
|
"columnName": "thstart",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.thursday.end",
|
||||||
|
"columnName": "thend",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.friday.start",
|
||||||
|
"columnName": "frstart",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.friday.end",
|
||||||
|
"columnName": "frend",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.saturday.start",
|
||||||
|
"columnName": "sastart",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.saturday.end",
|
||||||
|
"columnName": "saend",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.sunday.start",
|
||||||
|
"columnName": "sustart",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.sunday.end",
|
||||||
|
"columnName": "suend",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.holiday.start",
|
||||||
|
"columnName": "hostart",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "openinghours.days.holiday.end",
|
||||||
|
"columnName": "hoend",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "cost.freecharging",
|
||||||
|
"columnName": "freecharging",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "cost.freeparking",
|
||||||
|
"columnName": "freeparking",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "cost.descriptionShort",
|
||||||
|
"columnName": "descriptionShort",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "cost.descriptionLong",
|
||||||
|
"columnName": "descriptionLong",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "chargepriceData.country",
|
||||||
|
"columnName": "chargepricecountry",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "chargepriceData.network",
|
||||||
|
"columnName": "chargepricenetwork",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "chargepriceData.plugTypes",
|
||||||
|
"columnName": "chargepriceplugTypes",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id",
|
||||||
|
"dataSource"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "Favorite",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`favoriteId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chargerId` INTEGER NOT NULL, `chargerDataSource` TEXT NOT NULL, FOREIGN KEY(`chargerId`, `chargerDataSource`) REFERENCES `ChargeLocation`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "favoriteId",
|
||||||
|
"columnName": "favoriteId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "chargerId",
|
||||||
|
"columnName": "chargerId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "chargerDataSource",
|
||||||
|
"columnName": "chargerDataSource",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"favoriteId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_Favorite_chargerId_chargerDataSource",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"chargerId",
|
||||||
|
"chargerDataSource"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_Favorite_chargerId_chargerDataSource` ON `${TABLE_NAME}` (`chargerId`, `chargerDataSource`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "ChargeLocation",
|
||||||
|
"onDelete": "NO ACTION",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"chargerId",
|
||||||
|
"chargerDataSource"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id",
|
||||||
|
"dataSource"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "BooleanFilterValue",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "key",
|
||||||
|
"columnName": "key",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "value",
|
||||||
|
"columnName": "value",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dataSource",
|
||||||
|
"columnName": "dataSource",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "profile",
|
||||||
|
"columnName": "profile",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"key",
|
||||||
|
"profile",
|
||||||
|
"dataSource"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_BooleanFilterValue_profile_dataSource",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"profile",
|
||||||
|
"dataSource"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_BooleanFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "FilterProfile",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"profile",
|
||||||
|
"dataSource"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id",
|
||||||
|
"dataSource"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "MultipleChoiceFilterValue",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "key",
|
||||||
|
"columnName": "key",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "values",
|
||||||
|
"columnName": "values",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "all",
|
||||||
|
"columnName": "all",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dataSource",
|
||||||
|
"columnName": "dataSource",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "profile",
|
||||||
|
"columnName": "profile",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"key",
|
||||||
|
"profile",
|
||||||
|
"dataSource"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_MultipleChoiceFilterValue_profile_dataSource",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"profile",
|
||||||
|
"dataSource"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_MultipleChoiceFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "FilterProfile",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"profile",
|
||||||
|
"dataSource"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id",
|
||||||
|
"dataSource"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "SliderFilterValue",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "key",
|
||||||
|
"columnName": "key",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "value",
|
||||||
|
"columnName": "value",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dataSource",
|
||||||
|
"columnName": "dataSource",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "profile",
|
||||||
|
"columnName": "profile",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"key",
|
||||||
|
"profile",
|
||||||
|
"dataSource"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_SliderFilterValue_profile_dataSource",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"profile",
|
||||||
|
"dataSource"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_SliderFilterValue_profile_dataSource` ON `${TABLE_NAME}` (`profile`, `dataSource`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "FilterProfile",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"profile",
|
||||||
|
"dataSource"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id",
|
||||||
|
"dataSource"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "FilterProfile",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `id` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`dataSource`, `id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dataSource",
|
||||||
|
"columnName": "dataSource",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"dataSource",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_FilterProfile_dataSource_name",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"dataSource",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_FilterProfile_dataSource_name` ON `${TABLE_NAME}` (`dataSource`, `name`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "RecentAutocompletePlace",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dataSource",
|
||||||
|
"columnName": "dataSource",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timestamp",
|
||||||
|
"columnName": "timestamp",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "primaryText",
|
||||||
|
"columnName": "primaryText",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "secondaryText",
|
||||||
|
"columnName": "secondaryText",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "latLng",
|
||||||
|
"columnName": "latLng",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "viewport",
|
||||||
|
"columnName": "viewport",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "types",
|
||||||
|
"columnName": "types",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id",
|
||||||
|
"dataSource"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "GEPlug",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "GENetwork",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "GEChargeCard",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "OCMConnectionType",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "formalName",
|
||||||
|
"columnName": "formalName",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "discontinued",
|
||||||
|
"columnName": "discontinued",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "obsolete",
|
||||||
|
"columnName": "obsolete",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "OCMCountry",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isoCode",
|
||||||
|
"columnName": "isoCode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "continentCode",
|
||||||
|
"columnName": "continentCode",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "OCMOperator",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "websiteUrl",
|
||||||
|
"columnName": "websiteUrl",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "contactEmail",
|
||||||
|
"columnName": "contactEmail",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "contactTelephone1",
|
||||||
|
"columnName": "contactTelephone1",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "contactTelephone2",
|
||||||
|
"columnName": "contactTelephone2",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "SavedRegion",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`region` BLOB NOT NULL, `dataSource` TEXT NOT NULL, `timeRetrieved` INTEGER NOT NULL, `filters` TEXT, `isDetailed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "region",
|
||||||
|
"columnName": "region",
|
||||||
|
"affinity": "BLOB",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dataSource",
|
||||||
|
"columnName": "dataSource",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timeRetrieved",
|
||||||
|
"columnName": "timeRetrieved",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filters",
|
||||||
|
"columnName": "filters",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDetailed",
|
||||||
|
"columnName": "isDetailed",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_SavedRegion_filters_dataSource",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"filters",
|
||||||
|
"dataSource"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_SavedRegion_filters_dataSource` ON `${TABLE_NAME}` (`filters`, `dataSource`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5dbaaa5adf8cb9b6e8a8314bb7766447')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
171
app/src/automotive/java/net/vonforst/evmap/auto/CarInfo.kt
Normal file
171
app/src/automotive/java/net/vonforst/evmap/auto/CarInfo.kt
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import android.car.Car
|
||||||
|
import android.car.VehiclePropertyIds
|
||||||
|
import android.car.VehicleUnit
|
||||||
|
import android.car.hardware.CarPropertyValue
|
||||||
|
import android.car.hardware.property.CarPropertyManager
|
||||||
|
import android.car.hardware.property.CarPropertyManager.CarPropertyEventCallback
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.car.app.CarContext
|
||||||
|
import androidx.car.app.annotations.ExperimentalCarApi
|
||||||
|
import androidx.car.app.hardware.CarHardwareManager
|
||||||
|
import androidx.car.app.hardware.common.CarUnit
|
||||||
|
import androidx.car.app.hardware.common.CarValue
|
||||||
|
import androidx.car.app.hardware.common.OnCarDataAvailableListener
|
||||||
|
import androidx.car.app.hardware.info.CarInfo
|
||||||
|
import androidx.car.app.hardware.info.EnergyLevel
|
||||||
|
import androidx.car.app.hardware.info.EnergyProfile
|
||||||
|
import androidx.car.app.hardware.info.EvStatus
|
||||||
|
import androidx.car.app.hardware.info.Mileage
|
||||||
|
import androidx.car.app.hardware.info.Model
|
||||||
|
import androidx.car.app.hardware.info.Speed
|
||||||
|
import androidx.car.app.hardware.info.TollCard
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
|
|
||||||
|
val CarContext.patchedCarInfo: CarInfo
|
||||||
|
get() = CarInfoWrapper(this)
|
||||||
|
|
||||||
|
class CarInfoWrapper(ctx: CarContext) : CarInfo {
|
||||||
|
private val wrapped =
|
||||||
|
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
|
||||||
|
private val carPropertyManager = try {
|
||||||
|
val car = Car.createCar(ctx)
|
||||||
|
car.getCarManager(Car.PROPERTY_SERVICE) as CarPropertyManager
|
||||||
|
} catch (e: NoClassDefFoundError) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
private val callbacks = mutableMapOf<OnCarDataAvailableListener<*>, CarPropertyEventCallback>()
|
||||||
|
|
||||||
|
override fun fetchModel(executor: Executor, listener: OnCarDataAvailableListener<Model>) =
|
||||||
|
wrapped.fetchModel(executor, listener)
|
||||||
|
|
||||||
|
override fun fetchEnergyProfile(
|
||||||
|
executor: Executor,
|
||||||
|
listener: OnCarDataAvailableListener<EnergyProfile>
|
||||||
|
) = wrapped.fetchEnergyProfile(executor, listener)
|
||||||
|
|
||||||
|
override fun addTollListener(
|
||||||
|
executor: Executor,
|
||||||
|
listener: OnCarDataAvailableListener<TollCard>
|
||||||
|
) = wrapped.addTollListener(executor, listener)
|
||||||
|
|
||||||
|
override fun removeTollListener(listener: OnCarDataAvailableListener<TollCard>) =
|
||||||
|
wrapped.removeTollListener(listener)
|
||||||
|
|
||||||
|
override fun addEnergyLevelListener(
|
||||||
|
executor: Executor,
|
||||||
|
listener: OnCarDataAvailableListener<EnergyLevel>
|
||||||
|
) = wrapped.addEnergyLevelListener(executor, listener)
|
||||||
|
|
||||||
|
override fun removeEnergyLevelListener(listener: OnCarDataAvailableListener<EnergyLevel>) =
|
||||||
|
wrapped.removeEnergyLevelListener(listener)
|
||||||
|
|
||||||
|
override fun addSpeedListener(executor: Executor, listener: OnCarDataAvailableListener<Speed>) {
|
||||||
|
// TODO: This is a emporary workaround until Car App Library 1.7.0 is released - previous versions would crash if the car reported an invalid speed display unit
|
||||||
|
carPropertyManager ?: return
|
||||||
|
val callback = object : CarPropertyEventCallback {
|
||||||
|
private var speedRaw: CarPropertyValue<Float>? = null
|
||||||
|
private var speedDisplay: CarPropertyValue<Float>? = null
|
||||||
|
private var speedUnit: CarPropertyValue<Int>? = null
|
||||||
|
|
||||||
|
override fun onChangeEvent(value: CarPropertyValue<*>?) {
|
||||||
|
when (value?.propertyId) {
|
||||||
|
VehiclePropertyIds.PERF_VEHICLE_SPEED -> speedRaw =
|
||||||
|
value as CarPropertyValue<Float>?
|
||||||
|
|
||||||
|
VehiclePropertyIds.PERF_VEHICLE_SPEED_DISPLAY -> speedDisplay =
|
||||||
|
value as CarPropertyValue<Float>?
|
||||||
|
|
||||||
|
VehiclePropertyIds.VEHICLE_SPEED_DISPLAY_UNITS -> speedUnit =
|
||||||
|
value as CarPropertyValue<Int>?
|
||||||
|
}
|
||||||
|
|
||||||
|
executor.execute {
|
||||||
|
listener.onCarDataAvailable(Speed.Builder().apply {
|
||||||
|
speedRaw?.let {
|
||||||
|
setRawSpeedMetersPerSecond(
|
||||||
|
CarValue(
|
||||||
|
it.value,
|
||||||
|
it.timestamp,
|
||||||
|
if (it.value != null) CarValue.STATUS_SUCCESS else CarValue.STATUS_UNKNOWN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
speedDisplay?.let {
|
||||||
|
setDisplaySpeedMetersPerSecond(
|
||||||
|
CarValue(
|
||||||
|
it.value,
|
||||||
|
it.timestamp,
|
||||||
|
if (it.value != null) CarValue.STATUS_SUCCESS else CarValue.STATUS_UNKNOWN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
speedUnit?.let {
|
||||||
|
val unit = when (it.value) {
|
||||||
|
VehicleUnit.METER_PER_SEC -> CarUnit.METERS_PER_SEC
|
||||||
|
VehicleUnit.MILES_PER_HOUR -> CarUnit.MILES_PER_HOUR
|
||||||
|
VehicleUnit.KILOMETERS_PER_HOUR -> CarUnit.KILOMETERS_PER_HOUR
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
setSpeedDisplayUnit(
|
||||||
|
CarValue(
|
||||||
|
unit,
|
||||||
|
it.timestamp,
|
||||||
|
if (unit != null) CarValue.STATUS_SUCCESS else CarValue.STATUS_UNKNOWN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onErrorEvent(propertyId: Int, areaId: Int) {
|
||||||
|
listener.onCarDataAvailable(
|
||||||
|
Speed.Builder()
|
||||||
|
.setRawSpeedMetersPerSecond(CarValue(null, 0, CarValue.STATUS_UNKNOWN))
|
||||||
|
.setDisplaySpeedMetersPerSecond(CarValue(null, 0, CarValue.STATUS_UNKNOWN))
|
||||||
|
.setSpeedDisplayUnit(CarValue(null, 0, CarValue.STATUS_UNKNOWN))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
carPropertyManager.registerCallback(
|
||||||
|
callback,
|
||||||
|
VehiclePropertyIds.PERF_VEHICLE_SPEED,
|
||||||
|
CarPropertyManager.SENSOR_RATE_NORMAL
|
||||||
|
)
|
||||||
|
carPropertyManager.registerCallback(
|
||||||
|
callback,
|
||||||
|
VehiclePropertyIds.PERF_VEHICLE_SPEED_DISPLAY,
|
||||||
|
CarPropertyManager.SENSOR_RATE_NORMAL
|
||||||
|
)
|
||||||
|
carPropertyManager.registerCallback(
|
||||||
|
callback,
|
||||||
|
VehiclePropertyIds.VEHICLE_SPEED_DISPLAY_UNITS,
|
||||||
|
CarPropertyManager.SENSOR_RATE_NORMAL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeSpeedListener(listener: OnCarDataAvailableListener<Speed>) {
|
||||||
|
val callback = callbacks[listener] ?: return
|
||||||
|
carPropertyManager?.unregisterCallback(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addMileageListener(
|
||||||
|
executor: Executor,
|
||||||
|
listener: OnCarDataAvailableListener<Mileage>
|
||||||
|
) = wrapped.addMileageListener(executor, listener)
|
||||||
|
|
||||||
|
override fun removeMileageListener(listener: OnCarDataAvailableListener<Mileage>) =
|
||||||
|
wrapped.removeMileageListener(listener)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCarApi::class)
|
||||||
|
override fun addEvStatusListener(
|
||||||
|
executor: Executor,
|
||||||
|
listener: OnCarDataAvailableListener<EvStatus>
|
||||||
|
) = wrapped.addEvStatusListener(executor, listener)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCarApi::class)
|
||||||
|
override fun removeEvStatusListener(listener: OnCarDataAvailableListener<EvStatus>) =
|
||||||
|
wrapped.removeEvStatusListener(listener)
|
||||||
|
}
|
||||||
5
app/src/automotive/res/values-cs/strings.xml
Normal file
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>
|
||||||
@@ -11,6 +11,7 @@ import com.facebook.flipper.plugins.network.NetworkFlipperPlugin
|
|||||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
|
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
|
||||||
import com.facebook.soloader.SoLoader
|
import com.facebook.soloader.SoLoader
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
private val networkFlipperPlugin = NetworkFlipperPlugin()
|
private val networkFlipperPlugin = NetworkFlipperPlugin()
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ fun addDebugInterceptors(context: Context) {
|
|||||||
client.addPlugin(DatabasesFlipperPlugin(context))
|
client.addPlugin(DatabasesFlipperPlugin(context))
|
||||||
client.addPlugin(SharedPreferencesFlipperPlugin(context))
|
client.addPlugin(SharedPreferencesFlipperPlugin(context))
|
||||||
client.start()
|
client.start()
|
||||||
|
|
||||||
|
Timber.plant(Timber.DebugTree())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder {
|
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">EVMap (debug)</string>
|
<string name="app_name">EVMap</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,10 +3,12 @@ package net.vonforst.evmap
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun checkPlayServices(activity: Activity): Boolean {
|
fun checkPlayServices(activity: Activity): Boolean {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -5,16 +5,17 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.ui.setupWithNavController
|
import androidx.navigation.ui.setupWithNavController
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
import net.vonforst.evmap.MapsActivity
|
import net.vonforst.evmap.MapsActivity
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.databinding.FragmentDonateBinding
|
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 binding: FragmentDonateBinding
|
||||||
|
private lateinit var referrals: FragmentDonateReferralBinding
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -28,6 +29,7 @@ class DonateFragment : Fragment() {
|
|||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentDonateBinding.inflate(inflater, container, false)
|
binding = FragmentDonateBinding.inflate(inflater, container, false)
|
||||||
|
referrals = binding.referrals
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +42,9 @@ class DonateFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
binding.btnDonate.setOnClickListener {
|
binding.btnDonate.setOnClickListener {
|
||||||
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
|
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link), binding.root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupReferrals(referrals)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:id="@+id/linearLayout2"
|
android:id="@+id/linearLayout2"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
<com.google.android.material.appbar.AppBarLayout
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
android:id="@+id/toolbar_container"
|
android:id="@+id/toolbar_container"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:fitsSystemWindows="true"
|
android:fitsSystemWindows="true">
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
|
||||||
|
|
||||||
<androidx.appcompat.widget.Toolbar
|
<androidx.appcompat.widget.Toolbar
|
||||||
android:id="@+id/toolbar"
|
android:id="@+id/toolbar"
|
||||||
@@ -21,31 +19,55 @@
|
|||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
<Button
|
<ScrollView
|
||||||
android:id="@+id/btnDonate"
|
android:layout_width="match_parent"
|
||||||
style="@style/Widget.Material3.Button.Icon"
|
android:layout_height="match_parent">
|
||||||
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" />
|
|
||||||
|
|
||||||
<TextView
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/textView20"
|
android:layout_height="wrap_content"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent">
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="16dp"
|
<Button
|
||||||
android:layout_marginTop="16dp"
|
android:id="@+id/btnDonate"
|
||||||
android:layout_marginEnd="16dp"
|
style="@style/Widget.Material3.Button.Icon"
|
||||||
android:text="@string/donations_info"
|
android:layout_width="wrap_content"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/btnDonate"
|
android:layout_height="wrap_content"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
android:layout_marginTop="8dp"
|
||||||
app:layout_constraintHorizontal_bias="0.5"
|
android:layout_marginStart="16dp"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
android:text="@string/donate_paypal"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/toolbar_container"
|
app:icon="@drawable/ic_paypal"
|
||||||
app:layout_constraintVertical_chainStyle="packed" />
|
app:layout_constraintBottom_toTopOf="@id/referrals"
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
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
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.</string>
|
||||||
|
</resources>
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.</string>
|
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.</string>
|
||||||
<string name="donate_paypal">Mit PayPal spenden</string>
|
<string name="donate_paypal">Mit PayPal spenden</string>
|
||||||
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap (Mapbox).</string>
|
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.</string>
|
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.</string>
|
||||||
<string name="data_sources_hint">Les données cartographiques de l\'application sont fournies par OpenStreetMap (Mapbox).</string>
|
<string name="data_sources_hint">Les données cartographiques de l\'application sont fournies par OpenStreetMap.</string>
|
||||||
<string name="donate_paypal">Faire un don avec PayPal</string>
|
<string name="donate_paypal">Faire un don avec PayPal</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="donate_paypal">Doner med PayPal</string>
|
<string name="donate_paypal">Doner med PayPal</string>
|
||||||
<string name="data_sources_hint">Kartdata i programmet tilbys av OpenStreetMap (Mapbox).</string>
|
<string name="data_sources_hint">Kartdata i programmet tilbys av OpenStreetMap.</string>
|
||||||
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende en slant til utvikleren.</string>
|
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende en slant til utvikleren.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="donations_info" formatted="false">Vond je EVMap nuttig\? Je kan de ontwikkeling ondersteunen door een donatie te sturen naar de ontwikkelaar.</string>
|
<string name="donations_info" formatted="false">Vond je EVMap nuttig\? Je kan de ontwikkeling ondersteunen door een donatie te sturen naar de ontwikkelaar.</string>
|
||||||
<string name="donate_paypal">Doneer via PayPal</string>
|
<string name="donate_paypal">Doneer via PayPal</string>
|
||||||
<string name="data_sources_hint">De kaartgegevens zijn afkomstig van OpenStreetMap (Mapbox).</string>
|
<string name="data_sources_hint">De kaartgegevens zijn afkomstig van OpenStreetMap.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="data_sources_hint">Os dados do mapa são fornecidos pelo OpenStreetMap (Mapbox).</string>
|
<string name="data_sources_hint">Os dados do mapa são fornecidos pelo OpenStreetMap.</string>
|
||||||
<string name="donate_paypal">Doar com o PayPal</string>
|
<string name="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>
|
<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>
|
</resources>
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="donations_info" formatted="false">Crezi ca EVMap este util? Sprijina dezvoltarea printr-o donatie pentru dezvoltator.</string>
|
<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="donate_paypal">Doneaza cu PayPal</string>
|
||||||
<string name="data_sources_hint">Hartile din aplicatie sunt furnizate de OpenStreetMap (Mapbox).</string>
|
<string name="data_sources_hint">Hartile din aplicatie sunt furnizate de OpenStreetMap.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
|
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
|
||||||
<string name="donate_paypal">Donate with PayPal</string>
|
<string name="donate_paypal">Donate with PayPal</string>
|
||||||
<string name="data_sources_hint">Map data in the app is provided by OpenStreetMap (Mapbox).</string>
|
<string name="data_sources_hint">Map data in the app is provided by OpenStreetMap.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -6,11 +6,10 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.databinding.DataBindingUtil
|
import androidx.databinding.DataBindingUtil
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.lifecycle.Observer
|
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.ui.setupWithNavController
|
import androidx.navigation.ui.setupWithNavController
|
||||||
|
import androidx.recyclerview.widget.ConcatAdapter
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.google.android.material.snackbar.Snackbar
|
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.MapsActivity
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.adapter.DonationAdapter
|
import net.vonforst.evmap.adapter.DonationAdapter
|
||||||
|
import net.vonforst.evmap.adapter.SingleViewAdapter
|
||||||
import net.vonforst.evmap.databinding.FragmentDonateBinding
|
import net.vonforst.evmap.databinding.FragmentDonateBinding
|
||||||
|
import net.vonforst.evmap.databinding.FragmentDonateHeaderBinding
|
||||||
|
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
|
||||||
import net.vonforst.evmap.viewmodel.DonateViewModel
|
import net.vonforst.evmap.viewmodel.DonateViewModel
|
||||||
|
|
||||||
class DonateFragment : Fragment() {
|
class DonateFragment : DonateFragmentBase() {
|
||||||
private lateinit var binding: FragmentDonateBinding
|
private lateinit var binding: FragmentDonateBinding
|
||||||
private val vm: DonateViewModel by viewModels()
|
private val vm: DonateViewModel by viewModels()
|
||||||
|
private lateinit var header: FragmentDonateHeaderBinding
|
||||||
|
private lateinit var referrals: FragmentDonateReferralBinding
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -40,6 +44,9 @@ class DonateFragment : Fragment() {
|
|||||||
binding.lifecycleOwner = this
|
binding.lifecycleOwner = this
|
||||||
binding.vm = vm
|
binding.vm = vm
|
||||||
|
|
||||||
|
header = FragmentDonateHeaderBinding.inflate(inflater, container, false)
|
||||||
|
referrals = FragmentDonateReferralBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,25 +58,33 @@ class DonateFragment : Fragment() {
|
|||||||
(requireActivity() as MapsActivity).appBarConfiguration
|
(requireActivity() as MapsActivity).appBarConfiguration
|
||||||
)
|
)
|
||||||
|
|
||||||
binding.productsList.apply {
|
val donationAdapter = DonationAdapter().apply {
|
||||||
adapter = DonationAdapter().apply {
|
onClickListener = {
|
||||||
onClickListener = {
|
vm.startPurchase(it, requireActivity())
|
||||||
vm.startPurchase(it, requireActivity())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
binding.productsList.apply {
|
||||||
|
val joinedAdapter = ConcatAdapter(
|
||||||
|
SingleViewAdapter(header.root),
|
||||||
|
donationAdapter,
|
||||||
|
SingleViewAdapter(referrals.root)
|
||||||
|
)
|
||||||
|
adapter = joinedAdapter
|
||||||
layoutManager = LinearLayoutManager(context)
|
layoutManager = LinearLayoutManager(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
vm.products.observe(viewLifecycleOwner) {
|
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()
|
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()
|
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
|
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
|
||||||
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
||||||
|
|||||||
@@ -35,29 +35,16 @@
|
|||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
</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
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/products_list"
|
android:id="@+id/products_list"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_marginTop="16dp"
|
|
||||||
app:data="@{vm.products.data}"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/textView20"
|
app:layout_constraintTop_toBottomOf="@id/toolbar_container"
|
||||||
tools:listitem="@layout/item_donation" />
|
tools:itemCount="1"
|
||||||
|
tools:listitem="@layout/fragment_donate_preview" />
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
android:id="@+id/progressBar3"
|
android:id="@+id/progressBar3"
|
||||||
|
|||||||
10
app/src/google/res/layout/fragment_donate_header.xml
Normal file
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
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
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.</string>
|
||||||
|
</resources>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
|
||||||
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap (Mapbox) für die Kartendaten wechseln.</string>
|
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap für die Kartendaten wechseln.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.
|
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.
|
||||||
\n
|
\n
|
||||||
\nGoogle prend 15% sur chaque don.</string>
|
\nGoogle prend 15% sur chaque don.</string>
|
||||||
<string name="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap (Mapbox) pour les données cartographiques.</string>
|
<string name="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap pour les données cartographiques.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende penger til utvikleren.
|
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende penger til utvikleren.
|
||||||
\n
|
\n
|
||||||
\nGoogle tar 15% av alle donasjoner.</string>
|
\nGoogle tar 15% av alle donasjoner.</string>
|
||||||
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap (Mapbox) for kartdata.</string>
|
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap for kartdata.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
<string name="donations_info" formatted="false">Vind je EVMap nuttig\? Je kan de ontwikkeling steunen via een donatie aan de ontwikkelaar.
|
<string name="donations_info" formatted="false">Vind je EVMap nuttig\? Je kan de ontwikkeling steunen via een donatie aan de ontwikkelaar.
|
||||||
\n
|
\n
|
||||||
\nGoogle houdt 15% in van elke donatie.</string>
|
\nGoogle houdt 15% in van elke donatie.</string>
|
||||||
<string name="data_sources_hint">In de instellingen kan je ook wisselen tussen Google Maps en OpenStreetMap (Mapbox) voor de kaartgegevens.</string>
|
<string name="data_sources_hint">In de instellingen kan je ook wisselen tussen Google Maps en OpenStreetMap voor de kaartgegevens.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
<string name="donations_info" formatted="false">Acha que o EVMap é útil\? Apoie a manutenção e desenvolvimento com uma doação para o desenvolvedor da app.
|
<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
|
\n
|
||||||
\nA Google cobra 15% de cada doação.</string>
|
\nA Google cobra 15% de cada doação.</string>
|
||||||
<string name="data_sources_hint">Também pode mudar entre o Google Maps e OpenStreetMap (Mapbox) nas definições da app.</string>
|
<string name="data_sources_hint">Também pode mudar entre o Google Maps e OpenStreetMap nas definições da app.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string-array name="pref_map_provider_names">
|
<string-array name="pref_map_provider_names">
|
||||||
<item>@string/pref_provider_google_maps</item>
|
<item>@string/pref_provider_google_maps</item>
|
||||||
<item>@string/pref_provider_osm_mapbox</item>
|
<item>@string/pref_provider_osm</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="pref_map_provider_values" translatable="false">
|
<string-array name="pref_map_provider_values" translatable="false">
|
||||||
<item>google</item>
|
<item>google</item>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
|
||||||
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap (Mapbox) for the map data.</string>
|
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap for the map data.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
|
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
|
||||||
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
|
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
|
||||||
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
|
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
|
||||||
@@ -23,9 +24,14 @@
|
|||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="http" />
|
||||||
|
</intent>
|
||||||
|
|
||||||
<package android:name="com.google.android.projection.gearhead" />
|
<package android:name="com.google.android.projection.gearhead" />
|
||||||
<package android:name="com.google.android.apps.automotive.templates.host" />
|
<package android:name="com.google.android.apps.automotive.templates.host" />
|
||||||
|
<package android:name="com.google.android.apps.maps" />
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
@@ -40,12 +46,20 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:localeConfig="@xml/locales_config">
|
android:enableOnBackInvokedCallback="true">
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.mapbox.ACCESS_TOKEN"
|
android:name="com.mapbox.ACCESS_TOKEN"
|
||||||
android:value="@string/mapbox_key" />
|
android:value="@string/mapbox_key" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="io.jawg.ACCESS_TOKEN"
|
||||||
|
android:value="@string/jawg_key" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.arcgis.ACCESS_TOKEN"
|
||||||
|
android:value="@string/arcgis_key" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MapsActivity"
|
android:name=".MapsActivity"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -272,6 +286,10 @@
|
|||||||
android:host="openchargemap.org"
|
android:host="openchargemap.org"
|
||||||
android:pathPattern="/site/poi/details/..*"
|
android:pathPattern="/site/poi/details/..*"
|
||||||
android:scheme="https" />
|
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.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
@@ -289,6 +307,10 @@
|
|||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity android:name=".auto.OAuthLoginActivity">
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
@@ -327,11 +349,11 @@
|
|||||||
android:name=".auto.CarAppService"
|
android:name=".auto.CarAppService"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:foregroundServiceType="location">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action
|
<action android:name="androidx.car.app.CarAppService" />
|
||||||
android:name="androidx.car.app.CarAppService"
|
<category android:name="androidx.car.app.category.POI" />
|
||||||
android:category="androidx.car.app.category.POI" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
package net.vonforst.evmap
|
package net.vonforst.evmap
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.work.*
|
import androidx.work.Configuration
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
import net.vonforst.evmap.storage.CleanupCacheWorker
|
import net.vonforst.evmap.storage.CleanupCacheWorker
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
import net.vonforst.evmap.ui.updateAppLocale
|
import net.vonforst.evmap.ui.updateAppLocale
|
||||||
import net.vonforst.evmap.ui.updateNightMode
|
import net.vonforst.evmap.ui.updateNightMode
|
||||||
import org.acra.config.dialog
|
import org.acra.config.dialog
|
||||||
|
import org.acra.config.httpSender
|
||||||
import org.acra.config.limiter
|
import org.acra.config.limiter
|
||||||
import org.acra.config.mailSender
|
|
||||||
import org.acra.data.StringFormat
|
import org.acra.data.StringFormat
|
||||||
import org.acra.ktx.initAcra
|
import org.acra.ktx.initAcra
|
||||||
|
import org.acra.sender.HttpSender
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
class EvMapApplication : Application(), Configuration.Provider {
|
class EvMapApplication : Application(), Configuration.Provider {
|
||||||
@@ -22,7 +28,7 @@ class EvMapApplication : Application(), Configuration.Provider {
|
|||||||
|
|
||||||
// Convert to new AppCompat storage for app language
|
// Convert to new AppCompat storage for app language
|
||||||
val lang = prefs.language
|
val lang = prefs.language
|
||||||
if (lang != null && lang !in listOf("", "default")) {
|
if (lang != null) {
|
||||||
updateAppLocale(lang)
|
updateAppLocale(lang)
|
||||||
prefs.language = null
|
prefs.language = null
|
||||||
}
|
}
|
||||||
@@ -33,10 +39,15 @@ class EvMapApplication : Application(), Configuration.Provider {
|
|||||||
if (!BuildConfig.DEBUG) {
|
if (!BuildConfig.DEBUG) {
|
||||||
initAcra {
|
initAcra {
|
||||||
buildConfigClass = BuildConfig::class.java
|
buildConfigClass = BuildConfig::class.java
|
||||||
reportFormat = StringFormat.KEY_VALUE_LIST
|
|
||||||
|
|
||||||
mailSender {
|
// Vehicles often don't have an email app, so use HTTP to send instead
|
||||||
mailTo = "evmap+crashreport@vonforst.net"
|
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 {
|
dialog {
|
||||||
@@ -45,6 +56,10 @@ class EvMapApplication : Application(), Configuration.Provider {
|
|||||||
commentPrompt = getString(R.string.crash_report_comment_prompt)
|
commentPrompt = getString(R.string.crash_report_comment_prompt)
|
||||||
resIcon = R.drawable.ic_launcher_foreground
|
resIcon = R.drawable.ic_launcher_foreground
|
||||||
resTheme = R.style.AppTheme
|
resTheme = R.style.AppTheme
|
||||||
|
if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||||
|
reportDialogClass =
|
||||||
|
Class.forName("androidx.car.app.activity.CarAppActivity") as Class<out Activity>?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
limiter {
|
limiter {
|
||||||
@@ -61,11 +76,9 @@ class EvMapApplication : Application(), Configuration.Provider {
|
|||||||
}
|
}
|
||||||
}.build()).build()
|
}.build()).build()
|
||||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||||
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.REPLACE, cleanupCacheRequest
|
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.UPDATE, cleanupCacheRequest
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getWorkManagerConfiguration(): Configuration {
|
override val workManagerConfiguration = Configuration.Builder().build()
|
||||||
return Configuration.Builder().build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,8 @@ package net.vonforst.evmap
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ResolveInfo
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -11,7 +13,6 @@ import android.view.View
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||||
import androidx.browser.customtabs.CustomTabsClient
|
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.splashscreen.SplashScreen
|
import androidx.core.splashscreen.SplashScreen
|
||||||
@@ -44,14 +45,11 @@ const val EXTRA_DONATE = "donate"
|
|||||||
|
|
||||||
class MapsActivity : AppCompatActivity(),
|
class MapsActivity : AppCompatActivity(),
|
||||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
|
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
|
||||||
interface FragmentCallback {
|
|
||||||
fun getRootView(): View
|
|
||||||
}
|
|
||||||
|
|
||||||
private var reenterState: Bundle? = null
|
private var reenterState: Bundle? = null
|
||||||
private lateinit var navController: NavController
|
private lateinit var navController: NavController
|
||||||
|
private lateinit var navHostFragment: NavHostFragment
|
||||||
lateinit var appBarConfiguration: AppBarConfiguration
|
lateinit var appBarConfiguration: AppBarConfiguration
|
||||||
var fragmentCallback: FragmentCallback? = null
|
|
||||||
private lateinit var prefs: PreferenceDataSource
|
private lateinit var prefs: PreferenceDataSource
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -60,6 +58,7 @@ class MapsActivity : AppCompatActivity(),
|
|||||||
|
|
||||||
setContentView(R.layout.activity_maps)
|
setContentView(R.layout.activity_maps)
|
||||||
|
|
||||||
|
val drawerLayout = findViewById<DrawerLayout>(R.id.drawer_layout)
|
||||||
appBarConfiguration = AppBarConfiguration(
|
appBarConfiguration = AppBarConfiguration(
|
||||||
setOf(
|
setOf(
|
||||||
R.id.map,
|
R.id.map,
|
||||||
@@ -67,9 +66,9 @@ class MapsActivity : AppCompatActivity(),
|
|||||||
R.id.about,
|
R.id.about,
|
||||||
R.id.settings
|
R.id.settings
|
||||||
),
|
),
|
||||||
findViewById<DrawerLayout>(R.id.drawer_layout)
|
drawerLayout
|
||||||
)
|
)
|
||||||
val navHostFragment =
|
navHostFragment =
|
||||||
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||||
navController = navHostFragment.navController
|
navController = navHostFragment.navController
|
||||||
val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)
|
val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)
|
||||||
@@ -87,6 +86,17 @@ class MapsActivity : AppCompatActivity(),
|
|||||||
|
|
||||||
checkPlayServices(this)
|
checkPlayServices(this)
|
||||||
|
|
||||||
|
navController.setGraph(navGraph, MapFragmentArgs(appStart = true).toBundle())
|
||||||
|
var deepLink: PendingIntent? = null
|
||||||
|
|
||||||
|
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||||
|
if (destination.id == R.id.onboarding) {
|
||||||
|
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
|
} else {
|
||||||
|
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
|
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
// wait for splash screen animation to finish on first start
|
// wait for splash screen animation to finish on first start
|
||||||
@@ -104,133 +114,128 @@ class MapsActivity : AppCompatActivity(),
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
navGraph.setStartDestination(R.id.onboarding)
|
} else if (intent?.scheme == "geo") {
|
||||||
navController.graph = navGraph
|
val query = intent.data?.query?.split("=")?.get(1)
|
||||||
return
|
val coords = getLocationFromIntent(intent)
|
||||||
} else if (!prefs.privacyAccepted) {
|
|
||||||
navGraph.setStartDestination(R.id.onboarding)
|
|
||||||
navController.graph = navGraph
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
navGraph.setStartDestination(R.id.map)
|
|
||||||
navController.setGraph(navGraph, MapFragmentArgs(appStart = true).toBundle())
|
|
||||||
var deepLink: PendingIntent? = null
|
|
||||||
|
|
||||||
if (intent?.scheme == "geo") {
|
if (coords != null) {
|
||||||
val query = intent.data?.query?.split("=")?.get(1)
|
val lat = coords[0]
|
||||||
val coords = getLocationFromIntent(intent)
|
val lon = coords[1]
|
||||||
|
|
||||||
if (coords != null) {
|
|
||||||
val lat = coords[0]
|
|
||||||
val lon = coords[1]
|
|
||||||
deepLink = navController.createDeepLink()
|
|
||||||
.setGraph(R.navigation.nav_graph)
|
|
||||||
.setDestination(R.id.map)
|
|
||||||
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
|
|
||||||
.createPendingIntent()
|
|
||||||
} else if (!query.isNullOrEmpty()) {
|
|
||||||
deepLink = navController.createDeepLink()
|
|
||||||
.setGraph(R.navigation.nav_graph)
|
|
||||||
.setDestination(R.id.map)
|
|
||||||
.setArguments(MapFragmentArgs(locationName = query).toBundle())
|
|
||||||
.createPendingIntent()
|
|
||||||
}
|
|
||||||
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
|
|
||||||
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
|
|
||||||
if (id != null) {
|
|
||||||
if (prefs.dataSource != "goingelectric") {
|
|
||||||
prefs.dataSource = "goingelectric"
|
|
||||||
Toast.makeText(
|
|
||||||
this,
|
|
||||||
getString(
|
|
||||||
R.string.data_source_switched_to,
|
|
||||||
getString(R.string.data_source_goingelectric)
|
|
||||||
),
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
deepLink = navController.createDeepLink()
|
|
||||||
.setGraph(R.navigation.nav_graph)
|
|
||||||
.setDestination(R.id.map)
|
|
||||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
|
||||||
.createPendingIntent()
|
|
||||||
}
|
|
||||||
} else if (intent?.scheme == "https" && intent?.data?.host == "openchargemap.org") {
|
|
||||||
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
|
|
||||||
if (id != null) {
|
|
||||||
if (prefs.dataSource != "openchargemap") {
|
|
||||||
prefs.dataSource = "openchargemap"
|
|
||||||
Toast.makeText(
|
|
||||||
this,
|
|
||||||
getString(
|
|
||||||
R.string.data_source_switched_to,
|
|
||||||
getString(R.string.data_source_openchargemap)
|
|
||||||
),
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
deepLink = navController.createDeepLink()
|
|
||||||
.setGraph(R.navigation.nav_graph)
|
|
||||||
.setDestination(R.id.map)
|
|
||||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
|
||||||
.createPendingIntent()
|
|
||||||
}
|
|
||||||
} else if (intent.scheme == "net.vonforst.evmap") {
|
|
||||||
intent.data?.let {
|
|
||||||
if (it.host == "find_charger") {
|
|
||||||
val lat = it.getQueryParameter("latitude")?.toDouble()
|
|
||||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
|
||||||
val name = it.getQueryParameter("name")
|
|
||||||
if (lat != null && lon != null) {
|
|
||||||
deepLink = navController.createDeepLink()
|
|
||||||
.setGraph(R.navigation.nav_graph)
|
|
||||||
.setDestination(R.id.map)
|
|
||||||
.setArguments(
|
|
||||||
MapFragmentArgs(
|
|
||||||
latLng = LatLng(lat, lon),
|
|
||||||
locationName = name
|
|
||||||
).toBundle()
|
|
||||||
)
|
|
||||||
.createPendingIntent()
|
|
||||||
} else if (name != null) {
|
|
||||||
deepLink = navController.createDeepLink()
|
|
||||||
.setGraph(R.navigation.nav_graph)
|
|
||||||
.setDestination(R.id.map)
|
|
||||||
.setArguments(MapFragmentArgs(locationName = name).toBundle())
|
|
||||||
.createPendingIntent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
|
|
||||||
deepLink = navController.createDeepLink()
|
deepLink = navController.createDeepLink()
|
||||||
|
.setGraph(R.navigation.nav_graph)
|
||||||
.setDestination(R.id.map)
|
.setDestination(R.id.map)
|
||||||
.setArguments(
|
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
|
||||||
MapFragmentArgs(
|
|
||||||
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
|
|
||||||
latLng = LatLng(
|
|
||||||
intent.getDoubleExtra(EXTRA_LAT, 0.0),
|
|
||||||
intent.getDoubleExtra(EXTRA_LON, 0.0)
|
|
||||||
)
|
|
||||||
).toBundle()
|
|
||||||
)
|
|
||||||
.createPendingIntent()
|
.createPendingIntent()
|
||||||
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
|
} else if (!query.isNullOrEmpty()) {
|
||||||
deepLink = navController.createDeepLink()
|
deepLink = navController.createDeepLink()
|
||||||
.setGraph(navGraph)
|
.setGraph(R.navigation.nav_graph)
|
||||||
.setDestination(R.id.favs)
|
.setDestination(R.id.map)
|
||||||
.createPendingIntent()
|
.setArguments(MapFragmentArgs(locationName = query).toBundle())
|
||||||
} else if (intent.hasExtra(EXTRA_DONATE)) {
|
|
||||||
deepLink = navController.createDeepLink()
|
|
||||||
.setGraph(navGraph)
|
|
||||||
.setDestination(R.id.donate)
|
|
||||||
.createPendingIntent()
|
.createPendingIntent()
|
||||||
}
|
}
|
||||||
|
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
|
||||||
deepLink?.send()
|
val id = intent.data?.pathSegments?.lastOrNull()?.toLongOrNull()
|
||||||
|
if (id != null) {
|
||||||
|
if (prefs.dataSource != "goingelectric") {
|
||||||
|
prefs.dataSource = "goingelectric"
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
getString(
|
||||||
|
R.string.data_source_switched_to,
|
||||||
|
getString(R.string.data_source_goingelectric)
|
||||||
|
),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
deepLink = navController.createDeepLink()
|
||||||
|
.setGraph(R.navigation.nav_graph)
|
||||||
|
.setDestination(R.id.map)
|
||||||
|
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||||
|
.createPendingIntent()
|
||||||
|
}
|
||||||
|
} else if (intent?.scheme == "https" && intent?.data?.host in listOf(
|
||||||
|
"openchargemap.org",
|
||||||
|
"map.openchargemap.io"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val id = when (intent.data?.host) {
|
||||||
|
"openchargemap.org" -> intent.data?.pathSegments?.lastOrNull()?.toLongOrNull()
|
||||||
|
"map.openchargemap.io" -> intent.data?.getQueryParameter("id")?.toLongOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (id != null) {
|
||||||
|
if (prefs.dataSource != "openchargemap") {
|
||||||
|
prefs.dataSource = "openchargemap"
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
getString(
|
||||||
|
R.string.data_source_switched_to,
|
||||||
|
getString(R.string.data_source_openchargemap)
|
||||||
|
),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
deepLink = navController.createDeepLink()
|
||||||
|
.setGraph(R.navigation.nav_graph)
|
||||||
|
.setDestination(R.id.map)
|
||||||
|
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||||
|
.createPendingIntent()
|
||||||
|
}
|
||||||
|
} else if (intent.scheme == "net.vonforst.evmap") {
|
||||||
|
intent.data?.let {
|
||||||
|
if (it.host == "find_charger") {
|
||||||
|
val lat = it.getQueryParameter("latitude")?.toDouble()
|
||||||
|
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||||
|
val name = it.getQueryParameter("name")
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
deepLink = navController.createDeepLink()
|
||||||
|
.setGraph(R.navigation.nav_graph)
|
||||||
|
.setDestination(R.id.map)
|
||||||
|
.setArguments(
|
||||||
|
MapFragmentArgs(
|
||||||
|
latLng = LatLng(lat, lon),
|
||||||
|
locationName = name
|
||||||
|
).toBundle()
|
||||||
|
)
|
||||||
|
.createPendingIntent()
|
||||||
|
} else if (name != null) {
|
||||||
|
deepLink = navController.createDeepLink()
|
||||||
|
.setGraph(R.navigation.nav_graph)
|
||||||
|
.setDestination(R.id.map)
|
||||||
|
.setArguments(MapFragmentArgs(locationName = name).toBundle())
|
||||||
|
.createPendingIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
|
||||||
|
deepLink = navController.createDeepLink()
|
||||||
|
.setDestination(R.id.map)
|
||||||
|
.setArguments(
|
||||||
|
MapFragmentArgs(
|
||||||
|
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
|
||||||
|
latLng = LatLng(
|
||||||
|
intent.getDoubleExtra(EXTRA_LAT, 0.0),
|
||||||
|
intent.getDoubleExtra(EXTRA_LON, 0.0)
|
||||||
|
)
|
||||||
|
).toBundle()
|
||||||
|
)
|
||||||
|
.createPendingIntent()
|
||||||
|
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
|
||||||
|
deepLink = navController.createDeepLink()
|
||||||
|
.setGraph(navGraph)
|
||||||
|
.setDestination(R.id.favs)
|
||||||
|
.createPendingIntent()
|
||||||
|
} else if (intent.hasExtra(EXTRA_DONATE)) {
|
||||||
|
deepLink = navController.createDeepLink()
|
||||||
|
.setGraph(navGraph)
|
||||||
|
.setDestination(R.id.donate)
|
||||||
|
.createPendingIntent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deepLink?.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun navigateTo(charger: ChargeLocation) {
|
fun navigateTo(charger: ChargeLocation, rootView: View) {
|
||||||
// google maps navigation
|
// google maps navigation
|
||||||
val coord = charger.coordinates
|
val coord = charger.coordinates
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
@@ -240,11 +245,11 @@ class MapsActivity : AppCompatActivity(),
|
|||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
} else {
|
} else {
|
||||||
// fallback: generic geo intent
|
// fallback: generic geo intent
|
||||||
showLocation(charger)
|
showLocation(charger, rootView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showLocation(charger: ChargeLocation) {
|
fun showLocation(charger: ChargeLocation, rootView: View) {
|
||||||
val coord = charger.coordinates
|
val coord = charger.coordinates
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
intent.data = Uri.parse(
|
intent.data = Uri.parse(
|
||||||
@@ -252,20 +257,33 @@ class MapsActivity : AppCompatActivity(),
|
|||||||
Uri.encode(charger.name)
|
Uri.encode(charger.name)
|
||||||
})"
|
})"
|
||||||
)
|
)
|
||||||
if (intent.resolveActivity(packageManager) != null) {
|
|
||||||
|
val resolveInfo =
|
||||||
|
packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||||
|
val pkg =
|
||||||
|
resolveInfo?.activityInfo?.packageName.takeIf { it != "android" && it != packageName }
|
||||||
|
if (pkg == null) {
|
||||||
|
// There is no default maps app or EVMap itself is the current default, fall back to app chooser
|
||||||
|
val chooserIntent = Intent.createChooser(intent, null).apply {
|
||||||
|
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(componentName))
|
||||||
|
}
|
||||||
|
startActivity(chooserIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
intent.setPackage(pkg)
|
||||||
|
|
||||||
|
try {
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
} else {
|
} catch (e: ActivityNotFoundException) {
|
||||||
val cb = fragmentCallback ?: return
|
|
||||||
Snackbar.make(
|
Snackbar.make(
|
||||||
cb.getRootView(),
|
rootView,
|
||||||
R.string.no_maps_app_found,
|
R.string.no_maps_app_found,
|
||||||
Snackbar.LENGTH_SHORT
|
Snackbar.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openUrl(url: String) {
|
fun openUrl(url: String, rootView: View, preferBrowser: Boolean = true) {
|
||||||
val pkg = CustomTabsClient.getPackageName(this, null)
|
|
||||||
val intent = CustomTabsIntent.Builder()
|
val intent = CustomTabsIntent.Builder()
|
||||||
.setDefaultColorSchemeParams(
|
.setDefaultColorSchemeParams(
|
||||||
CustomTabColorSchemeParams.Builder()
|
CustomTabColorSchemeParams.Builder()
|
||||||
@@ -273,17 +291,49 @@ class MapsActivity : AppCompatActivity(),
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
pkg?.let {
|
|
||||||
// prefer to open URL in custom tab, even if native app
|
val uri = Uri.parse(url)
|
||||||
// available (such as EVMap itself)
|
val viewIntent = Intent(Intent.ACTION_VIEW, uri)
|
||||||
|
if (preferBrowser) {
|
||||||
|
// EVMap may be set as default app for this link, but we want to open it in a browser
|
||||||
|
// try to find default web browser
|
||||||
|
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://"))
|
||||||
|
val resolveInfo =
|
||||||
|
packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||||
|
val pkg = resolveInfo?.activityInfo?.packageName.takeIf { it != "android" }
|
||||||
|
if (pkg == null) {
|
||||||
|
// There is no default browser, fall back to app chooser
|
||||||
|
val chooserIntent = Intent.createChooser(viewIntent, null).apply {
|
||||||
|
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(componentName))
|
||||||
|
}
|
||||||
|
val targets: List<ResolveInfo> = packageManager.queryIntentActivities(
|
||||||
|
viewIntent,
|
||||||
|
PackageManager.MATCH_DEFAULT_ONLY
|
||||||
|
)
|
||||||
|
|
||||||
|
// add missing browsers (if EVMap is already set as default, Android might not find other browsers with the specific intent)
|
||||||
|
val browsers = packageManager.queryIntentActivities(
|
||||||
|
browserIntent,
|
||||||
|
PackageManager.MATCH_DEFAULT_ONLY
|
||||||
|
)
|
||||||
|
val extraIntents = browsers.filter { browser ->
|
||||||
|
targets.find { it.activityInfo.packageName == browser.activityInfo.packageName } == null
|
||||||
|
}.map { browser ->
|
||||||
|
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||||
|
setPackage(browser.activityInfo.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents.toTypedArray())
|
||||||
|
startActivity(chooserIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
intent.intent.setPackage(pkg)
|
intent.intent.setPackage(pkg)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
intent.launchUrl(this, Uri.parse(url))
|
intent.launchUrl(this, uri)
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
val cb = fragmentCallback ?: return
|
|
||||||
Snackbar.make(
|
Snackbar.make(
|
||||||
cb.getRootView(),
|
rootView,
|
||||||
R.string.no_browser_app_found,
|
R.string.no_browser_app_found,
|
||||||
Snackbar.LENGTH_SHORT
|
Snackbar.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
package net.vonforst.evmap
|
package net.vonforst.evmap
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.icu.util.LocaleData
|
||||||
|
import android.icu.util.ULocale
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.*
|
import android.text.Spannable
|
||||||
|
import android.text.SpannableString
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.SpannedString
|
||||||
|
import android.text.TextUtils
|
||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
import java.util.*
|
import android.view.View
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
|
import java.util.Currency
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
fun Bundle.optDouble(name: String): Double? {
|
fun Bundle.optDouble(name: String): Double? {
|
||||||
if (!this.containsKey(name)) return null
|
if (!this.containsKey(name)) return null
|
||||||
@@ -88,9 +99,25 @@ fun Context.isDarkMode() =
|
|||||||
|
|
||||||
const val kmPerMile = 1.609344
|
const val kmPerMile = 1.609344
|
||||||
const val meterPerFt = 0.3048
|
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 =
|
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
|
||||||
@@ -98,4 +125,31 @@ fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): Pa
|
|||||||
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
|
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
|
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int = 0): ApplicationInfo =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(flags.toLong()))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") getApplicationInfo(packageName, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun PackageManager.isAppInstalled(packageName: String): Boolean {
|
||||||
|
return try {
|
||||||
|
getApplicationInfoCompat(packageName, 0).enabled
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currencyDisplayName(code: String) = "${Currency.getInstance(code).displayName} ($code)"
|
||||||
|
|
||||||
|
inline fun View.waitForLayout(crossinline f: () -> Unit) =
|
||||||
|
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.vonforst.evmap.adapter
|
package net.vonforst.evmap.adapter
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.databinding.DataBindingUtil
|
import androidx.databinding.DataBindingUtil
|
||||||
import androidx.databinding.ViewDataBinding
|
import androidx.databinding.ViewDataBinding
|
||||||
@@ -21,6 +22,7 @@ import net.vonforst.evmap.databinding.ItemChargepriceVehicleChipBinding
|
|||||||
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
|
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
|
||||||
import net.vonforst.evmap.model.Chargepoint
|
import net.vonforst.evmap.model.Chargepoint
|
||||||
import net.vonforst.evmap.ui.CheckableConstraintLayout
|
import net.vonforst.evmap.ui.CheckableConstraintLayout
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
interface Equatable {
|
interface Equatable {
|
||||||
override fun equals(other: Any?): Boolean
|
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)) {
|
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback(getKey)) {
|
||||||
|
|
||||||
var onClickListener: ((T) -> Unit)? = null
|
var onClickListener: ((T) -> Unit)? = null
|
||||||
|
var onLongClickListener: ((T) -> Boolean)? = null
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> {
|
||||||
val layoutInflater = LayoutInflater.from(parent.context)
|
val layoutInflater = LayoutInflater.from(parent.context)
|
||||||
@@ -54,6 +57,12 @@ abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
|
|||||||
listener(item)
|
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>() {
|
class DiffCallback<T : Equatable>(val getKey: ((T) -> Any)?) : DiffUtil.ItemCallback<T>() {
|
||||||
@@ -87,7 +96,19 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
|
|||||||
override fun getItemViewType(position: Int): Int = R.layout.item_connector
|
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>() {
|
DataBindingAdapter<ChargePrice>() {
|
||||||
|
|
||||||
val viewPool = RecyclerView.RecycledViewPool()
|
val viewPool = RecyclerView.RecycledViewPool()
|
||||||
@@ -209,8 +230,8 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
|
|||||||
checkedItem = item
|
checkedItem = item
|
||||||
root.post {
|
root.post {
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
|
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
|
||||||
}
|
}
|
||||||
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import android.text.style.StyleSpan
|
|||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
|
import net.vonforst.evmap.api.availability.tesla.Pricing
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.Rates
|
||||||
import net.vonforst.evmap.bold
|
import net.vonforst.evmap.bold
|
||||||
import net.vonforst.evmap.joinToSpannedString
|
import net.vonforst.evmap.joinToSpannedString
|
||||||
import net.vonforst.evmap.model.ChargeCard
|
import net.vonforst.evmap.model.ChargeCard
|
||||||
@@ -47,7 +48,7 @@ fun buildDetails(
|
|||||||
loc: ChargeLocation?,
|
loc: ChargeLocation?,
|
||||||
chargeCards: Map<Long, ChargeCard>?,
|
chargeCards: Map<Long, ChargeCard>?,
|
||||||
filteredChargeCards: Set<Long>?,
|
filteredChargeCards: Set<Long>?,
|
||||||
teslaPricing: TeslaGraphQlApi.Pricing?,
|
teslaPricing: Pricing?,
|
||||||
ctx: Context
|
ctx: Context
|
||||||
): List<DetailsAdapter.Detail> {
|
): List<DetailsAdapter.Detail> {
|
||||||
if (loc == null) return emptyList()
|
if (loc == null) return emptyList()
|
||||||
@@ -139,7 +140,7 @@ fun buildDetails(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
|
fun formatTeslaParkingFee(teslaPricing: Pricing, ctx: Context) =
|
||||||
teslaPricing.memberRates?.activePricebook?.parking?.let { parkingFee ->
|
teslaPricing.memberRates?.activePricebook?.parking?.let { parkingFee ->
|
||||||
ctx.getString(
|
ctx.getString(
|
||||||
R.string.tesla_pricing_blocking_fee,
|
R.string.tesla_pricing_blocking_fee,
|
||||||
@@ -147,7 +148,7 @@ private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Co
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
|
fun formatTeslaPricing(teslaPricing: Pricing, ctx: Context) =
|
||||||
buildSpannedString {
|
buildSpannedString {
|
||||||
teslaPricing.memberRates?.let { memberRates ->
|
teslaPricing.memberRates?.let { memberRates ->
|
||||||
append(
|
append(
|
||||||
@@ -168,7 +169,7 @@ private fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Conte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTeslaPricingRates(rates: TeslaGraphQlApi.Rates, ctx: Context) =
|
private fun formatTeslaPricingRates(rates: Rates, ctx: Context) =
|
||||||
buildSpannedString {
|
buildSpannedString {
|
||||||
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
if (rates.activePricebook.charging.touRates.enabled) {
|
if (rates.activePricebook.charging.touRates.enabled) {
|
||||||
|
|||||||
@@ -5,16 +5,15 @@ import android.content.Context
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewTreeObserver.OnGlobalLayoutListener
|
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import coil.load
|
import coil.load
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
import net.vonforst.evmap.BuildConfig
|
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.model.ChargerPhoto
|
import net.vonforst.evmap.model.ChargerPhoto
|
||||||
|
import net.vonforst.evmap.waitForLayout
|
||||||
|
|
||||||
|
|
||||||
class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener? = null) :
|
class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener? = null) :
|
||||||
@@ -40,12 +39,9 @@ class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener?
|
|||||||
val item = getItem(position)
|
val item = getItem(position)
|
||||||
|
|
||||||
if (holder.view.height == 0) {
|
if (holder.view.height == 0) {
|
||||||
holder.view.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
|
holder.view.waitForLayout {
|
||||||
override fun onGlobalLayout() {
|
loadImage(item, holder)
|
||||||
holder.view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
}
|
||||||
loadImage(item, holder)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
loadImage(item, holder)
|
loadImage(item, holder)
|
||||||
}
|
}
|
||||||
@@ -71,7 +67,7 @@ class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener?
|
|||||||
memoryKeys[item.id] = metadata.memoryCacheKey
|
memoryKeys[item.id] = metadata.memoryCacheKey
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
allowHardware(!BuildConfig.DEBUG)
|
allowHardware(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
package net.vonforst.evmap.api.availability
|
package net.vonforst.evmap.api.availability
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
import net.vonforst.evmap.addDebugInterceptors
|
import net.vonforst.evmap.addDebugInterceptors
|
||||||
import net.vonforst.evmap.api.RateLimitInterceptor
|
import net.vonforst.evmap.api.RateLimitInterceptor
|
||||||
import net.vonforst.evmap.api.await
|
|
||||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||||
import net.vonforst.evmap.cartesianProduct
|
import net.vonforst.evmap.cartesianProduct
|
||||||
import net.vonforst.evmap.model.ChargeLocation
|
import net.vonforst.evmap.model.ChargeLocation
|
||||||
import net.vonforst.evmap.model.Chargepoint
|
import net.vonforst.evmap.model.Chargepoint
|
||||||
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
|
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
|
||||||
import net.vonforst.evmap.viewmodel.Resource
|
import net.vonforst.evmap.viewmodel.Resource
|
||||||
|
import okhttp3.Cache
|
||||||
import okhttp3.JavaNetCookieJar
|
import okhttp3.JavaNetCookieJar
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.CookieManager
|
import java.net.CookieManager
|
||||||
import java.net.CookiePolicy
|
import java.net.CookiePolicy
|
||||||
|
import java.time.Instant
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
interface AvailabilityDetector {
|
interface AvailabilityDetector {
|
||||||
@@ -36,16 +38,6 @@ interface AvailabilityDetector {
|
|||||||
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
|
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
|
||||||
protected val radius = 150 // max radius in meters
|
protected val radius = 150 // max radius in meters
|
||||||
|
|
||||||
protected suspend fun httpGet(url: String): String {
|
|
||||||
val request = Request.Builder().url(url).build()
|
|
||||||
val response = client.newCall(request).await()
|
|
||||||
|
|
||||||
if (!response.isSuccessful) throw IOException(response.message)
|
|
||||||
|
|
||||||
val str = response.body!!.string()
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun getCorrespondingChargepoint(
|
protected fun getCorrespondingChargepoint(
|
||||||
cps: Iterable<Chargepoint>, type: String, power: Double
|
cps: Iterable<Chargepoint>, type: String, power: Double
|
||||||
): Chargepoint? {
|
): Chargepoint? {
|
||||||
@@ -136,7 +128,9 @@ data class ChargeLocationStatus(
|
|||||||
val status: Map<Chargepoint, List<ChargepointStatus>>,
|
val status: Map<Chargepoint, List<ChargepointStatus>>,
|
||||||
val source: String,
|
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 congestionHistogram: List<Double>? = null,
|
||||||
|
val lastChange: Map<Chargepoint, List<Instant?>>? = null,
|
||||||
val extraData: Any? = null // API-specific data
|
val extraData: Any? = null // API-specific data
|
||||||
) {
|
) {
|
||||||
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
|
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
|
||||||
@@ -152,51 +146,73 @@ data class ChargeLocationStatus(
|
|||||||
val totalChargepoints = status.map { it.key.count }.sum()
|
val totalChargepoints = status.map { it.key.count }.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ChargepointStatus {
|
@Parcelize
|
||||||
|
enum class ChargepointStatus : Parcelable {
|
||||||
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED
|
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED
|
||||||
}
|
}
|
||||||
|
|
||||||
class AvailabilityDetectorException(message: String) : Exception(message)
|
class AvailabilityDetectorException(message: String) : Exception(message)
|
||||||
|
|
||||||
|
class NotSignedInException : IOException("not signed in")
|
||||||
|
|
||||||
private val cookieManager = CookieManager().apply {
|
private val cookieManager = CookieManager().apply {
|
||||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||||
}
|
}
|
||||||
|
|
||||||
class AvailabilityRepository(context: Context) {
|
class AvailabilityRepository(context: Context) {
|
||||||
|
private val cacheSize = 5L * 1024 * 1024 // 5MB
|
||||||
private val okhttp = OkHttpClient.Builder()
|
private val okhttp = OkHttpClient.Builder()
|
||||||
.addInterceptor(RateLimitInterceptor())
|
.addInterceptor(RateLimitInterceptor())
|
||||||
.addDebugInterceptors()
|
.addDebugInterceptors()
|
||||||
.readTimeout(10, TimeUnit.SECONDS)
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||||
|
.cache(Cache(context.cacheDir, cacheSize))
|
||||||
.build()
|
.build()
|
||||||
|
private val teslaOwnerAvailabilityDetector =
|
||||||
|
TeslaOwnerAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context))
|
||||||
private val availabilityDetectors = listOf(
|
private val availabilityDetectors = listOf(
|
||||||
RheinenergieAvailabilityDetector(okhttp),
|
RheinenergieAvailabilityDetector(okhttp),
|
||||||
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
|
teslaOwnerAvailabilityDetector,
|
||||||
|
TeslaGuestAvailabilityDetector(okhttp),
|
||||||
EnBwAvailabilityDetector(okhttp),
|
EnBwAvailabilityDetector(okhttp),
|
||||||
NewMotionAvailabilityDetector(okhttp)
|
NewMotionAvailabilityDetector(okhttp)
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
|
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
|
||||||
var value: Resource<ChargeLocationStatus>? = null
|
var result: ChargeLocationStatus? = null
|
||||||
|
var exception: Throwable? = null
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
for (ad in availabilityDetectors) {
|
for (ad in availabilityDetectors) {
|
||||||
if (!ad.isChargerSupported(charger)) continue
|
if (!ad.isChargerSupported(charger)) continue
|
||||||
try {
|
try {
|
||||||
value = Resource.success(ad.getAvailability(charger))
|
result = ad.getAvailability(charger)
|
||||||
break
|
break
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
value = Resource.error(e.message, null)
|
exception = exception.takeIf { it is NotSignedInException } ?: e
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
value = Resource.error(e.message, null)
|
exception = exception.takeIf { it is NotSignedInException } ?: e
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
} catch (e: AvailabilityDetectorException) {
|
} catch (e: AvailabilityDetectorException) {
|
||||||
value = Resource.error(e.message, null)
|
exception = exception.takeIf { it is NotSignedInException } ?: e
|
||||||
|
e.printStackTrace()
|
||||||
|
} catch (e: NotSignedInException) {
|
||||||
|
exception = e
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return value ?: Resource.error(null, null)
|
result?.let {
|
||||||
|
return Resource.success(it)
|
||||||
|
}
|
||||||
|
return Resource.error(exception?.message, 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
|
package net.vonforst.evmap.api.availability
|
||||||
|
|
||||||
|
import com.squareup.moshi.FromJson
|
||||||
import com.squareup.moshi.JsonClass
|
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.ChargeLocation
|
||||||
import net.vonforst.evmap.model.Chargepoint
|
import net.vonforst.evmap.model.Chargepoint
|
||||||
import net.vonforst.evmap.utils.distanceBetween
|
import net.vonforst.evmap.utils.distanceBetween
|
||||||
@@ -10,6 +14,8 @@ import retrofit2.converter.moshi.MoshiConverterFactory
|
|||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
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 coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||||
private const val maxDistance = 60 // max distance between reported positions in meters
|
private const val maxDistance = 60 // max distance between reported positions in meters
|
||||||
@@ -53,7 +59,8 @@ interface EnBwApi {
|
|||||||
data class EnBwChargePoint(
|
data class EnBwChargePoint(
|
||||||
val evseId: String?,
|
val evseId: String?,
|
||||||
val status: String,
|
val status: String,
|
||||||
val connectors: List<EnBwConnector>
|
val connectors: List<EnBwConnector>,
|
||||||
|
val state: EnBwState?
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
@@ -70,6 +77,11 @@ interface EnBwApi {
|
|||||||
val upperRightLon: Double
|
val upperRightLon: Double
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class EnBwState(
|
||||||
|
val updatedAt: Instant?
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun create(client: OkHttpClient, baseUrl: String? = null): EnBwApi {
|
fun create(client: OkHttpClient, baseUrl: String? = null): EnBwApi {
|
||||||
val clientWithInterceptor = client.newBuilder()
|
val clientWithInterceptor = client.newBuilder()
|
||||||
@@ -85,7 +97,11 @@ interface EnBwApi {
|
|||||||
}.build()
|
}.build()
|
||||||
val retrofit = Retrofit.Builder()
|
val retrofit = Retrofit.Builder()
|
||||||
.baseUrl(baseUrl ?: "https://enbw-emp.azure-api.net/emobility-public-api/api/v1/")
|
.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)
|
.client(clientWithInterceptor)
|
||||||
.build()
|
.build()
|
||||||
return retrofit.create(EnBwApi::class.java)
|
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) :
|
class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||||
BaseAvailabilityDetector(client) {
|
BaseAvailabilityDetector(client) {
|
||||||
val api = EnBwApi.create(client, baseUrl)
|
val api = EnBwApi.create(client, baseUrl)
|
||||||
@@ -117,6 +150,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
|||||||
listOf(it)
|
listOf(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (markers.any { it.grouped }) throw AvailabilityDetectorException("markers still grouped")
|
||||||
|
|
||||||
val nearest = markers.minByOrNull { marker ->
|
val nearest = markers.minByOrNull { marker ->
|
||||||
distanceBetween(marker.lat, marker.lon, lat, lng)
|
distanceBetween(marker.lat, marker.lon, lat, lng)
|
||||||
@@ -149,21 +183,22 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
|||||||
markers = listOf(nearest)
|
markers = listOf(nearest)
|
||||||
}
|
}
|
||||||
|
|
||||||
val details = markers.map {
|
val details = markers.mapNotNull { it.stationId }.map {
|
||||||
// load details
|
// load details
|
||||||
api.getLocation(it.stationId!!)
|
api.getLocation(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
|
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
|
||||||
cp.connectors.map { connector ->
|
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 enbwConnectors = mutableMapOf<Long, Pair<Double, String>>()
|
||||||
val enbwStatus = mutableMapOf<Long, ChargepointStatus>()
|
val enbwStatus = mutableMapOf<Long, ChargepointStatus>()
|
||||||
val enbwEvseId = mutableMapOf<Long, String>()
|
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 id = index.toLong()
|
||||||
val power = connector.maxPowerInKw ?: 0.0
|
val power = connector.maxPowerInKw ?: 0.0
|
||||||
val type = when (connector.plugTypeName) {
|
val type = when (connector.plugTypeName) {
|
||||||
@@ -186,6 +221,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
|||||||
}
|
}
|
||||||
enbwConnectors[id] = power to type
|
enbwConnectors[id] = power to type
|
||||||
enbwStatus[id] = status
|
enbwStatus[id] = status
|
||||||
|
enbwLastChange[id] = updatedAt
|
||||||
evseId?.let { enbwEvseId[id] = it }
|
evseId?.let { enbwEvseId[id] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +232,13 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
|||||||
val evseIds = if (enbwEvseId.size == enbwStatus.size) match.mapValues { entry ->
|
val evseIds = if (enbwEvseId.size == enbwStatus.size) match.mapValues { entry ->
|
||||||
entry.value.map { enbwEvseId[it]!! }
|
entry.value.map { enbwEvseId[it]!! }
|
||||||
} else null
|
} else null
|
||||||
|
val lastChange =
|
||||||
|
if (enbwLastChange.size == enbwStatus.size) match.mapValues { entry -> entry.value.map { enbwLastChange[it] } } else null
|
||||||
return ChargeLocationStatus(
|
return ChargeLocationStatus(
|
||||||
chargepointStatus,
|
chargepointStatus,
|
||||||
"EnBW",
|
"EnBW",
|
||||||
evseIds
|
evseIds,
|
||||||
|
lastChange = lastChange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package net.vonforst.evmap.api.availability
|
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.JsonClass
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.ToJson
|
||||||
import net.vonforst.evmap.model.ChargeLocation
|
import net.vonforst.evmap.model.ChargeLocation
|
||||||
import net.vonforst.evmap.model.Chargepoint
|
import net.vonforst.evmap.model.Chargepoint
|
||||||
import net.vonforst.evmap.utils.distanceBetween
|
import net.vonforst.evmap.utils.distanceBetween
|
||||||
@@ -9,6 +13,11 @@ import retrofit2.Retrofit
|
|||||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
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.*
|
import java.util.*
|
||||||
|
|
||||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||||
@@ -42,7 +51,12 @@ interface NewMotionApi {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@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)
|
@JsonClass(generateAdapter = true)
|
||||||
data class NMConnector(
|
data class NMConnector(
|
||||||
@@ -78,7 +92,11 @@ interface NewMotionApi {
|
|||||||
fun create(client: OkHttpClient, baseUrl: String? = null): NewMotionApi {
|
fun create(client: OkHttpClient, baseUrl: String? = null): NewMotionApi {
|
||||||
val retrofit = Retrofit.Builder()
|
val retrofit = Retrofit.Builder()
|
||||||
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
|
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
|
||||||
.addConverterFactory(MoshiConverterFactory.create())
|
.addConverterFactory(
|
||||||
|
MoshiConverterFactory.create(
|
||||||
|
Moshi.Builder().add(ZonedDateTimeAdapter()).build()
|
||||||
|
)
|
||||||
|
)
|
||||||
.client(client)
|
.client(client)
|
||||||
.build()
|
.build()
|
||||||
return retrofit.create(NewMotionApi::class.java)
|
return retrofit.create(NewMotionApi::class.java)
|
||||||
@@ -86,6 +104,21 @@ interface NewMotionApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class ZonedDateTimeAdapter {
|
||||||
|
@FromJson
|
||||||
|
fun fromJson(value: String): ZonedDateTime? = ZonedDateTime.parse(value)
|
||||||
|
|
||||||
|
@ToJson
|
||||||
|
fun toJson(value: ZonedDateTime): String = value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class NmStatus(
|
||||||
|
val conn: NewMotionApi.NMConnector,
|
||||||
|
val status: String,
|
||||||
|
val evseId: String?,
|
||||||
|
val updated: ZonedDateTime?
|
||||||
|
)
|
||||||
|
|
||||||
class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
|
||||||
BaseAvailabilityDetector(client) {
|
BaseAvailabilityDetector(client) {
|
||||||
val api = NewMotionApi.create(client, baseUrl)
|
val api = NewMotionApi.create(client, baseUrl)
|
||||||
@@ -111,9 +144,9 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
|||||||
throw AvailabilityDetectorException("no candidates found")
|
throw AvailabilityDetectorException("no candidates found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nearest.evseCount < location.totalChargepoints) {
|
markers = if (nearest.evseCount < location.totalChargepoints) {
|
||||||
// combine related stations
|
// combine related stations
|
||||||
markers = markers.filter { marker ->
|
markers.filter { marker ->
|
||||||
distanceBetween(
|
distanceBetween(
|
||||||
marker.coordinates.latitude,
|
marker.coordinates.latitude,
|
||||||
marker.coordinates.longitude,
|
marker.coordinates.longitude,
|
||||||
@@ -122,7 +155,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
|||||||
) < maxDistance
|
) < maxDistance
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
markers = listOf(nearest)
|
listOf(nearest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// load details
|
// load details
|
||||||
@@ -135,14 +168,15 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
|||||||
}
|
}
|
||||||
val connectorStatus = details.flatMap { it.evses }.flatMap { evse ->
|
val connectorStatus = details.flatMap { it.evses }.flatMap { evse ->
|
||||||
evse.connectors.map { connector ->
|
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 nmConnectors = mutableMapOf<Long, Pair<Double, String>>()
|
||||||
val nmStatus = mutableMapOf<Long, ChargepointStatus>()
|
val nmStatus = mutableMapOf<Long, ChargepointStatus>()
|
||||||
val nmEvseId = mutableMapOf<Long, String>()
|
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 id = connector.uid
|
||||||
val power = connector.electricalProperties.getPower()
|
val power = connector.electricalProperties.getPower()
|
||||||
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
|
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
|
||||||
@@ -168,6 +202,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
|||||||
nmConnectors.put(id, power to type)
|
nmConnectors.put(id, power to type)
|
||||||
nmStatus.put(id, status)
|
nmStatus.put(id, status)
|
||||||
evseId?.let { nmEvseId[id] = it }
|
evseId?.let { nmEvseId[id] = it }
|
||||||
|
updated?.let { nmUpdated[id] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
val match = matchChargepoints(nmConnectors, location.chargepointsMerged)
|
val match = matchChargepoints(nmConnectors, location.chargepointsMerged)
|
||||||
@@ -177,10 +212,12 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
|||||||
val evseIds = if (nmEvseId.size == nmStatus.size) match.mapValues { entry ->
|
val evseIds = if (nmEvseId.size == nmStatus.size) match.mapValues { entry ->
|
||||||
entry.value.map { nmEvseId[it]!! }
|
entry.value.map { nmEvseId[it]!! }
|
||||||
} else null
|
} else null
|
||||||
|
val updated = match.mapValues { entry -> entry.value.map { nmUpdated[it]?.toInstant() } }
|
||||||
return ChargeLocationStatus(
|
return ChargeLocationStatus(
|
||||||
chargepointStatus,
|
chargepointStatus,
|
||||||
"NewMotion",
|
"NewMotion",
|
||||||
evseIds
|
evseIds,
|
||||||
|
lastChange = updated
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package net.vonforst.evmap.api.availability
|
||||||
|
|
||||||
|
import com.squareup.moshi.JsonDataException
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.ChargerAvailability
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.TeslaChargingGuestGraphQlApi
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.TeslaCuaApi
|
||||||
|
import net.vonforst.evmap.model.ChargeLocation
|
||||||
|
import net.vonforst.evmap.model.Chargepoint
|
||||||
|
import net.vonforst.evmap.utils.distanceBetween
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||||
|
|
||||||
|
class TeslaGuestAvailabilityDetector(
|
||||||
|
client: OkHttpClient,
|
||||||
|
baseUrl: String? = null
|
||||||
|
) :
|
||||||
|
BaseAvailabilityDetector(client) {
|
||||||
|
|
||||||
|
private var cuaApi = TeslaCuaApi.create(client, baseUrl)
|
||||||
|
private var api = TeslaChargingGuestGraphQlApi.create(client, baseUrl)
|
||||||
|
|
||||||
|
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||||
|
val results = cuaApi.getTeslaLocations()
|
||||||
|
|
||||||
|
val result =
|
||||||
|
results.minByOrNull {
|
||||||
|
if (it.latitude != null && it.longitude != null) {
|
||||||
|
distanceBetween(
|
||||||
|
it.latitude,
|
||||||
|
it.longitude,
|
||||||
|
location.coordinates.lat,
|
||||||
|
location.coordinates.lng
|
||||||
|
)
|
||||||
|
} else Double.POSITIVE_INFINITY
|
||||||
|
} ?: throw AvailabilityDetectorException("no candidates found.")
|
||||||
|
|
||||||
|
val resultDetails = try {
|
||||||
|
cuaApi.getTeslaLocation(result.locationId)
|
||||||
|
} catch (e: JsonDataException) {
|
||||||
|
// instead of a single location, this may also return an empty JSON list []. This is hard to fix with Moshi
|
||||||
|
if (e.message == "Expected BEGIN_OBJECT but was BEGIN_ARRAY at path \$") {
|
||||||
|
throw AvailabilityDetectorException("no candidates found.")
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val trtId = resultDetails.trtId?.toLongOrNull()
|
||||||
|
?: throw AvailabilityDetectorException("charger data not available through guest API")
|
||||||
|
|
||||||
|
val (detailsA, guestPricing) = coroutineScope {
|
||||||
|
val details = async {
|
||||||
|
api.getSiteDetails(
|
||||||
|
TeslaChargingGuestGraphQlApi.GetSiteDetailsRequest(
|
||||||
|
TeslaChargingGuestGraphQlApi.GetSiteDetailsVariables(
|
||||||
|
TeslaChargingGuestGraphQlApi.Identifier(
|
||||||
|
TeslaChargingGuestGraphQlApi.ChargingSiteIdentifier(
|
||||||
|
trtId, TeslaChargingGuestGraphQlApi.Experience.ADHOC
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).data.chargingNetwork?.site
|
||||||
|
?: throw AvailabilityDetectorException("no candidates found.")
|
||||||
|
}
|
||||||
|
val guestPricing = async {
|
||||||
|
api.getSiteDetails(
|
||||||
|
TeslaChargingGuestGraphQlApi.GetSiteDetailsRequest(
|
||||||
|
TeslaChargingGuestGraphQlApi.GetSiteDetailsVariables(
|
||||||
|
TeslaChargingGuestGraphQlApi.Identifier(
|
||||||
|
TeslaChargingGuestGraphQlApi.ChargingSiteIdentifier(
|
||||||
|
trtId, TeslaChargingGuestGraphQlApi.Experience.GUEST
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).data.chargingNetwork?.site?.pricing
|
||||||
|
}
|
||||||
|
details to guestPricing
|
||||||
|
}
|
||||||
|
val details = detailsA.await()
|
||||||
|
|
||||||
|
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
|
||||||
|
val scV2CCSConnectors = location.chargepoints.filter {
|
||||||
|
it.type in listOf(
|
||||||
|
Chargepoint.CCS_TYPE_2,
|
||||||
|
Chargepoint.CCS_UNKNOWN
|
||||||
|
) && it.power != null && it.power <= 150
|
||||||
|
}
|
||||||
|
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
|
||||||
|
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
|
||||||
|
}
|
||||||
|
val scV3Connectors = location.chargepoints.filter {
|
||||||
|
it.type in listOf(
|
||||||
|
Chargepoint.CCS_TYPE_2,
|
||||||
|
Chargepoint.CCS_UNKNOWN
|
||||||
|
) && it.power != null && it.power > 150
|
||||||
|
}
|
||||||
|
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
|
||||||
|
"charger has unknown connectors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var detailsSorted = details.chargerList
|
||||||
|
.sortedBy { c -> c.labelLetter }
|
||||||
|
.sortedBy { c -> c.labelNumber }
|
||||||
|
|
||||||
|
if (detailsSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
|
||||||
|
// apparently some connectors are missing in Tesla data
|
||||||
|
// If we have just one type of charger, we can still match
|
||||||
|
val numMissing =
|
||||||
|
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - detailsSorted.size
|
||||||
|
if ((scV2Connectors.isEmpty() || scV3Connectors.isEmpty()) && numMissing > 0) {
|
||||||
|
detailsSorted =
|
||||||
|
detailsSorted + List(numMissing) {
|
||||||
|
TeslaChargingGuestGraphQlApi.ChargerDetail(
|
||||||
|
ChargerAvailability.UNKNOWN,
|
||||||
|
"", ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val detailsMap =
|
||||||
|
mutableMapOf<Chargepoint, List<TeslaChargingGuestGraphQlApi.ChargerDetail>>()
|
||||||
|
var i = 0
|
||||||
|
for (connector in scV2Connectors) {
|
||||||
|
detailsMap[connector] =
|
||||||
|
detailsSorted.subList(i, i + connector.count)
|
||||||
|
i += connector.count
|
||||||
|
}
|
||||||
|
if (scV2CCSConnectors.isNotEmpty()) {
|
||||||
|
i = 0
|
||||||
|
for (connector in scV2CCSConnectors) {
|
||||||
|
detailsMap[connector] =
|
||||||
|
detailsSorted.subList(i, i + connector.count)
|
||||||
|
i += connector.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (connector in scV3Connectors) {
|
||||||
|
detailsMap[connector] =
|
||||||
|
detailsSorted.subList(i, i + connector.count)
|
||||||
|
i += connector.count
|
||||||
|
}
|
||||||
|
|
||||||
|
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
|
||||||
|
val labelsMap = detailsMap.mapValues { it.value.map { it.label } }
|
||||||
|
|
||||||
|
val pricing = details.pricing.copy(memberRates = guestPricing.await()?.userRates)
|
||||||
|
|
||||||
|
return ChargeLocationStatus(
|
||||||
|
statusMap,
|
||||||
|
"Tesla",
|
||||||
|
labels = labelsMap,
|
||||||
|
extraData = pricing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||||
|
return when (charger.dataSource) {
|
||||||
|
"goingelectric" -> charger.network == "Tesla Supercharger"
|
||||||
|
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package net.vonforst.evmap.api.availability
|
||||||
|
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.ChargerAvailability
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.TeslaChargingOwnershipGraphQlApi
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.asTeslaCoord
|
||||||
|
import net.vonforst.evmap.model.ChargeLocation
|
||||||
|
import net.vonforst.evmap.model.Chargepoint
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||||
|
|
||||||
|
class TeslaOwnerAvailabilityDetector(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
private val tokenStore: TokenStore,
|
||||||
|
private val baseUrl: String? = null
|
||||||
|
) :
|
||||||
|
BaseAvailabilityDetector(client) {
|
||||||
|
|
||||||
|
private val authApi = TeslaAuthenticationApi.create(client, null)
|
||||||
|
private var api: TeslaChargingOwnershipGraphQlApi? = null
|
||||||
|
|
||||||
|
interface TokenStore {
|
||||||
|
var teslaRefreshToken: String?
|
||||||
|
var teslaAccessToken: String?
|
||||||
|
var teslaAccessTokenExpiry: Long
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||||
|
val api = initApi()
|
||||||
|
val req = TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesRequest(
|
||||||
|
TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesVariables(
|
||||||
|
TeslaChargingOwnershipGraphQlApi.GetNearbyChargingSitesArgs(
|
||||||
|
location.coordinates.asTeslaCoord(),
|
||||||
|
TeslaChargingOwnershipGraphQlApi.Coordinate(
|
||||||
|
location.coordinates.lat + coordRange,
|
||||||
|
location.coordinates.lng - coordRange
|
||||||
|
),
|
||||||
|
TeslaChargingOwnershipGraphQlApi.Coordinate(
|
||||||
|
location.coordinates.lat - coordRange,
|
||||||
|
location.coordinates.lng + coordRange
|
||||||
|
),
|
||||||
|
TeslaChargingOwnershipGraphQlApi.OpenToNonTeslasFilterValue(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val results = api.getNearbyChargingSites(
|
||||||
|
req,
|
||||||
|
req.operationName
|
||||||
|
).data.charging?.nearbySites?.sitesAndDistances
|
||||||
|
?: throw AvailabilityDetectorException("no candidates found.")
|
||||||
|
val result =
|
||||||
|
results.minByOrNull { it.haversineDistanceMiles.value }
|
||||||
|
?: throw AvailabilityDetectorException("no candidates found.")
|
||||||
|
|
||||||
|
val details = api.getChargingSiteInformation(
|
||||||
|
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationRequest(
|
||||||
|
TeslaChargingOwnershipGraphQlApi.GetChargingSiteInformationVariables(
|
||||||
|
TeslaChargingOwnershipGraphQlApi.ChargingSiteIdentifier(result.locationGUID),
|
||||||
|
TeslaChargingOwnershipGraphQlApi.VehicleMakeType.NON_TESLA
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).data.charging.site ?: throw AvailabilityDetectorException("no candidates found.")
|
||||||
|
|
||||||
|
|
||||||
|
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
|
||||||
|
val scV2CCSConnectors = location.chargepoints.filter {
|
||||||
|
it.type in listOf(
|
||||||
|
Chargepoint.CCS_TYPE_2,
|
||||||
|
Chargepoint.CCS_UNKNOWN
|
||||||
|
) && it.power != null && it.power <= 150
|
||||||
|
}
|
||||||
|
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
|
||||||
|
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
|
||||||
|
}
|
||||||
|
val scV3Connectors = location.chargepoints.filter {
|
||||||
|
it.type in listOf(
|
||||||
|
Chargepoint.CCS_TYPE_2,
|
||||||
|
Chargepoint.CCS_UNKNOWN
|
||||||
|
) && it.power != null && it.power > 150
|
||||||
|
}
|
||||||
|
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
|
||||||
|
"charger has unknown connectors"
|
||||||
|
)
|
||||||
|
|
||||||
|
val chargerDetails = details.siteDynamic.chargerDetails
|
||||||
|
val chargers = details.siteStatic.chargers.associateBy { it.id }
|
||||||
|
var detailsSorted = chargerDetails
|
||||||
|
.sortedBy { c -> c.charger.labelLetter ?: chargers[c.charger.id]?.labelLetter }
|
||||||
|
.sortedBy { c -> c.charger.labelNumber ?: chargers[c.charger.id]?.labelNumber }
|
||||||
|
if (detailsSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
|
||||||
|
// apparently some connectors are missing in Tesla data
|
||||||
|
// If we have just one type of charger, we can still match
|
||||||
|
val numMissing =
|
||||||
|
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - detailsSorted.size
|
||||||
|
if ((scV2Connectors.isEmpty() || scV3Connectors.isEmpty()) && numMissing > 0) {
|
||||||
|
detailsSorted =
|
||||||
|
detailsSorted + List(numMissing) {
|
||||||
|
TeslaChargingOwnershipGraphQlApi.ChargerDetail(
|
||||||
|
ChargerAvailability.UNKNOWN,
|
||||||
|
TeslaChargingOwnershipGraphQlApi.ChargerId(
|
||||||
|
TeslaChargingOwnershipGraphQlApi.Text(""),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val detailsMap =
|
||||||
|
emptyMap<Chargepoint, List<TeslaChargingOwnershipGraphQlApi.ChargerDetail>>().toMutableMap()
|
||||||
|
var i = 0
|
||||||
|
for (connector in scV2Connectors) {
|
||||||
|
detailsMap[connector] =
|
||||||
|
detailsSorted.subList(i, i + connector.count)
|
||||||
|
i += connector.count
|
||||||
|
}
|
||||||
|
if (scV2CCSConnectors.isNotEmpty()) {
|
||||||
|
i = 0
|
||||||
|
for (connector in scV2CCSConnectors) {
|
||||||
|
detailsMap[connector] =
|
||||||
|
detailsSorted.subList(i, i + connector.count)
|
||||||
|
i += connector.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (connector in scV3Connectors) {
|
||||||
|
detailsMap[connector] =
|
||||||
|
detailsSorted.subList(i, i + connector.count)
|
||||||
|
i += connector.count
|
||||||
|
}
|
||||||
|
|
||||||
|
val congestionHistogram = details.congestionPriceHistogram?.let { cph ->
|
||||||
|
val indexOfMidnight = cph.dataAttributes.indexOfFirst { it.label == "12AM" }
|
||||||
|
indexOfMidnight.takeIf { it >= 0 }?.let { index ->
|
||||||
|
val data = cph.data.toMutableList()
|
||||||
|
Collections.rotate(data, -index)
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
|
||||||
|
val labelsMap = detailsMap.mapValues {
|
||||||
|
it.value.map {
|
||||||
|
it.charger.label?.value ?: chargers[it.charger.id]?.label?.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChargeLocationStatus(
|
||||||
|
statusMap,
|
||||||
|
"Tesla",
|
||||||
|
labels = labelsMap,
|
||||||
|
congestionHistogram = congestionHistogram,
|
||||||
|
extraData = details.pricing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||||
|
return when (charger.dataSource) {
|
||||||
|
"goingelectric" -> charger.network == "Tesla Supercharger"
|
||||||
|
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun initApi(): TeslaChargingOwnershipGraphQlApi {
|
||||||
|
|
||||||
|
return api ?: run {
|
||||||
|
val newApi = TeslaChargingOwnershipGraphQlApi.create(client, baseUrl) {
|
||||||
|
val now = Instant.now().epochSecond
|
||||||
|
val token =
|
||||||
|
tokenStore.teslaAccessToken.takeIf { tokenStore.teslaAccessTokenExpiry > now }
|
||||||
|
?: run {
|
||||||
|
val refreshToken = tokenStore.teslaRefreshToken
|
||||||
|
?: throw NotSignedInException()
|
||||||
|
val response =
|
||||||
|
authApi.getToken(
|
||||||
|
TeslaAuthenticationApi.RefreshTokenRequest(
|
||||||
|
refreshToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tokenStore.teslaAccessToken = response.accessToken
|
||||||
|
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
|
||||||
|
response.accessToken
|
||||||
|
}
|
||||||
|
token
|
||||||
|
}
|
||||||
|
api = newApi
|
||||||
|
newApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSignedIn() = tokenStore.teslaRefreshToken != null
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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,153 @@
|
|||||||
|
package net.vonforst.evmap.api.availability.tesla
|
||||||
|
|
||||||
|
import com.squareup.moshi.Json
|
||||||
|
import com.squareup.moshi.JsonClass
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Query
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
interface TeslaCuaApi {
|
||||||
|
@GET("tesla-locations")
|
||||||
|
suspend fun getTeslaLocations(
|
||||||
|
@Query("translate") translate: String = "en_US",
|
||||||
|
@Query("usetrt") usetrt: Boolean = true,
|
||||||
|
): List<TeslaLocation>
|
||||||
|
|
||||||
|
@GET("tesla-location")
|
||||||
|
suspend fun getTeslaLocation(
|
||||||
|
@Query("id") id: String,
|
||||||
|
@Query("translate") translate: String = "en_US",
|
||||||
|
@Query("usetrt") usetrt: Boolean = true
|
||||||
|
): TeslaLocation
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class TeslaLocation(
|
||||||
|
val latitude: Double?,
|
||||||
|
val longitude: Double?,
|
||||||
|
@Json(name = "location_id") val locationId: String,
|
||||||
|
val title: String?,
|
||||||
|
@Json(name = "location_type") val locationType: List<String>,
|
||||||
|
val trtId: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(
|
||||||
|
client: OkHttpClient,
|
||||||
|
baseUrl: String? = null
|
||||||
|
): TeslaCuaApi {
|
||||||
|
val clientWithInterceptor = client.newBuilder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
// increase cache duration to 24h (useful for the large getTeslaLocations request)
|
||||||
|
val request = chain.request().newBuilder()
|
||||||
|
.cacheControl(CacheControl.Builder().maxStale(24, TimeUnit.HOURS).build())
|
||||||
|
.build()
|
||||||
|
chain.proceed(request)
|
||||||
|
}.build()
|
||||||
|
val retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(baseUrl ?: "https://www.tesla.com/cua-api/")
|
||||||
|
.addConverterFactory(
|
||||||
|
MoshiConverterFactory.create(
|
||||||
|
Moshi.Builder().add(LocalTimeAdapter()).build()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.client(clientWithInterceptor)
|
||||||
|
.build()
|
||||||
|
return retrofit.create(TeslaCuaApi::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeslaChargingGuestGraphQlApi {
|
||||||
|
@POST("graphql")
|
||||||
|
suspend fun getSiteDetails(
|
||||||
|
@Body request: GetSiteDetailsRequest,
|
||||||
|
@Query("operationName") operationName: String = "GetSiteDetails"
|
||||||
|
): GetChargingSiteDetailsResponse
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class GetSiteDetailsRequest(
|
||||||
|
override val variables: GetSiteDetailsVariables,
|
||||||
|
override val operationName: String = "GetSiteDetails",
|
||||||
|
override val query: String =
|
||||||
|
"\n query GetSiteDetails(\$siteId: SiteIdInput!) {\n chargingNetwork {\n site(siteId: \$siteId) {\n address {\n countryCode\n }\n chargerList {\n id\n label\n availability\n }\n holdAmount {\n amount\n currencyCode\n }\n maxPowerKw\n name\n programType\n publicStallCount\n trtId\n pricing {\n userRates {\n activePricebook {\n charging {\n ...ChargingRate\n }\n parking {\n ...ChargingRate\n }\n congestion {\n ...ChargingRate\n }\n }\n }\n }\n }\n }\n}\n \n fragment ChargingRate on ChargingUserRate {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n}\n "
|
||||||
|
) : GraphQlRequest()
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class GetSiteDetailsVariables(
|
||||||
|
val siteId: Identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class Experience {
|
||||||
|
ADHOC, GUEST
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class Identifier(
|
||||||
|
val byTrtId: ChargingSiteIdentifier
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class ChargingSiteIdentifier(
|
||||||
|
val trtId: Long,
|
||||||
|
val chargingExperience: Experience,
|
||||||
|
val programType: String = "PTSCH",
|
||||||
|
val locale: String = "de-DE",
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class GetChargingSiteDetailsResponse(val data: GetChargingSiteDetailsResponseDataNetwork)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class GetChargingSiteDetailsResponseDataNetwork(val chargingNetwork: GetChargingSiteDetailsResponseData?)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class GetChargingSiteDetailsResponseData(val site: ChargingSiteInformation?)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class ChargingSiteInformation(
|
||||||
|
val activeOutages: List<Outage>?,
|
||||||
|
val chargerList: List<ChargerDetail>,
|
||||||
|
val trtId: Long,
|
||||||
|
val maxPowerKw: Int,
|
||||||
|
val name: String,
|
||||||
|
val pricing: Pricing,
|
||||||
|
val publicStallCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class ChargerDetail(
|
||||||
|
val availability: ChargerAvailability,
|
||||||
|
val label: String?,
|
||||||
|
val id: String
|
||||||
|
) {
|
||||||
|
val labelNumber
|
||||||
|
get() = label?.replace(Regex("""\D"""), "")?.toInt()
|
||||||
|
val labelLetter
|
||||||
|
get() = label?.replace(Regex("""\d"""), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(
|
||||||
|
client: OkHttpClient,
|
||||||
|
baseUrl: String? = null
|
||||||
|
): TeslaChargingGuestGraphQlApi {
|
||||||
|
val retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(baseUrl ?: "https://www.tesla.com/de_DE/charging/guest/api/")
|
||||||
|
.addConverterFactory(
|
||||||
|
MoshiConverterFactory.create(
|
||||||
|
Moshi.Builder().add(LocalTimeAdapter()).build()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.client(client)
|
||||||
|
.build()
|
||||||
|
return retrofit.create(TeslaChargingGuestGraphQlApi::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
package net.vonforst.evmap.api.availability
|
package net.vonforst.evmap.api.availability.tesla
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import com.squareup.moshi.FromJson
|
|
||||||
import com.squareup.moshi.Json
|
import com.squareup.moshi.Json
|
||||||
import com.squareup.moshi.JsonClass
|
import com.squareup.moshi.JsonClass
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.ToJson
|
|
||||||
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
|
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import net.vonforst.evmap.model.ChargeLocation
|
|
||||||
import net.vonforst.evmap.model.Chargepoint
|
|
||||||
import net.vonforst.evmap.model.Coordinate
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||||
@@ -18,14 +14,8 @@ import retrofit2.http.Body
|
|||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import java.io.IOException
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.time.Instant
|
|
||||||
import java.time.LocalTime
|
|
||||||
import java.util.Collections
|
|
||||||
|
|
||||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
|
||||||
|
|
||||||
interface TeslaAuthenticationApi {
|
interface TeslaAuthenticationApi {
|
||||||
@POST("oauth2/v3/token")
|
@POST("oauth2/v3/token")
|
||||||
@@ -102,6 +92,18 @@ interface TeslaAuthenticationApi {
|
|||||||
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +130,8 @@ interface TeslaOwnerApi {
|
|||||||
// add API key to every request
|
// add API key to every request
|
||||||
val request = chain.request().newBuilder()
|
val request = chain.request().newBuilder()
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.header("User-Agent", "okhttp/4.9.2")
|
.header("User-Agent", "okhttp/4.11.0")
|
||||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
.header("x-tesla-user-agent", "TeslaApp/4.44.5-3304/3a5d531cc3/android/27")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
.build()
|
.build()
|
||||||
chain.proceed(request)
|
chain.proceed(request)
|
||||||
@@ -144,7 +146,7 @@ interface TeslaOwnerApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeslaGraphQlApi {
|
interface TeslaChargingOwnershipGraphQlApi {
|
||||||
@POST("/graphql")
|
@POST("/graphql")
|
||||||
suspend fun getNearbyChargingSites(
|
suspend fun getNearbyChargingSites(
|
||||||
@Body request: GetNearbyChargingSitesRequest,
|
@Body request: GetNearbyChargingSitesRequest,
|
||||||
@@ -170,7 +172,7 @@ interface TeslaGraphQlApi {
|
|||||||
override val variables: GetNearbyChargingSitesVariables,
|
override val variables: GetNearbyChargingSitesVariables,
|
||||||
override val operationName: String = "GetNearbyChargingSites",
|
override val operationName: String = "GetNearbyChargingSites",
|
||||||
override val query: String =
|
override val query: String =
|
||||||
"\n query GetNearbyChargingSites(\$args: GetNearbyChargingSitesRequestType!) {\n charging {\n nearbySites(args: \$args) {\n sitesAndDistances {\n ...ChargingNearbySitesFragment\n }\n }\n }\n}\n \n fragment ChargingNearbySitesFragment on ChargerSiteAndDistanceType {\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n availableStalls {\n value\n }\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n drivingDistanceMiles {\n value\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n haversineDistanceMiles {\n value\n }\n id {\n text\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n trtId {\n value\n }\n totalStalls {\n value\n }\n siteType\n accessType\n waitEstimateBucket\n hasHighCongestion\n}\n \n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n "
|
"\n query GetNearbyChargingSites(\$args: GetNearbyChargingSitesRequestType!) {\n charging {\n nearbySites(args: \$args) {\n sitesAndDistances {\n ...ChargingNearbySitesFragment\n }\n }\n }\n}\n \n fragment ChargingNearbySitesFragment on ChargerSiteAndDistanceType {\n availableStalls {\n value\n }\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n drivingDistanceMiles {\n value\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n haversineDistanceMiles {\n value\n }\n id {\n text\n }\n locationGUID\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n trtId {\n value\n }\n totalStalls {\n value\n }\n siteType\n accessType\n waitEstimateBucket\n hasHighCongestion\n teslaExclusive\n amenities\n chargingAccessibility\n ownerType\n isThirdPartySite\n usabilityArchetype\n accessHours {\n shouldDisplay\n openNow\n hour\n }\n isMagicDockSupportedSite\n hasParkingBenefit\n hasTou\n}\n \n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n"
|
||||||
) : GraphQlRequest()
|
) : GraphQlRequest()
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
@@ -199,7 +201,7 @@ interface TeslaGraphQlApi {
|
|||||||
override val variables: GetChargingSiteInformationVariables,
|
override val variables: GetChargingSiteInformationVariables,
|
||||||
override val operationName: String = "getChargingSiteInformation",
|
override val operationName: String = "getChargingSiteInformation",
|
||||||
override val query: String =
|
override val query: String =
|
||||||
"\n query getChargingSiteInformation(\$id: ChargingSiteIdentifierInputType!, \$vehicleMakeType: ChargingVehicleMakeTypeEnum, \$deviceCountry: String!, \$deviceLanguage: String!) {\n charging {\n site(\n id: \$id\n deviceCountry: \$deviceCountry\n deviceLanguage: \$deviceLanguage\n vehicleMakeType: \$vehicleMakeType\n ) {\n siteStatic {\n ...SiteStaticFragmentNoHoldAmt\n }\n siteDynamic {\n ...SiteDynamicFragment\n }\n pricing(vehicleMakeType: \$vehicleMakeType) {\n userRates {\n ...ChargingActiveRateFragment\n }\n memberRates {\n ...ChargingActiveRateFragment\n }\n hasMembershipPricing\n hasMSPPricing\n canDisplayCombinedComparison\n }\n holdAmount(vehicleMakeType: \$vehicleMakeType) {\n currencyCode\n holdAmount\n }\n congestionPriceHistogram(vehicleMakeType: \$vehicleMakeType) {\n ...CongestionPriceHistogramFragment\n }\n }\n }\n}\n \n fragment SiteStaticFragmentNoHoldAmt on ChargingSiteStaticType {\n address {\n ...AddressFragment\n }\n amenities\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n id {\n text\n }\n accessCode {\n value\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n name\n openToPublic\n chargers {\n id {\n text\n }\n label {\n value\n }\n }\n publicStallCount\n timeZone {\n id\n version\n }\n fastchargeSiteId {\n value\n }\n siteType\n accessType\n isMagicDockSupportedSite\n trtId {\n value\n }\n}\n \n fragment AddressFragment on EnergySvcAddressType {\n streetNumber {\n value\n }\n street {\n value\n }\n district {\n value\n }\n city {\n value\n }\n state {\n value\n }\n postalCode {\n value\n }\n country\n}\n \n\n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n \n\n fragment SiteDynamicFragment on ChargingSiteDynamicType {\n id {\n text\n }\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n chargersAvailable {\n value\n }\n chargerDetails {\n charger {\n id {\n text\n }\n label {\n value\n }\n name\n }\n availability\n }\n waitEstimateBucket\n currentCongestion\n}\n \n\n fragment ChargingActiveRateFragment on ChargingActiveRateType {\n activePricebook {\n charging {\n ...ChargingUserRateFragment\n }\n parking {\n ...ChargingUserRateFragment\n }\n priceBookID\n }\n}\n \n fragment ChargingUserRateFragment on ChargingUserRateType {\n currencyCode\n programType\n rates\n buckets {\n start\n end\n }\n bucketUom\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n uom\n vehicleMakeType\n}\n \n\n fragment CongestionPriceHistogramFragment on HistogramData {\n axisLabels {\n index\n value\n }\n regionLabels {\n index\n value {\n ...ChargingPriceFragment\n ... on HistogramRegionLabelValueString {\n value\n }\n }\n }\n chargingUom\n parkingUom\n parkingRate {\n ...ChargingPriceFragment\n }\n data\n activeBar\n maxRateIndex\n whenRateChanges\n dataAttributes {\n congestionThreshold\n label\n }\n}\n \n fragment ChargingPriceFragment on ChargingPrice {\n currencyCode\n price\n}\n"
|
"\n query getChargingSiteInformation(\$id: ChargingSiteIdentifierInputType!, \$vehicleMakeType: ChargingVehicleMakeTypeEnum, \$deviceCountry: String!, \$deviceLanguage: String!) {\n charging {\n site(\n id: \$id\n deviceCountry: \$deviceCountry\n deviceLanguage: \$deviceLanguage\n vehicleMakeType: \$vehicleMakeType\n ) {\n siteStatic {\n ...SiteStaticFragmentNoHoldAmt\n }\n siteDynamic {\n ...SiteDynamicFragment\n }\n pricing(vehicleMakeType: \$vehicleMakeType) {\n userRates {\n ...ChargingActiveRateFragment\n }\n memberRates {\n ...ChargingActiveRateFragment\n }\n hasMembershipPricing\n hasMSPPricing\n canDisplayCombinedComparison\n }\n holdAmount(vehicleMakeType: \$vehicleMakeType) {\n currencyCode\n holdAmount\n }\n congestionPriceHistogram(vehicleMakeType: \$vehicleMakeType) {\n ...CongestionPriceHistogramFragment\n }\n upsellingBanner(vehicleMakeType: \$vehicleMakeType) {\n header\n caption\n backgroundImageUrl\n routeName\n }\n nacsOnlyAssets {\n banner {\n header\n caption\n link\n }\n disclaimer {\n text\n sheetTitle\n sheetContent\n }\n }\n enableChargingSiteReportIssue\n }\n }\n}\n \n fragment SiteStaticFragmentNoHoldAmt on ChargingSiteStaticType {\n address {\n ...AddressFragment\n }\n amenities\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n id {\n text\n }\n locationGUID\n accessCode {\n value\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n name\n openToPublic\n chargers {\n id {\n text\n }\n label {\n value\n }\n }\n publicStallCount\n timeZone {\n id\n version\n }\n fastchargeSiteId {\n value\n }\n siteType\n accessType\n isThirdPartySite\n isMagicDockSupportedSite\n trtId {\n value\n }\n siteDisclaimer\n chargingAccessibility\n accessHours {\n shouldDisplay\n openNow\n hour\n }\n isCanvasSite\n ownerDisclaimer\n chargingFeesDisclaimer {\n title\n description\n }\n idleFeesDisclaimer {\n title\n description\n }\n}\n \n fragment AddressFragment on EnergySvcAddressType {\n streetNumber {\n value\n }\n street {\n value\n }\n district {\n value\n }\n city {\n value\n }\n state {\n value\n }\n postalCode {\n value\n }\n country\n}\n \n\n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n \n\n fragment SiteDynamicFragment on ChargingSiteDynamicType {\n id {\n text\n }\n chargersAvailable {\n value\n }\n chargerDetails {\n charger {\n id {\n text\n }\n label {\n value\n }\n name\n }\n availability\n stateOfCharge\n chargerDisabled\n }\n waitEstimateBucket\n currentCongestion\n usabilityArchetype\n}\n \n\n fragment ChargingActiveRateFragment on ChargingActiveRateType {\n activePricebook {\n charging {\n dynamicRates {\n enabled\n }\n ...ChargingUserRateFragment\n }\n parking {\n ...ChargingUserRateFragment\n }\n congestion {\n ...ChargingUserRateFragment\n }\n service {\n ...ChargingUserRateFragment\n }\n electricity {\n ...ChargingUserRateFragment\n }\n priceBookID\n }\n}\n \n fragment ChargingUserRateFragment on ChargingUserRateType {\n currencyCode\n programType\n rates\n buckets {\n start\n end\n }\n bucketUom\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n uom\n vehicleMakeType\n stateOfCharge\n congestionGracePeriodSecs\n congestionPercent\n}\n \n\n fragment CongestionPriceHistogramFragment on HistogramData {\n axisLabels {\n index\n value\n }\n regionLabels {\n index\n value {\n ...ChargingPriceFragment\n ... on HistogramRegionLabelValueString {\n value\n }\n }\n }\n chargingUom\n parkingUom\n parkingRate {\n ...ChargingPriceFragment\n }\n data\n activeBar\n maxRateIndex\n whenRateChanges\n dataAttributes {\n congestionThreshold\n label\n }\n}\n \n fragment ChargingPriceFragment on ChargingPrice {\n currencyCode\n price\n}\n"
|
||||||
) : GraphQlRequest()
|
) : GraphQlRequest()
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
@@ -214,28 +216,22 @@ interface TeslaGraphQlApi {
|
|||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class ChargingSiteIdentifier(
|
data class ChargingSiteIdentifier(
|
||||||
val id: String,
|
val id: String,
|
||||||
val type: ChargingSiteIdentifierType = ChargingSiteIdentifierType.SITE_ID
|
val type: ChargingSiteIdentifierType = ChargingSiteIdentifierType.LOCATION_GUID
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ChargingSiteIdentifierType {
|
enum class ChargingSiteIdentifierType {
|
||||||
SITE_ID
|
SITE_ID, LOCATION_GUID
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class VehicleMakeType {
|
enum class VehicleMakeType {
|
||||||
TESLA, NON_TESLA
|
TESLA, NON_TESLA
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class GraphQlRequest {
|
|
||||||
abstract val operationName: String
|
|
||||||
abstract val query: String
|
|
||||||
abstract val variables: Any?
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class GetNearbyChargingSitesResponse(val data: GetNearbyChargingSitesResponseData)
|
data class GetNearbyChargingSitesResponse(val data: GetNearbyChargingSitesResponseData)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class GetNearbyChargingSitesResponseData(val charging: GetNearbyChargingSitesResponseDataCharging)
|
data class GetNearbyChargingSitesResponseData(val charging: GetNearbyChargingSitesResponseDataCharging?)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class GetNearbyChargingSitesResponseDataCharging(val nearbySites: GetNearbyChargingSitesResponseDataChargingNearbySites)
|
data class GetNearbyChargingSitesResponseDataCharging(val nearbySites: GetNearbyChargingSitesResponseDataChargingNearbySites)
|
||||||
@@ -245,7 +241,6 @@ interface TeslaGraphQlApi {
|
|||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class ChargingSite(
|
data class ChargingSite(
|
||||||
val activeOutages: List<Outage>,
|
|
||||||
val availableStalls: Value<Int>?,
|
val availableStalls: Value<Int>?,
|
||||||
val centroid: Coordinate,
|
val centroid: Coordinate,
|
||||||
val drivingDistanceMiles: Value<Double>?,
|
val drivingDistanceMiles: Value<Double>?,
|
||||||
@@ -254,19 +249,11 @@ interface TeslaGraphQlApi {
|
|||||||
val id: Text,
|
val id: Text,
|
||||||
val localizedSiteName: Value<String>,
|
val localizedSiteName: Value<String>,
|
||||||
val maxPowerKw: Value<Int>,
|
val maxPowerKw: Value<Int>,
|
||||||
val totalStalls: Value<Int>
|
val totalStalls: Value<Int>,
|
||||||
|
val locationGUID: String
|
||||||
// TODO: siteType, accessType
|
// TODO: siteType, accessType
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class Outage(val message: String /* TODO: */)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class Value<T : Any>(val value: T)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class Text(val text: String)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class GetChargingSiteInformationResponse(val data: GetChargingSiteInformationResponseData)
|
data class GetChargingSiteInformationResponse(val data: GetChargingSiteInformationResponseData)
|
||||||
|
|
||||||
@@ -274,7 +261,7 @@ interface TeslaGraphQlApi {
|
|||||||
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
|
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation)
|
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation?)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class ChargingSiteInformation(
|
data class ChargingSiteInformation(
|
||||||
@@ -286,7 +273,6 @@ interface TeslaGraphQlApi {
|
|||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class SiteDynamic(
|
data class SiteDynamic(
|
||||||
val activeOutages: List<Outage>,
|
|
||||||
val chargerDetails: List<ChargerDetail>,
|
val chargerDetails: List<ChargerDetail>,
|
||||||
val chargersAvailable: Value<Int>?,
|
val chargersAvailable: Value<Int>?,
|
||||||
val currentCongestion: Double,
|
val currentCongestion: Double,
|
||||||
@@ -294,24 +280,24 @@ interface TeslaGraphQlApi {
|
|||||||
val waitEstimateBucket: WaitEstimateBucket
|
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)
|
@JsonClass(generateAdapter = true)
|
||||||
data class ChargerDetail(
|
data class ChargerDetail(
|
||||||
val availability: ChargerAvailability,
|
val availability: ChargerAvailability,
|
||||||
val charger: ChargerId
|
val charger: ChargerId
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class ChargerId(
|
|
||||||
val id: Text,
|
|
||||||
val label: Value<String>,
|
|
||||||
val name: String?
|
|
||||||
) {
|
|
||||||
val labelNumber
|
|
||||||
get() = label.value.replace(Regex("""\D"""), "").toInt()
|
|
||||||
val labelLetter
|
|
||||||
get() = label.value.replace(Regex("""\d"""), "")
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class SiteStatic(
|
data class SiteStatic(
|
||||||
val accessCode: Value<String>?,
|
val accessCode: Value<String>?,
|
||||||
@@ -329,58 +315,6 @@ interface TeslaGraphQlApi {
|
|||||||
// TODO: siteType, accessType, address, amenities, timeZone
|
// TODO: siteType, accessType, address, amenities, timeZone
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class Pricing(
|
|
||||||
val canDisplayCombinedComparison: Boolean,
|
|
||||||
val hasMSPPricing: Boolean,
|
|
||||||
val hasMembershipPricing: Boolean,
|
|
||||||
val memberRates: Rates?, // rates for Tesla drivers & non-Tesla drivers with subscription
|
|
||||||
val userRates: Rates? // rates without subscription
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class Rates(
|
|
||||||
val activePricebook: Pricebook
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class Pricebook(
|
|
||||||
val charging: PricebookDetails,
|
|
||||||
val parking: PricebookDetails,
|
|
||||||
val priceBookID: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class PricebookDetails(
|
|
||||||
val bucketUom: String, // unit of measurement for buckets (typically "kw")
|
|
||||||
val buckets: List<Bucket>, // buckets of charging power (used for minute-based pricing)
|
|
||||||
val currencyCode: String,
|
|
||||||
val programType: String,
|
|
||||||
val rates: List<Double>,
|
|
||||||
val touRates: TouRates,
|
|
||||||
val uom: String, // unit of measurement ("kwh" or "min")
|
|
||||||
val vehicleMakeType: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class Bucket(
|
|
||||||
val start: Int,
|
|
||||||
val end: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class TouRates(
|
|
||||||
val activeRatesByTime: List<ActiveRatesByTime>,
|
|
||||||
val enabled: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class ActiveRatesByTime(
|
|
||||||
val startTime: LocalTime,
|
|
||||||
val endTime: LocalTime,
|
|
||||||
val rates: List<Double>
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class CongestionPriceHistogram(
|
data class CongestionPriceHistogram(
|
||||||
val data: List<Double>,
|
val data: List<Double>,
|
||||||
@@ -393,25 +327,11 @@ interface TeslaGraphQlApi {
|
|||||||
val label: String // "1AM", "2AM", etc.
|
val label: String // "1AM", "2AM", etc.
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ChargerAvailability {
|
@JsonClass(generateAdapter = true)
|
||||||
@Json(name = "CHARGER_AVAILABILITY_AVAILABLE")
|
data class Value<T : Any>(val value: T)
|
||||||
AVAILABLE,
|
|
||||||
|
|
||||||
@Json(name = "CHARGER_AVAILABILITY_OCCUPIED")
|
@JsonClass(generateAdapter = true)
|
||||||
OCCUPIED,
|
data class Text(val text: String)
|
||||||
|
|
||||||
@Json(name = "CHARGER_AVAILABILITY_DOWN")
|
|
||||||
DOWN,
|
|
||||||
@Json(name = "CHARGER_AVAILABILITY_UNKNOWN")
|
|
||||||
UNKNOWN;
|
|
||||||
|
|
||||||
fun toStatus() = when (this) {
|
|
||||||
AVAILABLE -> ChargepointStatus.AVAILABLE
|
|
||||||
OCCUPIED -> ChargepointStatus.OCCUPIED
|
|
||||||
DOWN -> ChargepointStatus.FAULTED
|
|
||||||
UNKNOWN -> ChargepointStatus.UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class WaitEstimateBucket {
|
enum class WaitEstimateBucket {
|
||||||
@Json(name = "WAIT_ESTIMATE_BUCKET_NO_WAIT")
|
@Json(name = "WAIT_ESTIMATE_BUCKET_NO_WAIT")
|
||||||
@@ -432,6 +352,9 @@ interface TeslaGraphQlApi {
|
|||||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
|
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
|
||||||
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")
|
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
|
||||||
UNKNOWN
|
UNKNOWN
|
||||||
}
|
}
|
||||||
@@ -441,15 +364,15 @@ interface TeslaGraphQlApi {
|
|||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
baseUrl: String? = null,
|
baseUrl: String? = null,
|
||||||
token: suspend () -> String
|
token: suspend () -> String
|
||||||
): TeslaGraphQlApi {
|
): TeslaChargingOwnershipGraphQlApi {
|
||||||
val clientWithInterceptor = client.newBuilder()
|
val clientWithInterceptor = client.newBuilder()
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
val t = runBlocking { token() }
|
val t = runBlocking { token() }
|
||||||
// add API key to every request
|
// add API key to every request
|
||||||
val request = chain.request().newBuilder()
|
val request = chain.request().newBuilder()
|
||||||
.header("Authorization", "Bearer $t")
|
.header("Authorization", "Bearer $t")
|
||||||
.header("User-Agent", "okhttp/4.9.2")
|
.header("User-Agent", "okhttp/4.11.0")
|
||||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
.header("x-tesla-user-agent", "TeslaApp/4.44.5-3304/3a5d531cc3/android/27")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
.build()
|
.build()
|
||||||
chain.proceed(request)
|
chain.proceed(request)
|
||||||
@@ -463,182 +386,7 @@ interface TeslaGraphQlApi {
|
|||||||
)
|
)
|
||||||
.client(clientWithInterceptor)
|
.client(clientWithInterceptor)
|
||||||
.build()
|
.build()
|
||||||
return retrofit.create(TeslaGraphQlApi::class.java)
|
return retrofit.create(TeslaChargingOwnershipGraphQlApi::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
internal class LocalTimeAdapter {
|
|
||||||
@FromJson
|
|
||||||
fun fromJson(value: String?): LocalTime? = value?.let {
|
|
||||||
if (it == "24:00") LocalTime.MAX else LocalTime.parse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ToJson
|
|
||||||
fun toJson(value: LocalTime?): String? = value?.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Coordinate.asTeslaCoord() =
|
|
||||||
TeslaGraphQlApi.Coordinate(this.lat, this.lng)
|
|
||||||
|
|
||||||
class TeslaAvailabilityDetector(
|
|
||||||
private val client: OkHttpClient,
|
|
||||||
private val tokenStore: TokenStore,
|
|
||||||
private val baseUrl: String? = null
|
|
||||||
) :
|
|
||||||
BaseAvailabilityDetector(client) {
|
|
||||||
|
|
||||||
private val authApi = TeslaAuthenticationApi.create(client, null)
|
|
||||||
private var api: TeslaGraphQlApi? = null
|
|
||||||
|
|
||||||
interface TokenStore {
|
|
||||||
var teslaRefreshToken: String?
|
|
||||||
var teslaAccessToken: String?
|
|
||||||
var teslaAccessTokenExpiry: Long
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
|
||||||
val api = initApi()
|
|
||||||
val req = TeslaGraphQlApi.GetNearbyChargingSitesRequest(
|
|
||||||
TeslaGraphQlApi.GetNearbyChargingSitesVariables(
|
|
||||||
TeslaGraphQlApi.GetNearbyChargingSitesArgs(
|
|
||||||
location.coordinates.asTeslaCoord(),
|
|
||||||
TeslaGraphQlApi.Coordinate(
|
|
||||||
location.coordinates.lat + coordRange,
|
|
||||||
location.coordinates.lng - coordRange
|
|
||||||
),
|
|
||||||
TeslaGraphQlApi.Coordinate(
|
|
||||||
location.coordinates.lat - coordRange,
|
|
||||||
location.coordinates.lng + coordRange
|
|
||||||
),
|
|
||||||
TeslaGraphQlApi.OpenToNonTeslasFilterValue(false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val results = api.getNearbyChargingSites(
|
|
||||||
req,
|
|
||||||
req.operationName
|
|
||||||
).data.charging.nearbySites.sitesAndDistances
|
|
||||||
val result =
|
|
||||||
results.minByOrNull { it.haversineDistanceMiles.value }
|
|
||||||
?: throw AvailabilityDetectorException("no candidates found.")
|
|
||||||
|
|
||||||
val details = api.getChargingSiteInformation(
|
|
||||||
TeslaGraphQlApi.GetChargingSiteInformationRequest(
|
|
||||||
TeslaGraphQlApi.GetChargingSiteInformationVariables(
|
|
||||||
TeslaGraphQlApi.ChargingSiteIdentifier(result.id.text),
|
|
||||||
TeslaGraphQlApi.VehicleMakeType.NON_TESLA
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).data.charging.site
|
|
||||||
|
|
||||||
|
|
||||||
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
|
|
||||||
val scV2CCSConnectors = location.chargepoints.filter {
|
|
||||||
it.type in listOf(
|
|
||||||
Chargepoint.CCS_TYPE_2,
|
|
||||||
Chargepoint.CCS_UNKNOWN
|
|
||||||
) && it.power != null && it.power <= 150
|
|
||||||
}
|
|
||||||
if (scV2CCSConnectors.sumOf { it.count } != 0 && scV2CCSConnectors.sumOf { it.count } != scV2Connectors.sumOf { it.count }) {
|
|
||||||
throw AvailabilityDetectorException("number of V2 connectors does not match number of V2 CCS connectors")
|
|
||||||
}
|
|
||||||
val scV3Connectors = location.chargepoints.filter {
|
|
||||||
it.type in listOf(
|
|
||||||
Chargepoint.CCS_TYPE_2,
|
|
||||||
Chargepoint.CCS_UNKNOWN
|
|
||||||
) && it.power != null && it.power > 150
|
|
||||||
}
|
|
||||||
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
|
|
||||||
"charger has unknown connectors"
|
|
||||||
)
|
|
||||||
|
|
||||||
var statusSorted = details.siteDynamic.chargerDetails.sortedBy { it.charger.labelLetter }
|
|
||||||
.sortedBy { it.charger.labelNumber }.map { it.availability }
|
|
||||||
if (statusSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
|
|
||||||
// apparently some connectors are missing in Tesla data
|
|
||||||
// If we have just one type of charger, we can still match
|
|
||||||
val numMissing =
|
|
||||||
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - statusSorted.size
|
|
||||||
if (scV2Connectors.isEmpty() || scV3Connectors.isEmpty() && numMissing > 0) {
|
|
||||||
statusSorted =
|
|
||||||
statusSorted + List(numMissing) { TeslaGraphQlApi.ChargerAvailability.UNKNOWN }
|
|
||||||
} else {
|
|
||||||
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val statusMap = emptyMap<Chargepoint, List<ChargepointStatus>>().toMutableMap()
|
|
||||||
var i = 0
|
|
||||||
for (connector in scV2Connectors) {
|
|
||||||
statusMap[connector] =
|
|
||||||
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
|
|
||||||
i += connector.count
|
|
||||||
}
|
|
||||||
if (scV2CCSConnectors.isNotEmpty()) {
|
|
||||||
i = 0
|
|
||||||
for (connector in scV2CCSConnectors) {
|
|
||||||
statusMap[connector] =
|
|
||||||
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
|
|
||||||
i += connector.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (connector in scV3Connectors) {
|
|
||||||
statusMap[connector] =
|
|
||||||
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
|
|
||||||
i += connector.count
|
|
||||||
}
|
|
||||||
|
|
||||||
val congestionHistogram = details.congestionPriceHistogram?.let { cph ->
|
|
||||||
val indexOfMidnight = cph.dataAttributes.indexOfFirst { it.label == "12AM" }
|
|
||||||
indexOfMidnight.takeIf { it >= 0 }?.let { index ->
|
|
||||||
val data = cph.data.toMutableList()
|
|
||||||
Collections.rotate(data, -index)
|
|
||||||
data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChargeLocationStatus(
|
|
||||||
statusMap,
|
|
||||||
"Tesla",
|
|
||||||
congestionHistogram = congestionHistogram,
|
|
||||||
extraData = details.pricing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
|
||||||
return when (charger.dataSource) {
|
|
||||||
"goingelectric" -> charger.network == "Tesla Supercharger"
|
|
||||||
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun initApi(): TeslaGraphQlApi {
|
|
||||||
|
|
||||||
return api ?: run {
|
|
||||||
val newApi = TeslaGraphQlApi.create(client, baseUrl) {
|
|
||||||
val now = Instant.now().epochSecond
|
|
||||||
val token =
|
|
||||||
tokenStore.teslaAccessToken.takeIf { tokenStore.teslaAccessTokenExpiry > now }
|
|
||||||
?: run {
|
|
||||||
val refreshToken = tokenStore.teslaRefreshToken
|
|
||||||
?: throw IOException("not signed in")
|
|
||||||
val response =
|
|
||||||
authApi.getToken(
|
|
||||||
TeslaAuthenticationApi.RefreshTokenRequest(
|
|
||||||
refreshToken
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tokenStore.teslaAccessToken = response.accessToken
|
|
||||||
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
|
|
||||||
response.accessToken
|
|
||||||
}
|
|
||||||
token
|
|
||||||
}
|
|
||||||
api = newApi
|
|
||||||
newApi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,16 +12,41 @@ import kotlinx.coroutines.withContext
|
|||||||
import net.vonforst.evmap.BuildConfig
|
import net.vonforst.evmap.BuildConfig
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.addDebugInterceptors
|
import net.vonforst.evmap.addDebugInterceptors
|
||||||
import net.vonforst.evmap.api.*
|
import net.vonforst.evmap.api.ChargepointApi
|
||||||
import net.vonforst.evmap.model.*
|
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.Resource
|
||||||
import net.vonforst.evmap.viewmodel.getClusterDistance
|
import net.vonforst.evmap.viewmodel.getClusterDistance
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import retrofit2.HttpException
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
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.io.IOException
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
@@ -122,6 +147,8 @@ interface GoingElectricApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val STATUS_OK = "ok"
|
||||||
|
|
||||||
class GoingElectricApiWrapper(
|
class GoingElectricApiWrapper(
|
||||||
val apikey: String,
|
val apikey: String,
|
||||||
baseurl: String = "https://api.goingelectric.de",
|
baseurl: String = "https://api.goingelectric.de",
|
||||||
@@ -153,6 +180,10 @@ class GoingElectricApiWrapper(
|
|||||||
// no connectors chosen
|
// no connectors chosen
|
||||||
return Resource.success(ChargepointList.empty())
|
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 connectors = formatMultipleChoice(connectorsVal)
|
||||||
|
|
||||||
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
|
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
|
||||||
@@ -206,15 +237,17 @@ class GoingElectricApiWrapper(
|
|||||||
categories = categories,
|
categories = categories,
|
||||||
startkey = startkey
|
startkey = startkey
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful || response.body()!!.status != "ok") {
|
if (!response.isSuccessful || response.body()!!.status != STATUS_OK) {
|
||||||
return Resource.error(response.message(), null)
|
return Resource.error(response.message(), null)
|
||||||
} else {
|
} else {
|
||||||
val body = response.body()!!
|
val body = response.body()!!
|
||||||
data.addAll(body.chargelocations)
|
data.addAll(body.chargelocations!!)
|
||||||
startkey = body.startkey
|
startkey = body.startkey
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return Resource.error(e.message, null)
|
return Resource.error(e.message, null)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
return Resource.error(e.message, null)
|
||||||
}
|
}
|
||||||
} while (startkey != null && startkey < 10000)
|
} while (startkey != null && startkey < 10000)
|
||||||
|
|
||||||
@@ -247,6 +280,10 @@ class GoingElectricApiWrapper(
|
|||||||
// no connectors chosen
|
// no connectors chosen
|
||||||
return Resource.success(ChargepointList.empty())
|
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 connectors = formatMultipleChoice(connectorsVal)
|
||||||
|
|
||||||
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
|
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
|
||||||
@@ -297,15 +334,17 @@ class GoingElectricApiWrapper(
|
|||||||
categories = categories,
|
categories = categories,
|
||||||
startkey = startkey
|
startkey = startkey
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful || response.body()!!.status != "ok") {
|
if (!response.isSuccessful || response.body()!!.status != STATUS_OK) {
|
||||||
return Resource.error(response.message(), null)
|
return Resource.error(response.message(), null)
|
||||||
} else {
|
} else {
|
||||||
val body = response.body()!!
|
val body = response.body()!!
|
||||||
data.addAll(body.chargelocations)
|
data.addAll(body.chargelocations!!)
|
||||||
startkey = body.startkey
|
startkey = body.startkey
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return Resource.error(e.message, null)
|
return Resource.error(e.message, null)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
return Resource.error(e.message, null)
|
||||||
}
|
}
|
||||||
} while (startkey != null && startkey < 10000)
|
} while (startkey != null && startkey < 10000)
|
||||||
|
|
||||||
@@ -380,9 +419,9 @@ class GoingElectricApiWrapper(
|
|||||||
): Resource<ChargeLocation> {
|
): Resource<ChargeLocation> {
|
||||||
try {
|
try {
|
||||||
val response = api.getChargepointDetail(id)
|
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(
|
Resource.success(
|
||||||
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
|
(response.body()!!.chargelocations!![0] as GEChargeLocation).convert(
|
||||||
apikey, true
|
apikey, true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -391,6 +430,8 @@ class GoingElectricApiWrapper(
|
|||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return Resource.error(e.message, null)
|
return Resource.error(e.message, null)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
return Resource.error(e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,19 +449,27 @@ class GoingElectricApiWrapper(
|
|||||||
|
|
||||||
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
|
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
|
||||||
|
|
||||||
if (responses.map { it.isSuccessful }.all { it }) {
|
if (responses.map { it.isSuccessful }.all { it }
|
||||||
|
&& plugsResponse.body()!!.status == STATUS_OK
|
||||||
|
&& chargeCardsResponse.body()!!.status == STATUS_OK
|
||||||
|
&& networksResponse.body()!!.status == STATUS_OK
|
||||||
|
&& plugsResponse.body()!!.result != null
|
||||||
|
&& chargeCardsResponse.body()!!.result != null
|
||||||
|
&& networksResponse.body()!!.result != null) {
|
||||||
Resource.success(
|
Resource.success(
|
||||||
GEReferenceData(
|
GEReferenceData(
|
||||||
plugsResponse.body()!!.result,
|
plugsResponse.body()!!.result!!,
|
||||||
networksResponse.body()!!.result,
|
networksResponse.body()!!.result!!,
|
||||||
chargeCardsResponse.body()!!.result
|
chargeCardsResponse.body()!!.result!!
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
|
Resource.error(responses.find { !it.isSuccessful }?.message(), null)
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Resource.error(e.message, null)
|
Resource.error(e.message, null)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
Resource.error(e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,9 +483,18 @@ class GoingElectricApiWrapper(
|
|||||||
val networks = refData.networks
|
val networks = refData.networks
|
||||||
val chargeCards = refData.chargecards
|
val chargeCards = refData.chargecards
|
||||||
|
|
||||||
val plugMap = plugs.associateWith { plug ->
|
/*
|
||||||
nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
|
"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 networkMap = networks.associateWith { it }
|
||||||
val chargecardMap = chargeCards.associate { it.id.toString() to it.name }
|
val chargecardMap = chargeCards.associate { it.id.toString() to it.name }
|
||||||
val categoryMap = mapOf(
|
val categoryMap = mapOf(
|
||||||
|
|||||||
@@ -5,27 +5,42 @@ import androidx.room.PrimaryKey
|
|||||||
import com.squareup.moshi.Json
|
import com.squareup.moshi.Json
|
||||||
import com.squareup.moshi.JsonClass
|
import com.squareup.moshi.JsonClass
|
||||||
import kotlinx.parcelize.Parcelize
|
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.Instant
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class GEChargepointList(
|
data class GEChargepointList(
|
||||||
val status: String,
|
val status: String,
|
||||||
val chargelocations: List<GEChargepointListItem>,
|
val chargelocations: List<GEChargepointListItem>?,
|
||||||
@JsonObjectOrFalse val startkey: Int?
|
@JsonObjectOrFalse val startkey: Int?
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class GEStringList(
|
data class GEStringList(
|
||||||
val status: String,
|
val status: String,
|
||||||
val result: List<String>
|
val result: List<String>?
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class GEChargeCardList(
|
data class GEChargeCardList(
|
||||||
val status: String,
|
val status: String,
|
||||||
val result: List<GEChargeCard>
|
val result: List<GEChargeCard>?
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class GEChargepointListItem {
|
sealed class GEChargepointListItem {
|
||||||
@@ -35,7 +50,7 @@ sealed class GEChargepointListItem {
|
|||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class GEChargeLocation(
|
data class GEChargeLocation(
|
||||||
@Json(name = "ge_id") val id: Long,
|
@Json(name = "ge_id") val id: Long,
|
||||||
val name: String,
|
val name: String?,
|
||||||
val coordinates: GECoordinate,
|
val coordinates: GECoordinate,
|
||||||
val address: GEAddress,
|
val address: GEAddress,
|
||||||
val chargepoints: List<GEChargepoint>,
|
val chargepoints: List<GEChargepoint>,
|
||||||
@@ -57,7 +72,7 @@ data class GEChargeLocation(
|
|||||||
override fun convert(apikey: String, isDetailed: Boolean) = ChargeLocation(
|
override fun convert(apikey: String, isDetailed: Boolean) = ChargeLocation(
|
||||||
id,
|
id,
|
||||||
"goingelectric",
|
"goingelectric",
|
||||||
name,
|
name ?: "Charging station",
|
||||||
coordinates.convert(),
|
coordinates.convert(),
|
||||||
address.convert(),
|
address.convert(),
|
||||||
chargepoints.map { it.convert() },
|
chargepoints.map { it.convert() },
|
||||||
|
|||||||
@@ -8,11 +8,30 @@ import com.squareup.moshi.Moshi
|
|||||||
import net.vonforst.evmap.BuildConfig
|
import net.vonforst.evmap.BuildConfig
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.addDebugInterceptors
|
import net.vonforst.evmap.addDebugInterceptors
|
||||||
import net.vonforst.evmap.api.*
|
import net.vonforst.evmap.api.ChargepointApi
|
||||||
import net.vonforst.evmap.model.*
|
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.Resource
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import retrofit2.HttpException
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||||
@@ -168,6 +187,8 @@ class OpenChargeMapApiWrapper(
|
|||||||
return Resource.success(ChargepointList(result, data.size < maxResults))
|
return Resource.success(ChargepointList(result, data.size < maxResults))
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return Resource.error(e.message, null)
|
return Resource.error(e.message, null)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
return Resource.error(e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +244,8 @@ class OpenChargeMapApiWrapper(
|
|||||||
return Resource.success(ChargepointList(result, data.size < 499))
|
return Resource.success(ChargepointList(result, data.size < 499))
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return Resource.error(e.message, null)
|
return Resource.error(e.message, null)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
return Resource.error(e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +282,8 @@ class OpenChargeMapApiWrapper(
|
|||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return Resource.error(e.message, null)
|
return Resource.error(e.message, null)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
return Resource.error(e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +297,8 @@ class OpenChargeMapApiWrapper(
|
|||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return Resource.error(e.message, null)
|
return Resource.error(e.message, null)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
return Resource.error(e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,9 +410,7 @@ class OpenChargeMapApiWrapper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
|
override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean {
|
||||||
val operators = filters.getMultipleChoiceValue("operators")
|
return false
|
||||||
return (operators != null && !operators.all)
|
|
||||||
// TODO: it would be possible to implement this without requiring details if we extended the data structure to also save the operator ID in the DB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,15 @@ import com.squareup.moshi.Json
|
|||||||
import com.squareup.moshi.JsonClass
|
import com.squareup.moshi.JsonClass
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import net.vonforst.evmap.max
|
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.Instant
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
@@ -55,9 +63,9 @@ data class OCMChargepoint(
|
|||||||
Coordinate(addressInfo.latitude, addressInfo.longitude),
|
Coordinate(addressInfo.latitude, addressInfo.longitude),
|
||||||
addressInfo.toAddress(refData),
|
addressInfo.toAddress(refData),
|
||||||
connections.map { it.convert(refData) },
|
connections.map { it.convert(refData) },
|
||||||
operatorInfo?.title,
|
operatorInfo?.title ?: refData.operators.find { it.id == operatorId }?.title,
|
||||||
"https://openchargemap.org/site/poi/details/$id",
|
"https://map.openchargemap.io/?id=$id",
|
||||||
"https://openchargemap.org/site/poi/edit/$id",
|
"https://map.openchargemap.io/?id=$id",
|
||||||
convertFaultReport(),
|
convertFaultReport(),
|
||||||
recentlyVerified,
|
recentlyVerified,
|
||||||
null,
|
null,
|
||||||
@@ -151,7 +159,9 @@ data class OCMConnection(
|
|||||||
fun convert(refData: OCMReferenceData) = Chargepoint(
|
fun convert(refData: OCMReferenceData) = Chargepoint(
|
||||||
convertConnectionTypeFromOCM(connectionTypeId, refData),
|
convertConnectionTypeFromOCM(connectionTypeId, refData),
|
||||||
power,
|
power,
|
||||||
quantity ?: 1
|
quantity ?: 1,
|
||||||
|
voltage?.toDouble(),
|
||||||
|
amps?.toDouble()
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -165,7 +175,8 @@ data class OCMConnection(
|
|||||||
17L -> Chargepoint.CEE_ROT
|
17L -> Chargepoint.CEE_ROT
|
||||||
28L -> Chargepoint.SCHUKO
|
28L -> Chargepoint.SCHUKO
|
||||||
8L -> Chargepoint.TESLA_ROADSTER_HPC
|
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
|
25L -> Chargepoint.TYPE_2_SOCKET
|
||||||
1036L -> Chargepoint.TYPE_2_PLUG
|
1036L -> Chargepoint.TYPE_2_PLUG
|
||||||
1L -> Chargepoint.TYPE_1
|
1L -> Chargepoint.TYPE_1
|
||||||
@@ -243,7 +254,7 @@ data class OCMUserComment(
|
|||||||
@Json(name = "ID") val id: Long,
|
@Json(name = "ID") val id: Long,
|
||||||
@Json(name = "CommentTypeID") val commentTypeId: Long,
|
@Json(name = "CommentTypeID") val commentTypeId: Long,
|
||||||
@Json(name = "Comment") val comment: String?,
|
@Json(name = "Comment") val comment: String?,
|
||||||
@Json(name = "UserName") val userName: String,
|
@Json(name = "UserName") val userName: String?,
|
||||||
@Json(name = "DateCreated") val dateCreated: ZonedDateTime
|
@Json(name = "DateCreated") val dateCreated: ZonedDateTime
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import net.vonforst.evmap.location.LocationEngine
|
|||||||
import net.vonforst.evmap.location.Priority
|
import net.vonforst.evmap.location.Priority
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
import net.vonforst.evmap.utils.checkFineLocationPermission
|
import net.vonforst.evmap.utils.checkFineLocationPermission
|
||||||
|
import org.acra.interaction.DialogInteraction
|
||||||
|
|
||||||
|
|
||||||
interface LocationAwareScreen {
|
interface LocationAwareScreen {
|
||||||
@@ -44,13 +45,21 @@ interface LocationAwareScreen {
|
|||||||
class CarAppService : androidx.car.app.CarAppService() {
|
class CarAppService : androidx.car.app.CarAppService() {
|
||||||
private val CHANNEL_ID = "car_location"
|
private val CHANNEL_ID = "car_location"
|
||||||
private val NOTIFICATION_ID = 1000
|
private val NOTIFICATION_ID = 1000
|
||||||
|
private val TAG = "CarAppService"
|
||||||
|
private var foregroundStarted = false
|
||||||
|
|
||||||
override fun onCreate() {
|
fun ensureForegroundService() {
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
// we want to run as a foreground service to make sure we can use location
|
// we want to run as a foreground service to make sure we can use location
|
||||||
createNotificationChannel()
|
try {
|
||||||
startForeground(NOTIFICATION_ID, getNotification())
|
if (!foregroundStarted) {
|
||||||
|
createNotificationChannel()
|
||||||
|
startForeground(NOTIFICATION_ID, getNotification())
|
||||||
|
foregroundStarted = true
|
||||||
|
Log.i(TAG, "Started foreground service")
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Failed to start foreground service: ", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
@@ -122,11 +131,13 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateScreen(intent: Intent): Screen {
|
override fun onCreateScreen(intent: Intent): Screen {
|
||||||
handleActionsIntent(intent)
|
|
||||||
|
|
||||||
val mapScreen = MapScreen(carContext, this)
|
val mapScreen = MapScreen(carContext, this)
|
||||||
val screens = mutableListOf<Screen>(mapScreen)
|
val screens = mutableListOf<Screen>(mapScreen)
|
||||||
|
|
||||||
|
handleActionsIntent(intent)?.let {
|
||||||
|
screens.add(it)
|
||||||
|
}
|
||||||
if (!prefs.dataSourceSet) {
|
if (!prefs.dataSourceSet) {
|
||||||
screens.add(
|
screens.add(
|
||||||
ChooseDataSourceScreen(
|
ChooseDataSourceScreen(
|
||||||
@@ -149,6 +160,14 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!prefs.privacyAccepted) {
|
||||||
|
screens.add(
|
||||||
|
AcceptPrivacyScreen(carContext, this)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleACRAIntent(intent)?.let {
|
||||||
|
screens.add(it)
|
||||||
|
}
|
||||||
|
|
||||||
if (screens.size > 1) {
|
if (screens.size > 1) {
|
||||||
val screenManager = carContext.getCarService(ScreenManager::class.java)
|
val screenManager = carContext.getCarService(ScreenManager::class.java)
|
||||||
@@ -160,7 +179,13 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
|||||||
return screens.last()
|
return screens.last()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleActionsIntent(intent: Intent): Boolean {
|
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 {
|
intent.data?.let {
|
||||||
if (it.host == "find_charger") {
|
if (it.host == "find_charger") {
|
||||||
val lat = it.getQueryParameter("latitude")?.toDouble()
|
val lat = it.getQueryParameter("latitude")?.toDouble()
|
||||||
@@ -169,15 +194,14 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
|||||||
if (lat != null && lon != null) {
|
if (lat != null && lon != null) {
|
||||||
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
|
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
|
||||||
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
|
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
|
||||||
return true
|
return null
|
||||||
} else if (name != null) {
|
} else if (name != null) {
|
||||||
val screenManager = carContext.getCarService(ScreenManager::class.java)
|
val screen = PlaceSearchScreen(carContext, this, name)
|
||||||
screenManager.push(PlaceSearchScreen(carContext, this, name))
|
return screen
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
@@ -206,6 +230,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
|||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun requestLocationUpdates() {
|
fun requestLocationUpdates() {
|
||||||
if (!locationPermissionGranted()) return
|
if (!locationPermissionGranted()) return
|
||||||
|
cas.ensureForegroundService()
|
||||||
Log.i(TAG, "Requesting location updates")
|
Log.i(TAG, "Requesting location updates")
|
||||||
requestCarHardwareLocationUpdates()
|
requestCarHardwareLocationUpdates()
|
||||||
requestPhoneLocationUpdates()
|
requestPhoneLocationUpdates()
|
||||||
|
|||||||
@@ -3,13 +3,22 @@ package net.vonforst.evmap.auto
|
|||||||
import androidx.car.app.CarContext
|
import androidx.car.app.CarContext
|
||||||
import androidx.car.app.CarToast
|
import androidx.car.app.CarToast
|
||||||
import androidx.car.app.Screen
|
import androidx.car.app.Screen
|
||||||
|
import androidx.car.app.annotations.ExperimentalCarApi
|
||||||
import androidx.car.app.constraints.ConstraintManager
|
import androidx.car.app.constraints.ConstraintManager
|
||||||
import androidx.car.app.hardware.CarHardwareManager
|
import androidx.car.app.hardware.CarHardwareManager
|
||||||
import androidx.car.app.hardware.info.Model
|
import androidx.car.app.hardware.info.Model
|
||||||
import androidx.car.app.model.*
|
import androidx.car.app.model.Action
|
||||||
|
import androidx.car.app.model.ActionStrip
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.ItemList
|
||||||
|
import androidx.car.app.model.ListTemplate
|
||||||
|
import androidx.car.app.model.Row
|
||||||
|
import androidx.car.app.model.SectionedItemList
|
||||||
|
import androidx.car.app.model.Template
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
|
||||||
import jsonapi.Meta
|
import jsonapi.Meta
|
||||||
import jsonapi.Relationship
|
import jsonapi.Relationship
|
||||||
import jsonapi.Relationships
|
import jsonapi.Relationships
|
||||||
@@ -18,7 +27,16 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.api.chargeprice.*
|
import net.vonforst.evmap.api.chargeprice.ChargePrice
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceInclude
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceMeta
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceOptions
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceRequest
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceRequestTariffMeta
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceStation
|
||||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||||
import net.vonforst.evmap.api.nameForPlugType
|
import net.vonforst.evmap.api.nameForPlugType
|
||||||
import net.vonforst.evmap.api.stringProvider
|
import net.vonforst.evmap.api.stringProvider
|
||||||
@@ -28,10 +46,13 @@ import net.vonforst.evmap.storage.AppDatabase
|
|||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
import net.vonforst.evmap.ui.currency
|
import net.vonforst.evmap.ui.currency
|
||||||
import net.vonforst.evmap.ui.time
|
import net.vonforst.evmap.ui.time
|
||||||
|
import retrofit2.HttpException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(ctx) {
|
@ExperimentalCarApi
|
||||||
|
class ChargepriceScreen(ctx: CarContext, val session: EVMapSession, val charger: ChargeLocation) :
|
||||||
|
Screen(ctx) {
|
||||||
private val prefs = PreferenceDataSource(ctx)
|
private val prefs = PreferenceDataSource(ctx)
|
||||||
private val db = AppDatabase.getInstance(carContext)
|
private val db = AppDatabase.getInstance(carContext)
|
||||||
private val api by lazy {
|
private val api by lazy {
|
||||||
@@ -69,7 +90,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
|||||||
carContext.stringProvider(),
|
carContext.stringProvider(),
|
||||||
chargepoint.type
|
chargepoint.type
|
||||||
)
|
)
|
||||||
} ${chargepoint.formatPower()} ${
|
} ${chargepoint.formatPower(carContext.currentOrDefaultLocale)} ${
|
||||||
carContext.getString(
|
carContext.getString(
|
||||||
R.string.chargeprice_stats,
|
R.string.chargeprice_stats,
|
||||||
meta.energy,
|
meta.energy,
|
||||||
@@ -129,7 +150,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
|||||||
)
|
)
|
||||||
).build()
|
).build()
|
||||||
).setOnClickListener {
|
).setOnClickListener {
|
||||||
openUrl(carContext, ChargepriceApi.getPoiUrl(charger))
|
openUrl(carContext, session.cas, ChargepriceApi.getPoiUrl(charger))
|
||||||
}.build()
|
}.build()
|
||||||
).build()
|
).build()
|
||||||
)
|
)
|
||||||
@@ -303,6 +324,15 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
|||||||
)
|
)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
CarToast.makeText(
|
||||||
|
carContext,
|
||||||
|
R.string.chargeprice_connection_error,
|
||||||
|
CarToast.LENGTH_LONG
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
} catch (e: NoVehicleSelectedException) {
|
} catch (e: NoVehicleSelectedException) {
|
||||||
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
|
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
|
||||||
invalidate()
|
invalidate()
|
||||||
|
|||||||
@@ -1,32 +1,59 @@
|
|||||||
package net.vonforst.evmap.auto
|
package net.vonforst.evmap.auto
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.*
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.RectF
|
||||||
import android.graphics.drawable.BitmapDrawable
|
import android.graphics.drawable.BitmapDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.text.SpannableString
|
import android.text.SpannableString
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
|
import android.util.Log
|
||||||
import androidx.car.app.CarContext
|
import androidx.car.app.CarContext
|
||||||
import androidx.car.app.CarToast
|
import androidx.car.app.CarToast
|
||||||
|
import androidx.car.app.HostException
|
||||||
import androidx.car.app.Screen
|
import androidx.car.app.Screen
|
||||||
|
import androidx.car.app.annotations.ExperimentalCarApi
|
||||||
import androidx.car.app.constraints.ConstraintManager
|
import androidx.car.app.constraints.ConstraintManager
|
||||||
import androidx.car.app.model.*
|
import androidx.car.app.model.Action
|
||||||
|
import androidx.car.app.model.ActionStrip
|
||||||
|
import androidx.car.app.model.CarColor
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.CarIconSpan
|
||||||
|
import androidx.car.app.model.ForegroundCarColorSpan
|
||||||
|
import androidx.car.app.model.Pane
|
||||||
|
import androidx.car.app.model.PaneTemplate
|
||||||
|
import androidx.car.app.model.ParkedOnlyOnClickListener
|
||||||
|
import androidx.car.app.model.Row
|
||||||
|
import androidx.car.app.model.Template
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import coil.imageLoader
|
import coil.imageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
|
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import net.vonforst.evmap.*
|
import net.vonforst.evmap.BuildConfig
|
||||||
|
import net.vonforst.evmap.EXTRA_CHARGER_ID
|
||||||
|
import net.vonforst.evmap.EXTRA_LAT
|
||||||
|
import net.vonforst.evmap.EXTRA_LON
|
||||||
|
import net.vonforst.evmap.MapsActivity
|
||||||
|
import net.vonforst.evmap.R
|
||||||
|
import net.vonforst.evmap.adapter.formatTeslaParkingFee
|
||||||
|
import net.vonforst.evmap.adapter.formatTeslaPricing
|
||||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.Pricing
|
||||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||||
import net.vonforst.evmap.api.createApi
|
import net.vonforst.evmap.api.createApi
|
||||||
|
import net.vonforst.evmap.api.fronyx.PredictionData
|
||||||
import net.vonforst.evmap.api.iconForPlugType
|
import net.vonforst.evmap.api.iconForPlugType
|
||||||
import net.vonforst.evmap.api.nameForPlugType
|
import net.vonforst.evmap.api.nameForPlugType
|
||||||
import net.vonforst.evmap.api.stringProvider
|
import net.vonforst.evmap.api.stringProvider
|
||||||
@@ -34,6 +61,7 @@ import net.vonforst.evmap.model.ChargeLocation
|
|||||||
import net.vonforst.evmap.model.Cost
|
import net.vonforst.evmap.model.Cost
|
||||||
import net.vonforst.evmap.model.FaultReport
|
import net.vonforst.evmap.model.FaultReport
|
||||||
import net.vonforst.evmap.model.Favorite
|
import net.vonforst.evmap.model.Favorite
|
||||||
|
import net.vonforst.evmap.plus
|
||||||
import net.vonforst.evmap.storage.AppDatabase
|
import net.vonforst.evmap.storage.AppDatabase
|
||||||
import net.vonforst.evmap.storage.ChargeLocationsRepository
|
import net.vonforst.evmap.storage.ChargeLocationsRepository
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
@@ -45,13 +73,23 @@ import net.vonforst.evmap.viewmodel.awaitFinished
|
|||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val TAG = "ChargerDetailScreen"
|
||||||
|
|
||||||
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
|
@ExperimentalCarApi
|
||||||
|
class ChargerDetailScreen(
|
||||||
|
ctx: CarContext,
|
||||||
|
val chargerSparse: ChargeLocation,
|
||||||
|
val session: EVMapSession
|
||||||
|
) : Screen(ctx) {
|
||||||
var charger: ChargeLocation? = null
|
var charger: ChargeLocation? = null
|
||||||
var photo: Bitmap? = null
|
var photo: Bitmap? = null
|
||||||
private var availability: ChargeLocationStatus? = null
|
private var availability: ChargeLocationStatus? = null
|
||||||
|
private var prediction: PredictionData? = null
|
||||||
|
private var fronyxSupported = false
|
||||||
|
private var teslaSupported = false
|
||||||
|
|
||||||
val prefs = PreferenceDataSource(ctx)
|
val prefs = PreferenceDataSource(ctx)
|
||||||
private val db = AppDatabase.getInstance(carContext)
|
private val db = AppDatabase.getInstance(carContext)
|
||||||
@@ -59,6 +97,9 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
|||||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||||
|
|
||||||
|
//private val predictionRepo = PredictionRepository(ctx)
|
||||||
|
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
|
||||||
private val imageSize = 128 // images should be 128dp according to docs
|
private val imageSize = 128 // images should be 128dp according to docs
|
||||||
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
|
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
|
||||||
|
|
||||||
@@ -118,7 +159,22 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
|||||||
)
|
)
|
||||||
.setTitle(carContext.getString(R.string.auto_prices))
|
.setTitle(carContext.getString(R.string.auto_prices))
|
||||||
.setOnClickListener {
|
.setOnClickListener {
|
||||||
screenManager.push(ChargepriceScreen(carContext, charger))
|
if (prefs.chargepriceNativeIntegration) {
|
||||||
|
screenManager.push(
|
||||||
|
ChargepriceScreen(
|
||||||
|
carContext,
|
||||||
|
session,
|
||||||
|
charger
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val intent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
Uri.parse(ChargepriceApi.getPoiUrl(charger))
|
||||||
|
)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
session.cas.startActivity(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.build())
|
.build())
|
||||||
}
|
}
|
||||||
@@ -136,12 +192,12 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
|||||||
Action.Builder()
|
Action.Builder()
|
||||||
.setTitle(carContext.getString(R.string.open_in_app))
|
.setTitle(carContext.getString(R.string.open_in_app))
|
||||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
val intent = Intent(carContext, MapsActivity::class.java)
|
val intent = Intent(session.cas, MapsActivity::class.java)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
.putExtra(EXTRA_CHARGER_ID, chargerSparse.id)
|
.putExtra(EXTRA_CHARGER_ID, chargerSparse.id)
|
||||||
.putExtra(EXTRA_LAT, chargerSparse.coordinates.lat)
|
.putExtra(EXTRA_LAT, chargerSparse.coordinates.lat)
|
||||||
.putExtra(EXTRA_LON, chargerSparse.coordinates.lng)
|
.putExtra(EXTRA_LON, chargerSparse.coordinates.lng)
|
||||||
carContext.startActivity(intent)
|
session.cas.startActivity(intent)
|
||||||
CarToast.makeText(
|
CarToast.makeText(
|
||||||
carContext,
|
carContext,
|
||||||
R.string.opened_on_phone,
|
R.string.opened_on_phone,
|
||||||
@@ -290,9 +346,106 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
|||||||
}.build())
|
}.build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (rows.count() < maxRows && charger.generalInformation != null) {
|
||||||
|
rows.add(Row.Builder().apply {
|
||||||
|
setTitle(carContext.getString(R.string.general_info))
|
||||||
|
addText(charger.generalInformation)
|
||||||
|
}.build())
|
||||||
|
}
|
||||||
|
if (rows.count() < maxRows && charger.amenities != null) {
|
||||||
|
rows.add(Row.Builder().apply {
|
||||||
|
setTitle(carContext.getString(R.string.amenities))
|
||||||
|
addText(charger.amenities)
|
||||||
|
}.build())
|
||||||
|
}
|
||||||
|
if (rows.count() < maxRows && ((fronyxSupported && prefs.predictionEnabled) || teslaSupported)) {
|
||||||
|
rows.add(1, Row.Builder().apply {
|
||||||
|
setTitle(
|
||||||
|
if (fronyxSupported) {
|
||||||
|
carContext.getString(R.string.utilization_prediction) + " (" + carContext.getString(
|
||||||
|
R.string.powered_by_fronyx
|
||||||
|
) + ")"
|
||||||
|
} else carContext.getString(R.string.average_utilization)
|
||||||
|
)
|
||||||
|
generatePredictionGraph()?.let { addText(it) }
|
||||||
|
?: addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
|
||||||
|
}.build())
|
||||||
|
}
|
||||||
|
if (rows.count() < maxRows && teslaSupported) {
|
||||||
|
val teslaPricing = availability?.extraData as? Pricing
|
||||||
|
rows.add(3, Row.Builder().apply {
|
||||||
|
setTitle(carContext.getString(R.string.cost))
|
||||||
|
teslaPricing?.let {
|
||||||
|
var text = formatTeslaPricing(teslaPricing, carContext) as CharSequence
|
||||||
|
formatTeslaParkingFee(teslaPricing, carContext)?.let { text += "\n\n" + it }
|
||||||
|
addText(text)
|
||||||
|
} ?: run {
|
||||||
|
addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
|
||||||
|
}
|
||||||
|
}.build())
|
||||||
|
}
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun generatePredictionGraph(): CharSequence? {
|
||||||
|
val predictionData = prediction ?: return null
|
||||||
|
val graphData = predictionData.predictionGraph?.toList() ?: return null
|
||||||
|
val maxValue = predictionData.maxValue
|
||||||
|
|
||||||
|
val maxWidth = if (BuildConfig.FLAVOR_automotive == "automotive") 25 else 18
|
||||||
|
val step = maxOf(graphData.size.toFloat() / maxWidth, 1f)
|
||||||
|
val values = graphData.map { it.second }
|
||||||
|
|
||||||
|
val graph = buildGraph(values, step, maxValue, predictionData.isPercentage)
|
||||||
|
|
||||||
|
val measurer = TextMeasurer(carContext)
|
||||||
|
val width = measurer.measureText(graph)
|
||||||
|
|
||||||
|
val startTime = timeFormat.format(graphData[0].first)
|
||||||
|
val endTime = timeFormat.format(graphData.last().first)
|
||||||
|
|
||||||
|
val baseWidth = measurer.measureText(startTime + endTime)
|
||||||
|
val spaceWidth = measurer.measureText(" ")
|
||||||
|
val numSpaces = floor((width - baseWidth) / spaceWidth).toInt()
|
||||||
|
val legend = startTime + " ".repeat(numSpaces) + endTime
|
||||||
|
|
||||||
|
return graph + "\n" + legend
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGraph(
|
||||||
|
values: List<Double>,
|
||||||
|
step: Float,
|
||||||
|
maxValue: Double,
|
||||||
|
isPercentage: Boolean
|
||||||
|
): CharSequence {
|
||||||
|
val sparklines = "▁▂▃▄▅▆▇█"
|
||||||
|
val graph = SpannableStringBuilder()
|
||||||
|
var i = 0f
|
||||||
|
while (i.roundToInt() < values.size) {
|
||||||
|
val v = values[i.roundToInt()]
|
||||||
|
val fraction = v / maxValue
|
||||||
|
val sparkline = sparklines[(fraction * (sparklines.length - 1)).roundToInt()].toString()
|
||||||
|
|
||||||
|
val color = if (isPercentage) {
|
||||||
|
when (v) {
|
||||||
|
in 0.0..0.5 -> CarColor.GREEN
|
||||||
|
in 0.5..0.8 -> CarColor.YELLOW
|
||||||
|
else -> CarColor.RED
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (v < maxValue) CarColor.GREEN else CarColor.RED
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.append(
|
||||||
|
sparkline,
|
||||||
|
ForegroundCarColorSpan.create(color),
|
||||||
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
|
)
|
||||||
|
i += step
|
||||||
|
}
|
||||||
|
return graph
|
||||||
|
}
|
||||||
|
|
||||||
private fun generateCostStatusText(cost: Cost): CharSequence {
|
private fun generateCostStatusText(cost: Cost): CharSequence {
|
||||||
val string = SpannableString(cost.getStatusText(carContext, emoji = true))
|
val string = SpannableString(cost.getStatusText(carContext, emoji = true))
|
||||||
// replace emoji with CarIcon
|
// replace emoji with CarIcon
|
||||||
@@ -371,8 +524,10 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
|||||||
} else {
|
} else {
|
||||||
append(nameForPlugType(carContext.stringProvider(), cp.type))
|
append(nameForPlugType(carContext.stringProvider(), cp.type))
|
||||||
}
|
}
|
||||||
append(" ")
|
cp.formatPower(carContext.currentOrDefaultLocale)?.let {
|
||||||
append(cp.formatPower())
|
append(" ")
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
availability?.status?.get(cp)?.let { status ->
|
availability?.status?.get(cp)?.let { status ->
|
||||||
chargepointsText.append(
|
chargepointsText.append(
|
||||||
@@ -403,13 +558,55 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToCharger(charger: ChargeLocation) {
|
private fun navigateToCharger(charger: ChargeLocation) {
|
||||||
|
var success = navigateCarApp(charger)
|
||||||
|
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
|
||||||
|
// on AAOS, some OEMs' navigation apps might not support
|
||||||
|
success = navigateRegularApp(charger)
|
||||||
|
}
|
||||||
|
if (!success) {
|
||||||
|
CarToast.makeText(carContext, R.string.no_maps_app_found, CarToast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateCarApp(charger: ChargeLocation): Boolean {
|
||||||
val coord = charger.coordinates
|
val coord = charger.coordinates
|
||||||
val intent =
|
val intent =
|
||||||
Intent(
|
Intent(
|
||||||
CarContext.ACTION_NAVIGATE,
|
CarContext.ACTION_NAVIGATE,
|
||||||
Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
|
Uri.parse("geo:${coord.lat},${coord.lng}")
|
||||||
)
|
)
|
||||||
carContext.startCarApp(intent)
|
try {
|
||||||
|
carContext.startCarApp(intent)
|
||||||
|
return true
|
||||||
|
} catch (e: HostException) {
|
||||||
|
Log.w(TAG, "Could not start navigation using car app intent")
|
||||||
|
Log.w(TAG, intent.toString())
|
||||||
|
e.printStackTrace()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Could not start navigation using car app intent")
|
||||||
|
Log.w(TAG, intent.toString())
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateRegularApp(charger: ChargeLocation): Boolean {
|
||||||
|
val coord = charger.coordinates
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.data = Uri.parse(
|
||||||
|
"geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
|
||||||
|
Uri.encode(charger.name)
|
||||||
|
})"
|
||||||
|
)
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
if (intent.resolveActivity(carContext.packageManager) != null) {
|
||||||
|
carContext.startActivity(intent)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Could not start navigation using regular intent")
|
||||||
|
Log.w(TAG, intent.toString())
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadCharger() {
|
private fun loadCharger() {
|
||||||
@@ -430,7 +627,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
|||||||
val url = photo.getUrl(size = size)
|
val url = photo.getUrl(size = size)
|
||||||
val request = ImageRequest.Builder(carContext).data(url).build()
|
val request = ImageRequest.Builder(carContext).data(url).build()
|
||||||
val img =
|
val img =
|
||||||
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
|
(carContext.imageLoader.execute(request).drawable as? BitmapDrawable)?.bitmap ?: return@let
|
||||||
|
|
||||||
// draw icon on top of image
|
// draw icon on top of image
|
||||||
val icon = iconGen.getBitmap(
|
val icon = iconGen.getBitmap(
|
||||||
@@ -463,12 +660,23 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
|||||||
)
|
)
|
||||||
this@ChargerDetailScreen.photo = outImg
|
this@ChargerDetailScreen.photo = outImg
|
||||||
}
|
}
|
||||||
|
fronyxSupported = false /*charger.chargepoints.any {
|
||||||
|
FronyxApi.isChargepointSupported(
|
||||||
|
charger,
|
||||||
|
it
|
||||||
|
)
|
||||||
|
} && !availabilityRepo.isSupercharger(charger)*/
|
||||||
|
teslaSupported = availabilityRepo.isTeslaSupported(charger)
|
||||||
|
|
||||||
invalidate()
|
invalidate()
|
||||||
|
|
||||||
availability = availabilityRepo.getAvailability(charger).data
|
availability = availabilityRepo.getAvailability(charger).data
|
||||||
|
|
||||||
invalidate()
|
invalidate()
|
||||||
|
|
||||||
|
//prediction = predictionRepo.getPredictionData(charger, availability)
|
||||||
|
|
||||||
|
invalidate()
|
||||||
} else {
|
} else {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
|
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package net.vonforst.evmap.auto
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.car.app.CarContext
|
||||||
|
import androidx.car.app.Screen
|
||||||
|
import androidx.car.app.model.Action
|
||||||
|
import androidx.car.app.model.CarColor
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.MessageTemplate
|
||||||
|
import androidx.car.app.model.Template
|
||||||
|
import net.vonforst.evmap.R
|
||||||
|
import org.acra.dialog.CrashReportDialogHelper
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ACRA-compatible crash reporting screen for the Car App Library
|
||||||
|
*
|
||||||
|
* only used on Android Automotive OS
|
||||||
|
*/
|
||||||
|
class CrashReportScreen(ctx: CarContext, intent: Intent) : Screen(ctx) {
|
||||||
|
val helper = CrashReportDialogHelper(ctx, intent)
|
||||||
|
override fun onGetTemplate(): Template {
|
||||||
|
return MessageTemplate.Builder(carContext.getString(R.string.crash_report_text)).apply {
|
||||||
|
setHeaderAction(Action.APP_ICON)
|
||||||
|
setTitle(carContext.getString(R.string.app_name))
|
||||||
|
addAction(
|
||||||
|
Action.Builder()
|
||||||
|
.setTitle(carContext.getString(R.string.ok))
|
||||||
|
.setFlags(Action.FLAG_PRIMARY)
|
||||||
|
.setBackgroundColor(CarColor.PRIMARY)
|
||||||
|
.setOnClickListener {
|
||||||
|
helper.sendCrash(null, null)
|
||||||
|
screenManager.pop()
|
||||||
|
}.build()
|
||||||
|
)
|
||||||
|
addAction(
|
||||||
|
Action.Builder()
|
||||||
|
.setTitle(carContext.getString(R.string.cancel))
|
||||||
|
.setOnClickListener {
|
||||||
|
helper.cancelReports()
|
||||||
|
screenManager.pop()
|
||||||
|
}.build()
|
||||||
|
)
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,14 +9,36 @@ import androidx.car.app.CarContext
|
|||||||
import androidx.car.app.CarToast
|
import androidx.car.app.CarToast
|
||||||
import androidx.car.app.Screen
|
import androidx.car.app.Screen
|
||||||
import androidx.car.app.constraints.ConstraintManager
|
import androidx.car.app.constraints.ConstraintManager
|
||||||
import androidx.car.app.model.*
|
import androidx.car.app.model.Action
|
||||||
|
import androidx.car.app.model.ActionStrip
|
||||||
|
import androidx.car.app.model.CarColor
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.CarText
|
||||||
|
import androidx.car.app.model.ForegroundCarColorSpan
|
||||||
|
import androidx.car.app.model.ItemList
|
||||||
|
import androidx.car.app.model.ListTemplate
|
||||||
|
import androidx.car.app.model.Pane
|
||||||
|
import androidx.car.app.model.PaneTemplate
|
||||||
|
import androidx.car.app.model.ParkedOnlyOnClickListener
|
||||||
|
import androidx.car.app.model.Row
|
||||||
|
import androidx.car.app.model.Template
|
||||||
|
import androidx.car.app.model.Toggle
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.map
|
import androidx.lifecycle.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.model.*
|
import net.vonforst.evmap.model.BooleanFilter
|
||||||
|
import net.vonforst.evmap.model.BooleanFilterValue
|
||||||
|
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||||
|
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||||
|
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||||
|
import net.vonforst.evmap.model.FilterValues
|
||||||
|
import net.vonforst.evmap.model.MultipleChoiceFilter
|
||||||
|
import net.vonforst.evmap.model.MultipleChoiceFilterValue
|
||||||
|
import net.vonforst.evmap.model.SliderFilter
|
||||||
|
import net.vonforst.evmap.model.SliderFilterValue
|
||||||
import net.vonforst.evmap.storage.AppDatabase
|
import net.vonforst.evmap.storage.AppDatabase
|
||||||
import net.vonforst.evmap.storage.FilterProfile
|
import net.vonforst.evmap.storage.FilterProfile
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
@@ -41,7 +63,9 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
|||||||
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
|
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
|
||||||
page = 0
|
page = 0
|
||||||
} else {
|
} else {
|
||||||
page = paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
|
val index =
|
||||||
|
paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
|
||||||
|
page = index.takeUnless { it == -1 } ?: 0
|
||||||
}
|
}
|
||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
@@ -204,6 +228,36 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
|||||||
Row.IMAGE_TYPE_ICON
|
Row.IMAGE_TYPE_ICON
|
||||||
)
|
)
|
||||||
setOnClickListener { onItemClick(it.id) }
|
setOnClickListener { onItemClick(it.id) }
|
||||||
|
if (carContext.carAppApiLevel >= 6) {
|
||||||
|
// Delete action
|
||||||
|
addAction(Action.Builder().apply {
|
||||||
|
setIcon(
|
||||||
|
CarIcon.Builder(
|
||||||
|
IconCompat.createWithResource(
|
||||||
|
carContext,
|
||||||
|
R.drawable.ic_delete
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
|
||||||
|
)
|
||||||
|
setOnClickListener {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
db.filterProfileDao().delete(it)
|
||||||
|
if (prefs.filterStatus == it.id) {
|
||||||
|
prefs.filterStatus = FILTERS_DISABLED
|
||||||
|
}
|
||||||
|
CarToast.makeText(
|
||||||
|
carContext,
|
||||||
|
carContext.getString(
|
||||||
|
R.string.deleted_item,
|
||||||
|
it.name
|
||||||
|
),
|
||||||
|
CarToast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.build())
|
||||||
|
}
|
||||||
}.build())
|
}.build())
|
||||||
}
|
}
|
||||||
if (page < paginatedProfiles.size - 1) {
|
if (page < paginatedProfiles.size - 1) {
|
||||||
@@ -293,7 +347,8 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
|
|
||||||
setActionStrip(ActionStrip.Builder().apply {
|
setActionStrip(ActionStrip.Builder().apply {
|
||||||
val currentProfile = vm.filterProfile.value
|
val currentProfile = vm.filterProfile.value
|
||||||
if (currentProfile != null) {
|
if (currentProfile != null && carContext.carAppApiLevel < 6) {
|
||||||
|
// Delete action (when row actions are not available)
|
||||||
addAction(Action.Builder().apply {
|
addAction(Action.Builder().apply {
|
||||||
setIcon(
|
setIcon(
|
||||||
CarIcon.Builder(
|
CarIcon.Builder(
|
||||||
@@ -310,12 +365,11 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
CarToast.makeText(
|
CarToast.makeText(
|
||||||
carContext,
|
carContext,
|
||||||
carContext.getString(
|
carContext.getString(
|
||||||
R.string.deleted_filterprofile,
|
R.string.deleted_item,
|
||||||
currentProfile.name
|
currentProfile.name
|
||||||
),
|
),
|
||||||
CarToast.LENGTH_SHORT
|
CarToast.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
invalidate()
|
|
||||||
screenManager.pop()
|
screenManager.pop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,7 +401,6 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
}
|
}
|
||||||
if (!saveSuccess) return@pushForResult
|
if (!saveSuccess) return@pushForResult
|
||||||
}
|
}
|
||||||
invalidate()
|
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,14 +15,34 @@ import androidx.car.app.hardware.info.CarInfo
|
|||||||
import androidx.car.app.hardware.info.CarSensors
|
import androidx.car.app.hardware.info.CarSensors
|
||||||
import androidx.car.app.hardware.info.Compass
|
import androidx.car.app.hardware.info.Compass
|
||||||
import androidx.car.app.hardware.info.EnergyLevel
|
import androidx.car.app.hardware.info.EnergyLevel
|
||||||
import androidx.car.app.model.*
|
import androidx.car.app.model.Action
|
||||||
|
import androidx.car.app.model.ActionStrip
|
||||||
|
import androidx.car.app.model.CarColor
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.CarIconSpan
|
||||||
|
import androidx.car.app.model.CarLocation
|
||||||
|
import androidx.car.app.model.CarText
|
||||||
|
import androidx.car.app.model.DistanceSpan
|
||||||
|
import androidx.car.app.model.ForegroundCarColorSpan
|
||||||
|
import androidx.car.app.model.ItemList
|
||||||
|
import androidx.car.app.model.Metadata
|
||||||
|
import androidx.car.app.model.OnContentRefreshListener
|
||||||
|
import androidx.car.app.model.Place
|
||||||
|
import androidx.car.app.model.PlaceListMapTemplate
|
||||||
|
import androidx.car.app.model.PlaceMarker
|
||||||
|
import androidx.car.app.model.Row
|
||||||
|
import androidx.car.app.model.Template
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.car2go.maps.model.LatLng
|
import com.car2go.maps.model.LatLng
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import net.vonforst.evmap.BuildConfig
|
import net.vonforst.evmap.BuildConfig
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||||
@@ -45,6 +65,7 @@ import net.vonforst.evmap.utils.headingDiff
|
|||||||
import net.vonforst.evmap.viewmodel.Status
|
import net.vonforst.evmap.viewmodel.Status
|
||||||
import net.vonforst.evmap.viewmodel.awaitFinished
|
import net.vonforst.evmap.viewmodel.awaitFinished
|
||||||
import net.vonforst.evmap.viewmodel.filtersWithValue
|
import net.vonforst.evmap.viewmodel.filtersWithValue
|
||||||
|
import retrofit2.HttpException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -348,7 +369,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
|||||||
DistanceSpan.create(
|
DistanceSpan.create(
|
||||||
roundValueToDistance(
|
roundValueToDistance(
|
||||||
distanceMeters,
|
distanceMeters,
|
||||||
energyLevel?.distanceDisplayUnit?.value
|
energyLevel?.distanceDisplayUnit?.value,
|
||||||
|
carContext
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
@@ -384,7 +406,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
|||||||
)
|
)
|
||||||
|
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
screenManager.push(ChargerDetailScreen(carContext, charger))
|
screenManager.push(ChargerDetailScreen(carContext, charger, session))
|
||||||
session.mapScreen = null
|
session.mapScreen = null
|
||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
@@ -470,13 +492,13 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
|||||||
zoom = 16f,
|
zoom = 16f,
|
||||||
filtersWithValue
|
filtersWithValue
|
||||||
).awaitFinished()
|
).awaitFinished()
|
||||||
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
|
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data?.items.isNullOrEmpty() else response.data == null) {
|
||||||
loadingError = true
|
loadingError = true
|
||||||
this@MapScreen.chargers = null
|
this@MapScreen.chargers = null
|
||||||
invalidate()
|
invalidate()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
|
chargers = response.data?.items?.filterIsInstance<ChargeLocation>()
|
||||||
if (prefs.placeSearchResultAndroidAutoName == null) {
|
if (prefs.placeSearchResultAndroidAutoName == null) {
|
||||||
chargers = headingFilter(
|
chargers = headingFilter(
|
||||||
chargers,
|
chargers,
|
||||||
@@ -501,6 +523,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
|||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
loadingError = true
|
loadingError = true
|
||||||
invalidate()
|
invalidate()
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
loadingError = true
|
||||||
|
invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package net.vonforst.evmap.auto
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.fragment.app.add
|
||||||
|
import androidx.fragment.app.commit
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import net.vonforst.evmap.R
|
||||||
|
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
|
||||||
|
|
||||||
|
class OAuthLoginActivity : AppCompatActivity(R.layout.activity_oauth_login) {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
supportFragmentManager.commit {
|
||||||
|
setReorderingAllowed(true)
|
||||||
|
add<OAuthLoginFragment>(R.id.fragment_container_view, args = intent.extras)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalBroadcastManager.getInstance(this).registerReceiver(object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(ctx: Context, intent: Intent) {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ class PermissionScreen(
|
|||||||
Action.Builder()
|
Action.Builder()
|
||||||
.setTitle(carContext.getString(R.string.grant_on_phone))
|
.setTitle(carContext.getString(R.string.grant_on_phone))
|
||||||
.setBackgroundColor(CarColor.PRIMARY)
|
.setBackgroundColor(CarColor.PRIMARY)
|
||||||
|
.setFlags(Action.FLAG_PRIMARY)
|
||||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
requestPermissions()
|
requestPermissions()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ class PlaceSearchScreen(
|
|||||||
DistanceSpan.create(
|
DistanceSpan.create(
|
||||||
roundValueToDistance(
|
roundValueToDistance(
|
||||||
it,
|
it,
|
||||||
energyLevel?.distanceDisplayUnit?.value
|
energyLevel?.distanceDisplayUnit?.value,
|
||||||
|
carContext
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
@@ -115,7 +116,7 @@ class PlaceSearchScreen(
|
|||||||
|
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val placeDetails = getDetails(place.id)
|
val placeDetails = getDetails(place.id) ?: return@launch
|
||||||
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
|
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
|
||||||
prefs.placeSearchResultAndroidAutoName =
|
prefs.placeSearchResultAndroidAutoName =
|
||||||
place.primaryText.toString()
|
place.primaryText.toString()
|
||||||
@@ -225,9 +226,9 @@ class PlaceSearchScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getDetails(id: String): PlaceWithBounds {
|
suspend fun getDetails(id: String): PlaceWithBounds? {
|
||||||
val provider = currentProvider!!
|
val provider = currentProvider!!
|
||||||
val result = resultList!!.find { it.id == id }!!
|
val result = resultList?.find { it.id == id } ?: return null
|
||||||
|
|
||||||
val recentPlace = recentResults.find { it.id == id }
|
val recentPlace = recentResults.find { it.id == id }
|
||||||
if (recentPlace != null) return recentPlace.asPlaceWithBounds()
|
if (recentPlace != null) return recentPlace.asPlaceWithBounds()
|
||||||
|
|||||||
@@ -4,11 +4,22 @@ import androidx.car.app.CarContext
|
|||||||
import androidx.car.app.CarToast
|
import androidx.car.app.CarToast
|
||||||
import androidx.car.app.Screen
|
import androidx.car.app.Screen
|
||||||
import androidx.car.app.constraints.ConstraintManager
|
import androidx.car.app.constraints.ConstraintManager
|
||||||
import androidx.car.app.model.*
|
import androidx.car.app.model.Action
|
||||||
|
import androidx.car.app.model.ActionStrip
|
||||||
|
import androidx.car.app.model.CarColor
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.ItemList
|
||||||
|
import androidx.car.app.model.Row
|
||||||
|
import androidx.car.app.model.SearchTemplate
|
||||||
|
import androidx.car.app.model.Template
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
|
import okio.IOException
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
||||||
SearchTemplate.SearchCallback {
|
SearchTemplate.SearchCallback {
|
||||||
@@ -22,9 +33,15 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
|||||||
override fun onGetTemplate(): Template {
|
override fun onGetTemplate(): Template {
|
||||||
if (fullList == null) {
|
if (fullList == null) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
fullList = loadData()
|
try {
|
||||||
filterList()
|
fullList = loadData()
|
||||||
invalidate()
|
filterList()
|
||||||
|
invalidate()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
showLoadingError()
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
showLoadingError()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +52,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
|||||||
} ?: run {
|
} ?: run {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
}
|
}
|
||||||
if (isMultiSelect) {
|
if (isMultiSelect && shouldShowSelectAll) {
|
||||||
setActionStrip(ActionStrip.Builder().apply {
|
setActionStrip(ActionStrip.Builder().apply {
|
||||||
addAction(
|
addAction(
|
||||||
Action.Builder().setIcon(
|
Action.Builder().setIcon(
|
||||||
@@ -62,6 +79,17 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
|||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun showLoadingError() {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
CarToast.makeText(
|
||||||
|
carContext,
|
||||||
|
R.string.generic_connection_error,
|
||||||
|
CarToast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
screenManager.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun filterList() {
|
private fun filterList() {
|
||||||
currentList = fullList?.let {
|
currentList = fullList?.let {
|
||||||
it.sortedBy { getLabel(it).lowercase() }
|
it.sortedBy { getLabel(it).lowercase() }
|
||||||
|
|||||||
@@ -1,26 +1,58 @@
|
|||||||
package net.vonforst.evmap.auto
|
package net.vonforst.evmap.auto
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager.NameNotFoundException
|
import android.content.pm.PackageManager.NameNotFoundException
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.net.Uri
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.car.app.CarContext
|
import androidx.car.app.CarContext
|
||||||
import androidx.car.app.CarToast
|
import androidx.car.app.CarToast
|
||||||
import androidx.car.app.Screen
|
import androidx.car.app.Screen
|
||||||
import androidx.car.app.annotations.ExperimentalCarApi
|
import androidx.car.app.annotations.ExperimentalCarApi
|
||||||
import androidx.car.app.constraints.ConstraintManager
|
import androidx.car.app.constraints.ConstraintManager
|
||||||
import androidx.car.app.model.*
|
import androidx.car.app.model.Action
|
||||||
|
import androidx.car.app.model.CarColor
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.GridItem
|
||||||
|
import androidx.car.app.model.GridTemplate
|
||||||
|
import androidx.car.app.model.ItemList
|
||||||
|
import androidx.car.app.model.ListTemplate
|
||||||
|
import androidx.car.app.model.MessageTemplate
|
||||||
|
import androidx.car.app.model.ParkedOnlyOnClickListener
|
||||||
|
import androidx.car.app.model.Row
|
||||||
|
import androidx.car.app.model.SectionedItemList
|
||||||
|
import androidx.car.app.model.Template
|
||||||
|
import androidx.car.app.model.Toggle
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.core.text.HtmlCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.vonforst.evmap.*
|
import net.vonforst.evmap.BuildConfig
|
||||||
|
import net.vonforst.evmap.EXTRA_DONATE
|
||||||
|
import net.vonforst.evmap.MapsActivity
|
||||||
|
import net.vonforst.evmap.R
|
||||||
|
import net.vonforst.evmap.addDebugInterceptors
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
|
||||||
|
import net.vonforst.evmap.api.availability.tesla.TeslaOwnerApi
|
||||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||||
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
|
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
|
||||||
|
import net.vonforst.evmap.currencyDisplayName
|
||||||
|
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
|
||||||
|
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
|
||||||
|
import net.vonforst.evmap.getPackageInfoCompat
|
||||||
import net.vonforst.evmap.storage.AppDatabase
|
import net.vonforst.evmap.storage.AppDatabase
|
||||||
|
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.io.IOException
|
||||||
|
import java.time.Instant
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@@ -47,7 +79,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
|||||||
)
|
)
|
||||||
setBrowsable(true)
|
setBrowsable(true)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
screenManager.push(DataSettingsScreen(carContext))
|
screenManager.push(DataSettingsScreen(carContext, session))
|
||||||
}
|
}
|
||||||
}.build())
|
}.build())
|
||||||
addItem(Row.Builder().apply {
|
addItem(Row.Builder().apply {
|
||||||
@@ -112,7 +144,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
|||||||
)
|
)
|
||||||
.setBrowsable(true)
|
.setBrowsable(true)
|
||||||
.setOnClickListener {
|
.setOnClickListener {
|
||||||
screenManager.push(AboutScreen(carContext))
|
screenManager.push(AboutScreen(carContext, session))
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
@@ -121,8 +153,10 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
@ExperimentalCarApi
|
||||||
|
class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||||
val prefs = PreferenceDataSource(ctx)
|
val prefs = PreferenceDataSource(ctx)
|
||||||
|
val encryptedPrefs = EncryptedPreferenceDataStore(ctx)
|
||||||
val db = AppDatabase.getInstance(ctx)
|
val db = AppDatabase.getInstance(ctx)
|
||||||
|
|
||||||
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
|
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
|
||||||
@@ -132,6 +166,8 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
val searchProviderValues =
|
val searchProviderValues =
|
||||||
carContext.resources.getStringArray(R.array.pref_search_provider_values)
|
carContext.resources.getStringArray(R.array.pref_search_provider_values)
|
||||||
|
|
||||||
|
var teslaLoggingIn = false
|
||||||
|
|
||||||
override fun onGetTemplate(): Template {
|
override fun onGetTemplate(): Template {
|
||||||
return ListTemplate.Builder().apply {
|
return ListTemplate.Builder().apply {
|
||||||
setTitle(carContext.getString(R.string.settings_data_sources))
|
setTitle(carContext.getString(R.string.settings_data_sources))
|
||||||
@@ -181,9 +217,122 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.build())
|
}.build())
|
||||||
|
/*addItem(
|
||||||
|
Row.Builder()
|
||||||
|
.setTitle(carContext.getString(R.string.pref_prediction_enabled))
|
||||||
|
.addText(carContext.getString(R.string.pref_prediction_enabled_summary))
|
||||||
|
.setToggle(Toggle.Builder {
|
||||||
|
prefs.predictionEnabled = it
|
||||||
|
}.setChecked(prefs.predictionEnabled).build())
|
||||||
|
.build()
|
||||||
|
)*/
|
||||||
|
addItem(Row.Builder().apply {
|
||||||
|
setTitle(carContext.getString(R.string.pref_tesla_account))
|
||||||
|
addText(
|
||||||
|
if (encryptedPrefs.teslaRefreshToken != null) {
|
||||||
|
carContext.getString(
|
||||||
|
R.string.pref_tesla_account_enabled,
|
||||||
|
encryptedPrefs.teslaEmail
|
||||||
|
)
|
||||||
|
} else if (teslaLoggingIn) {
|
||||||
|
carContext.getString(R.string.logging_in)
|
||||||
|
} else {
|
||||||
|
carContext.getString(R.string.pref_tesla_account_disabled)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (encryptedPrefs.teslaRefreshToken != null) {
|
||||||
|
setOnClickListener {
|
||||||
|
teslaLogout()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
|
teslaLogin()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}.build())
|
||||||
}.build())
|
}.build())
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun teslaLogin() {
|
||||||
|
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
|
||||||
|
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
|
||||||
|
val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
|
||||||
|
|
||||||
|
val args = OAuthLoginFragmentArgs(
|
||||||
|
uri.toString(),
|
||||||
|
TeslaAuthenticationApi.resultUrlPrefix,
|
||||||
|
"#000000"
|
||||||
|
).toBundle()
|
||||||
|
val intent = Intent(carContext, OAuthLoginActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
.putExtras(args)
|
||||||
|
|
||||||
|
LocalBroadcastManager.getInstance(carContext)
|
||||||
|
.registerReceiver(object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(ctx: Context, intent: Intent) {
|
||||||
|
val url = IntentCompat.getParcelableExtra(
|
||||||
|
intent,
|
||||||
|
OAuthLoginFragment.EXTRA_URL,
|
||||||
|
Uri::class.java
|
||||||
|
)
|
||||||
|
teslaGetAccessToken(url!!, codeVerifier)
|
||||||
|
}
|
||||||
|
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
|
||||||
|
|
||||||
|
session.cas.startActivity(intent)
|
||||||
|
|
||||||
|
if (BuildConfig.FLAVOR_automotive != "automotive") {
|
||||||
|
CarToast.makeText(
|
||||||
|
carContext,
|
||||||
|
R.string.opened_on_phone,
|
||||||
|
CarToast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun teslaGetAccessToken(url: Uri, codeVerifier: String) {
|
||||||
|
teslaLoggingIn = true
|
||||||
|
invalidate()
|
||||||
|
|
||||||
|
val code = url.getQueryParameter("code") ?: return
|
||||||
|
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
|
||||||
|
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val time = Instant.now().epochSecond
|
||||||
|
val response =
|
||||||
|
TeslaAuthenticationApi.create(okhttp).getToken(request)
|
||||||
|
val userResponse =
|
||||||
|
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
|
||||||
|
|
||||||
|
encryptedPrefs.teslaEmail = userResponse.response.email
|
||||||
|
encryptedPrefs.teslaAccessToken = response.accessToken
|
||||||
|
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
|
||||||
|
encryptedPrefs.teslaRefreshToken = response.refreshToken
|
||||||
|
} catch (e: IOException) {
|
||||||
|
CarToast.makeText(
|
||||||
|
carContext,
|
||||||
|
R.string.generic_connection_error,
|
||||||
|
CarToast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
} finally {
|
||||||
|
teslaLoggingIn = false
|
||||||
|
}
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun teslaLogout() {
|
||||||
|
// sign out
|
||||||
|
encryptedPrefs.teslaRefreshToken = null
|
||||||
|
encryptedPrefs.teslaAccessToken = null
|
||||||
|
encryptedPrefs.teslaAccessTokenExpiry = -1
|
||||||
|
encryptedPrefs.teslaEmail = null
|
||||||
|
CarToast.makeText(carContext, R.string.logged_out, CarToast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChooseDataSourceScreen(
|
class ChooseDataSourceScreen(
|
||||||
@@ -278,12 +427,21 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
setTitle(carContext.getString(R.string.settings_chargeprice))
|
setTitle(carContext.getString(R.string.settings_chargeprice))
|
||||||
setHeaderAction(Action.BACK)
|
setHeaderAction(Action.BACK)
|
||||||
setSingleList(ItemList.Builder().apply {
|
setSingleList(ItemList.Builder().apply {
|
||||||
|
addItem(Row.Builder().apply {
|
||||||
|
setTitle(carContext.getString(R.string.pref_chargeprice_native_integration))
|
||||||
|
addText(carContext.getString(if (prefs.chargepriceNativeIntegration) R.string.pref_chargeprice_native_integration_on else R.string.pref_chargeprice_native_integration_off))
|
||||||
|
setToggle(Toggle.Builder {
|
||||||
|
prefs.chargepriceNativeIntegration = it
|
||||||
|
invalidate()
|
||||||
|
}.setChecked(prefs.chargepriceNativeIntegration).build())
|
||||||
|
}.build())
|
||||||
addItem(Row.Builder().apply {
|
addItem(Row.Builder().apply {
|
||||||
setTitle(carContext.getString(R.string.pref_my_vehicle))
|
setTitle(carContext.getString(R.string.pref_my_vehicle))
|
||||||
setBrowsable(true)
|
setBrowsable(true)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
screenManager.push(SelectVehiclesScreen(carContext))
|
screenManager.push(SelectVehiclesScreen(carContext))
|
||||||
}
|
}
|
||||||
|
setEnabled(prefs.chargepriceNativeIntegration)
|
||||||
}.build())
|
}.build())
|
||||||
addItem(Row.Builder().apply {
|
addItem(Row.Builder().apply {
|
||||||
setTitle(carContext.getString(R.string.pref_my_tariffs))
|
setTitle(carContext.getString(R.string.pref_my_tariffs))
|
||||||
@@ -307,6 +465,7 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
setEnabled(prefs.chargepriceNativeIntegration)
|
||||||
}.build())
|
}.build())
|
||||||
addItem(Row.Builder().apply {
|
addItem(Row.Builder().apply {
|
||||||
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
|
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
|
||||||
@@ -324,14 +483,14 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
screenManager.push(SelectChargingRangeScreen(carContext))
|
screenManager.push(SelectChargingRangeScreen(carContext))
|
||||||
}
|
}
|
||||||
|
setEnabled(prefs.chargepriceNativeIntegration)
|
||||||
}.build())
|
}.build())
|
||||||
addItem(Row.Builder().apply {
|
addItem(Row.Builder().apply {
|
||||||
setTitle(carContext.getString(R.string.pref_chargeprice_currency))
|
setTitle(carContext.getString(R.string.pref_chargeprice_currency))
|
||||||
|
|
||||||
val names =
|
|
||||||
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
|
|
||||||
val values =
|
val values =
|
||||||
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
|
carContext.resources.getStringArray(R.array.pref_chargeprice_currencies)
|
||||||
|
val names = values.map(::currencyDisplayName)
|
||||||
val index = values.indexOf(prefs.chargepriceCurrency)
|
val index = values.indexOf(prefs.chargepriceCurrency)
|
||||||
addText(if (index >= 0) names[index] else "")
|
addText(if (index >= 0) names[index] else "")
|
||||||
|
|
||||||
@@ -339,27 +498,31 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
screenManager.push(SelectCurrencyScreen(carContext))
|
screenManager.push(SelectCurrencyScreen(carContext))
|
||||||
}
|
}
|
||||||
|
setEnabled(prefs.chargepriceNativeIntegration)
|
||||||
}.build())
|
}.build())
|
||||||
addItem(Row.Builder().apply {
|
addItem(Row.Builder().apply {
|
||||||
setTitle(carContext.getString(R.string.pref_chargeprice_no_base_fee))
|
setTitle(carContext.getString(R.string.pref_chargeprice_no_base_fee))
|
||||||
setToggle(Toggle.Builder {
|
setToggle(Toggle.Builder {
|
||||||
prefs.chargepriceNoBaseFee = it
|
prefs.chargepriceNoBaseFee = it
|
||||||
}.setChecked(prefs.chargepriceNoBaseFee).build())
|
}.setChecked(prefs.chargepriceNoBaseFee).build())
|
||||||
}.build())
|
setEnabled(prefs.chargepriceNativeIntegration)
|
||||||
addItem(Row.Builder().apply {
|
|
||||||
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
|
|
||||||
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
|
|
||||||
setToggle(Toggle.Builder {
|
|
||||||
prefs.chargepriceShowProviderCustomerTariffs = it
|
|
||||||
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
|
|
||||||
}.build())
|
}.build())
|
||||||
if (maxRows > 6) {
|
if (maxRows > 6) {
|
||||||
|
addItem(Row.Builder().apply {
|
||||||
|
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
|
||||||
|
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
|
||||||
|
setToggle(Toggle.Builder {
|
||||||
|
prefs.chargepriceShowProviderCustomerTariffs = it
|
||||||
|
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
|
||||||
|
setEnabled(prefs.chargepriceNativeIntegration)
|
||||||
|
}.build())
|
||||||
addItem(Row.Builder().apply {
|
addItem(Row.Builder().apply {
|
||||||
setTitle(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load))
|
setTitle(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load))
|
||||||
addText(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load_summary))
|
addText(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load_summary))
|
||||||
setToggle(Toggle.Builder {
|
setToggle(Toggle.Builder {
|
||||||
prefs.chargepriceAllowUnbalancedLoad = it
|
prefs.chargepriceAllowUnbalancedLoad = it
|
||||||
}.setChecked(prefs.chargepriceAllowUnbalancedLoad).build())
|
}.setChecked(prefs.chargepriceAllowUnbalancedLoad).build())
|
||||||
|
setEnabled(prefs.chargepriceNativeIntegration)
|
||||||
}.build())
|
}.build())
|
||||||
}
|
}
|
||||||
}.build())
|
}.build())
|
||||||
@@ -467,8 +630,8 @@ class SelectCurrencyScreen(ctx: CarContext) : MultiSelectSearchScreen<Pair<Strin
|
|||||||
override fun getLabel(it: Pair<String, String>): String = it.first
|
override fun getLabel(it: Pair<String, String>): String = it.first
|
||||||
|
|
||||||
override suspend fun loadData(): List<Pair<String, String>> {
|
override suspend fun loadData(): List<Pair<String, String>> {
|
||||||
val names = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
|
val values = carContext.resources.getStringArray(R.array.pref_chargeprice_currencies)
|
||||||
val values = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
|
val names = values.map(::currencyDisplayName)
|
||||||
return names.zip(values)
|
return names.zip(values)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -590,7 +753,8 @@ class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
@ExperimentalCarApi
|
||||||
|
class AboutScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||||
val prefs = PreferenceDataSource(ctx)
|
val prefs = PreferenceDataSource(ctx)
|
||||||
var developerOptionsCounter = 0
|
var developerOptionsCounter = 0
|
||||||
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||||
@@ -635,7 +799,11 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
.setTitle(carContext.getString(R.string.faq))
|
.setTitle(carContext.getString(R.string.faq))
|
||||||
.setBrowsable(true)
|
.setBrowsable(true)
|
||||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
openUrl(carContext, carContext.getString(R.string.faq_link))
|
openUrl(
|
||||||
|
carContext,
|
||||||
|
session.cas,
|
||||||
|
carContext.getString(R.string.faq_link)
|
||||||
|
)
|
||||||
}).build()
|
}).build()
|
||||||
)
|
)
|
||||||
addItem(
|
addItem(
|
||||||
@@ -646,12 +814,16 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
if (BuildConfig.FLAVOR_automotive == "automotive") {
|
if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||||
// we can't open the donation page on the phone in this case
|
// we can't open the donation page on the phone in this case
|
||||||
openUrl(carContext, carContext.getString(R.string.paypal_link))
|
openUrl(
|
||||||
|
carContext,
|
||||||
|
session.cas,
|
||||||
|
carContext.getString(R.string.donate_link)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
val intent = Intent(carContext, MapsActivity::class.java)
|
val intent = Intent(carContext, MapsActivity::class.java)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
.putExtra(EXTRA_DONATE, true)
|
.putExtra(EXTRA_DONATE, true)
|
||||||
carContext.startActivity(intent)
|
session.cas.startActivity(intent)
|
||||||
CarToast.makeText(
|
CarToast.makeText(
|
||||||
carContext,
|
carContext,
|
||||||
R.string.opened_on_phone,
|
R.string.opened_on_phone,
|
||||||
@@ -663,39 +835,75 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
}.build(), carContext.getString(R.string.about)))
|
}.build(), carContext.getString(R.string.about)))
|
||||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||||
addItem(Row.Builder()
|
addItem(Row.Builder()
|
||||||
.setTitle(carContext.getString(R.string.twitter))
|
.setTitle(carContext.getString(R.string.mastodon))
|
||||||
.addText(carContext.getString(R.string.twitter_handle))
|
.addText(carContext.getString(R.string.mastodon_handle))
|
||||||
.setBrowsable(true)
|
.setBrowsable(true)
|
||||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
openUrl(carContext, carContext.getString(R.string.twitter_url))
|
openUrl(
|
||||||
|
carContext,
|
||||||
|
session.cas,
|
||||||
|
carContext.getString(R.string.mastodon_url)
|
||||||
|
)
|
||||||
}).build()
|
}).build()
|
||||||
)
|
)
|
||||||
|
if (maxRows > 8) {
|
||||||
|
addItem(
|
||||||
|
Row.Builder()
|
||||||
|
.setTitle(carContext.getString(R.string.twitter))
|
||||||
|
.addText(carContext.getString(R.string.twitter_handle))
|
||||||
|
.setBrowsable(true)
|
||||||
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
|
openUrl(
|
||||||
|
carContext,
|
||||||
|
session.cas,
|
||||||
|
carContext.getString(R.string.twitter_url)
|
||||||
|
)
|
||||||
|
}).build()
|
||||||
|
)
|
||||||
|
}
|
||||||
if (maxRows > 6) {
|
if (maxRows > 6) {
|
||||||
addItem(Row.Builder()
|
addItem(Row.Builder()
|
||||||
.setTitle(carContext.getString(R.string.goingelectric_forum))
|
.setTitle(carContext.getString(R.string.goingelectric_forum))
|
||||||
.setBrowsable(true)
|
.setBrowsable(true)
|
||||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
openUrl(
|
openUrl(
|
||||||
carContext,
|
carContext, session.cas,
|
||||||
carContext.getString(R.string.goingelectric_forum_url)
|
carContext.getString(R.string.goingelectric_forum_url)
|
||||||
)
|
)
|
||||||
}).build()
|
}).build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (maxRows > 7) {
|
||||||
|
addItem(
|
||||||
|
Row.Builder()
|
||||||
|
.setTitle(carContext.getString(R.string.tff_forum))
|
||||||
|
.setBrowsable(true)
|
||||||
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
|
openUrl(
|
||||||
|
carContext, session.cas,
|
||||||
|
carContext.getString(R.string.tff_forum_url)
|
||||||
|
)
|
||||||
|
}).build()
|
||||||
|
)
|
||||||
|
}
|
||||||
}.build(), carContext.getString(R.string.contact)))
|
}.build(), carContext.getString(R.string.contact)))
|
||||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||||
addItem(Row.Builder()
|
addItem(Row.Builder()
|
||||||
.setTitle(carContext.getString(R.string.github_link_title))
|
.setTitle(carContext.getString(R.string.github_link_title))
|
||||||
.setBrowsable(true)
|
.setBrowsable(true)
|
||||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
openUrl(carContext, carContext.getString(R.string.github_link))
|
openUrl(carContext, session.cas, carContext.getString(R.string.github_link))
|
||||||
}).build()
|
}).build()
|
||||||
)
|
)
|
||||||
addItem(Row.Builder()
|
addItem(Row.Builder()
|
||||||
.setTitle(carContext.getString(R.string.privacy))
|
.setTitle(carContext.getString(R.string.privacy))
|
||||||
.setBrowsable(true)
|
.setBrowsable(true)
|
||||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
openUrl(carContext, carContext.getString(R.string.privacy_link))
|
openUrl(
|
||||||
|
carContext,
|
||||||
|
session.cas,
|
||||||
|
carContext.getString(R.string.privacy_link)
|
||||||
|
)
|
||||||
}).build()
|
}).build()
|
||||||
)
|
)
|
||||||
}.build(), carContext.getString(R.string.other)))
|
}.build(), carContext.getString(R.string.other)))
|
||||||
@@ -703,6 +911,35 @@ class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExperimentalCarApi
|
||||||
|
class AcceptPrivacyScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||||
|
val prefs = PreferenceDataSource(ctx)
|
||||||
|
override fun onGetTemplate(): Template {
|
||||||
|
val textWithoutLink = HtmlCompat.fromHtml(
|
||||||
|
carContext.getString(R.string.accept_privacy),
|
||||||
|
HtmlCompat.FROM_HTML_MODE_LEGACY
|
||||||
|
).toString()
|
||||||
|
return MessageTemplate.Builder(textWithoutLink).apply {
|
||||||
|
setTitle(carContext.getString(R.string.privacy))
|
||||||
|
addAction(Action.Builder()
|
||||||
|
.setTitle(carContext.getString(R.string.ok))
|
||||||
|
.setFlags(Action.FLAG_PRIMARY)
|
||||||
|
.setBackgroundColor(CarColor.PRIMARY)
|
||||||
|
.setOnClickListener {
|
||||||
|
prefs.privacyAccepted = true
|
||||||
|
screenManager.pop()
|
||||||
|
}.build()
|
||||||
|
)
|
||||||
|
addAction(Action.Builder()
|
||||||
|
.setTitle(carContext.getString(R.string.privacy))
|
||||||
|
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||||
|
openUrl(carContext, session.cas, carContext.getString(R.string.privacy_link))
|
||||||
|
}).build()
|
||||||
|
)
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class DeveloperOptionsScreen(ctx: CarContext) : Screen(ctx) {
|
class DeveloperOptionsScreen(ctx: CarContext) : Screen(ctx) {
|
||||||
val prefs = PreferenceDataSource(ctx)
|
val prefs = PreferenceDataSource(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,34 @@ import android.content.ActivityNotFoundException
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Typeface
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.text.TextPaint
|
||||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.car.app.CarContext
|
import androidx.car.app.CarContext
|
||||||
import androidx.car.app.CarToast
|
import androidx.car.app.CarToast
|
||||||
import androidx.car.app.Screen
|
import androidx.car.app.Screen
|
||||||
|
import androidx.car.app.annotations.ExperimentalCarApi
|
||||||
import androidx.car.app.constraints.ConstraintManager
|
import androidx.car.app.constraints.ConstraintManager
|
||||||
import androidx.car.app.hardware.common.CarUnit
|
import androidx.car.app.hardware.common.CarUnit
|
||||||
import androidx.car.app.model.*
|
import androidx.car.app.model.CarColor
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.Distance
|
||||||
|
import androidx.car.app.model.MessageTemplate
|
||||||
|
import androidx.car.app.model.Template
|
||||||
import androidx.car.app.versioning.CarAppApiLevels
|
import androidx.car.app.versioning.CarAppApiLevels
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import net.vonforst.evmap.BuildConfig
|
import net.vonforst.evmap.BuildConfig
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||||
|
import net.vonforst.evmap.ftPerMile
|
||||||
import net.vonforst.evmap.getPackageInfoCompat
|
import net.vonforst.evmap.getPackageInfoCompat
|
||||||
import java.util.*
|
import net.vonforst.evmap.kmPerMile
|
||||||
|
import net.vonforst.evmap.shouldUseImperialUnits
|
||||||
|
import net.vonforst.evmap.ydPerMile
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
|
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
|
||||||
@@ -69,33 +80,26 @@ val emptyCarIcon: CarIcon by lazy {
|
|||||||
).asCarIcon()
|
).asCarIcon()
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val kmPerMile = 1.609344
|
|
||||||
private const val ftPerMile = 5280
|
|
||||||
private const val ydPerMile = 1760
|
|
||||||
|
|
||||||
fun getDefaultDistanceUnit(): Int {
|
fun getDefaultDistanceUnit(ctx: Context): Int {
|
||||||
return if (usesImperialUnits(Locale.getDefault())) {
|
return if (shouldUseImperialUnits(ctx)) {
|
||||||
CarUnit.MILE
|
CarUnit.MILE
|
||||||
} else {
|
} else {
|
||||||
CarUnit.KILOMETER
|
CarUnit.KILOMETER
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun usesImperialUnits(locale: Locale): Boolean {
|
fun getDefaultSpeedUnit(ctx: Context): Int {
|
||||||
return locale.country in listOf("US", "GB", "MM", "LR")
|
return if (shouldUseImperialUnits(ctx)) {
|
||||||
|| locale.country == "" && locale.language == "en"
|
CarUnit.MILES_PER_HOUR
|
||||||
}
|
} else {
|
||||||
|
CarUnit.KILOMETERS_PER_HOUR
|
||||||
fun getDefaultSpeedUnit(): Int {
|
|
||||||
return when (Locale.getDefault().country) {
|
|
||||||
"US", "GB", "MM", "LR" -> CarUnit.MILES_PER_HOUR
|
|
||||||
else -> CarUnit.KILOMETERS_PER_HOUR
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun formatCarUnitDistance(value: Float?, unit: Int?): String {
|
fun formatCarUnitDistance(value: Float?, unit: Int?, ctx: Context): String {
|
||||||
if (value == null) return ""
|
if (value == null) return ""
|
||||||
return when (unit ?: getDefaultDistanceUnit()) {
|
return when (unit ?: getDefaultDistanceUnit(ctx)) {
|
||||||
// distance units: base unit is meters
|
// distance units: base unit is meters
|
||||||
CarUnit.METER -> "%.0f m".format(value)
|
CarUnit.METER -> "%.0f m".format(value)
|
||||||
CarUnit.KILOMETER -> "%.1f km".format(value / 1000)
|
CarUnit.KILOMETER -> "%.1f km".format(value / 1000)
|
||||||
@@ -105,9 +109,9 @@ fun formatCarUnitDistance(value: Float?, unit: Int?): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun formatCarUnitSpeed(value: Float?, unit: Int?): String {
|
fun formatCarUnitSpeed(value: Float?, unit: Int?, ctx: Context): String {
|
||||||
if (value == null) return ""
|
if (value == null) return ""
|
||||||
return when (unit ?: getDefaultSpeedUnit()) {
|
return when (unit ?: getDefaultSpeedUnit(ctx)) {
|
||||||
// speed units: base unit is meters per second
|
// speed units: base unit is meters per second
|
||||||
CarUnit.METERS_PER_SEC -> "%.0f m/s".format(value)
|
CarUnit.METERS_PER_SEC -> "%.0f m/s".format(value)
|
||||||
CarUnit.KILOMETERS_PER_HOUR -> "%.0f km/h".format(value * 3.6)
|
CarUnit.KILOMETERS_PER_HOUR -> "%.0f km/h".format(value * 3.6)
|
||||||
@@ -116,9 +120,9 @@ fun formatCarUnitSpeed(value: Float?, unit: Int?): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun roundValueToDistance(value: Double, unit: Int? = null): Distance {
|
fun roundValueToDistance(value: Double, unit: Int? = null, ctx: Context): Distance {
|
||||||
// value is in meters
|
// value is in meters
|
||||||
when (unit ?: getDefaultDistanceUnit()) {
|
when (unit ?: getDefaultDistanceUnit(ctx)) {
|
||||||
CarUnit.MILE -> {
|
CarUnit.MILE -> {
|
||||||
// imperial system
|
// imperial system
|
||||||
val miles = value / 1000 / kmPerMile
|
val miles = value / 1000 / kmPerMile
|
||||||
@@ -198,7 +202,7 @@ fun <T> List<T>.paginate(nSingle: Int, nFirst: Int, nOther: Int, nLast: Int): Li
|
|||||||
|
|
||||||
fun getAndroidAutoVersion(ctx: Context): List<String> {
|
fun getAndroidAutoVersion(ctx: Context): List<String> {
|
||||||
val info = ctx.packageManager.getPackageInfoCompat("com.google.android.projection.gearhead", 0)
|
val info = ctx.packageManager.getPackageInfoCompat("com.google.android.projection.gearhead", 0)
|
||||||
return info.versionName.split(".")
|
return info.versionName!!.split(".")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun supportsCarApiLevel3(ctx: CarContext): Boolean {
|
fun supportsCarApiLevel3(ctx: CarContext): Boolean {
|
||||||
@@ -208,7 +212,9 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
|
|||||||
val version = getAndroidAutoVersion(ctx)
|
val version = getAndroidAutoVersion(ctx)
|
||||||
// Android Auto 6.7 is required. 6.6 reports supporting API Level 3,
|
// Android Auto 6.7 is required. 6.6 reports supporting API Level 3,
|
||||||
// but crashes when using it. See: https://issuetracker.google.com/issues/199509584
|
// but crashes when using it. See: https://issuetracker.google.com/issues/199509584
|
||||||
if (version[0] < "6" || version[0] == "6" && version[1] < "7") {
|
val major = version[0].toIntOrNull() ?: return false
|
||||||
|
val minor = version[1].toIntOrNull() ?: return false
|
||||||
|
if (major < 6 || major < 6 && minor < 7) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,13 +222,14 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openUrl(carContext: CarContext, url: String) {
|
@ExperimentalCarApi
|
||||||
|
fun openUrl(carContext: CarContext, cas: CarAppService, url: String) {
|
||||||
val intent = CustomTabsIntent.Builder()
|
val intent = CustomTabsIntent.Builder()
|
||||||
.setDefaultColorSchemeParams(
|
.setDefaultColorSchemeParams(
|
||||||
CustomTabColorSchemeParams.Builder()
|
CustomTabColorSchemeParams.Builder()
|
||||||
.setToolbarColor(
|
.setToolbarColor(
|
||||||
ContextCompat.getColor(
|
ContextCompat.getColor(
|
||||||
carContext,
|
cas,
|
||||||
R.color.colorPrimary
|
R.color.colorPrimary
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -232,7 +239,7 @@ fun openUrl(carContext: CarContext, url: String) {
|
|||||||
intent.data = Uri.parse(url)
|
intent.data = Uri.parse(url)
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
try {
|
try {
|
||||||
carContext.startActivity(intent)
|
cas.startActivity(intent)
|
||||||
if (BuildConfig.FLAVOR_automotive != "automotive") {
|
if (BuildConfig.FLAVOR_automotive != "automotive") {
|
||||||
// only show the toast "opened on phone" if we're running on a phone
|
// only show the toast "opened on phone" if we're running on a phone
|
||||||
CarToast.makeText(
|
CarToast.makeText(
|
||||||
@@ -261,4 +268,17 @@ class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextMeasurer(ctx: CarContext) {
|
||||||
|
val textPaint = TextPaint()
|
||||||
|
|
||||||
|
init {
|
||||||
|
textPaint.textSize = ctx.resources.displayMetrics.density * 24
|
||||||
|
textPaint.typeface = Typeface.DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun measureText(text: CharSequence): Float {
|
||||||
|
return textPaint.measureText(text, 0, text.length)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,18 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.car.app.CarContext
|
import androidx.car.app.CarContext
|
||||||
import androidx.car.app.Screen
|
import androidx.car.app.Screen
|
||||||
import androidx.car.app.hardware.CarHardwareManager
|
import androidx.car.app.hardware.info.CarSensors
|
||||||
import androidx.car.app.hardware.info.*
|
import androidx.car.app.hardware.info.Compass
|
||||||
import androidx.car.app.model.*
|
import androidx.car.app.hardware.info.EnergyLevel
|
||||||
|
import androidx.car.app.hardware.info.Model
|
||||||
|
import androidx.car.app.hardware.info.Speed
|
||||||
|
import androidx.car.app.model.Action
|
||||||
|
import androidx.car.app.model.CarColor
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.GridItem
|
||||||
|
import androidx.car.app.model.GridTemplate
|
||||||
|
import androidx.car.app.model.ItemList
|
||||||
|
import androidx.car.app.model.Template
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
@@ -18,14 +27,14 @@ import net.vonforst.evmap.R
|
|||||||
import net.vonforst.evmap.ui.CompassNeedle
|
import net.vonforst.evmap.ui.CompassNeedle
|
||||||
import net.vonforst.evmap.ui.Gauge
|
import net.vonforst.evmap.ui.Gauge
|
||||||
import net.vonforst.evmap.utils.formatDecimal
|
import net.vonforst.evmap.utils.formatDecimal
|
||||||
|
import patchedCarInfo
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@androidx.car.app.annotations.ExperimentalCarApi
|
@androidx.car.app.annotations.ExperimentalCarApi
|
||||||
class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
|
class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
|
||||||
LocationAwareScreen, DefaultLifecycleObserver {
|
LocationAwareScreen, DefaultLifecycleObserver {
|
||||||
private val carInfo =
|
private val carInfo = carContext.patchedCarInfo
|
||||||
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
|
|
||||||
private val carSensors = carContext.patchedCarSensors
|
private val carSensors = carContext.patchedCarSensors
|
||||||
private var model: Model? = null
|
private var model: Model? = null
|
||||||
private var energyLevel: EnergyLevel? = null
|
private var energyLevel: EnergyLevel? = null
|
||||||
@@ -139,7 +148,8 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
|
|||||||
setText(
|
setText(
|
||||||
formatCarUnitDistance(
|
formatCarUnitDistance(
|
||||||
energyLevel.rangeRemainingMeters.value,
|
energyLevel.rangeRemainingMeters.value,
|
||||||
energyLevel.distanceDisplayUnit.value
|
energyLevel.distanceDisplayUnit.value,
|
||||||
|
carContext
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
setImage(
|
setImage(
|
||||||
@@ -173,7 +183,8 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
|
|||||||
setText(
|
setText(
|
||||||
formatCarUnitSpeed(
|
formatCarUnitSpeed(
|
||||||
rawSpeed,
|
rawSpeed,
|
||||||
speed.speedDisplayUnit.value
|
speed.speedDisplayUnit.value,
|
||||||
|
carContext
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
setImage(
|
setImage(
|
||||||
@@ -183,7 +194,8 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
|
|||||||
setText(
|
setText(
|
||||||
formatCarUnitSpeed(
|
formatCarUnitSpeed(
|
||||||
speed.displaySpeedMetersPerSecond.value,
|
speed.displaySpeedMetersPerSecond.value,
|
||||||
speed.speedDisplayUnit.value
|
speed.speedDisplayUnit.value,
|
||||||
|
carContext
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
setImage(
|
setImage(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.mapbox.api.geocoding.v5.models.CarmenFeature
|
|||||||
import com.mapbox.geojson.BoundingBox
|
import com.mapbox.geojson.BoundingBox
|
||||||
import com.mapbox.geojson.Point
|
import com.mapbox.geojson.Point
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
|
import retrofit2.HttpException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
||||||
@@ -25,7 +26,7 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
|||||||
override val id = "mapbox"
|
override val id = "mapbox"
|
||||||
|
|
||||||
override fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace> {
|
override fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace> {
|
||||||
val result = MapboxGeocoding.builder().apply {
|
val request = MapboxGeocoding.builder().apply {
|
||||||
location?.let {
|
location?.let {
|
||||||
proximity(Point.fromLngLat(location.longitude, location.latitude))
|
proximity(Point.fromLngLat(location.longitude, location.latitude))
|
||||||
}
|
}
|
||||||
@@ -33,7 +34,12 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
|||||||
accessToken(context.getString(R.string.mapbox_key))
|
accessToken(context.getString(R.string.mapbox_key))
|
||||||
autocomplete(true)
|
autocomplete(true)
|
||||||
this.query(query)
|
this.query(query)
|
||||||
}.build().executeCall()
|
}
|
||||||
|
val result = try {
|
||||||
|
request.build().executeCall()
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
throw IOException(e)
|
||||||
|
}
|
||||||
if (!result.isSuccessful) {
|
if (!result.isSuccessful) {
|
||||||
throw IOException(result.message())
|
throw IOException(result.message())
|
||||||
}
|
}
|
||||||
@@ -113,8 +119,7 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
|||||||
|
|
||||||
override fun getAttributionString(): Int = R.string.powered_by_mapbox
|
override fun getAttributionString(): Int = R.string.powered_by_mapbox
|
||||||
|
|
||||||
override fun getAttributionImage(dark: Boolean): Int =
|
override fun getAttributionImage(dark: Boolean): Int = R.drawable.mapbox_logo
|
||||||
if (dark) com.mapbox.mapboxsdk.R.drawable.mapbox_logo_icon else R.drawable.mapbox_logo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BoundingBox.toLatLngBounds(): LatLngBounds {
|
private fun BoundingBox.toLatLngBounds(): LatLngBounds {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import net.vonforst.evmap.api.equivalentPlugTypes
|
|||||||
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
|
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
|
||||||
import net.vonforst.evmap.databinding.FragmentChargepriceHeaderBinding
|
import net.vonforst.evmap.databinding.FragmentChargepriceHeaderBinding
|
||||||
import net.vonforst.evmap.model.Chargepoint
|
import net.vonforst.evmap.model.Chargepoint
|
||||||
|
import net.vonforst.evmap.navigation.safeNavigate
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
|
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
|
||||||
import net.vonforst.evmap.viewmodel.Status
|
import net.vonforst.evmap.viewmodel.Status
|
||||||
@@ -61,7 +62,7 @@ class ChargepriceFragment : Fragment() {
|
|||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
val prefs = PreferenceDataSource(requireContext())
|
val prefs = PreferenceDataSource(requireContext())
|
||||||
prefs.chargepriceCounter += 1
|
prefs.chargepriceCounter += 1
|
||||||
if ((prefs.chargepriceCounter - 30).mod(50) == 0) {
|
if ((prefs.chargepriceCounter).mod(30) == 0) {
|
||||||
showDonationDialog()
|
showDonationDialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +82,7 @@ class ChargepriceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
.setPositiveButton(R.string.donate) { di, _ ->
|
.setPositiveButton(R.string.donate) { di, _ ->
|
||||||
di.dismiss()
|
di.dismiss()
|
||||||
findNavController().navigate(R.id.action_chargeprice_to_donateFragment)
|
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToDonateFragment())
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
@@ -99,9 +100,9 @@ class ChargepriceFragment : Fragment() {
|
|||||||
inflater,
|
inflater,
|
||||||
R.layout.fragment_chargeprice_header, container, false
|
R.layout.fragment_chargeprice_header, container, false
|
||||||
)
|
)
|
||||||
binding.lifecycleOwner = this
|
binding.lifecycleOwner = viewLifecycleOwner
|
||||||
binding.vm = vm
|
binding.vm = vm
|
||||||
headerBinding.lifecycleOwner = this
|
headerBinding.lifecycleOwner = viewLifecycleOwner
|
||||||
headerBinding.vm = vm
|
headerBinding.vm = vm
|
||||||
|
|
||||||
binding.toolbar.inflateMenu(R.menu.chargeprice)
|
binding.toolbar.inflateMenu(R.menu.chargeprice)
|
||||||
@@ -140,7 +141,7 @@ class ChargepriceFragment : Fragment() {
|
|||||||
|
|
||||||
val chargepriceAdapter = ChargepriceAdapter().apply {
|
val chargepriceAdapter = ChargepriceAdapter().apply {
|
||||||
onClickListener = {
|
onClickListener = {
|
||||||
(requireActivity() as MapsActivity).openUrl(it.url)
|
(requireActivity() as MapsActivity).openUrl(it.url, binding.root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val joinedAdapter = ConcatAdapter(
|
val joinedAdapter = ConcatAdapter(
|
||||||
@@ -167,7 +168,7 @@ class ChargepriceFragment : Fragment() {
|
|||||||
chargepriceAdapter.myTariffsAll = it
|
chargepriceAdapter.myTariffsAll = it
|
||||||
}
|
}
|
||||||
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
|
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
|
||||||
it?.data?.let { chargepriceAdapter.submitList(it) }
|
chargepriceAdapter.submitList(it?.data ?: emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
val connectorsAdapter = CheckableConnectorAdapter()
|
val connectorsAdapter = CheckableConnectorAdapter()
|
||||||
@@ -193,11 +194,14 @@ class ChargepriceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.imgChargepriceLogo.setOnClickListener {
|
binding.imgChargepriceLogo.setOnClickListener {
|
||||||
(requireActivity() as MapsActivity).openUrl(ChargepriceApi.getPoiUrl(charger))
|
(requireActivity() as MapsActivity).openUrl(
|
||||||
|
ChargepriceApi.getPoiUrl(charger),
|
||||||
|
binding.root
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnSettings.setOnClickListener {
|
binding.btnSettings.setOnClickListener {
|
||||||
findNavController().navigate(R.id.action_chargeprice_to_chargepriceSettingsFragment)
|
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToChargepriceSettingsFragment())
|
||||||
}
|
}
|
||||||
|
|
||||||
headerBinding.batteryRange.setLabelFormatter { value: Float ->
|
headerBinding.batteryRange.setLabelFormatter { value: Float ->
|
||||||
@@ -212,11 +216,19 @@ class ChargepriceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
headerBinding.tvChargeFromTo.setOnClickListener {
|
||||||
|
it.postDelayed({
|
||||||
|
vm.resetBatteryRangeToDefault()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
binding.toolbar.setOnMenuItemClickListener {
|
binding.toolbar.setOnMenuItemClickListener {
|
||||||
when (it.itemId) {
|
when (it.itemId) {
|
||||||
R.id.menu_help -> {
|
R.id.menu_help -> {
|
||||||
(activity as? MapsActivity)?.openUrl(getString(R.string.chargeprice_faq_link))
|
(activity as? MapsActivity)?.openUrl(
|
||||||
|
getString(R.string.chargeprice_faq_link),
|
||||||
|
binding.root
|
||||||
|
)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package net.vonforst.evmap.fragment
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import androidx.databinding.DataBindingUtil
|
||||||
|
import androidx.recyclerview.widget.ConcatAdapter
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import net.vonforst.evmap.R
|
||||||
|
import net.vonforst.evmap.adapter.ConnectorAdapter
|
||||||
|
import net.vonforst.evmap.adapter.ConnectorDetailsAdapter
|
||||||
|
import net.vonforst.evmap.adapter.SingleViewAdapter
|
||||||
|
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||||
|
import net.vonforst.evmap.databinding.DialogConnectorDetailsBinding
|
||||||
|
import net.vonforst.evmap.databinding.DialogConnectorDetailsHeaderBinding
|
||||||
|
import net.vonforst.evmap.model.Chargepoint
|
||||||
|
|
||||||
|
class ConnectorDetailsDialog(
|
||||||
|
binding: DialogConnectorDetailsBinding,
|
||||||
|
context: Context,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
private var headerBinding_: DialogConnectorDetailsHeaderBinding? = null
|
||||||
|
private val headerBinding get() = headerBinding_!!
|
||||||
|
private val detailsAdapter = ConnectorDetailsAdapter()
|
||||||
|
|
||||||
|
init {
|
||||||
|
binding.list.apply {
|
||||||
|
itemAnimator = null
|
||||||
|
layoutManager =
|
||||||
|
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
|
||||||
|
}
|
||||||
|
headerBinding_ = DataBindingUtil.inflate(
|
||||||
|
LayoutInflater.from(context),
|
||||||
|
R.layout.dialog_connector_details_header, binding.list, false
|
||||||
|
)
|
||||||
|
binding.list.adapter = ConcatAdapter(
|
||||||
|
SingleViewAdapter(headerBinding.root),
|
||||||
|
detailsAdapter
|
||||||
|
)
|
||||||
|
binding.btnClose.setOnClickListener {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setData(cp: Chargepoint, status: ChargeLocationStatus?) {
|
||||||
|
val cpStatus = status?.status?.get(cp)
|
||||||
|
val items = if (status != null) {
|
||||||
|
List(cp.count) { i ->
|
||||||
|
ConnectorDetailsAdapter.ConnectorDetails(
|
||||||
|
cpStatus?.get(i),
|
||||||
|
status.evseIds?.get(cp)?.get(i),
|
||||||
|
status.labels?.get(cp)?.get(i),
|
||||||
|
status.lastChange?.get(cp)?.get(i)
|
||||||
|
)
|
||||||
|
}.sortedBy { it.evseId ?: it.label }
|
||||||
|
} else emptyList()
|
||||||
|
detailsAdapter.submitList(items)
|
||||||
|
|
||||||
|
headerBinding.divider.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE
|
||||||
|
headerBinding.item = ConnectorAdapter.ChargepointWithAvailability(cp, cpStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDestroy() {
|
||||||
|
headerBinding_ = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package net.vonforst.evmap.fragment
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import net.vonforst.evmap.R
|
||||||
|
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
|
||||||
|
|
||||||
|
abstract class DonateFragmentBase : Fragment() {
|
||||||
|
fun setupReferrals(referrals: FragmentDonateReferralBinding) {
|
||||||
|
referrals.referralWebView.loadUrl(getString(R.string.referral_link))
|
||||||
|
referrals.referralWebView.webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(
|
||||||
|
view: WebView,
|
||||||
|
request: WebResourceRequest
|
||||||
|
): Boolean {
|
||||||
|
Intent(Intent.ACTION_VIEW, request.url).apply {
|
||||||
|
startActivity(this)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,6 @@ import net.vonforst.evmap.databinding.FragmentFavoritesBinding
|
|||||||
import net.vonforst.evmap.databinding.ItemFavoriteBinding
|
import net.vonforst.evmap.databinding.ItemFavoriteBinding
|
||||||
import net.vonforst.evmap.location.FusionEngine
|
import net.vonforst.evmap.location.FusionEngine
|
||||||
import net.vonforst.evmap.location.LocationEngine
|
import net.vonforst.evmap.location.LocationEngine
|
||||||
import net.vonforst.evmap.model.Favorite
|
|
||||||
import net.vonforst.evmap.model.FavoriteWithDetail
|
import net.vonforst.evmap.model.FavoriteWithDetail
|
||||||
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
||||||
import net.vonforst.evmap.viewmodel.FavoritesViewModel
|
import net.vonforst.evmap.viewmodel.FavoritesViewModel
|
||||||
@@ -37,7 +36,6 @@ import net.vonforst.evmap.viewmodel.viewModelFactory
|
|||||||
class FavoritesFragment : Fragment() {
|
class FavoritesFragment : Fragment() {
|
||||||
private lateinit var binding: FragmentFavoritesBinding
|
private lateinit var binding: FragmentFavoritesBinding
|
||||||
private lateinit var locationEngine: LocationEngine
|
private lateinit var locationEngine: LocationEngine
|
||||||
private var toDelete: Favorite? = null
|
|
||||||
private var deleteSnackbar: Snackbar? = null
|
private var deleteSnackbar: Snackbar? = null
|
||||||
private lateinit var adapter: FavoritesAdapter
|
private lateinit var adapter: FavoritesAdapter
|
||||||
|
|
||||||
@@ -67,7 +65,7 @@ class FavoritesFragment : Fragment() {
|
|||||||
inflater,
|
inflater,
|
||||||
R.layout.fragment_favorites, container, false
|
R.layout.fragment_favorites, container, false
|
||||||
)
|
)
|
||||||
binding.lifecycleOwner = this
|
binding.lifecycleOwner = viewLifecycleOwner
|
||||||
binding.vm = vm
|
binding.vm = vm
|
||||||
|
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -113,6 +111,32 @@ class FavoritesFragment : Fragment() {
|
|||||||
binding.swipeRefresh.isRefreshing = false
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vm.deletedFavorite.observe(viewLifecycleOwner) { fav ->
|
||||||
|
if (fav == null) {
|
||||||
|
deleteSnackbar?.dismiss()
|
||||||
|
return@observe
|
||||||
|
}
|
||||||
|
val snackbar = Snackbar.make(
|
||||||
|
requireView(),
|
||||||
|
getString(
|
||||||
|
R.string.deleted_item,
|
||||||
|
fav.charger.name
|
||||||
|
),
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).setAction(R.string.undo) {
|
||||||
|
vm.undoDeletion()
|
||||||
|
}.addCallback(object : Snackbar.Callback() {
|
||||||
|
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||||
|
// if undo was not clicked, actually delete
|
||||||
|
if (event == DISMISS_EVENT_TIMEOUT || event == DISMISS_EVENT_SWIPE) {
|
||||||
|
vm.deletedFavorite.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
deleteSnackbar = snackbar
|
||||||
|
snackbar.show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
@@ -127,41 +151,7 @@ class FavoritesFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun delete(fav: FavoriteWithDetail) {
|
fun delete(fav: FavoriteWithDetail) {
|
||||||
val position =
|
vm.deleteFavoriteWithUndo(fav)
|
||||||
vm.listData.value?.indexOfFirst { it.fav.favorite.favoriteId == fav.favorite.favoriteId }
|
|
||||||
?: return
|
|
||||||
// if there is already a profile to delete, delete it now
|
|
||||||
actuallyDelete()
|
|
||||||
deleteSnackbar?.dismiss()
|
|
||||||
|
|
||||||
toDelete = fav.favorite
|
|
||||||
|
|
||||||
view?.let {
|
|
||||||
val snackbar = Snackbar.make(
|
|
||||||
it,
|
|
||||||
getString(R.string.deleted_filterprofile, fav.charger.name),
|
|
||||||
Snackbar.LENGTH_LONG
|
|
||||||
).setAction(R.string.undo) {
|
|
||||||
toDelete = null
|
|
||||||
adapter.notifyItemChanged(position)
|
|
||||||
}.addCallback(object : Snackbar.Callback() {
|
|
||||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
|
||||||
// if undo was not clicked, actually delete
|
|
||||||
if (event == DISMISS_EVENT_TIMEOUT || event == DISMISS_EVENT_SWIPE) {
|
|
||||||
actuallyDelete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
deleteSnackbar = snackbar
|
|
||||||
snackbar.show()
|
|
||||||
} ?: run {
|
|
||||||
actuallyDelete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun actuallyDelete() {
|
|
||||||
toDelete?.let { vm.deleteFavorite(it) }
|
|
||||||
toDelete = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createTouchHelper(): ItemTouchHelper {
|
private fun createTouchHelper(): ItemTouchHelper {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package net.vonforst.evmap.fragment
|
package net.vonforst.evmap.fragment
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.*
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
import androidx.databinding.DataBindingUtil
|
import androidx.databinding.DataBindingUtil
|
||||||
@@ -40,7 +45,7 @@ class FilterFragment : Fragment(), MenuProvider {
|
|||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_filter, container, false)
|
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_filter, container, false)
|
||||||
binding.lifecycleOwner = this
|
binding.lifecycleOwner = viewLifecycleOwner
|
||||||
binding.vm = vm
|
binding.vm = vm
|
||||||
vm.filterProfile.observe(viewLifecycleOwner) {}
|
vm.filterProfile.observe(viewLifecycleOwner) {}
|
||||||
|
|
||||||
@@ -108,31 +113,19 @@ class FilterFragment : Fragment(), MenuProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveProfile(error: Boolean = false) {
|
private fun saveProfile() {
|
||||||
showEditTextDialog(requireContext()) { dialog, input ->
|
showEditTextDialog(requireContext(), { dialog, input ->
|
||||||
vm.filterProfile.value?.let { profile ->
|
vm.filterProfile.value?.let { profile ->
|
||||||
input.setText(profile.name)
|
input.setText(profile.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
|
||||||
input.error = getString(R.string.required)
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.setTitle(R.string.save_as_profile)
|
dialog.setTitle(R.string.save_as_profile)
|
||||||
.setMessage(R.string.save_profile_enter_name)
|
.setMessage(R.string.save_profile_enter_name)
|
||||||
.setPositiveButton(R.string.ok) { _, _ ->
|
}, {
|
||||||
if (input.text.isBlank()) {
|
lifecycleScope.launch {
|
||||||
saveProfile(true)
|
vm.saveAsProfile(it)
|
||||||
} else {
|
findNavController().popBackStack()
|
||||||
lifecycleScope.launch {
|
}
|
||||||
vm.saveAsProfile(input.text.toString())
|
})
|
||||||
findNavController().popBackStack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setNegativeButton(R.string.cancel) { _, _ ->
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ class FilterProfilesFragment : Fragment() {
|
|||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentFilterProfilesBinding.inflate(inflater, container, false)
|
binding = FragmentFilterProfilesBinding.inflate(inflater, container, false)
|
||||||
binding.lifecycleOwner = this
|
binding.lifecycleOwner = viewLifecycleOwner
|
||||||
binding.vm = vm
|
binding.vm = vm
|
||||||
|
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -183,20 +183,24 @@ class FilterProfilesFragment : Fragment() {
|
|||||||
adapter = FilterProfilesAdapter(touchHelper, onDelete = { fp ->
|
adapter = FilterProfilesAdapter(touchHelper, onDelete = { fp ->
|
||||||
delete(fp)
|
delete(fp)
|
||||||
}, onRename = { fp ->
|
}, onRename = { fp ->
|
||||||
showEditTextDialog(requireContext()) { dialog, input ->
|
showEditTextDialog(requireContext(), { dialog, input ->
|
||||||
input.setText(fp.name)
|
input.setText(fp.name)
|
||||||
|
|
||||||
dialog.setTitle(R.string.rename)
|
dialog.setTitle(R.string.rename)
|
||||||
.setMessage(R.string.save_profile_enter_name)
|
.setMessage(R.string.save_profile_enter_name)
|
||||||
.setPositiveButton(R.string.ok) { _, _ ->
|
}, { newName ->
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
vm.update(fp.copy(name = input.text.toString()))
|
if (vm.filterProfiles.value?.find { it.name == newName } != null) {
|
||||||
}
|
Snackbar.make(
|
||||||
|
view,
|
||||||
|
R.string.filterprofile_name_not_unique,
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
vm.update(fp.copy(name = newName))
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.cancel) { _, _ ->
|
}
|
||||||
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
binding.filterProfilesList.apply {
|
binding.filterProfilesList.apply {
|
||||||
this.adapter = this@FilterProfilesFragment.adapter
|
this.adapter = this@FilterProfilesFragment.adapter
|
||||||
@@ -230,7 +234,7 @@ class FilterProfilesFragment : Fragment() {
|
|||||||
view?.let {
|
view?.let {
|
||||||
val snackbar = Snackbar.make(
|
val snackbar = Snackbar.make(
|
||||||
it,
|
it,
|
||||||
getString(R.string.deleted_filterprofile, fp.name),
|
getString(R.string.deleted_item, fp.name),
|
||||||
Snackbar.LENGTH_LONG
|
Snackbar.LENGTH_LONG
|
||||||
).setAction(R.string.undo) {
|
).setAction(R.string.undo) {
|
||||||
toDelete = null
|
toDelete = null
|
||||||
|
|||||||
@@ -3,25 +3,40 @@ package net.vonforst.evmap.fragment
|
|||||||
import android.Manifest.permission.ACCESS_COARSE_LOCATION
|
import android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
import android.Manifest.permission.ACCESS_FINE_LOCATION
|
import android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.location.Geocoder
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.method.KeyListener
|
import android.text.method.KeyListener
|
||||||
import android.view.*
|
import android.view.ContextThemeWrapper
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.AdapterView
|
import android.widget.AdapterView
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.BackEventCompat
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.annotation.RequiresPermission
|
import androidx.annotation.RequiresPermission
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.location.LocationListenerCompat
|
import androidx.core.location.LocationListenerCompat
|
||||||
import androidx.core.view.*
|
import androidx.core.view.MenuCompat
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.doOnLayout
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.databinding.DataBindingUtil
|
import androidx.databinding.DataBindingUtil
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
@@ -29,7 +44,6 @@ import androidx.fragment.app.viewModels
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.findNavController
|
|
||||||
import androidx.navigation.fragment.FragmentNavigatorExtras
|
import androidx.navigation.fragment.FragmentNavigatorExtras
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
@@ -41,21 +55,25 @@ import androidx.transition.TransitionManager
|
|||||||
import coil.load
|
import coil.load
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
import com.car2go.maps.AnyMap
|
import com.car2go.maps.AnyMap
|
||||||
|
import com.car2go.maps.MapFactory
|
||||||
import com.car2go.maps.MapFragment
|
import com.car2go.maps.MapFragment
|
||||||
import com.car2go.maps.OnMapReadyCallback
|
import com.car2go.maps.OnMapReadyCallback
|
||||||
import com.car2go.maps.model.BitmapDescriptor
|
import com.car2go.maps.model.BitmapDescriptor
|
||||||
import com.car2go.maps.model.LatLng
|
import com.car2go.maps.model.LatLng
|
||||||
import com.car2go.maps.model.Marker
|
import com.car2go.maps.model.Marker
|
||||||
import com.car2go.maps.model.MarkerOptions
|
import com.car2go.maps.model.MarkerOptions
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_SETTLING
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.google.android.material.transition.MaterialArcMotion
|
import com.google.android.material.transition.MaterialArcMotion
|
||||||
import com.google.android.material.transition.MaterialContainerTransform
|
import com.google.android.material.transition.MaterialContainerTransform
|
||||||
|
import com.google.android.material.transition.MaterialContainerTransform.FADE_MODE_CROSS
|
||||||
import com.google.android.material.transition.MaterialFadeThrough
|
import com.google.android.material.transition.MaterialFadeThrough
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
|
|
||||||
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.*
|
|
||||||
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
|
|
||||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||||
import io.michaelrocks.bimap.HashBiMap
|
import io.michaelrocks.bimap.HashBiMap
|
||||||
import io.michaelrocks.bimap.MutableBiMap
|
import io.michaelrocks.bimap.MutableBiMap
|
||||||
@@ -69,23 +87,47 @@ import net.vonforst.evmap.adapter.ConnectorAdapter
|
|||||||
import net.vonforst.evmap.adapter.DetailsAdapter
|
import net.vonforst.evmap.adapter.DetailsAdapter
|
||||||
import net.vonforst.evmap.adapter.GalleryAdapter
|
import net.vonforst.evmap.adapter.GalleryAdapter
|
||||||
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
|
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
|
||||||
|
import net.vonforst.evmap.api.ChargepointList
|
||||||
|
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||||
import net.vonforst.evmap.autocomplete.ApiUnavailableException
|
import net.vonforst.evmap.autocomplete.ApiUnavailableException
|
||||||
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
||||||
import net.vonforst.evmap.bold
|
import net.vonforst.evmap.bold
|
||||||
import net.vonforst.evmap.databinding.FragmentMapBinding
|
import net.vonforst.evmap.databinding.FragmentMapBinding
|
||||||
import net.vonforst.evmap.fragment.preference.DataSettingsFragmentArgs
|
|
||||||
import net.vonforst.evmap.location.FusionEngine
|
import net.vonforst.evmap.location.FusionEngine
|
||||||
import net.vonforst.evmap.location.LocationEngine
|
import net.vonforst.evmap.location.LocationEngine
|
||||||
import net.vonforst.evmap.location.Priority
|
import net.vonforst.evmap.location.Priority
|
||||||
import net.vonforst.evmap.model.*
|
import net.vonforst.evmap.model.ChargeLocation
|
||||||
|
import net.vonforst.evmap.model.ChargeLocationCluster
|
||||||
|
import net.vonforst.evmap.model.ChargepointListItem
|
||||||
|
import net.vonforst.evmap.model.ChargerPhoto
|
||||||
|
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||||
|
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||||
|
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||||
|
import net.vonforst.evmap.navigation.safeNavigate
|
||||||
|
import net.vonforst.evmap.shouldUseImperialUnits
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
import net.vonforst.evmap.ui.*
|
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||||
|
import net.vonforst.evmap.ui.ClusterIconGenerator
|
||||||
|
import net.vonforst.evmap.ui.HideOnScrollFabBehavior
|
||||||
|
import net.vonforst.evmap.ui.MarkerAnimator
|
||||||
|
import net.vonforst.evmap.ui.chargerZ
|
||||||
|
import net.vonforst.evmap.ui.clusterZ
|
||||||
|
import net.vonforst.evmap.ui.getMarkerTint
|
||||||
|
import net.vonforst.evmap.ui.placeSearchZ
|
||||||
|
import net.vonforst.evmap.ui.setTouchModal
|
||||||
import net.vonforst.evmap.utils.boundingBox
|
import net.vonforst.evmap.utils.boundingBox
|
||||||
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
||||||
import net.vonforst.evmap.utils.checkFineLocationPermission
|
import net.vonforst.evmap.utils.checkFineLocationPermission
|
||||||
import net.vonforst.evmap.utils.distanceBetween
|
import net.vonforst.evmap.utils.distanceBetween
|
||||||
import net.vonforst.evmap.viewmodel.*
|
import net.vonforst.evmap.utils.formatDecimal
|
||||||
|
import net.vonforst.evmap.viewmodel.GalleryViewModel
|
||||||
|
import net.vonforst.evmap.viewmodel.MapPosition
|
||||||
|
import net.vonforst.evmap.viewmodel.MapViewModel
|
||||||
|
import net.vonforst.evmap.viewmodel.Resource
|
||||||
|
import net.vonforst.evmap.viewmodel.Status
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
import kotlin.collections.component1
|
import kotlin.collections.component1
|
||||||
import kotlin.collections.component2
|
import kotlin.collections.component2
|
||||||
import kotlin.collections.contains
|
import kotlin.collections.contains
|
||||||
@@ -93,29 +135,61 @@ import kotlin.collections.set
|
|||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
|
|
||||||
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback, MenuProvider {
|
class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
|
||||||
private lateinit var binding: FragmentMapBinding
|
private var _binding: FragmentMapBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
private val vm: MapViewModel by viewModels()
|
private val vm: MapViewModel by viewModels()
|
||||||
private val galleryVm: GalleryViewModel by activityViewModels()
|
private val galleryVm: GalleryViewModel by activityViewModels()
|
||||||
private var mapFragment: MapFragment? = null
|
private var mapFragment: MapFragment? = null
|
||||||
private var map: AnyMap? = null
|
private var map: AnyMap? = null
|
||||||
private lateinit var locationEngine: LocationEngine
|
private lateinit var locationEngine: LocationEngine
|
||||||
private var requestingLocationUpdates = false
|
private var requestingLocationUpdates = false
|
||||||
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
|
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
|
||||||
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
|
private lateinit var detailsDialog: ConnectorDetailsDialog
|
||||||
private lateinit var prefs: PreferenceDataSource
|
private lateinit var prefs: PreferenceDataSource
|
||||||
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
|
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
|
||||||
private var clusterMarkers: List<Marker> = emptyList()
|
private var clusterMarkers: List<Marker> = emptyList()
|
||||||
private var searchResultMarker: Marker? = null
|
private var searchResultMarker: Marker? = null
|
||||||
private var searchResultIcon: BitmapDescriptor? = null
|
private var searchResultIcon: BitmapDescriptor? = null
|
||||||
private var connectionErrorSnackbar: Snackbar? = null
|
private var connectionErrorSnackbar: Snackbar? = null
|
||||||
|
private var zoomInSnackbar: Snackbar? = null
|
||||||
private var previousChargepointIds: Set<Long>? = null
|
private var previousChargepointIds: Set<Long>? = null
|
||||||
private var mapTopPadding: Int = 0
|
private var mapTopPadding: Int = 0
|
||||||
|
private var popupMenu: PopupMenu? = null
|
||||||
|
|
||||||
private lateinit var clusterIconGenerator: ClusterIconGenerator
|
private lateinit var clusterIconGenerator: ClusterIconGenerator
|
||||||
private lateinit var chargerIconGenerator: ChargerIconGenerator
|
private lateinit var chargerIconGenerator: ChargerIconGenerator
|
||||||
private lateinit var animator: MarkerAnimator
|
private lateinit var animator: MarkerAnimator
|
||||||
private lateinit var favToggle: MenuItem
|
private lateinit var favToggle: MenuItem
|
||||||
|
|
||||||
|
private val bottomSheetBackPressedCallback = object : OnBackPressedCallback(false) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
val state = bottomSheetBehavior.state
|
||||||
|
when (state) {
|
||||||
|
STATE_COLLAPSED -> vm.chargerSparse.value = null
|
||||||
|
STATE_HIDDEN -> return
|
||||||
|
else -> if (bottomSheetCollapsible) {
|
||||||
|
bottomSheetBehavior.state = STATE_COLLAPSED
|
||||||
|
} else {
|
||||||
|
vm.chargerSparse.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bottomSheetBehavior.cancelBackProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnBackStarted(backEvent: BackEventCompat) {
|
||||||
|
bottomSheetBehavior.startBackProgress(backEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
|
||||||
|
bottomSheetBehavior.updateBackProgress(backEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnBackCancelled() {
|
||||||
|
bottomSheetBehavior.cancelBackProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val backPressedCallback = object : OnBackPressedCallback(false) {
|
private val backPressedCallback = object : OnBackPressedCallback(false) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
val value = vm.layersMenuOpen.value
|
val value = vm.layersMenuOpen.value
|
||||||
@@ -124,20 +198,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (binding.search.hasFocus()) {
|
if (vm.selectedChargepoint.value != null) {
|
||||||
removeSearchFocus()
|
closeConnectorDetailsDialog()
|
||||||
|
vm.selectedChargepoint.value = null
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val state = bottomSheetBehavior.state
|
if (binding.search.hasFocus()) {
|
||||||
when (state) {
|
removeSearchFocus()
|
||||||
STATE_COLLAPSED -> vm.chargerSparse.value = null
|
return
|
||||||
STATE_HIDDEN -> vm.searchResult.value = null
|
|
||||||
else -> if (bottomSheetCollapsible) {
|
|
||||||
bottomSheetBehavior.state = STATE_COLLAPSED
|
|
||||||
} else {
|
|
||||||
vm.chargerSparse.value = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vm.searchResult.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +232,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
|
_binding = DataBindingUtil.inflate(inflater, R.layout.fragment_map, container, false)
|
||||||
println(binding.detailView.sourceButton)
|
println(binding.detailView.sourceButton)
|
||||||
binding.lifecycleOwner = this
|
binding.lifecycleOwner = viewLifecycleOwner
|
||||||
binding.vm = vm
|
binding.vm = vm
|
||||||
|
|
||||||
val provider = prefs.mapProvider
|
val provider = prefs.mapProvider
|
||||||
@@ -170,16 +242,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
mapFragment =
|
mapFragment =
|
||||||
childFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
|
childFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
|
||||||
}
|
}
|
||||||
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
|
if (mapFragment == null || mapFragment!!.priority[0] != getMapProvider(provider)) {
|
||||||
mapFragment = MapFragment()
|
mapFragment = MapFragment()
|
||||||
mapFragment!!.priority = arrayOf(
|
mapFragment!!.priority = arrayOf(
|
||||||
when (provider) {
|
getMapProvider(provider),
|
||||||
"mapbox" -> MapFragment.MAPBOX
|
MapFactory.GOOGLE,
|
||||||
"google" -> MapFragment.GOOGLE
|
MapFactory.MAPLIBRE
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
MapFragment.GOOGLE,
|
|
||||||
MapFragment.MAPBOX
|
|
||||||
)
|
)
|
||||||
childFragmentManager
|
childFragmentManager
|
||||||
.beginTransaction()
|
.beginTransaction()
|
||||||
@@ -194,15 +262,21 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
searchResultIcon = null
|
searchResultIcon = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.detailView.toolbar.popupTheme =
|
||||||
|
com.google.android.material.R.style.ThemeOverlay_AppCompat_DayNight
|
||||||
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(
|
ViewCompat.setOnApplyWindowInsetsListener(
|
||||||
binding.root
|
binding.root
|
||||||
) { _, insets ->
|
) { _, insets ->
|
||||||
ViewCompat.onApplyWindowInsets(binding.root, insets)
|
ViewCompat.onApplyWindowInsets(binding.root, insets)
|
||||||
|
|
||||||
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
|
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
|
||||||
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
/*binding.detailView.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
topMargin = systemWindowInsetTop
|
topMargin = systemWindowInsetTop
|
||||||
}
|
}*/
|
||||||
|
|
||||||
|
val insetsBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
|
||||||
|
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom + insetsBottom
|
||||||
|
|
||||||
// margin of layers button: status bar height + toolbar height + margin
|
// margin of layers button: status bar height + toolbar height + margin
|
||||||
val density = resources.displayMetrics.density
|
val density = resources.displayMetrics.density
|
||||||
@@ -221,7 +295,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
|
|
||||||
// set map padding so that compass is not obstructed by toolbar
|
// set map padding so that compass is not obstructed by toolbar
|
||||||
mapTopPadding = systemWindowInsetTop + (48 * density).toInt() + (16 * density).toInt()
|
mapTopPadding = systemWindowInsetTop + (48 * density).toInt() + (16 * density).toInt()
|
||||||
// if we actually use map.setPadding here, Mapbox will re-trigger onApplyWindowInsets
|
// if we actually use map.setPadding here, MapLibre will re-trigger onApplyWindowInsets
|
||||||
// and cause an infinite loop. So we rely on onMapReady being called later than
|
// and cause an infinite loop. So we rely on onMapReady being called later than
|
||||||
// onApplyWindowInsets.
|
// onApplyWindowInsets.
|
||||||
|
|
||||||
@@ -235,32 +309,46 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
viewLifecycleOwner,
|
viewLifecycleOwner,
|
||||||
backPressedCallback
|
backPressedCallback
|
||||||
)
|
)
|
||||||
|
requireActivity().onBackPressedDispatcher.addCallback(
|
||||||
|
viewLifecycleOwner,
|
||||||
|
bottomSheetBackPressedCallback
|
||||||
|
)
|
||||||
|
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getMapProvider(provider: String) = when (provider) {
|
||||||
|
"mapbox" -> MapFactory.MAPLIBRE
|
||||||
|
"google" -> MapFactory.GOOGLE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
val bottomSheetCollapsible
|
val bottomSheetCollapsible
|
||||||
get() = resources.getBoolean(R.bool.bottom_sheet_collapsible)
|
get() = resources.getBoolean(R.bool.bottom_sheet_collapsible)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet || !prefs.privacyAccepted) {
|
||||||
|
findNavController().navigate(R.id.onboarding)
|
||||||
|
}
|
||||||
|
|
||||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
mapFragment!!.getMapAsync(this)
|
mapFragment!!.getMapAsync(this)
|
||||||
bottomSheetBehavior = from(binding.bottomSheet)
|
bottomSheetBehavior = BottomSheetBehavior.from(binding.detailView.root)
|
||||||
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
|
//detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
|
||||||
|
|
||||||
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
|
binding.detailView.toolbar.inflateMenu(R.menu.detail)
|
||||||
favToggle = binding.detailAppBar.toolbar.menu.findItem(R.id.menu_fav)
|
favToggle = binding.detailView.toolbar.menu.findItem(R.id.menu_fav)
|
||||||
|
|
||||||
vm.apiName.observe(viewLifecycleOwner) {
|
vm.apiName.observe(viewLifecycleOwner) {
|
||||||
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
|
binding.detailView.toolbar.menu.findItem(R.id.menu_edit).title =
|
||||||
getString(R.string.edit_at_datasource, it)
|
getString(R.string.edit_at_datasource, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.detailView.topPart.doOnNextLayout {
|
vm.bottomSheetState.value?.let { bottomSheetBehavior.state = it }
|
||||||
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
|
bottomSheetBehavior.skipCollapsed = !bottomSheetCollapsible
|
||||||
}
|
bottomSheetBehavior.state = STATE_HIDDEN
|
||||||
bottomSheetBehavior.isCollapsible = bottomSheetCollapsible
|
binding.detailView.connectorDetails
|
||||||
|
|
||||||
setupObservers()
|
setupObservers()
|
||||||
setupClickListeners()
|
setupClickListeners()
|
||||||
@@ -272,9 +360,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
(requireActivity() as MapsActivity).appBarConfiguration
|
(requireActivity() as MapsActivity).appBarConfiguration
|
||||||
)
|
)
|
||||||
|
|
||||||
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
|
if (prefs.appStartCounter > 5 && Duration.between(
|
||||||
|
prefs.opensourceDonationsDialogLastShown,
|
||||||
|
Instant.now()
|
||||||
|
) > Duration.ofDays(30)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
findNavController().navigate(R.id.action_map_to_opensource_donations)
|
findNavController().safeNavigate(MapFragmentDirections.actionMapToOpensourceDonations())
|
||||||
} catch (ignored: IllegalArgumentException) {
|
} catch (ignored: IllegalArgumentException) {
|
||||||
// when there is already another navigation going on
|
// when there is already another navigation going on
|
||||||
} catch (ignored: IllegalStateException) {
|
} catch (ignored: IllegalStateException) {
|
||||||
@@ -283,7 +375,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
}
|
}
|
||||||
/*if (!prefs.update060AndroidAutoDialogShown) {
|
/*if (!prefs.update060AndroidAutoDialogShown) {
|
||||||
try {
|
try {
|
||||||
navController.navigate(R.id.action_map_to_update_060_androidauto)
|
navController.safeNavigate(MapFragmentDirections.actionMapToUpdate060AndroidAuto())
|
||||||
} catch (ignored: IllegalArgumentException) {
|
} catch (ignored: IllegalArgumentException) {
|
||||||
// when there is already another navigation going on
|
// when there is already another navigation going on
|
||||||
}
|
}
|
||||||
@@ -298,9 +390,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
|
|
||||||
binding.appLogo.root.animate().alpha(1f)
|
binding.appLogo.root.animate().alpha(1f)
|
||||||
.withEndAction {
|
.withEndAction {
|
||||||
|
if (_binding == null) return@withEndAction
|
||||||
binding.appLogo.root.animate().alpha(0f).apply {
|
binding.appLogo.root.animate().alpha(0f).apply {
|
||||||
startDelay = 1000
|
startDelay = 1000
|
||||||
}.withEndAction {
|
}.withEndAction {
|
||||||
|
if (_binding == null) return@withEndAction
|
||||||
binding.appLogo.root.visibility = View.GONE
|
binding.appLogo.root.visibility = View.GONE
|
||||||
binding.search.visibility = View.VISIBLE
|
binding.search.visibility = View.VISIBLE
|
||||||
binding.search.alpha = 0f
|
binding.search.alpha = 0f
|
||||||
@@ -314,13 +408,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
binding.appLogo.root.visibility = View.GONE
|
binding.appLogo.root.visibility = View.GONE
|
||||||
binding.search.visibility = View.VISIBLE
|
binding.search.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detailsDialog =
|
||||||
|
ConnectorDetailsDialog(binding.detailView.connectorDetails, requireContext()) {
|
||||||
|
closeConnectorDetailsDialog()
|
||||||
|
vm.selectedChargepoint.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
val hostActivity = activity as? MapsActivity ?: return
|
|
||||||
hostActivity.fragmentCallback = this
|
|
||||||
|
|
||||||
vm.reloadPrefs()
|
vm.reloadPrefs()
|
||||||
if (requestingLocationUpdates && requireContext().checkAnyLocationPermission()
|
if (requestingLocationUpdates && requireContext().checkAnyLocationPermission()
|
||||||
) {
|
) {
|
||||||
@@ -352,7 +449,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
val charger = vm.charger.value?.data
|
val charger = vm.charger.value?.data
|
||||||
if (charger != null) {
|
if (charger != null) {
|
||||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||||
(requireActivity() as MapsActivity).navigateTo(charger)
|
(requireActivity() as MapsActivity).navigateTo(charger, binding.root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,31 +462,36 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
binding.detailView.sourceButton.setOnClickListener {
|
binding.detailView.sourceButton.setOnClickListener {
|
||||||
val charger = vm.charger.value?.data
|
val charger = vm.charger.value?.data
|
||||||
if (charger != null) {
|
if (charger != null) {
|
||||||
(activity as? MapsActivity)?.openUrl(charger.url)
|
(activity as? MapsActivity)?.openUrl(charger.url, binding.root, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.detailView.btnChargeprice.setOnClickListener {
|
binding.detailView.btnChargeprice.setOnClickListener {
|
||||||
val charger = vm.charger.value?.data ?: return@setOnClickListener
|
val charger = vm.charger.value?.data ?: return@setOnClickListener
|
||||||
val extras =
|
if (prefs.chargepriceNativeIntegration) {
|
||||||
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
|
val extras =
|
||||||
findNavController().navigate(
|
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
|
||||||
R.id.action_map_to_chargepriceFragment,
|
findNavController().safeNavigate(
|
||||||
ChargepriceFragmentArgs(charger).toBundle(),
|
MapFragmentDirections.actionMapToChargepriceFragment(charger),
|
||||||
null, extras
|
extras
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
(activity as? MapsActivity)?.openUrl(
|
||||||
|
ChargepriceApi.getPoiUrl(charger),
|
||||||
|
binding.root
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
binding.detailView.btnChargerWebsite.setOnClickListener {
|
binding.detailView.btnChargerWebsite.setOnClickListener {
|
||||||
val charger = vm.charger.value?.data ?: return@setOnClickListener
|
val charger = vm.charger.value?.data ?: return@setOnClickListener
|
||||||
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
|
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it, binding.root) }
|
||||||
}
|
}
|
||||||
binding.detailView.btnLogin.setOnClickListener {
|
binding.detailView.btnLogin.setOnClickListener {
|
||||||
findNavController().navigate(
|
findNavController().safeNavigate(
|
||||||
R.id.settings_data,
|
MapFragmentDirections.actionMapToDataSettings(true)
|
||||||
DataSettingsFragmentArgs(true).toBundle()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
binding.detailView.imgPredictionSource.setOnClickListener {
|
binding.detailView.imgPredictionSource.setOnClickListener {
|
||||||
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url))
|
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url), binding.root)
|
||||||
}
|
}
|
||||||
binding.detailView.btnPredictionHelp.setOnClickListener {
|
binding.detailView.btnPredictionHelp.setOnClickListener {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
@@ -398,17 +500,22 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
binding.detailView.topPart.setOnClickListener {
|
binding.detailView.topPart.setOnClickListener {
|
||||||
bottomSheetBehavior.state = STATE_ANCHOR_POINT
|
bottomSheetBehavior.state = STATE_HALF_EXPANDED
|
||||||
|
}
|
||||||
|
binding.detailView.topPart.setOnLongClickListener {
|
||||||
|
val charger = vm.charger.value?.data ?: return@setOnLongClickListener false
|
||||||
|
copyToClipboard(ClipData.newPlainText(getString(R.string.charger_name), charger.name))
|
||||||
|
return@setOnLongClickListener true
|
||||||
}
|
}
|
||||||
setupSearchAutocomplete()
|
setupSearchAutocomplete()
|
||||||
binding.detailAppBar.toolbar.setNavigationOnClickListener {
|
binding.detailView.toolbar.setNavigationOnClickListener {
|
||||||
if (bottomSheetCollapsible) {
|
if (bottomSheetCollapsible) {
|
||||||
bottomSheetBehavior.state = STATE_COLLAPSED
|
bottomSheetBehavior.state = STATE_COLLAPSED
|
||||||
} else {
|
} else {
|
||||||
vm.chargerSparse.value = null
|
vm.chargerSparse.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.detailAppBar.toolbar.setOnMenuItemClickListener {
|
binding.detailView.toolbar.setOnMenuItemClickListener {
|
||||||
when (it.itemId) {
|
when (it.itemId) {
|
||||||
R.id.menu_fav -> {
|
R.id.menu_fav -> {
|
||||||
toggleFavorite()
|
toggleFavorite()
|
||||||
@@ -424,7 +531,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
R.id.menu_edit -> {
|
R.id.menu_edit -> {
|
||||||
val charger = vm.charger.value?.data
|
val charger = vm.charger.value?.data
|
||||||
if (charger?.editUrl != null) {
|
if (charger?.editUrl != null) {
|
||||||
(activity as? MapsActivity)?.openUrl(charger.editUrl)
|
(activity as? MapsActivity)?.openUrl(charger.editUrl, binding.root, true)
|
||||||
if (vm.apiId.value == "goingelectric") {
|
if (vm.apiId.value == "goingelectric") {
|
||||||
// instructions specific to GoingElectric
|
// instructions specific to GoingElectric
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
@@ -569,7 +676,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
|
|
||||||
private fun setupObservers() {
|
private fun setupObservers() {
|
||||||
bottomSheetBehavior.addBottomSheetCallback(object :
|
bottomSheetBehavior.addBottomSheetCallback(object :
|
||||||
BottomSheetCallback() {
|
BottomSheetBehavior.BottomSheetCallback() {
|
||||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||||
if (bottomSheetBehavior.state == STATE_HIDDEN) {
|
if (bottomSheetBehavior.state == STATE_HIDDEN) {
|
||||||
map?.setPadding(0, mapTopPadding, 0, 0)
|
map?.setPadding(0, mapTopPadding, 0, 0)
|
||||||
@@ -586,7 +693,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
|
|
||||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||||
vm.bottomSheetState.value = newState
|
vm.bottomSheetState.value = newState
|
||||||
updateBackPressedCallback()
|
bottomSheetBackPressedCallback.isEnabled = newState != STATE_HIDDEN
|
||||||
|
|
||||||
if (vm.layersMenuOpen.value!! && newState !in listOf(
|
if (vm.layersMenuOpen.value!! && newState !in listOf(
|
||||||
STATE_SETTLING,
|
STATE_SETTLING,
|
||||||
@@ -596,17 +703,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
) {
|
) {
|
||||||
closeLayersMenu()
|
closeLayersMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vm.selectedChargepoint.value != null && newState in listOf(
|
||||||
|
STATE_HALF_EXPANDED, STATE_COLLAPSED
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
closeConnectorDetailsDialog()
|
||||||
|
vm.selectedChargepoint.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
vm.chargerSparse.observe(viewLifecycleOwner) {
|
vm.chargerSparse.observe(viewLifecycleOwner) {
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
if (vm.bottomSheetState.value != STATE_ANCHOR_POINT) {
|
if (vm.bottomSheetState.value != STATE_HALF_EXPANDED) {
|
||||||
bottomSheetBehavior.state =
|
bottomSheetBehavior.state =
|
||||||
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_ANCHOR_POINT
|
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_HALF_EXPANDED
|
||||||
}
|
}
|
||||||
removeSearchFocus()
|
removeSearchFocus()
|
||||||
binding.fabDirections.show()
|
binding.fabDirections.show()
|
||||||
detailAppBarBehavior.setToolbarTitle(it.name)
|
//detailAppBarBehavior.setToolbarTitle(it.name)
|
||||||
updateFavoriteToggle()
|
updateFavoriteToggle()
|
||||||
highlightMarker(it)
|
highlightMarker(it)
|
||||||
} else {
|
} else {
|
||||||
@@ -617,12 +732,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
|
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
|
||||||
val chargepoints = res.data
|
val chargepoints = res.data
|
||||||
if (chargepoints != null) {
|
if (chargepoints != null) {
|
||||||
updateMap(chargepoints)
|
updateMap(chargepoints.items)
|
||||||
}
|
}
|
||||||
|
val view = view ?: return@Observer
|
||||||
when (res.status) {
|
when (res.status) {
|
||||||
Status.ERROR -> {
|
Status.ERROR -> {
|
||||||
val view = view ?: return@Observer
|
zoomInSnackbar?.dismiss()
|
||||||
|
|
||||||
connectionErrorSnackbar?.dismiss()
|
connectionErrorSnackbar?.dismiss()
|
||||||
connectionErrorSnackbar = Snackbar
|
connectionErrorSnackbar = Snackbar
|
||||||
.make(view, R.string.connection_error, Snackbar.LENGTH_INDEFINITE)
|
.make(view, R.string.connection_error, Snackbar.LENGTH_INDEFINITE)
|
||||||
@@ -634,13 +749,20 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
}
|
}
|
||||||
Status.SUCCESS -> {
|
Status.SUCCESS -> {
|
||||||
connectionErrorSnackbar?.dismiss()
|
connectionErrorSnackbar?.dismiss()
|
||||||
|
if (res.data != null && !res.data.isComplete) {
|
||||||
|
zoomInSnackbar?.dismiss()
|
||||||
|
zoomInSnackbar = Snackbar
|
||||||
|
.make(view, R.string.zoom_in_to_see_more, Snackbar.LENGTH_INDEFINITE)
|
||||||
|
zoomInSnackbar!!.show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Status.LOADING -> {
|
Status.LOADING -> {
|
||||||
|
zoomInSnackbar?.dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
vm.useMiniMarkers.observe(viewLifecycleOwner) {
|
vm.useMiniMarkers.observe(viewLifecycleOwner) {
|
||||||
vm.chargepoints.value?.data?.let { updateMap(it) }
|
vm.chargepoints.value?.data?.let { updateMap(it.items) }
|
||||||
}
|
}
|
||||||
vm.favorites.observe(viewLifecycleOwner) {
|
vm.favorites.observe(viewLifecycleOwner) {
|
||||||
updateFavoriteToggle()
|
updateFavoriteToggle()
|
||||||
@@ -649,6 +771,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
displaySearchResult(place, moveCamera = true)
|
displaySearchResult(place, moveCamera = true)
|
||||||
}
|
}
|
||||||
vm.layersMenuOpen.observe(viewLifecycleOwner) { open ->
|
vm.layersMenuOpen.observe(viewLifecycleOwner) { open ->
|
||||||
|
HideOnScrollFabBehavior.from(binding.fabLayers)?.hidden = open
|
||||||
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
|
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
|
||||||
binding.layersSheet.visibility = if (open) View.VISIBLE else View.INVISIBLE
|
binding.layersSheet.visibility = if (open) View.VISIBLE else View.INVISIBLE
|
||||||
updateBackPressedCallback()
|
updateBackPressedCallback()
|
||||||
@@ -659,6 +782,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
vm.mapTrafficEnabled.observe(viewLifecycleOwner) {
|
vm.mapTrafficEnabled.observe(viewLifecycleOwner) {
|
||||||
map?.setTrafficEnabled(it)
|
map?.setTrafficEnabled(it)
|
||||||
}
|
}
|
||||||
|
vm.selectedChargepoint.observe(viewLifecycleOwner) {
|
||||||
|
binding.detailView.connectorDetailsCard.visibility =
|
||||||
|
if (it != null) View.VISIBLE else View.INVISIBLE
|
||||||
|
if (it != null) {
|
||||||
|
detailsDialog.setData(it, vm.availability.value?.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateBackPressedCallback()
|
updateBackPressedCallback()
|
||||||
}
|
}
|
||||||
@@ -698,9 +828,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateBackPressedCallback() {
|
private fun updateBackPressedCallback() {
|
||||||
backPressedCallback.isEnabled =
|
backPressedCallback.isEnabled = vm.searchResult.value != null
|
||||||
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|
|
||||||
|| vm.searchResult.value != null
|
|
||||||
|| (vm.layersMenuOpen.value ?: false)
|
|| (vm.layersMenuOpen.value ?: false)
|
||||||
|| binding.search.hasFocus()
|
|| binding.search.hasFocus()
|
||||||
}
|
}
|
||||||
@@ -773,6 +901,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
if (photo == photos[position] && imageCacheKey != null) {
|
if (photo == photos[position] && imageCacheKey != null) {
|
||||||
placeholderMemoryCacheKey(imageCacheKey)
|
placeholderMemoryCacheKey(imageCacheKey)
|
||||||
}
|
}
|
||||||
|
allowHardware(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.withTransitionFrom(view as ImageView)
|
.withTransitionFrom(view as ImageView)
|
||||||
@@ -803,7 +932,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.detailView.connectors.apply {
|
binding.detailView.connectors.apply {
|
||||||
adapter = ConnectorAdapter()
|
adapter = ConnectorAdapter().apply {
|
||||||
|
onClickListener = {
|
||||||
|
vm.selectedChargepoint.value = it.chargepoint
|
||||||
|
openConnectorDetailsDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
itemAnimator = null
|
itemAnimator = null
|
||||||
layoutManager =
|
layoutManager =
|
||||||
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||||
@@ -816,20 +950,59 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
if (charger != null) {
|
if (charger != null) {
|
||||||
when (it.icon) {
|
when (it.icon) {
|
||||||
R.drawable.ic_location, R.drawable.ic_address -> {
|
R.drawable.ic_location, R.drawable.ic_address -> {
|
||||||
(activity as? MapsActivity)?.showLocation(charger)
|
(activity as? MapsActivity)?.showLocation(charger, binding.root)
|
||||||
}
|
}
|
||||||
R.drawable.ic_fault_report -> {
|
R.drawable.ic_fault_report -> {
|
||||||
(activity as? MapsActivity)?.openUrl(charger.url)
|
(activity as? MapsActivity)?.openUrl(
|
||||||
|
charger.url,
|
||||||
|
binding.root,
|
||||||
|
true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
R.drawable.ic_payment -> {
|
R.drawable.ic_payment -> {
|
||||||
showPaymentMethodsDialog(charger)
|
showPaymentMethodsDialog(charger)
|
||||||
}
|
}
|
||||||
|
|
||||||
R.drawable.ic_network -> {
|
R.drawable.ic_network -> {
|
||||||
charger.networkUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
|
charger.networkUrl?.let {
|
||||||
|
(activity as? MapsActivity)?.openUrl(
|
||||||
|
it,
|
||||||
|
binding.root
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onLongClickListener = {
|
||||||
|
val charger = vm.chargerDetails.value?.data
|
||||||
|
if (charger != null) {
|
||||||
|
when (it.icon) {
|
||||||
|
R.drawable.ic_address -> {
|
||||||
|
if (charger.address != null) {
|
||||||
|
copyToClipboard(ClipData.newPlainText(
|
||||||
|
getString(R.string.address),
|
||||||
|
charger.address.toString()
|
||||||
|
))
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
R.drawable.ic_location -> {
|
||||||
|
copyToClipboard(ClipData.newPlainText(
|
||||||
|
getString(R.string.coordinates),
|
||||||
|
charger.coordinates.formatDecimal()
|
||||||
|
))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
itemAnimator = null
|
itemAnimator = null
|
||||||
layoutManager =
|
layoutManager =
|
||||||
@@ -843,6 +1016,59 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun copyToClipboard(clip: ClipData) {
|
||||||
|
val clipboardManager =
|
||||||
|
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboardManager.setPrimaryClip(clip)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||||
|
Snackbar.make(
|
||||||
|
requireView(),
|
||||||
|
R.string.copied,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openConnectorDetailsDialog() {
|
||||||
|
val chargepoints = vm.chargerDetails.value?.data?.chargepointsMerged ?: return
|
||||||
|
val chargepoint = vm.selectedChargepoint.value ?: return
|
||||||
|
val index = chargepoints.indexOf(chargepoint).takeIf { it >= 0 } ?: return
|
||||||
|
val vh = binding.detailView.connectors.findViewHolderForAdapterPosition(index) ?: return
|
||||||
|
|
||||||
|
val materialTransform = MaterialContainerTransform().apply {
|
||||||
|
startView = vh.itemView
|
||||||
|
endView = binding.detailView.connectorDetailsCard
|
||||||
|
setPathMotion(MaterialArcMotion())
|
||||||
|
duration = 250
|
||||||
|
scrimColor = Color.TRANSPARENT
|
||||||
|
addTarget(binding.detailView.connectorDetailsCard)
|
||||||
|
isElevationShadowEnabled = false
|
||||||
|
fadeMode = FADE_MODE_CROSS
|
||||||
|
}
|
||||||
|
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeConnectorDetailsDialog() {
|
||||||
|
val chargepoints = vm.chargerDetails.value?.data?.chargepointsMerged ?: return
|
||||||
|
val chargepoint = vm.selectedChargepoint.value ?: return
|
||||||
|
val index = chargepoints.indexOf(chargepoint).takeIf { it >= 0 } ?: return
|
||||||
|
val vh = binding.detailView.connectors.findViewHolderForAdapterPosition(index) ?: return
|
||||||
|
|
||||||
|
val materialTransform = MaterialContainerTransform().apply {
|
||||||
|
startView = binding.detailView.connectorDetailsCard
|
||||||
|
endView = vh.itemView
|
||||||
|
setPathMotion(MaterialArcMotion())
|
||||||
|
duration = 200
|
||||||
|
scrimColor = Color.TRANSPARENT
|
||||||
|
addTarget(vh.itemView)
|
||||||
|
isElevationShadowEnabled = false
|
||||||
|
fadeMode = FADE_MODE_CROSS
|
||||||
|
}
|
||||||
|
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
|
||||||
|
}
|
||||||
|
|
||||||
private fun showPaymentMethodsDialog(charger: ChargeLocation) {
|
private fun showPaymentMethodsDialog(charger: ChargeLocation) {
|
||||||
val activity = activity ?: return
|
val activity = activity ?: return
|
||||||
val chargecardData = vm.chargeCardMap.value ?: return
|
val chargecardData = vm.chargeCardMap.value ?: return
|
||||||
@@ -863,17 +1089,21 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
.setTitle(R.string.charge_cards)
|
.setTitle(R.string.charge_cards)
|
||||||
.setItems(names.toTypedArray()) { _, i ->
|
.setItems(names.toTypedArray()) { _, i ->
|
||||||
val card = data[i]
|
val card = data[i]
|
||||||
(activity as? MapsActivity)?.openUrl("https:${card.url}")
|
(activity as? MapsActivity)?.openUrl("https:${card.url}", binding.root)
|
||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMapReady(map: AnyMap) {
|
override fun onMapReady(map: AnyMap) {
|
||||||
this.map = map
|
this.map = map
|
||||||
vm.mapProjection = map.projection
|
|
||||||
val context = this.context ?: return
|
val context = this.context ?: return
|
||||||
|
view ?: return
|
||||||
|
|
||||||
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
|
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
|
||||||
|
|
||||||
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
|
vm.mapTrafficSupported.value =
|
||||||
|
mapFragment?.let { AnyMap.Feature.TRAFFIC_LAYER in it.supportedFeatures } ?: false
|
||||||
|
|
||||||
|
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFactory.GOOGLE) {
|
||||||
// Google Maps: icons can be generated in background thread
|
// Google Maps: icons can be generated in background thread
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
@@ -881,7 +1111,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Mapbox: needs to be run on main thread
|
// MapLibre: needs to be run on main thread
|
||||||
chargerIconGenerator.preloadCache()
|
chargerIconGenerator.preloadCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,41 +1124,36 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
map.uiSettings.setIndoorLevelPickerEnabled(false)
|
map.uiSettings.setIndoorLevelPickerEnabled(false)
|
||||||
|
|
||||||
map.setOnCameraIdleListener {
|
map.setOnCameraIdleListener {
|
||||||
vm.mapProjection = map.projection
|
|
||||||
vm.mapPosition.value = MapPosition(
|
vm.mapPosition.value = MapPosition(
|
||||||
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
||||||
)
|
)
|
||||||
vm.reloadChargepoints()
|
vm.reloadChargepoints()
|
||||||
}
|
}
|
||||||
map.setOnCameraMoveListener {
|
map.setOnCameraMoveListener {
|
||||||
vm.mapProjection = map.projection
|
|
||||||
vm.mapPosition.value = MapPosition(
|
vm.mapPosition.value = MapPosition(
|
||||||
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.scaleView.apply {
|
binding.scaleView.apply {
|
||||||
when (prefs.mapScale) {
|
if (prefs.showMapScale) {
|
||||||
"both" -> {
|
visibility = View.VISIBLE
|
||||||
visibility = View.VISIBLE
|
if (prefs.mapScaleMetersAndMiles) {
|
||||||
metersAndMiles()
|
metersAndMiles()
|
||||||
|
} else {
|
||||||
|
if (shouldUseImperialUnits(requireContext())) {
|
||||||
|
milesOnly()
|
||||||
|
} else {
|
||||||
|
metersOnly()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
"meters" -> {
|
visibility = View.GONE
|
||||||
visibility = View.VISIBLE
|
|
||||||
metersOnly()
|
|
||||||
}
|
|
||||||
|
|
||||||
"miles" -> {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
milesOnly()
|
|
||||||
}
|
|
||||||
|
|
||||||
"off" -> visibility = View.GONE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
vm.mapPosition.observe(viewLifecycleOwner) {
|
vm.mapPosition.observe(viewLifecycleOwner) {
|
||||||
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
|
val target = map.cameraPosition.target ?: return@observe
|
||||||
|
binding.scaleView.update(map.cameraPosition.zoom, target.latitude)
|
||||||
}
|
}
|
||||||
|
|
||||||
map.setOnCameraMoveStartedListener { reason ->
|
map.setOnCameraMoveStartedListener { reason ->
|
||||||
@@ -945,6 +1170,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
map.setOnMarkerClickListener { marker ->
|
map.setOnMarkerClickListener { marker ->
|
||||||
|
val map = this@MapFragment.map ?: return@setOnMarkerClickListener false
|
||||||
when (marker) {
|
when (marker) {
|
||||||
in markers -> {
|
in markers -> {
|
||||||
vm.chargerSparse.value = markers[marker]
|
vm.chargerSparse.value = markers[marker]
|
||||||
@@ -960,6 +1186,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
)
|
)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
searchResultMarker -> true
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,6 +1194,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
map.setOnMapClickListener {
|
map.setOnMapClickListener {
|
||||||
if (backPressedCallback.isEnabled) {
|
if (backPressedCallback.isEnabled) {
|
||||||
backPressedCallback.handleOnBackPressed()
|
backPressedCallback.handleOnBackPressed()
|
||||||
|
} else if (bottomSheetBackPressedCallback.isEnabled) {
|
||||||
|
bottomSheetBackPressedCallback.handleOnBackPressed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
map.setMapType(vm.mapType.value)
|
map.setMapType(vm.mapType.value)
|
||||||
@@ -1021,10 +1250,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
// show charger detail after chargers were loaded
|
// show charger detail after chargers were loaded
|
||||||
vm.chargepoints.observe(
|
vm.chargepoints.observe(
|
||||||
viewLifecycleOwner,
|
viewLifecycleOwner,
|
||||||
object : Observer<Resource<List<ChargepointListItem>>> {
|
object : Observer<Resource<ChargepointList>> {
|
||||||
override fun onChanged(value: Resource<List<ChargepointListItem>>) {
|
override fun onChanged(value: Resource<ChargepointList>) {
|
||||||
if (value.data == null) return
|
if (value.data == null) return
|
||||||
for (item in value.data) {
|
for (item in value.data.items) {
|
||||||
if (item is ChargeLocation && item.id == chargerId) {
|
if (item is ChargeLocation && item.id == chargerId) {
|
||||||
vm.chargerSparse.value = item
|
vm.chargerSparse.value = item
|
||||||
vm.chargepoints.removeObserver(this)
|
vm.chargepoints.removeObserver(this)
|
||||||
@@ -1045,8 +1274,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
binding.search.setSelection(locationName.length)
|
binding.search.setSelection(locationName.length)
|
||||||
}
|
}
|
||||||
if (context.checkAnyLocationPermission()) {
|
if (context.checkAnyLocationPermission()) {
|
||||||
enableLocation(!positionSet, false)
|
if (prefs.currentMapMyLocationEnabled && !positionSet) {
|
||||||
positionSet = true
|
enableLocation(true, false)
|
||||||
|
positionSet = true
|
||||||
|
} else {
|
||||||
|
enableLocation(false, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!positionSet) {
|
if (!positionSet) {
|
||||||
// use position saved in preferences, fall back to default (Europe)
|
// use position saved in preferences, fall back to default (Europe)
|
||||||
@@ -1232,20 +1465,22 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
vm.copyFiltersToCustom()
|
vm.copyFiltersToCustom()
|
||||||
requireView().findNavController().navigate(
|
findNavController().safeNavigate(
|
||||||
R.id.action_map_to_filterFragment
|
MapFragmentDirections.actionMapToFilterFragment()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.menu_manage_filter_profiles -> {
|
R.id.menu_manage_filter_profiles -> {
|
||||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||||
requireView().findNavController().navigate(
|
findNavController().safeNavigate(
|
||||||
R.id.action_map_to_filterProfilesFragment
|
MapFragmentDirections.actionMapToFilterProfilesFragment()
|
||||||
)
|
)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val profileId = profilesMap.inverse[it]
|
val profileId = profilesMap.inverse[it]
|
||||||
if (profileId != null) {
|
if (profileId != null) {
|
||||||
@@ -1320,17 +1555,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
popup.setTouchModal(false)
|
popup.setTouchModal(false)
|
||||||
|
popupMenu = popup
|
||||||
popup.show()
|
popup.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
filterView?.setOnLongClickListener {
|
filterView?.setOnLongClickListener {
|
||||||
// enable/disable filters
|
// enable/disable filters
|
||||||
vm.toggleFilters()
|
vm.toggleFilters()
|
||||||
// haptic feedback
|
|
||||||
filterView.performHapticFeedback(
|
|
||||||
HapticFeedbackConstants.LONG_PRESS,
|
|
||||||
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
|
|
||||||
)
|
|
||||||
// show snackbar
|
// show snackbar
|
||||||
Snackbar.make(
|
Snackbar.make(
|
||||||
requireView(), if (vm.filterStatus.value != FILTERS_DISABLED) {
|
requireView(), if (vm.filterStatus.value != FILTERS_DISABLED) {
|
||||||
@@ -1353,10 +1584,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getRootView(): View {
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
|
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
|
||||||
private fun requestLocationUpdates() {
|
private fun requestLocationUpdates() {
|
||||||
locationEngine.requestLocationUpdates(
|
locationEngine.requestLocationUpdates(
|
||||||
@@ -1401,9 +1628,24 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
|||||||
prefs.currentMapLocation = it.bounds.center
|
prefs.currentMapLocation = it.bounds.center
|
||||||
prefs.currentMapZoom = it.zoom
|
prefs.currentMapZoom = it.zoom
|
||||||
}
|
}
|
||||||
|
vm.myLocationEnabled.value?.let {
|
||||||
|
prefs.currentMapMyLocationEnabled = it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroyView() {
|
||||||
super.onDestroy()
|
super.onDestroyView()
|
||||||
|
detailsDialog.onDestroy()
|
||||||
|
|
||||||
|
map = null
|
||||||
|
mapFragment = null
|
||||||
|
_binding = null
|
||||||
|
markers.clear()
|
||||||
|
clusterMarkers = emptyList()
|
||||||
|
searchResultMarker = null
|
||||||
|
searchResultIcon = null
|
||||||
|
/* if we don't dismiss the popup menu, it will be recreated in some cases
|
||||||
|
(split-screen mode) and then have references to a destroyed fragment. */
|
||||||
|
popupMenu?.dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,22 +6,28 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.drawable.AnimatedVectorDrawable
|
import android.graphics.drawable.AnimatedVectorDrawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Html
|
|
||||||
import android.text.method.LinkMovementMethod
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.animation.DecelerateInterpolator
|
import android.view.animation.DecelerateInterpolator
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.text.HtmlCompat
|
||||||
|
import androidx.core.text.method.LinkMovementMethodCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.databinding.*
|
import net.vonforst.evmap.databinding.FragmentOnboardingAndroidAutoBinding
|
||||||
|
import net.vonforst.evmap.databinding.FragmentOnboardingBinding
|
||||||
|
import net.vonforst.evmap.databinding.FragmentOnboardingDataSourceBinding
|
||||||
|
import net.vonforst.evmap.databinding.FragmentOnboardingIconsBinding
|
||||||
|
import net.vonforst.evmap.databinding.FragmentOnboardingWelcomeBinding
|
||||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||||
|
import net.vonforst.evmap.navigation.safeNavigate
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
|
import net.vonforst.evmap.waitForLayout
|
||||||
|
|
||||||
class OnboardingFragment : Fragment() {
|
class OnboardingFragment : Fragment() {
|
||||||
private lateinit var binding: FragmentOnboardingBinding
|
private lateinit var binding: FragmentOnboardingBinding
|
||||||
@@ -57,7 +63,6 @@ class OnboardingFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageSelected(position: Int) {
|
override fun onPageSelected(position: Int) {
|
||||||
binding.pageIndicatorView.selection = position
|
|
||||||
binding.forward?.visibility =
|
binding.forward?.visibility =
|
||||||
if (position == adapter.itemCount - 1) View.INVISIBLE else View.VISIBLE
|
if (position == adapter.itemCount - 1) View.INVISIBLE else View.VISIBLE
|
||||||
binding.backward?.visibility = if (position == 0) View.INVISIBLE else View.VISIBLE
|
binding.backward?.visibility = if (position == 0) View.INVISIBLE else View.VISIBLE
|
||||||
@@ -74,15 +79,19 @@ class OnboardingFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
if (prefs.welcomeDialogShown) {
|
binding.root.waitForLayout {
|
||||||
// skip to last page for selecting data source or accepting the privacy policy
|
binding.viewPager.currentItem = if (prefs.welcomeDialogShown) {
|
||||||
binding.viewPager.currentItem = adapter.itemCount - 1
|
// skip to last page for selecting data source or accepting the privacy policy
|
||||||
|
adapter.itemCount - 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
fun goToNext() {
|
||||||
if (binding.viewPager.currentItem == adapter.itemCount - 1) {
|
if (binding.viewPager.currentItem == adapter.itemCount - 1) {
|
||||||
findNavController().navigate(R.id.action_onboarding_to_map)
|
findNavController().safeNavigate(OnboardingFragmentDirections.actionOnboardingToMap())
|
||||||
} else {
|
} else {
|
||||||
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
|
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
|
||||||
}
|
}
|
||||||
@@ -225,9 +234,14 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
binding.cbAcceptPrivacy.text =
|
binding.cbAcceptPrivacy.text =
|
||||||
Html.fromHtml(getString(R.string.accept_privacy, getString(R.string.privacy_link)))
|
HtmlCompat.fromHtml(
|
||||||
|
getString(
|
||||||
|
R.string.accept_privacy,
|
||||||
|
getString(R.string.privacy_link)
|
||||||
|
), HtmlCompat.FROM_HTML_MODE_LEGACY
|
||||||
|
)
|
||||||
binding.cbAcceptPrivacy.linksClickable = true
|
binding.cbAcceptPrivacy.linksClickable = true
|
||||||
binding.cbAcceptPrivacy.movementMethod = LinkMovementMethod.getInstance()
|
binding.cbAcceptPrivacy.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||||
binding.btnGetStarted.visibility = View.INVISIBLE
|
binding.btnGetStarted.visibility = View.INVISIBLE
|
||||||
|
|
||||||
for (rb in listOf(
|
for (rb in listOf(
|
||||||
@@ -243,9 +257,11 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (prefs.dataSource) {
|
if (prefs.dataSourceSet) {
|
||||||
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
|
when (prefs.dataSource) {
|
||||||
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
|
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
|
||||||
|
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnGetStarted.setOnClickListener {
|
binding.btnGetStarted.setOnClickListener {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.vonforst.evmap.fragment.oauth
|
package net.vonforst.evmap.fragment.oauth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -13,17 +14,25 @@ import android.webkit.CookieManager
|
|||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.setFragmentResult
|
import androidx.fragment.app.setFragmentResult
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.ui.setupWithNavController
|
import androidx.navigation.ui.setupWithNavController
|
||||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
import net.vonforst.evmap.MapsActivity
|
import net.vonforst.evmap.MapsActivity
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
|
import java.lang.IllegalStateException
|
||||||
|
|
||||||
class OAuthLoginFragment : Fragment() {
|
class OAuthLoginFragment : Fragment() {
|
||||||
|
companion object {
|
||||||
|
val ACTION_OAUTH_RESULT = "oauth_result"
|
||||||
|
val EXTRA_URL = "url"
|
||||||
|
}
|
||||||
|
|
||||||
private lateinit var webView: WebView
|
private lateinit var webView: WebView
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -43,10 +52,24 @@ class OAuthLoginFragment : Fragment() {
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||||
toolbar.setupWithNavController(
|
val navController = try {
|
||||||
findNavController(),
|
findNavController()
|
||||||
(requireActivity() as MapsActivity).appBarConfiguration
|
} catch (e: IllegalStateException) {
|
||||||
)
|
null
|
||||||
|
// standalone in OAuthLoginActivity
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navController != null) {
|
||||||
|
toolbar.setupWithNavController(
|
||||||
|
navController,
|
||||||
|
(requireActivity() as MapsActivity).appBarConfiguration
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toolbar.title = getString(R.string.login)
|
||||||
|
toolbar.navigationIcon =
|
||||||
|
AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_back)
|
||||||
|
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
|
||||||
|
}
|
||||||
|
|
||||||
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
|
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
|
||||||
val uri = Uri.parse(args.url)
|
val uri = Uri.parse(args.url)
|
||||||
@@ -68,7 +91,12 @@ class OAuthLoginFragment : Fragment() {
|
|||||||
val result = Bundle()
|
val result = Bundle()
|
||||||
result.putString("url", url.toString())
|
result.putString("url", url.toString())
|
||||||
setFragmentResult(args.url, result)
|
setFragmentResult(args.url, result)
|
||||||
findNavController().popBackStack()
|
context?.let {
|
||||||
|
LocalBroadcastManager.getInstance(it).sendBroadcast(
|
||||||
|
Intent(ACTION_OAUTH_RESULT).putExtra(EXTRA_URL, url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
navController?.popBackStack()
|
||||||
}
|
}
|
||||||
|
|
||||||
return url.host != uri.host
|
return url.host != uri.host
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.mikepenz.aboutlibraries.LibsBuilder
|
|||||||
import net.vonforst.evmap.BuildConfig
|
import net.vonforst.evmap.BuildConfig
|
||||||
import net.vonforst.evmap.MapsActivity
|
import net.vonforst.evmap.MapsActivity
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
|
import net.vonforst.evmap.navigation.safeNavigate
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
|
|
||||||
|
|
||||||
@@ -77,22 +78,25 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
"website" -> {
|
"website" -> {
|
||||||
(activity as? MapsActivity)?.openUrl(getString(R.string.website_url))
|
(activity as? MapsActivity)?.openUrl(getString(R.string.website_url), requireView())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
"github_link" -> {
|
"github_link" -> {
|
||||||
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link))
|
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link), requireView())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
"privacy" -> {
|
"privacy" -> {
|
||||||
(activity as? MapsActivity)?.openUrl(getString(R.string.privacy_link))
|
(activity as? MapsActivity)?.openUrl(
|
||||||
|
getString(R.string.privacy_link),
|
||||||
|
requireView()
|
||||||
|
)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
"faq" -> {
|
"faq" -> {
|
||||||
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link))
|
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link), requireView())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
"oss_licenses" -> {
|
"oss_licenses" -> {
|
||||||
@@ -101,26 +105,42 @@ class AboutFragment : PreferenceFragmentCompat() {
|
|||||||
.withAboutVersionShown(false)
|
.withAboutVersionShown(false)
|
||||||
.withAboutIconShown(false)
|
.withAboutIconShown(false)
|
||||||
.withActivityTitle(getString(R.string.oss_licenses))
|
.withActivityTitle(getString(R.string.oss_licenses))
|
||||||
.withExcludedLibraries()
|
|
||||||
.start(requireActivity())
|
.start(requireActivity())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
"donate" -> {
|
"donate" -> {
|
||||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||||
findNavController().navigate(R.id.action_about_to_donateFragment)
|
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToDonateFragment())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
"github_sponsors" -> {
|
"github_sponsors" -> {
|
||||||
findNavController().navigate(R.id.action_about_to_github_sponsors)
|
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToGithubSponsors())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"mastodon" -> {
|
||||||
|
(activity as? MapsActivity)?.openUrl(
|
||||||
|
getString(R.string.mastodon_url),
|
||||||
|
requireView()
|
||||||
|
)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
"twitter" -> {
|
"twitter" -> {
|
||||||
(activity as? MapsActivity)?.openUrl(getString(R.string.twitter_url))
|
(activity as? MapsActivity)?.openUrl(getString(R.string.twitter_url), requireView())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
"goingelectric" -> {
|
"goingelectric" -> {
|
||||||
(activity as? MapsActivity)?.openUrl(getString(R.string.goingelectric_forum_url))
|
(activity as? MapsActivity)?.openUrl(
|
||||||
|
getString(R.string.goingelectric_forum_url),
|
||||||
|
requireView()
|
||||||
|
)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"tffforum" -> {
|
||||||
|
(activity as? MapsActivity)?.openUrl(
|
||||||
|
getString(R.string.tff_forum_url),
|
||||||
|
requireView()
|
||||||
|
)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onPreferenceTreeClick(preference)
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class AndroidAutoSettingsFragment : BaseSettingsFragment() {
|
|||||||
setPreferencesFromResource(R.xml.settings_android_auto, rootKey)
|
setPreferencesFromResource(R.xml.settings_android_auto, rootKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
|
||||||
when (key) {
|
when (key) {
|
||||||
"chargeprice_battery_range_android_auto_min", "chargeprice_battery_range_android_auto_max" -> {
|
"chargeprice_battery_range_android_auto_min", "chargeprice_battery_range_android_auto_max" -> {
|
||||||
updateRangePreferenceSummary()
|
updateRangePreferenceSummary()
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import android.text.Spanned
|
|||||||
import android.text.style.RelativeSizeSpan
|
import android.text.style.RelativeSizeSpan
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.preference.CheckBoxPreference
|
||||||
|
import androidx.preference.ListPreference
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
|
import net.vonforst.evmap.currencyDisplayName
|
||||||
import net.vonforst.evmap.ui.MultiSelectDialogPreference
|
import net.vonforst.evmap.ui.MultiSelectDialogPreference
|
||||||
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
||||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||||
@@ -28,9 +31,11 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
|||||||
|
|
||||||
private lateinit var myVehiclePreference: MultiSelectDialogPreference
|
private lateinit var myVehiclePreference: MultiSelectDialogPreference
|
||||||
private lateinit var myTariffsPreference: MultiSelectDialogPreference
|
private lateinit var myTariffsPreference: MultiSelectDialogPreference
|
||||||
|
private lateinit var nativeIntegrationPreference: CheckBoxPreference
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
nativeIntegrationPreference = findPreference("chargeprice_native_integration")!!
|
||||||
|
|
||||||
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
|
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
|
||||||
myVehiclePreference.isEnabled = false
|
myVehiclePreference.isEnabled = false
|
||||||
@@ -48,7 +53,7 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
myVehiclePreference.isEnabled = true
|
myVehiclePreference.isEnabled = nativeIntegrationPreference.isChecked
|
||||||
updateMyVehiclesSummary()
|
updateMyVehiclesSummary()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,10 +70,33 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
|||||||
it.name
|
it.name
|
||||||
}
|
}
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
myTariffsPreference.isEnabled = true
|
myTariffsPreference.isEnabled = nativeIntegrationPreference.isChecked
|
||||||
updateMyTariffsSummary()
|
updateMyTariffsSummary()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateNativeIntegrationState()
|
||||||
|
|
||||||
|
val currencyPreference = findPreference<ListPreference>("chargeprice_currency")!!
|
||||||
|
currencyPreference.entries = currencyPreference.entryValues.map {
|
||||||
|
currencyDisplayName(it.toString()).replaceFirstChar { it.uppercase() }
|
||||||
|
}.toTypedArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNativeIntegrationState() {
|
||||||
|
for (i in 0 until preferenceScreen.preferenceCount) {
|
||||||
|
val pref = preferenceScreen.getPreference(i)
|
||||||
|
if (pref == nativeIntegrationPreference) {
|
||||||
|
continue
|
||||||
|
} else if (pref == myTariffsPreference) {
|
||||||
|
pref.isEnabled =
|
||||||
|
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
|
||||||
|
} else if (pref == myVehiclePreference) {
|
||||||
|
pref.isEnabled =
|
||||||
|
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
|
||||||
|
} else {
|
||||||
|
pref.isEnabled = nativeIntegrationPreference.isChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateMyTariffsSummary() {
|
private fun updateMyTariffsSummary() {
|
||||||
@@ -101,14 +129,19 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
|
|||||||
setPreferencesFromResource(R.xml.settings_chargeprice, rootKey)
|
setPreferencesFromResource(R.xml.settings_chargeprice, rootKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
|
||||||
when (key) {
|
when (key) {
|
||||||
"chargeprice_my_vehicle" -> {
|
"chargeprice_my_vehicle" -> {
|
||||||
updateMyVehiclesSummary()
|
updateMyVehiclesSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
"chargeprice_my_tariffs" -> {
|
"chargeprice_my_tariffs" -> {
|
||||||
updateMyTariffsSummary()
|
updateMyTariffsSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"chargeprice_native_integration" -> {
|
||||||
|
updateNativeIntegrationState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,8 +15,8 @@ import com.google.android.material.snackbar.Snackbar
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.addDebugInterceptors
|
import net.vonforst.evmap.addDebugInterceptors
|
||||||
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
|
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
|
||||||
import net.vonforst.evmap.api.availability.TeslaOwnerApi
|
import net.vonforst.evmap.api.availability.tesla.TeslaOwnerApi
|
||||||
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
|
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
|
||||||
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
||||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||||
@@ -86,7 +86,7 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
|
||||||
when (key) {
|
when (key) {
|
||||||
"search_provider" -> {
|
"search_provider" -> {
|
||||||
if (prefs.searchProvider == "google") {
|
if (prefs.searchProvider == "google") {
|
||||||
@@ -141,18 +141,11 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
|||||||
private fun teslaLogin() {
|
private fun teslaLogin() {
|
||||||
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
|
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
|
||||||
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
|
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
|
||||||
val uri = Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
|
val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
|
||||||
.appendQueryParameter("client_id", "ownerapi")
|
|
||||||
.appendQueryParameter("code_challenge", codeChallenge)
|
|
||||||
.appendQueryParameter("code_challenge_method", "S256")
|
|
||||||
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
|
|
||||||
.appendQueryParameter("response_type", "code")
|
|
||||||
.appendQueryParameter("scope", "openid email offline_access")
|
|
||||||
.appendQueryParameter("state", "123").build()
|
|
||||||
|
|
||||||
val args = OAuthLoginFragmentArgs(
|
val args = OAuthLoginFragmentArgs(
|
||||||
uri.toString(),
|
uri.toString(),
|
||||||
"https://auth.tesla.com/void/callback",
|
TeslaAuthenticationApi.resultUrlPrefix,
|
||||||
"#000000"
|
"#000000"
|
||||||
).toBundle()
|
).toBundle()
|
||||||
|
|
||||||
@@ -184,7 +177,8 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
|||||||
encryptedPrefs.teslaRefreshToken = response.refreshToken
|
encryptedPrefs.teslaRefreshToken = response.refreshToken
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
view?.let {
|
view?.let {
|
||||||
Snackbar.make(it, R.string.connection_error, Snackbar.LENGTH_SHORT).show()
|
Snackbar.make(it, R.string.generic_connection_error, Snackbar.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshTeslaAccountStatus()
|
refreshTeslaAccountStatus()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class DeveloperSettingsFragment : BaseSettingsFragment() {
|
|||||||
setPreferencesFromResource(R.xml.settings_developer, rootKey)
|
setPreferencesFromResource(R.xml.settings_developer, rootKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class SettingsFragment : BaseSettingsFragment() {
|
|||||||
findPreference<Preference>("developer_options")?.isVisible = prefs.developerModeEnabled
|
findPreference<Preference>("developer_options")?.isVisible = prefs.developerModeEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,36 +6,69 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import androidx.preference.CheckBoxPreference
|
||||||
import androidx.preference.ListPreference
|
import androidx.preference.ListPreference
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
|
import com.github.erfansn.localeconfigx.configuredLocales
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
|
import net.vonforst.evmap.isAppInstalled
|
||||||
import net.vonforst.evmap.ui.getAppLocale
|
import net.vonforst.evmap.ui.getAppLocale
|
||||||
|
import net.vonforst.evmap.ui.map
|
||||||
import net.vonforst.evmap.ui.updateAppLocale
|
import net.vonforst.evmap.ui.updateAppLocale
|
||||||
import net.vonforst.evmap.ui.updateNightMode
|
import net.vonforst.evmap.ui.updateNightMode
|
||||||
|
|
||||||
class UiSettingsFragment : BaseSettingsFragment() {
|
class UiSettingsFragment : BaseSettingsFragment() {
|
||||||
override val isTopLevel = false
|
override val isTopLevel = false
|
||||||
lateinit var langPref: ListPreference
|
lateinit var langPref: ListPreference
|
||||||
|
lateinit var immediateNavPref: CheckBoxPreference
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.settings_ui, rootKey)
|
setPreferencesFromResource(R.xml.settings_ui, rootKey)
|
||||||
|
|
||||||
|
setupLangPref()
|
||||||
|
|
||||||
|
val appLinkPref = findPreference<Preference>("applink_associate")!!
|
||||||
|
appLinkPref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
immediateNavPref = findPreference("navigate_use_maps")!!
|
||||||
|
immediateNavPref.isVisible = isGoogleMapsInstalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupLangPref() {
|
||||||
langPref = findPreference("language")!!
|
langPref = findPreference("language")!!
|
||||||
|
val configuredLocales = requireContext().configuredLocales
|
||||||
|
val numLocalesByLang = configuredLocales.map { it.language }.groupingBy { it }.eachCount()
|
||||||
|
|
||||||
|
val localeNames = configuredLocales.map {
|
||||||
|
val name = if (numLocalesByLang[it.language]!! > 1) {
|
||||||
|
it.getDisplayName(it)
|
||||||
|
} else {
|
||||||
|
it.getDisplayLanguage(it)
|
||||||
|
}
|
||||||
|
name.replaceFirstChar { c -> c.uppercase(it) }
|
||||||
|
}
|
||||||
|
val localeTags = configuredLocales.map { it.toLanguageTag() }
|
||||||
|
|
||||||
|
langPref.entries =
|
||||||
|
(listOf(getString(R.string.pref_language_device_default)) + localeNames).toTypedArray()
|
||||||
|
langPref.entryValues =
|
||||||
|
(listOf("default") + localeTags).toTypedArray()
|
||||||
langPref.setOnPreferenceChangeListener { _, newValue ->
|
langPref.setOnPreferenceChangeListener { _, newValue ->
|
||||||
updateAppLocale(newValue as String)
|
updateAppLocale(newValue as String)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
val appLinkPref = findPreference<Preference>("applink_associate")!!
|
|
||||||
appLinkPref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isGoogleMapsInstalled() =
|
||||||
|
requireContext().packageManager.isAppInstalled("com.google.android.apps.maps")
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
langPref.value = getAppLocale()
|
langPref.value = getAppLocale(requireContext())
|
||||||
|
immediateNavPref.isVisible = isGoogleMapsInstalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
|
||||||
when (key) {
|
when (key) {
|
||||||
"darkmode" -> {
|
"darkmode" -> {
|
||||||
updateNightMode(prefs)
|
updateNightMode(prefs)
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import android.view.ViewGroup
|
|||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import net.vonforst.evmap.R
|
import net.vonforst.evmap.R
|
||||||
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
|
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
|
||||||
|
import net.vonforst.evmap.navigation.safeNavigate
|
||||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||||
import net.vonforst.evmap.ui.MaterialDialogFragment
|
import net.vonforst.evmap.ui.MaterialDialogFragment
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
|
class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
|
||||||
private lateinit var binding: DialogOpensourceDonationsBinding
|
private lateinit var binding: DialogOpensourceDonationsBinding
|
||||||
@@ -25,16 +27,16 @@ class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
|
|||||||
override fun initView(view: View, savedInstanceState: Bundle?) {
|
override fun initView(view: View, savedInstanceState: Bundle?) {
|
||||||
val prefs = PreferenceDataSource(requireContext())
|
val prefs = PreferenceDataSource(requireContext())
|
||||||
binding.btnOk.setOnClickListener {
|
binding.btnOk.setOnClickListener {
|
||||||
prefs.opensourceDonationsDialogShown = true
|
prefs.opensourceDonationsDialogLastShown = Instant.now()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
binding.btnDonate.setOnClickListener {
|
binding.btnDonate.setOnClickListener {
|
||||||
prefs.opensourceDonationsDialogShown = true
|
prefs.opensourceDonationsDialogLastShown = Instant.now()
|
||||||
findNavController().navigate(R.id.action_opensource_donations_to_donate)
|
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToDonate())
|
||||||
}
|
}
|
||||||
binding.btnGithubSponsors.setOnClickListener {
|
binding.btnGithubSponsors.setOnClickListener {
|
||||||
prefs.opensourceDonationsDialogShown = true
|
prefs.opensourceDonationsDialogLastShown = Instant.now()
|
||||||
findNavController().navigate(R.id.action_opensource_donations_to_github_sponsors)
|
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToGithubSponsors())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
|||||||
try {
|
try {
|
||||||
return locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)
|
return locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "Permissions not granted for fused provider", e)
|
Log.w(TAG, "Permissions not granted for fused provider", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "Permissions not granted for provider: $provider", e)
|
Log.w(TAG, "Permissions not granted for provider: $provider", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return bestLocation
|
return bestLocation
|
||||||
@@ -103,7 +103,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
|||||||
enableFused(gpsInterval)
|
enableFused(gpsInterval)
|
||||||
checkLastKnownFused()
|
checkLastKnownFused()
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "Permissions not granted for fused provider", e)
|
Log.w(TAG, "Permissions not granted for fused provider", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
|||||||
looper
|
looper
|
||||||
)
|
)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Log.e(TAG, "Unable to register for GPS updates.", e)
|
Log.w(TAG, "Unable to register for GPS updates.", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
|||||||
looper
|
looper
|
||||||
)
|
)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Log.e(TAG, "Unable to register for network updates.", e)
|
Log.w(TAG, "Unable to register for network updates.", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
|||||||
looper
|
looper
|
||||||
)
|
)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Log.e(TAG, "Unable to register for passive updates.", e)
|
Log.w(TAG, "Unable to register for passive updates.", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ class FusionEngine(context: Context) : LocationEngine(context),
|
|||||||
looper
|
looper
|
||||||
)
|
)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Log.e(TAG, "Unable to register for passive updates.", e)
|
Log.w(TAG, "Unable to register for passive updates.", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import java.time.LocalDate
|
|||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.util.*
|
import java.util.Locale
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
sealed class ChargepointListItem
|
sealed class ChargepointListItem
|
||||||
|
|
||||||
@@ -127,19 +128,22 @@ data class ChargeLocation(
|
|||||||
get() {
|
get() {
|
||||||
val variants = chargepoints.distinctBy { it.power to it.type }
|
val variants = chargepoints.distinctBy { it.power to it.type }
|
||||||
return variants.map { variant ->
|
return variants.map { variant ->
|
||||||
val count = chargepoints
|
val filtered = chargepoints
|
||||||
.filter { it.type == variant.type && it.power == variant.power }
|
.filter { it.type == variant.type && it.power == variant.power }
|
||||||
.sumOf { it.count }
|
val count = filtered.sumOf { it.count }
|
||||||
Chargepoint(variant.type, variant.power, count)
|
Chargepoint(variant.type, variant.power, count,
|
||||||
|
filtered.map { it.voltage }.distinct().singleOrNull(),
|
||||||
|
filtered.map { it.current }.distinct().singleOrNull()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val totalChargepoints: Int
|
val totalChargepoints: Int
|
||||||
get() = chargepoints.sumOf { it.count }
|
get() = chargepoints.sumOf { it.count }
|
||||||
|
|
||||||
fun formatChargepoints(sp: StringProvider): String {
|
fun formatChargepoints(sp: StringProvider, locale: Locale): String {
|
||||||
return chargepointsMerged.joinToString(" · ") {
|
return chargepointsMerged.joinToString(" · ") {
|
||||||
"${it.count} × ${nameForPlugType(sp, it.type)} ${it.formatPower()}"
|
"${it.count} × ${nameForPlugType(sp, it.type)} ${it.formatPower(locale)}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,31 +394,42 @@ data class Address(
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class Chargepoint(
|
data class Chargepoint(
|
||||||
// The chargepoint type (use one of the constants in the companion object)
|
// The connector type (use one of the constants in the companion object if applicable)
|
||||||
val type: String,
|
val type: String,
|
||||||
// Power in kW (or null if unknown)
|
// Power in kW (or null if unknown)
|
||||||
val power: Double?,
|
val power: Double?,
|
||||||
// How many instances of this plug/socket are available?
|
// How many instances of this plug/socket are available?
|
||||||
val count: Int,
|
val count: Int,
|
||||||
|
// Max current in A (or null if unknown)
|
||||||
|
val current: Double? = null,
|
||||||
|
// Max voltage in V (or null if unknown).
|
||||||
|
// note that for DC chargers: current * voltage may be larger than power
|
||||||
|
// (each of the three can be separately limited)
|
||||||
|
val voltage: Double? = null
|
||||||
) : Equatable, Parcelable {
|
) : Equatable, Parcelable {
|
||||||
fun hasKnownPower(): Boolean = power != null
|
fun hasKnownPower(): Boolean = power != null
|
||||||
|
fun hasKnownVoltageAndCurrent(): Boolean = voltage != null && current != null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If chargepoint power is defined, format it into a string.
|
* If chargepoint power is defined, format it into a string.
|
||||||
* Otherwise, return null.
|
* Otherwise, return null.
|
||||||
*/
|
*/
|
||||||
fun formatPower(): String? {
|
fun formatPower(locale: Locale): String? {
|
||||||
if (power == null) {
|
if (power == null) return null
|
||||||
return null
|
val powerFmt = if (abs(power - power.toInt()) < 0.1) {
|
||||||
}
|
"%.0f".format(locale, power)
|
||||||
val powerFmt = if (power - power.toInt() == 0.0) {
|
|
||||||
"%.0f".format(power)
|
|
||||||
} else {
|
} else {
|
||||||
"%.1f".format(power)
|
"%.1f".format(locale, power)
|
||||||
}
|
}
|
||||||
return "$powerFmt kW"
|
return "$powerFmt kW"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun formatVoltageAndCurrent(): String? {
|
||||||
|
if (current == null || voltage == null) return null
|
||||||
|
|
||||||
|
return "%.0f V · %.0f A".format(voltage, current)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TYPE_1 = "Type 1"
|
const val TYPE_1 = "Type 1"
|
||||||
const val TYPE_2_UNKNOWN = "Type 2 (either plug or socket)"
|
const val TYPE_2_UNKNOWN = "Type 2 (either plug or socket)"
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package net.vonforst.evmap.navigation
|
package net.vonforst.evmap.navigation
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -52,7 +54,15 @@ class CustomNavigator(
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
intent.launchUrl(context, Uri.parse(url))
|
try {
|
||||||
|
intent.launchUrl(context, Uri.parse(url))
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.no_browser_app_found,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun popBackStack() = true // Managed by Chrome Custom Tabs
|
override fun popBackStack() = true // Managed by Chrome Custom Tabs
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package net.vonforst.evmap.navigation
|
||||||
|
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavDirections
|
||||||
|
import androidx.navigation.Navigator
|
||||||
|
|
||||||
|
fun NavController.safeNavigate(
|
||||||
|
direction: NavDirections,
|
||||||
|
navigatorExtras: Navigator.Extras? = null
|
||||||
|
) {
|
||||||
|
currentDestination?.getAction(direction.actionId) ?: return
|
||||||
|
if (navigatorExtras != null) {
|
||||||
|
navigate(direction, navigatorExtras)
|
||||||
|
} else {
|
||||||
|
navigate(direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
|||||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||||
import net.vonforst.evmap.model.*
|
import net.vonforst.evmap.model.*
|
||||||
import net.vonforst.evmap.ui.cluster
|
import net.vonforst.evmap.ui.cluster
|
||||||
|
import net.vonforst.evmap.utils.crossesAntimeridian
|
||||||
|
import net.vonforst.evmap.utils.splitAtAntimeridian
|
||||||
import net.vonforst.evmap.viewmodel.Resource
|
import net.vonforst.evmap.viewmodel.Resource
|
||||||
import net.vonforst.evmap.viewmodel.Status
|
import net.vonforst.evmap.viewmodel.Status
|
||||||
import net.vonforst.evmap.viewmodel.await
|
import net.vonforst.evmap.viewmodel.await
|
||||||
@@ -144,7 +146,15 @@ class ChargeLocationsRepository(
|
|||||||
zoom: Float,
|
zoom: Float,
|
||||||
filters: FilterValues?,
|
filters: FilterValues?,
|
||||||
overrideCache: Boolean = false
|
overrideCache: Boolean = false
|
||||||
): LiveData<Resource<List<ChargepointListItem>>> {
|
): LiveData<Resource<ChargepointList>> {
|
||||||
|
if (bounds.crossesAntimeridian()) {
|
||||||
|
val (a, b) = bounds.splitAtAntimeridian()
|
||||||
|
val liveDataA = getChargepoints(a, zoom, filters, overrideCache)
|
||||||
|
val liveDataB = getChargepoints(b, zoom, filters, overrideCache)
|
||||||
|
return combineLiveData(liveDataA, liveDataB)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
val api = api.value!!
|
val api = api.value!!
|
||||||
|
|
||||||
val dbResult = if (filters == null) {
|
val dbResult = if (filters == null) {
|
||||||
@@ -158,7 +168,7 @@ class ChargeLocationsRepository(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
queryWithFilters(api, filters, bounds)
|
queryWithFilters(api, filters, bounds)
|
||||||
}.map { applyLocalClustering(it, zoom) }
|
}.map { ChargepointList(applyLocalClustering(it, zoom), true) }
|
||||||
val filtersSerialized =
|
val filtersSerialized =
|
||||||
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
|
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
|
||||||
?.serialize()
|
?.serialize()
|
||||||
@@ -208,12 +218,41 @@ class ChargeLocationsRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun combineLiveData(
|
||||||
|
liveDataA: LiveData<Resource<ChargepointList>>,
|
||||||
|
liveDataB: LiveData<Resource<ChargepointList>>
|
||||||
|
) = MediatorLiveData<Resource<ChargepointList>>().apply {
|
||||||
|
listOf(liveDataA, liveDataB).forEach {
|
||||||
|
addSource(it) {
|
||||||
|
val valA = liveDataA.value
|
||||||
|
val valB = liveDataB.value
|
||||||
|
val combinedList = if (valA?.data != null && valB?.data != null) {
|
||||||
|
ChargepointList(
|
||||||
|
valA.data.items + valB.data.items,
|
||||||
|
valA.data.isComplete && valB.data.isComplete
|
||||||
|
)
|
||||||
|
} else if (valA?.data != null) {
|
||||||
|
ChargepointList(valA.data.items, false)
|
||||||
|
} else if (valB?.data != null) {
|
||||||
|
ChargepointList(valB.data.items, false)
|
||||||
|
} else null
|
||||||
|
if (valA?.status == Status.SUCCESS && valB?.status == Status.SUCCESS) {
|
||||||
|
Resource.success(combinedList)
|
||||||
|
} else if (valA?.status == Status.ERROR || valB?.status == Status.ERROR) {
|
||||||
|
Resource.error(valA?.message ?: valB?.message, combinedList)
|
||||||
|
} else {
|
||||||
|
Resource.loading(combinedList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getChargepointsRadius(
|
fun getChargepointsRadius(
|
||||||
location: LatLng,
|
location: LatLng,
|
||||||
radius: Int,
|
radius: Int,
|
||||||
zoom: Float,
|
zoom: Float,
|
||||||
filters: FilterValues?
|
filters: FilterValues?
|
||||||
): LiveData<Resource<List<ChargepointListItem>>> {
|
): LiveData<Resource<ChargepointList>> {
|
||||||
val api = api.value!!
|
val api = api.value!!
|
||||||
|
|
||||||
val radiusMeters = radius.toDouble() * 1000
|
val radiusMeters = radius.toDouble() * 1000
|
||||||
@@ -227,7 +266,7 @@ class ChargeLocationsRepository(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
queryWithFilters(api, filters, location, radiusMeters)
|
queryWithFilters(api, filters, location, radiusMeters)
|
||||||
}.map { applyLocalClustering(it, zoom) }
|
}.map { ChargepointList(applyLocalClustering(it, zoom), true) }
|
||||||
val filtersSerialized =
|
val filtersSerialized =
|
||||||
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
|
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
|
||||||
?.serialize()
|
?.serialize()
|
||||||
@@ -277,18 +316,18 @@ class ChargeLocationsRepository(
|
|||||||
private fun applyLocalClustering(
|
private fun applyLocalClustering(
|
||||||
result: Resource<ChargepointList>,
|
result: Resource<ChargepointList>,
|
||||||
zoom: Float
|
zoom: Float
|
||||||
): Resource<List<ChargepointListItem>> {
|
): Resource<ChargepointList> {
|
||||||
val list = result.data ?: return Resource(result.status, null, result.message)
|
val list = result.data ?: return Resource(result.status, null, result.message)
|
||||||
val chargers = list.items.filterIsInstance<ChargeLocation>()
|
val chargers = list.items.filterIsInstance<ChargeLocation>()
|
||||||
|
|
||||||
if (chargers.size != list.items.size) return Resource(
|
if (chargers.size != list.items.size) return Resource(
|
||||||
result.status,
|
result.status,
|
||||||
list.items,
|
list,
|
||||||
result.message
|
result.message
|
||||||
) // list already contains clusters
|
) // list already contains clusters
|
||||||
|
|
||||||
val clustered = applyLocalClustering(chargers, zoom)
|
val clustered = applyLocalClustering(chargers, zoom)
|
||||||
return Resource(result.status, clustered, result.message)
|
return Resource(result.status, ChargepointList(clustered, list.isComplete), result.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyLocalClustering(
|
private fun applyLocalClustering(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.room.migration.Migration
|
|||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import co.anbora.labs.spatia.builder.SpatiaRoom
|
import co.anbora.labs.spatia.builder.SpatiaRoom
|
||||||
import co.anbora.labs.spatia.geometry.GeometryConverters
|
import co.anbora.labs.spatia.geometry.GeometryConverters
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import net.vonforst.evmap.api.goingelectric.GEChargeCard
|
import net.vonforst.evmap.api.goingelectric.GEChargeCard
|
||||||
import net.vonforst.evmap.api.goingelectric.GEChargepoint
|
import net.vonforst.evmap.api.goingelectric.GEChargepoint
|
||||||
import net.vonforst.evmap.api.openchargemap.OCMConnectionType
|
import net.vonforst.evmap.api.openchargemap.OCMConnectionType
|
||||||
@@ -35,7 +34,7 @@ import net.vonforst.evmap.model.*
|
|||||||
OCMCountry::class,
|
OCMCountry::class,
|
||||||
OCMOperator::class,
|
OCMOperator::class,
|
||||||
SavedRegion::class
|
SavedRegion::class
|
||||||
], version = 21
|
], version = 22
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class, GeometryConverters::class)
|
@TypeConverters(Converters::class, GeometryConverters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@@ -75,7 +74,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
|
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
|
||||||
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
|
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
|
||||||
MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16,
|
MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16,
|
||||||
MIGRATION_17, MIGRATION_18, MIGRATION_19, MIGRATION_20, MIGRATION_21
|
MIGRATION_17, MIGRATION_18, MIGRATION_19, MIGRATION_20, MIGRATION_21,
|
||||||
|
MIGRATION_22
|
||||||
)
|
)
|
||||||
.addCallback(object : Callback() {
|
.addCallback(object : Callback() {
|
||||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||||
@@ -452,6 +452,13 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
db.execSQL("DELETE FROM savedregion")
|
db.execSQL("DELETE FROM savedregion")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_22 = object : Migration(21, 22) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
// clear cache with this update
|
||||||
|
db.execSQL("DELETE FROM savedregion")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user