Compare commits

..

1 Commits

Author SHA1 Message Date
johan12345
a22b347edc implement chargeprice feedback dialog
#195
2022-09-04 11:11:46 +02:00
336 changed files with 2842 additions and 11007 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
github: johan12345
custom: ['https://paypal.me/johan98', 'http://ts.la/johan94494']
custom: 'https://paypal.me/johan98'

View File

@@ -4,6 +4,4 @@
<string name="goingelectric_key" translatable="false">ci</string>
<string name="chargeprice_key" translatable="false">ci</string>
<string name="openchargemap_key" translatable="false">ci</string>
<string name="fronyx_key" translatable="false">ci</string>
<string name="acra_credentials" translatable="false">ci:ci</string>
</resources>

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 17
java-version: 11
distribution: 'zulu'
cache: 'gradle'
- name: Decrypt keystore
@@ -31,8 +31,6 @@ jobs:
CHARGEPRICE_API_KEY: ${{ secrets.CHARGEPRICE_API_KEY }}
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
@@ -77,12 +75,3 @@ jobs:
asset_path: app/build/outputs/apk/googleAutomotive/release/app-google-automotive-release.apk
asset_name: app-google-automotive-release.apk
asset_content_type: application/vnd.android.package-archive
- name: upload Foss Automotive artifact
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fossAutomotive/release/app-foss-automotive-release.apk
asset_name: app-foss-automotive-release.apk
asset_content_type: application/vnd.android.package-archive

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
buildvariant: [ FossNormal, GoogleNormal, GoogleAutomotive ]
steps:
- name: Check out code
uses: actions/checkout@v2
@@ -21,7 +21,7 @@ jobs:
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 17
java-version: 11
distribution: 'zulu'
cache: 'gradle'

View File

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

View File

@@ -1,8 +1,7 @@
EVMap [![Build Status](https://github.com/ev-map/EVMap/actions/workflows/tests.yml/badge.svg)](https://github.com/ev-map/EVMap/actions)
EVMap [![Build Status](https://github.com/johan12345/EVMap/actions/workflows/tests.yml/badge.svg)](https://github.com/johan12345/EVMap/actions)
=====
<a href="https://ev-map.app" target="_blank">
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/></a>
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
Android app to find electric vehicle charging stations.
@@ -29,7 +28,7 @@ Features
Screenshots
-----------
<img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/ev-map/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
Development setup
-----------------
@@ -43,27 +42,13 @@ EVMap uses and put them into the app in the form of a resource file called `apik
features and how they can be obtained in our [documentation page](doc/api_keys.md).
There are three different build flavors, `googleNormal`, `fossNormal` and `googleAutomotive`.
- The `foss` variants only use Mapbox data and should run on most Android devices, even without
- The `foss` variant only uses Mapbox data and should run on most Android devices, even without
Google Play Services.
- `fossNormal` is intended to run on smartphones and tablets, and also includes the Android
Auto app for use on the car display (however for that to work, the Android Auto app is
necessary, which in turn does require Google Play Services).
- `fossAutomotive` can be installed directly on
[Android Automotive OS (AAOS)](https://source.android.com/docs/automotive/start/what_automotive)
headunits without Google services.
It does not provide the usual smartphone UI, and requires an implementation of the
[AOSP template app host](https://source.android.com/docs/automotive/hmi/aosp_host)
to be installed. If you are an OEM and would like to distribute EVMap to your AAOS vehicles,
please [get in touch](mailto:evmap@vonforst.net).
- The `google` variants also include access to Google Maps data.
- `googleNormal` is intended to run on smartphones and tablets, and also includes the Android
Auto app for use on the car display.
- `googleAutomotive` can be installed directly on car infotainment systems running the
Google-flavored Android Automotive OS (Google Automotive Services /
["Google built-in"](https://built-in.google/cars/)).
It does not provide the usual smartphone UI, and requires the
[Google Automotive App Host](https://play.google.com/store/apps/details?id=com.google.android.apps.automotive.templates.host)
to run, which should be preinstalled on those cars and can be updated through the Play Store.
- `googleAutomotive` variant is intended to be installed directly on car infotainment systems
using the Google-flavored Android Automotive OS. It does not provide the usual smartphone UI.
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
app.

View File

@@ -1,65 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 663.6 219.8" style="enable-background:new 0 0 663.6 219.8;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFB300;}
.st1{fill:#90A4AE;}
.st2{fill:#546E7A;}
.st3{fill:#00E676;}
.st4{fill:#FFFFFF;fill-opacity:0.2;}
.st5{fill:#3E2723;fill-opacity:0.2;}
.st6{opacity:0.45;enable-background:new ;}
.st7{enable-background:new ;}
.st8{fill:#1D1D1B;}
</style>
<g id="Ebene_2_1_">
<g>
<g>
<g>
<path class="st0"
d="M19.4,161.7l-4-35.1l-6.1,0.6l4,35.1L19.4,161.7z M41.2,159.1l-4-35.1l-6.1,0.6l4,35.1L41.2,159.1z" />
<path class="st1" d="M52.6,206.9c-1.9,2.3-3.4,3.8-3.6,4c-5.5,4.4-9.9,5.7-13.5,4c-6.3-3.2-5.9-15-5.7-16.3l4.4,0.2
c-0.2,3.4,0.4,10.6,3.4,12c1.7,0.8,4.6-0.2,8.5-3.4l0,0c0,0,12.3-12.3,9.7-22c-3-11.6,10.6-28.3,15-34l0.6-0.6l3.6,2.7l-0.6,0.8
c-13.7,16.9-15.2,25.6-14.2,30C62.3,192.9,56.6,202,52.6,206.9z" />
<path class="st1"
d="M5.9,161.2l1.7,14.4l13.3,8.9l18-1.9l11-11.6l-1.7-14.4L5.9,161.2z" />
<g>
<path class="st2" d="M38.6,182.6l-18,1.9l3.8,15.8l14.2-1.7V182.6L38.6,182.6z M51.5,144.5l1.5,13.1l-51.5,5.9L0,150.4
L51.5,144.5z" />
</g>
</g>
<g>
<g>
<path class="st3" d="M91.9,0c-37,0-67,30-67,67c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6s3.8-1.5,4-3.6
c6.8-72,63.2-98.4,63.2-148.9C158.8,29.8,128.8,0,91.9,0z" />
<path class="st4" d="M91.9,1.5c36.8,0,66.5,29.6,67,66.1c0-0.2,0-0.4,0-0.6c0-37-30-67-67-67s-67,29.8-67,67c0,0.2,0,0.4,0,0.6
C25.3,31.1,55.1,1.5,91.9,1.5L91.9,1.5z" />
<path class="st5" d="M95.9,214.3c-0.2,2.1-1.9,3.6-4,3.6s-3.8-1.5-4-3.6c-6.5-71.8-62.5-98.2-63-148.1c0,0.4,0,0.6,0,1.1
c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6s3.8-1.5,4-3.6c6.8-72,63.2-98.4,63.2-148.9c0-0.4,0-0.6,0-1.1
C158.4,116,102.4,142.4,95.9,214.3L95.9,214.3z" />
</g>
<path class="st6"
d="M76.5,34.3v40.6h11v33.2l25.8-44.4H98.5l14.8-29.6C113.4,34.3,76.5,34.3,76.5,34.3z" />
</g>
</g>
<g class="st7">
<path class="st8"
d="M307.9,102.9h-45.4v39.6h52.2v6.9h-60.4V52.3h60.1v6.9h-51.9v36.7h45.4V102.9z" />
<path class="st8"
d="M361.2,137.4l0.5,2.1l0.6-2.1l30.5-85.1h9l-36.1,97.1h-7.9l-36.1-97.1h8.9L361.2,137.4z" />
<path class="st8" d="M427,52.4l35.8,85.7l35.9-85.7h10.9v97.1h-8.2v-42.3l0.7-43.3L466,149.5h-6.3l-36-85.3l0.7,42.7v42.5h-8.2
V52.3H427V52.4z" />
<path class="st8" d="M578,149.4c-0.8-2.3-1.3-5.6-1.5-10.1c-2.8,3.6-6.4,6.5-10.7,8.4c-4.3,2-8.9,3-13.8,3
c-6.9,0-12.5-1.9-16.8-5.8s-6.4-8.8-6.4-14.7c0-7,2.9-12.6,8.8-16.7c5.8-4.1,14-6.1,24.4-6.1h14.5v-8.2c0-5.2-1.6-9.2-4.8-12.2
s-7.8-4.4-13.9-4.4c-5.6,0-10.2,1.4-13.8,4.3c-3.6,2.8-5.5,6.3-5.5,10.3l-8-0.1c0-5.7,2.7-10.7,8-14.9s11.9-6.3,19.7-6.3
c8,0,14.4,2,19,6s7,9.6,7.2,16.8v34.1c0,7,0.7,12.2,2.2,15.7v0.8H578V149.4z M552.9,143.7c5.3,0,10.1-1.3,14.3-3.9
s7.3-6,9.2-10.3v-15.9h-14.3c-8,0.1-14.2,1.5-18.7,4.4c-4.5,2.8-6.7,6.7-6.7,11.6c0,4,1.5,7.4,4.5,10.1S548.1,143.7,552.9,143.7z
" />
<path class="st8" d="M663.6,114.1c0,11.2-2.5,20.2-7.5,26.8s-11.6,9.9-20,9.9c-9.9,0-17.4-3.5-22.7-10.4v36.8h-7.9V77.3h7.4
l0.4,10.2C618.5,79.8,626,76,635.9,76c8.6,0,15.4,3.3,20.3,9.8c4.9,6.5,7.4,15.6,7.4,27.2V114.1z M655.6,112.7
c0-9.2-1.9-16.5-5.7-21.8s-9-8-15.8-8c-4.9,0-9.1,1.2-12.6,3.5c-3.5,2.4-6.2,5.8-8.1,10.3v34.6c1.9,4.1,4.6,7.3,8.2,9.5
c3.6,2.2,7.8,3.3,12.6,3.3c6.7,0,11.9-2.7,15.7-8C653.7,130.6,655.6,122.9,655.6,112.7z" />
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 566.9 254.9" style="enable-background:new 0 0 566.9 254.9;" xml:space="preserve">
<style type="text/css">
.st0{fill:#000044;}
</style>
<path class="st0" d="M60.8,86.3c-5.6,0-10,0.5-13.3,1.4c-3.3,0.9-5.7,2.7-7.1,5.2c-0.4,0.6-0.7,1.3-0.9,2c-1.1,3.3-1.4,6.8-1.4,10.2
c0,2.1,0,4.2,0,6.2c0,0.1,0,0.2,0,0.3h22.3v20.9H38.1v71.2v7.6H14.5v-7.6v-71.2H0v-20.9h14.5V104c0-13.4,3.9-23.8,11.2-31.3
c7.3-7.4,19-10.8,35.1-10V86.3 M157.1,161.2c0-15.5,11.2-27.3,25.7-27.3c14.5,0,25.5,11.8,25.5,27.3c0,15.6-11,27.3-25.5,27.3
C168.3,188.5,157.1,176.9,157.1,161.2 M182.8,110.1c-27.9,0-49.7,22.2-49.7,51.1c0,29.1,21.8,51.3,49.7,51.3
c27.9,0,49.4-22.2,49.4-51.3C232.1,132.3,210.7,110.1,182.8,110.1 M541.4,161.5c14.1,0,25.6-11.5,25.6-25.6
c0-14.1-11.5-25.6-25.6-25.6c-14.1,0-25.6,11.5-25.6,25.6C515.8,150,527.2,161.5,541.4,161.5 M129.6,110.5c-2.2-0.2-4.3-0.4-6.6-0.4
c-4.4,0-9.1,0.6-13.9,1.9c-4.8,1.3-8.5,3.4-10.7,6.5v-7H74.7V211h23.8v-58.2c0-5.4,1.4-9.5,4.1-12.3c4.4-4.5,10.6-6.5,16.7-6.8
c1.7-0.1,3.5,0,5.3,0.1c1.6,0.2,3.2,0.5,4.7,0.7c0-1,0-2,0-3c0-2,0-3.9,0-5.9c0-2.2,0-4.5,0-6.7c0-1.9,0.2-3.8,0.2-5.7
c0-0.9,0.2-1.7,0.2-2.6C129.6,110.6,129.6,110.5,129.6,110.5z M475.8,160.6l29.7-49h-28.7l-16.3,31.7l-16.5-31.7H415l30.1,49
l-30.7,50.6h28.7l17.3-33.1l17.7,33.1h28.7L475.8,160.6z M356.9,254.8c14.9,0.5,26.7-3.1,34.7-10.9c7.6-7.4,11.6-18.1,12-33l0,0
v-2.4v-97.1h-24.5c0,0,0,14.3,0,28.8v14.9v0.1v14.5c0,5.3-1.2,9.7-5.8,13.8c-4.3,3.9-10.9,5.1-15.4,5.1c-2.7,0-10.7-0.9-15.4-7.5
c-2.4-3.4-3.9-9.1-4.2-14c-0.3-5-0.3-7.4-0.6-14.7c-1.1-30.2-26-47.6-55-40.3c-5,1.3-9,3.4-11.2,6.5v-7.2h-23.8V211h23.8v-52v-6
c0-5.4,1.5-9.5,4.3-12.3c2.8-2.7,5.8-4.2,9.5-5.1c3.7-0.9,6.6-1,8.5-1c2.9,0,6.5,0.8,8,1.2c2.8,0.8,6.3,3.3,8.3,6.2
c3.7,5.3,3.3,12.8,3.5,18.9c0.2,7.6,0.4,15.3,2.7,22.7c3.2,10.3,9.7,20,19.7,24.6c9,4.1,20,5.2,29.7,3.4c3-0.6,6.3-1.5,9.1-3
c1.6-0.9,3.2-1.9,4.5-3.2c0.4,10.7,0.6,17.6-6,22.5c-1.8,1.4-4.3,2.5-6.5,2.9c-3.1,0.6-6.5,1-9.7,0.9L356.9,254.8z" />
<g>
<g>
<path class="st0" d="M97.6,89.8V39.2c0-1.6-0.1-3.2-0.2-4.8c-0.1-1.7-0.3-3.3-0.5-4.9h6.6l0.9,10h-1c0.9-3.3,2.7-5.9,5.5-7.9
c2.7-1.9,6-2.9,9.8-2.9c3.8,0,7.1,0.9,9.9,2.6c2.8,1.7,4.9,4.2,6.5,7.5c1.6,3.2,2.4,7.2,2.4,11.8c0,4.5-0.8,8.4-2.3,11.7
c-1.5,3.2-3.7,5.8-6.5,7.5c-2.8,1.8-6.1,2.6-9.9,2.6c-3.8,0-7-1-9.7-2.9c-2.7-1.9-4.6-4.5-5.5-7.8h0.9v28.1H97.6z M117.3,66.8
c4,0,7.2-1.4,9.6-4.2c2.4-2.8,3.6-6.8,3.6-12.1c0-5.4-1.2-9.5-3.6-12.2c-2.4-2.8-5.6-4.2-9.6-4.2c-4,0-7.2,1.4-9.5,4.2
c-2.4,2.8-3.6,6.8-3.6,12.2c0,5.3,1.2,9.4,3.6,12.1C110.2,65.4,113.4,66.8,117.3,66.8z" />
<path class="st0" d="M165.4,72.4c-4.1,0-7.6-0.9-10.6-2.6c-3-1.8-5.3-4.3-6.9-7.6c-1.6-3.3-2.4-7.2-2.4-11.6
c0-4.5,0.8-8.4,2.4-11.7c1.6-3.2,3.9-5.8,6.9-7.5c3-1.8,6.5-2.6,10.5-2.6c4.1,0,7.6,0.9,10.6,2.6c3,1.8,5.3,4.3,7,7.5
c1.7,3.2,2.5,7.1,2.5,11.7c0,4.5-0.8,8.4-2.5,11.6c-1.7,3.3-4,5.8-7,7.6C172.9,71.5,169.4,72.4,165.4,72.4z M165.4,66.8
c4,0,7.1-1.4,9.5-4.2c2.4-2.8,3.5-6.8,3.5-12.1c0-5.4-1.2-9.5-3.6-12.2c-2.4-2.8-5.6-4.2-9.5-4.2c-4,0-7.1,1.4-9.5,4.2
c-2.4,2.8-3.5,6.8-3.5,12.2c0,5.3,1.2,9.4,3.5,12.1C158.2,65.4,161.4,66.8,165.4,66.8z" />
<path class="st0" d="M207.2,71.6l-15.6-42.2h7.2l13,37.1h-2.2l13.4-37.1h5.9l13.2,37.1h-2.1l13.1-37.1h6.9l-15.7,42.2h-6.6
l-13.6-37.7h3.4l-13.7,37.7H207.2z" />
<path class="st0" d="M287.7,72.4c-6.6,0-11.8-1.9-15.6-5.8c-3.8-3.8-5.7-9.2-5.7-16c0-4.4,0.8-8.3,2.5-11.5c1.7-3.3,4-5.8,7.1-7.6
c3-1.8,6.5-2.7,10.4-2.7c3.9,0,7.1,0.8,9.7,2.4c2.6,1.6,4.6,3.9,6,6.9c1.4,3,2.1,6.5,2.1,10.6v2.5h-32.7v-4.3h28.2l-1.4,1.1
c0-4.5-1-8-3-10.5c-2-2.5-5-3.8-9-3.8c-4.2,0-7.5,1.5-9.8,4.4C274.2,41.1,273,45,273,50v0.8c0,5.3,1.3,9.3,3.9,12
c2.6,2.7,6.3,4.1,11,4.1c2.5,0,4.9-0.4,7.1-1.1c2.2-0.8,4.3-2,6.3-3.7l2.4,4.8c-1.8,1.8-4.2,3.2-7,4.2
C293.8,71.9,290.8,72.4,287.7,72.4z" />
<path class="st0" d="M314.5,71.6v-32c0-1.7,0-3.4-0.1-5.1c-0.1-1.7-0.2-3.4-0.4-5h6.6l0.8,10.2l-1.2,0.1c0.6-2.5,1.5-4.6,2.9-6.2
c1.4-1.6,3.1-2.8,5-3.7c1.9-0.8,3.9-1.2,6-1.2c0.8,0,1.6,0,2.2,0.1c0.6,0.1,1.2,0.2,1.8,0.4l-0.1,6c-0.8-0.3-1.6-0.5-2.3-0.5
s-1.5-0.1-2.4-0.1c-2.5,0-4.6,0.6-6.4,1.8c-1.8,1.2-3.2,2.7-4.1,4.5s-1.4,3.8-1.4,5.9v24.9H314.5z" />
<path class="st0" d="M363.9,72.4c-6.6,0-11.8-1.9-15.6-5.8c-3.8-3.8-5.7-9.2-5.7-16c0-4.4,0.8-8.3,2.5-11.5c1.7-3.3,4-5.8,7.1-7.6
c3-1.8,6.5-2.7,10.4-2.7c3.9,0,7.1,0.8,9.7,2.4c2.6,1.6,4.6,3.9,6,6.9c1.4,3,2.1,6.5,2.1,10.6v2.5h-32.7v-4.3H376l-1.4,1.1
c0-4.5-1-8-3-10.5c-2-2.5-5-3.8-9-3.8c-4.2,0-7.5,1.5-9.8,4.4c-2.4,2.9-3.5,6.9-3.5,11.9v0.8c0,5.3,1.3,9.3,3.9,12
c2.6,2.7,6.3,4.1,11,4.1c2.5,0,4.9-0.4,7.1-1.1c2.2-0.8,4.3-2,6.3-3.7l2.4,4.8c-1.8,1.8-4.2,3.2-7,4.2
C370,71.9,367,72.4,363.9,72.4z" />
<path class="st0" d="M406.8,72.4c-3.7,0-6.9-0.9-9.7-2.6c-2.8-1.8-5-4.3-6.5-7.5c-1.5-3.2-2.3-7.1-2.3-11.7
c0-4.6,0.8-8.5,2.3-11.8c1.5-3.2,3.7-5.7,6.5-7.5c2.8-1.7,6-2.6,9.7-2.6c3.8,0,7.1,1,9.9,2.9c2.8,1.9,4.6,4.5,5.6,7.7h-1V9.8h6.8
v61.8h-6.7V61.5h0.9c-0.9,3.4-2.7,6-5.5,7.9C413.9,71.4,410.6,72.4,406.8,72.4z M408.2,66.8c4,0,7.2-1.4,9.6-4.2
c2.4-2.8,3.6-6.8,3.6-12.1c0-5.4-1.2-9.5-3.6-12.2c-2.4-2.8-5.6-4.2-9.6-4.2c-4,0-7.2,1.4-9.5,4.2c-2.4,2.8-3.6,6.8-3.6,12.2
c0,5.3,1.2,9.4,3.6,12.1C401.1,65.4,404.3,66.8,408.2,66.8z" />
<path class="st0" d="M484.3,72.4c-3.8,0-7.1-1-9.8-2.9c-2.7-1.9-4.6-4.6-5.5-7.9h0.9v10.1h-6.7V9.8h6.8v29.5h-1
c1-3.2,2.8-5.8,5.5-7.7c2.7-1.9,6-2.9,9.8-2.9c3.8,0,7.1,0.9,9.9,2.6c2.8,1.8,4.9,4.3,6.5,7.5c1.5,3.2,2.3,7.1,2.3,11.7
s-0.8,8.4-2.4,11.7c-1.6,3.2-3.7,5.8-6.5,7.5C491.4,71.5,488.1,72.4,484.3,72.4z M482.9,66.8c4,0,7.2-1.4,9.6-4.1
c2.4-2.7,3.6-6.8,3.6-12.2s-1.2-9.5-3.6-12.2c-2.4-2.8-5.6-4.2-9.6-4.2c-4,0-7.2,1.4-9.5,4.2c-2.4,2.8-3.6,6.8-3.6,12.2
c0,5.3,1.2,9.4,3.6,12.1C475.8,65.4,478.9,66.8,482.9,66.8z" />
<path class="st0" d="M511.9,90.7l-1.6-5.6c2.6-0.6,4.8-1.3,6.6-2.1c1.8-0.8,3.2-1.9,4.4-3.2c1.2-1.3,2.2-3,3-5l2.2-5l-0.2,2.9
l-18.4-43.1h7.4l15.2,37h-2.2l15-37h7.1L531,75.1c-1.1,2.7-2.4,4.9-3.7,6.8c-1.3,1.8-2.8,3.3-4.3,4.5c-1.5,1.1-3.2,2.1-5.1,2.7
S514.1,90.3,511.9,90.7z" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 KiB

After

Width:  |  Height:  |  Size: 844 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 886 KiB

After

Width:  |  Height:  |  Size: 875 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 KiB

After

Width:  |  Height:  |  Size: 844 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1005 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 KiB

After

Width:  |  Height:  |  Size: 841 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 KiB

After

Width:  |  Height:  |  Size: 864 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 KiB

After

Width:  |  Height:  |  Size: 841 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -8,22 +8,24 @@ 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'
apply plugin: 'de.timfreiheit.resourceplaceholders'
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
def supportedLocales = "en,de,fr,nb-rNO"
android {
compileSdkVersion 33
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "net.vonforst.evmap"
compileSdk 34
minSdkVersion 21
targetSdkVersion 34
targetSdkVersion 31
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 202
versionName "1.7.0"
versionCode 106
versionName "1.3.10"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(',')
resConfigs supportedLocales.split(",")
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
}
@@ -71,6 +73,13 @@ android {
minSdkVersion 29
}
}
variantFilter { variant ->
def names = variant.flavors*.name
// Android Automotive OS app is always based on Google variant
if (names.contains("automotive") && !names.contains("google")) {
setIgnore(true)
}
}
compileOptions {
coreLibraryDesugaringEnabled true
@@ -82,12 +91,6 @@ android {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs).configureEach {
kotlinOptions {
jvmTarget = "1.8"
}
}
buildFeatures {
dataBinding = true
viewBinding true
@@ -104,7 +107,6 @@ android {
resourcePlaceholders {
files = ['xml/shortcuts.xml']
}
namespace 'net.vonforst.evmap'
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all { variant ->
@@ -138,28 +140,8 @@ android {
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
}
def acraKey = env.ACRA_CRASHREPORT_CREDENTIALS ?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
acraKey = decode(project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (acraKey != null) {
variant.resValue "string", "acra_credentials", acraKey
}
}
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 {
@@ -169,30 +151,31 @@ configurations {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation "androidx.activity:activity-ktx:1.7.2"
implementation "androidx.fragment:fragment-ktx:1.6.1"
implementation 'androidx.appcompat:appcompat:1.6.0-beta01'
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation "androidx.activity:activity-ktx:1.5.1"
implementation "androidx.fragment:fragment-ktx:1.5.1"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.android.material:material:1.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.browser:browser:1.6.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.browser:browser:1.4.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.github.johan12345:CustomBottomSheetBehavior:dd0167dbff'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.11.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
implementation 'com.squareup.moshi:moshi-adapters:1.15.0'
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
implementation 'io.coil-kt:coil:2.4.0'
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
implementation 'com.squareup.moshi:moshi-adapters:1.13.0'
implementation 'com.github.johan12345:jsonapi:50d72e7e55' // patched version for jsonapi-adapters
implementation('com.markomilos.jsonapi:jsonapi-retrofit:1.0.1') {
exclude group: 'com.markomilos.jsonapi', module: 'jsonapi-adapters'
}
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.johan12345: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'
@@ -202,30 +185,28 @@ dependencies {
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
def carAppVersion = '1.4.0-beta02'
implementation "androidx.car.app:app:$carAppVersion"
normalImplementation "androidx.car.app:app-projected:$carAppVersion"
automotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
def carAppVersion = '1.2.0-rc01'
googleImplementation "androidx.car.app:app:$carAppVersion"
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
// AnyMaps
def anyMapsVersion = '8f1226e1c5'
implementation "com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion"
def anyMapsVersion = 'a9b3dd7d99'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
implementation("com.github.johan12345.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'
// patched version of mapbox-android-core that removes build-time dependency on GMS
implementation 'com.github.johan12345:mapbox-events-android:a21c324501'
// Google Places
googleImplementation 'com.google.android.libraries.places:places:3.2.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.1'
googleImplementation 'com.google.android.libraries.places:places:2.6.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.1'
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
@@ -235,53 +216,48 @@ dependencies {
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.6.2"
def lifecycle_version = "2.5.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.6.0-beta01"
def room_version = "2.4.3"
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.1"
def billing_version = "4.1.0"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
// ACRA (crash reporting)
def acraVersion = "5.11.1"
implementation("ch.acra:acra-http:$acraVersion")
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'
implementation 'com.facebook.stetho:stetho:1.6.0'
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
// testing
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
testImplementation 'org.robolectric:robolectric:4.10.3'
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 'androidx.test:core:1.5.0'
testGoogleImplementation 'org.robolectric:robolectric:4.8.1'
testGoogleImplementation 'androidx.test:core:1.4.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'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0"
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
}
private static String decode(String s, String key) {

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Toestaan</string>
<string name="auto_location_permission_needed">Om EVmap te gebruiken in je wagen, moet je toegang geven tot je locatie.</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,9 +42,5 @@ class DonateFragment : Fragment() {
binding.btnDonate.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
}
binding.referrals.referralTesla.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.tesla_referral_link))
}
}
}

View File

@@ -0,0 +1,16 @@
package net.vonforst.evmap.fragment
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}

View File

@@ -1,16 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/linearLayout2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_width="0dp"
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
android:id="@+id/toolbar"
@@ -19,55 +21,31 @@
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/btnDonate"
style="@style/Widget.Material3.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="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" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_height="wrap_content"
android:layout_width="match_parent">
<Button
android:id="@+id/btnDonate"
style="@style/Widget.Material3.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp"
android:text="@string/donate_paypal"
app:icon="@drawable/ic_paypal"
app:layout_constraintBottom_toTopOf="@id/referrals"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView20" />
<TextView
android:id="@+id/textView20"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/donations_info"
app:layout_constraintBottom_toTopOf="@+id/btnDonate"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<include
android:id="@+id/referrals"
layout="@layout/fragment_donate_referral"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btnDonate" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</LinearLayout>
<TextView
android:id="@+id/textView20"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/donations_info"
app:layout_constraintBottom_toTopOf="@+id/btnDonate"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container"
app:layout_constraintVertical_chainStyle="packed" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

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

View File

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

View File

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

View File

@@ -2,4 +2,5 @@
<resources>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
</resources>

View File

@@ -1,9 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="net.vonforst.evmap">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
<uses-sdk tools:overrideLibrary="androidx.car.app,androidx.car.app.projected" />
<queries>
<package android:name="com.google.android.projection.gearhead" />
</queries>
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="androidx.car.app.theme"
android:resource="@style/CarAppTheme" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
<service
android:name=".auto.CarAppService"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:exported="true">
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.POI" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -27,14 +27,11 @@ import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.car2go.maps.model.LatLng
import net.vonforst.evmap.R
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.checkFineLocationPermission
import org.acra.interaction.DialogInteraction
interface LocationAwareScreen {
@@ -45,15 +42,13 @@ interface LocationAwareScreen {
class CarAppService : androidx.car.app.CarAppService() {
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
private var foregroundStarted = false
fun ensureForegroundService() {
override fun onCreate() {
super.onCreate()
// we want to run as a foreground service to make sure we can use location
if (!foregroundStarted) {
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
foregroundStarted = true
}
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
}
private fun createNotificationChannel() {
@@ -116,90 +111,27 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private val prefs: PreferenceDataSource by lazy {
PreferenceDataSource(carContext)
}
init {
lifecycle.addObserver(this)
}
override fun onCreateScreen(intent: Intent): Screen {
val mapScreen = MapScreen(carContext, this)
val screens = mutableListOf<Screen>(mapScreen)
handleActionsIntent(intent)?.let {
screens.add(it)
}
if (!prefs.dataSourceSet) {
screens.add(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.CHARGER_DATA_SOURCE,
initialChoice = true,
extraDesc = R.string.data_sources_description
)
)
}
if (!locationPermissionGranted()) {
screens.add(
PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
val screenManager = carContext.getCarService(ScreenManager::class.java)
screenManager.push(mapScreen)
return PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
if (!prefs.privacyAccepted) {
screens.add(
AcceptPrivacyScreen(carContext)
)
}
handleACRAIntent(intent)?.let {
screens.add(it)
}
if (screens.size > 1) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
for (i in 0 until screens.size - 1) {
screenManager.push(screens[i])
}
}
return screens.last()
}
private fun handleACRAIntent(intent: Intent): Screen? {
return if (intent.hasExtra(DialogInteraction.EXTRA_REPORT_CONFIG)) {
CrashReportScreen(carContext, intent)
} else null
}
private fun handleActionsIntent(intent: Intent): Screen? {
intent.data?.let {
if (it.host == "find_charger") {
val lat = it.getQueryParameter("latitude")?.toDouble()
val lon = it.getQueryParameter("longitude")?.toDouble()
val name = it.getQueryParameter("name")
if (lat != null && lon != null) {
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
return null
} else if (name != null) {
val screen = PlaceSearchScreen(carContext, this, name)
return screen
}
}
}
return null
}
override fun onNewIntent(intent: Intent) {
handleActionsIntent(intent)
return mapScreen
}
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
@@ -224,7 +156,6 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
@SuppressLint("MissingPermission")
fun requestLocationUpdates() {
if (!locationPermissionGranted()) return
cas.ensureForegroundService()
Log.i(TAG, "Requesting location updates")
requestCarHardwareLocationUpdates()
requestPhoneLocationUpdates()

View File

@@ -5,17 +5,9 @@ package net.vonforst.evmap.auto
* and human-readable vehicle models as listed by Chargeprice in their vehicle database.
*/
private val brands = mapOf(
"Saic" to "MG", // Seen on MG 4
"Google" to "Hyundai" // useful for debugging on the DHU. Delete in case there's ever a Google car ;)
)
private val models = mapOf(
"Audi" to mapOf(
"516 (G4x)" to "e-tron"
),
"Renault" to mapOf(
"BCB" to "Megane E-Tech"
)
)
@@ -24,11 +16,4 @@ fun getVehicleModel(manufacturer: String?, model: String?) =
models[manufacturer]?.get(model) ?: model
} else {
null
}
fun getVehicleBrand(manufacturer: String?) =
if (manufacturer != null) {
brands[manufacturer] ?: manufacturer
} else {
null
}

View File

@@ -1,5 +1,10 @@
package net.vonforst.evmap.auto
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
@@ -19,17 +24,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.*
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.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.currency
import net.vonforst.evmap.ui.time
import java.io.IOException
import kotlin.math.roundToInt
class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
@@ -42,8 +41,9 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
private var prices: List<ChargePrice>? = null
private var meta: ChargepriceChargepointMeta? = null
private var chargepoint: Chargepoint? = null
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private var errorMessage: String? = null
private val batteryRange = prefs.chargepriceBatteryRangeAndroidAuto
@@ -62,62 +62,17 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
if (prices == null && errorMessage == null) {
setLoading(true)
} else {
val header = meta?.let { meta ->
chargepoint?.let { chargepoint ->
"${
nameForPlugType(
carContext.stringProvider(),
chargepoint.type
)
} ${chargepoint.formatPower()} ${
carContext.getString(
R.string.chargeprice_stats,
meta.energy,
time(meta.duration.roundToInt()),
meta.energy / meta.duration * 60
)
}"
setSingleList(ItemList.Builder().apply {
setNoItemsMessage(
errorMessage ?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.take(maxRows)?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
}.build())
}
}
val myTariffs = prefs.chargepriceMyTariffs
val myTariffsAll = prefs.chargepriceMyTariffsAll
val prices = prices?.take(maxRows)
if (prices != null && prices.isNotEmpty() && !myTariffsAll && myTariffs != null) {
val (myPrices, otherPrices) = prices.partition { price -> price.tariffId in myTariffs }
val myPricesList = buildPricesList(myPrices)
val otherPricesList = buildPricesList(otherPrices)
if (myPricesList.items.isNotEmpty() && otherPricesList.items.isNotEmpty()) {
addSectionedList(
SectionedItemList.create(
myPricesList,
(header?.let { it + "\n" } ?: "") +
carContext.getString(R.string.chargeprice_header_my_tariffs)
)
)
addSectionedList(
SectionedItemList.create(
otherPricesList,
carContext.getString(R.string.chargeprice_header_other_tariffs)
)
)
} else {
val list =
if (myPricesList.items.isNotEmpty()) myPricesList else otherPricesList
if (header != null) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
} else {
val list = buildPricesList(prices)
if (header != null && list.items.isNotEmpty()) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
}.build())
}
setActionStrip(
ActionStrip.Builder().addAction(
@@ -129,28 +84,41 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
)
).build()
).setOnClickListener {
openUrl(carContext, ChargepriceApi.getPoiUrl(charger))
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
R.color.colorPrimary
)
)
.build()
)
.build().intent
intent.data =
Uri.parse(ChargepriceApi.getPoiUrl(charger))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
} catch (e: ActivityNotFoundException) {
CarToast.makeText(
carContext,
R.string.no_browser_app_found,
CarToast.LENGTH_LONG
).show()
}
}.build()
).build()
)
}.build()
}
private fun buildPricesList(prices: List<ChargePrice>?): ItemList {
return ItemList.Builder().apply {
setNoItemsMessage(
errorMessage
?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
}.build())
}
}.build()
}
private fun formatProvider(price: ChargePrice): String {
if (!price.tariffName.startsWith(price.provider)) {
return price.provider + " " + price.tariffName
@@ -160,21 +128,19 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
private fun formatPrice(price: ChargePrice): String {
val amount = price.chargepointPrices.first().price
?: return "${carContext.getString(R.string.chargeprice_price_not_available)} (${price.chargepointPrices.first().noPriceReason})"
val totalPrice = carContext.getString(
R.string.charge_price_format,
amount,
price.chargepointPrices.first().price,
currency(price.currency)
)
val kwhPrice = if (amount > 0f) {
val kwhPrice = if (price.chargepointPrices.first().price > 0f) {
carContext.getString(
if (price.chargepointPrices[0].priceDistribution.isOnlyKwh) {
R.string.charge_price_kwh_format
} else {
R.string.charge_price_average_format
},
amount / meta!!.energy,
price.chargepointPrices.get(0).price / meta!!.energy,
currency(price.currency)
)
} else null
@@ -206,21 +172,13 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
private fun loadPrices(model: Model?) {
val dataAdapter = ChargepriceApi.getDataAdapter(charger)
val manufacturer = getVehicleBrand(model?.manufacturer?.value)
val dataAdapter = ChargepriceApi.getDataAdapter(charger) ?: return
val manufacturer = model?.manufacturer?.value
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
lifecycleScope.launch {
try {
val car = determineVehicle(manufacturer, modelName)
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
if (cpStation.chargePoints.isEmpty()) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
val result = api.getChargePrices(
ChargepriceRequest(
dataAdapter = dataAdapter,
@@ -231,8 +189,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
showPriceUnavailable = true
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
),
relationships = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
@@ -256,14 +213,10 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
val myTariffs = prefs.chargepriceMyTariffs
// choose the highest power chargepoint
// (we have already filtered so that only compatible ones are included)
val chargepoint = cpStation.chargePoints.maxByOrNull { it.power }
val index = cpStation.chargePoints.indexOf(chargepoint)
this@ChargepriceScreen.chargepoint =
charger.chargepoints.filter { equivalentPlugTypes(it.type).any { it in car.compatibleEvmapConnectors } }[index]
// choose the highest power chargepoint compatible with the car
val chargepoint = cpStation.chargePoints.filterIndexed { i, cp ->
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
}.maxByOrNull { it.power }
if (chargepoint == null) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
@@ -273,9 +226,13 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
val metaMapped =
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.maxByOrNull { it.power }
meta = metaMapped.chargePoints.filterIndexed { i, cp ->
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
}.maxByOrNull {
it.power
}
prices = result.data!!.mapNotNull { cp ->
prices = result.data!!.map { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
@@ -287,8 +244,8 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
chargepointPrices = filteredPrices
)
}
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
}.filterNotNull()
.sortedBy { it.chargepointPrices.first().price }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariffId in myTariffs
@@ -345,14 +302,14 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
} else if (vehicles.size > 1) {
if (manufacturer != null) {
vehicles = vehicles.filter {
it.brand.lowercase() == getVehicleBrand(manufacturer)?.lowercase()
it.brand == manufacturer
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()
} else if (vehicles.size > 1) {
if (modelName != null) {
vehicles = vehicles.filter {
it.name.lowercase().startsWith(modelName.lowercase())
it.name.startsWith(modelName)
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()

View File

@@ -1,10 +1,10 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.*
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
@@ -12,7 +12,6 @@ import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.scale
import androidx.core.text.HtmlCompat
@@ -24,54 +23,37 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
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.ChargeLocationStatus
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.fronyx.FronyxApi
import net.vonforst.evmap.api.fronyx.PredictionData
import net.vonforst.evmap.api.fronyx.PredictionRepository
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FaultReport
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.awaitFinished
import net.vonforst.evmap.viewmodel.getReferenceData
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
private var prediction: PredictionData? = null
private var fronyxSupported = false
private var teslaSupported = false
val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val predictionRepo = PredictionRepository(ctx)
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
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
@@ -79,7 +61,9 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val iconGen =
ChargerIconGenerator(carContext, null, height = imageSize)
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
} else 2
private val largeImageSupported =
ctx.carAppApiLevel >= 4 // since API 4, Row.setImage is supported
@@ -87,7 +71,9 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private var favoriteUpdateJob: Job? = null
init {
loadCharger()
referenceData.observe(this) {
loadCharger()
}
}
override fun onGetTemplate(): Template {
@@ -113,29 +99,29 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
).build()
)
.setTitle(carContext.getString(R.string.navigate))
.setFlags(Action.FLAG_PRIMARY)
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
navigateToCharger(charger)
}
.build())
if (ChargepriceApi.isChargerSupported(charger)) {
addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
)
.setTitle(carContext.getString(R.string.auto_prices))
charger.chargepriceData?.country?.let { country ->
if (ChargepriceApi.isCountrySupported(country, charger.dataSource)) {
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
)
.setTitle(carContext.getString(R.string.auto_prices))
.setOnClickListener {
screenManager.push(ChargepriceScreen(carContext, charger))
}
.build())
}
}
} ?: setLoading(true)
}.build()
).apply {
@@ -258,9 +244,15 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val operatorText = generateOperatorText(charger)
setTitle(operatorText)
charger.cost?.let { addText(generateCostStatusText(it)) }
charger.cost?.let { addText(it.getStatusText(carContext, emoji = true)) }
charger.faultReport?.let { fault ->
addText(generateFaultReportTitle(fault))
addText(
carContext.getString(
R.string.auto_fault_report_date,
fault.created?.atZone(ZoneId.systemDefault())
?.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
}
}.build())
} else {
@@ -275,14 +267,20 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val operatorText = generateOperatorText(charger)
setTitle(operatorText)
charger.cost?.let {
addText(generateCostStatusText(it))
addText(it.getStatusText(carContext, emoji = true))
it.getDetailText()?.let { addText(it) }
}
}.build())
// row 3: fault report (if exists)
charger.faultReport?.let { fault ->
rows.add(Row.Builder().apply {
setTitle(generateFaultReportTitle(fault))
setTitle(
carContext.getString(
R.string.auto_fault_report_date,
fault.created?.atZone(ZoneId.systemDefault())
?.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
fault.description?.let {
addText(
HtmlCompat.fromHtml(
@@ -304,189 +302,21 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}.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? TeslaGraphQlApi.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)
} ?: {
addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
}
}.build())
}
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 {
val string = SpannableString(cost.getStatusText(carContext, emoji = true))
// replace emoji with CarIcon
string.indexOf('⚡').takeIf { it >= 0 }?.let { index ->
string.setSpan(
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_lightning
)
).setTint(CarColor.YELLOW).build()
), index, index + 1, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
)
}
string.indexOf('\uD83C').takeIf { it >= 0 }?.let { index ->
string.setSpan(
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_parking
)
).setTint(CarColor.BLUE).build()
), index, index + 2, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
)
}
return string
}
private fun generateFaultReportTitle(fault: FaultReport): CharSequence {
val string = SpannableString(
carContext.getString(
R.string.auto_fault_report_date,
fault.created?.atZone(ZoneId.systemDefault())
?.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
// replace emoji with CarIcon
string.setSpan(
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fault_report
)
).setTint(CarColor.YELLOW).build()
), 0, 1, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
)
return string
}
private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder {
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
chargepointsText.apply {
if (i > 0) append(" · ")
append("${cp.count}× ")
val plugIcon = iconForPlugType(cp.type)
if (plugIcon != 0) {
append(
nameForPlugType(carContext.stringProvider(), cp.type),
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
plugIcon
)
).setTint(
CarColor.createCustom(Color.WHITE, Color.BLACK)
).build()
),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
if (i > 0) chargepointsText.append(" · ")
chargepointsText.append(
"${cp.count}× ${
nameForPlugType(
carContext.stringProvider(),
cp.type
)
} else {
append(nameForPlugType(carContext.stringProvider(), cp.type))
}
cp.formatPower()?.let {
append(" ")
append(it)
}
}
} ${cp.formatPower()}"
)
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
@@ -526,23 +356,24 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}
private fun loadCharger() {
val referenceData = referenceData.value ?: return
lifecycleScope.launch {
favorite = db.favoritesDao().findFavorite(chargerSparse.id, chargerSparse.dataSource)
val response = repo.getChargepointDetail(chargerSparse.id).awaitFinished()
val response = api.getChargepointDetail(referenceData, chargerSparse.id)
if (response.status == Status.SUCCESS) {
val charger = response.data!!
this@ChargerDetailScreen.charger = charger
invalidate()
val photo = charger.photos?.firstOrNull()
photo?.let {
val density = carContext.resources.displayMetrics.density
val size =
(density * if (largeImageSupported) imageSizeLarge else imageSize).roundToInt()
val url = photo.getUrl(size = size)
val url = if (largeImageSupported) {
photo.getUrl(size = (imageSizeLarge * density).roundToInt())
} else {
photo.getUrl(size = (imageSize * density).roundToInt())
}
val request = ImageRequest.Builder(carContext).data(url).build()
val img =
var img =
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
// draw icon on top of image
@@ -552,45 +383,23 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
multi = charger.isMulti()
)
val outImg = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
img = img.copy(Bitmap.Config.ARGB_8888, true)
val iconSmall = icon.scale(
(size * 0.4 / icon.height * icon.width).roundToInt(),
(size * 0.4).roundToInt()
)
val canvas = Canvas(outImg)
val m = Matrix()
m.setRectToRect(
RectF(0f, 0f, img.width.toFloat(), img.height.toFloat()),
RectF(0f, 0f, size.toFloat(), size.toFloat()),
Matrix.ScaleToFit.CENTER
)
canvas.drawBitmap(
img.copy(Bitmap.Config.ARGB_8888, false), m, null
(img.height * 0.4 / icon.height * icon.width).roundToInt(),
(img.height * 0.4).roundToInt()
)
val canvas = Canvas(img)
canvas.drawBitmap(
iconSmall,
0f,
(size - iconSmall.height * 1.1).toFloat(),
(img.height - iconSmall.height * 1.1).toFloat(),
null
)
this@ChargerDetailScreen.photo = outImg
this@ChargerDetailScreen.photo = img
}
fronyxSupported = charger.chargepoints.any {
FronyxApi.isChargepointSupported(
charger,
it
)
} && !availabilityRepo.isSupercharger(charger)
teslaSupported = availabilityRepo.isTeslaSupported(charger)
this@ChargerDetailScreen.charger = charger
invalidate()
availability = availabilityRepo.getAvailability(charger).data
invalidate()
prediction = predictionRepo.getPredictionData(charger, availability)
availability = getAvailability(charger).data
invalidate()
} else {

View File

@@ -1,8 +1,6 @@
package net.vonforst.evmap.auto
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
@@ -13,7 +11,6 @@ import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.map
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.model.*
@@ -27,24 +24,15 @@ import kotlin.math.roundToInt
class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(ctx)
private val filterProfiles: LiveData<List<FilterProfile>> by lazy {
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
db.filterProfileDao().getProfiles(prefs.dataSource)
}
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
private var page = 0
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
init {
filterProfiles.observe(this) {
val filterStatus = prefs.filterStatus
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
page = 0
} else {
val index =
paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
page = index.takeUnless { it == -1 } ?: 0
}
invalidate()
}
}
@@ -52,27 +40,37 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
override fun onGetTemplate(): Template {
val filterStatus = prefs.filterStatus
return ListTemplate.Builder().apply {
var title = carContext.getString(R.string.menu_filter)
filterProfiles.value?.let {
val paginatedProfiles = paginateProfiles(it)
setSingleList(buildFilterProfilesList(paginatedProfiles, filterStatus))
val numPages = paginatedProfiles.size
if (numPages > 1) {
title += " " + carContext.getString(
R.string.auto_multipage,
page + 1,
numPages
)
}
setSingleList(buildFilterProfilesList(it, filterStatus))
} ?: setLoading(true)
setTitle(title)
setTitle(carContext.getString(R.string.menu_filter))
setHeaderAction(Action.BACK)
setActionStrip(
ActionStrip.Builder().apply {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (prefs.placeSearchResultAndroidAuto != null) {
R.drawable.ic_search_off
} else {
R.drawable.ic_search
}
)
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
if (prefs.placeSearchResultAndroidAuto != null) {
prefs.placeSearchResultAndroidAutoName = null
prefs.placeSearchResultAndroidAuto = null
screenManager.pop()
} else {
screenManager.push(PlaceSearchScreen(carContext, session))
}
})
}.build())
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
@@ -81,6 +79,7 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
R.drawable.ic_edit
)
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
lifecycleScope.launch {
@@ -95,180 +94,48 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
}.build()
}
private fun paginateProfiles(filterProfiles: List<FilterProfile>): List<List<FilterProfile>> {
val filterStatus = prefs.filterStatus
val extraRows = if (FILTERS_CUSTOM == filterStatus) 3 else 2
return filterProfiles.paginate(
maxRows - extraRows,
maxRows - extraRows - 1,
maxRows - 2,
maxRows - 1
)
}
private fun buildFilterProfilesList(
paginatedProfiles: List<List<FilterProfile>>,
profiles: List<FilterProfile>,
filterStatus: Long
): ItemList {
val extraRows = if (FILTERS_CUSTOM == filterStatus) 3 else 2
val profilesToShow =
profiles.sortedByDescending { it.id == filterStatus }.take(maxRows - extraRows)
return ItemList.Builder().apply {
if (page > 0) {
addItem(Row.Builder().apply {
setTitle(
CarText.Builder(
carContext.getString(R.string.auto_multipage_goto, page)
).build()
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_arrow_back
)
).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener {
page -= 1
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())
}
if (page == 0) {
addItem(Row.Builder().apply {
val active = filterStatus == FILTERS_DISABLED
setTitle(carContext.getString(R.string.no_filters))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_close
)
).setTint(if (active) CarColor.SECONDARY else CarColor.DEFAULT)
.build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener { onItemClick(FILTERS_DISABLED) }
}.build())
addItem(Row.Builder().apply {
val active = filterStatus == FILTERS_FAVORITES
setTitle(carContext.getString(R.string.filter_favorites))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
).setTint(if (active) CarColor.SECONDARY else CarColor.DEFAULT)
.build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener { onItemClick(FILTERS_FAVORITES) }
}.build())
if (FILTERS_CUSTOM == filterStatus) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.filter_custom))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_checkbox_checked
)
).setTint(CarColor.PRIMARY).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener { onItemClick(FILTERS_CUSTOM) }
}.build())
}
}
paginatedProfiles[page].forEach {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.no_filters))
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.filter_favorites))
}.build())
profilesToShow.forEach {
addItem(Row.Builder().apply {
val name =
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
val active = filterStatus == it.id
setTitle(name)
setImage(
if (active)
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_check
)
).setTint(CarColor.SECONDARY).build() else emptyCarIcon,
Row.IMAGE_TYPE_ICON
)
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_filterprofile,
it.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
}
}
}.build())
}
}.build())
}
if (page < paginatedProfiles.size - 1) {
if (FILTERS_CUSTOM == filterStatus) {
addItem(Row.Builder().apply {
setTitle(
CarText.Builder(
carContext.getString(R.string.auto_multipage_goto, page + 2)
).build()
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_arrow_forward
)
).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener {
page += 1
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
setTitle(carContext.getString(R.string.filter_custom))
}.build())
}
setSelectedIndex(when (filterStatus) {
FILTERS_DISABLED -> 0
FILTERS_FAVORITES -> 1
FILTERS_CUSTOM -> profilesToShow.size + 2
else -> profilesToShow.indexOfFirst { it.id == filterStatus } + 2
})
setOnSelectedListener { index ->
onItemClick(
when (index) {
0 -> FILTERS_DISABLED
1 -> FILTERS_FAVORITES
profilesToShow.size + 2 -> FILTERS_CUSTOM
else -> profilesToShow[index - 2].id
}
)
}
}.build()
}
@@ -282,16 +149,12 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
private val vm = FilterViewModel(carContext.applicationContext as Application)
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
private var page = 0
private var paginatedFilters = vm.filtersWithValue.map {
it?.paginate(maxRows, maxRows - 1, maxRows - 2, maxRows - 1)
}
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
init {
paginatedFilters.observe(this) {
vm.filtersWithValue.observe(this) {
vm.filterProfile.observe(this) {
invalidate()
}
@@ -302,32 +165,21 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
val currentProfileName = vm.filterProfile.value?.name
return ListTemplate.Builder().apply {
paginatedFilters.value?.let { paginatedFilters ->
setSingleList(buildFiltersList(paginatedFilters))
vm.filtersWithValue.value?.let { filtersWithValue ->
setSingleList(buildFiltersList(filtersWithValue.take(maxRows)))
} ?: setLoading(true)
var title = currentProfileName?.let {
setTitle(currentProfileName?.let {
carContext.getString(
R.string.edit_filter_profile,
it,
it
)
} ?: carContext.getString(R.string.menu_filter)
val numPages = paginatedFilters.value?.size ?: 0
if (numPages > 1) {
title += " " + carContext.getString(
R.string.auto_multipage,
page + 1,
numPages
)
}
setTitle(title)
} ?: carContext.getString(R.string.menu_filter))
setHeaderAction(Action.BACK)
setActionStrip(ActionStrip.Builder().apply {
val currentProfile = vm.filterProfile.value
if (currentProfile != null && carContext.carAppApiLevel < 6) {
// Delete action (when row actions are not available)
if (currentProfile != null) {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
@@ -366,65 +218,29 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
).build()
)
.setOnClickListener {
val textPromptScreen = TextPromptScreen(
carContext,
R.string.save_as_profile,
R.string.save_profile_enter_name,
currentProfileName
)
screenManager.pushForResult(textPromptScreen) { name ->
if (name == null) return@pushForResult
var saveSuccess = false
lifecycleScope.launch {
saveSuccess = vm.saveAsProfile(name as String)
screenManager.popTo(MapScreen.MARKER)
}
if (!saveSuccess) return@pushForResult
val textPromptScreen = TextPromptScreen(
carContext,
R.string.save_as_profile,
R.string.save_profile_enter_name,
currentProfileName
)
screenManager.pushForResult(textPromptScreen) { name ->
if (name == null) return@pushForResult
lifecycleScope.launch {
vm.saveAsProfile(name as String)
screenManager.popTo(MapScreen.MARKER)
}
invalidate()
}
.build()
}
.build()
)
}
.build())
}.build())
}.build()
}
private fun buildFiltersList(paginatedFilters: List<FilterValues>): ItemList {
private fun buildFiltersList(filters: List<FilterWithValue<out FilterValue>>): ItemList {
return ItemList.Builder().apply {
if (page > 0) {
addItem(Row.Builder().apply {
setTitle(
CarText.Builder(
carContext.getString(R.string.auto_multipage_goto, page)
).build()
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_arrow_back
)
).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener {
page -= 1
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())
}
paginatedFilters[page].forEach {
filters.forEach {
val filter = it.filter
val value = it.value
addItem(Row.Builder().apply {
@@ -478,37 +294,6 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
}
}.build())
}
if (page < paginatedFilters.size - 1) {
addItem(Row.Builder().apply {
setTitle(
CarText.Builder(
carContext.getString(R.string.auto_multipage_goto, page + 2)
).build()
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_arrow_forward
)
).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener {
page += 1
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())
}
}.build()
}
}

View File

@@ -2,55 +2,41 @@ package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.CarInfo
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.*
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterWithValue
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.bearingBetween
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.utils.headingDiff
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.awaitFinished
import net.vonforst.evmap.viewmodel.filtersWithValue
import net.vonforst.evmap.viewmodel.getFilterValues
import net.vonforst.evmap.viewmodel.getReferenceData
import java.io.IOException
import java.time.Duration
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
@@ -73,36 +59,34 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private var location: Location? = null
private var lastDistanceUpdateTime: Instant? = null
private var lastChargersUpdateTime: Instant? = null
private var chargers: List<ChargeLocation>? = null
private var isFavorite: List<Boolean>? = null
private var loadingError = false
private var locationError = false
private var prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val searchRadius = 5 // kilometers
private val distanceUpdateThreshold = Duration.ofSeconds(15)
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private val chargersUpdateThresholdDistance = 500 // meters
private val chargersUpdateThresholdTime = Duration.ofSeconds(30)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
HashMap()
private val maxRows =
min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST)
} else 6
private var filterStatus = prefs.filterStatus
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
private val carInfo: CarInfo by lazy {
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val filterStatus = MutableLiveData<Long>().apply {
value = prefs.filterStatus
}
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
private val filters =
Transformations.map(referenceData) { api.getFilters(it, carContext.stringProvider()) }
private val filtersWithValue = filtersWithValue(filters, filterValues)
private val hardwareMan: CarHardwareManager by lazy {
ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private val carSensors: CarSensors by lazy { carContext.patchedCarSensors }
private var energyLevel: EnergyLevel? = null
private var heading: Compass? = null
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
"android.car.permission.CAR_ENERGY",
@@ -117,55 +101,48 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private var searchLocation: LatLng? = null
private val iconGen =
ChargerIconGenerator(carContext, null, height = 96)
init {
lifecycle.addObserver(this)
marker = MARKER
}
override fun onGetTemplate(): Template {
session.requestLocationUpdates()
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
prefs.placeSearchResultAndroidAutoName?.let {
carContext.getString(R.string.auto_chargers_near_location, it)
} ?: carContext.getString(
if (filterStatus == FILTERS_FAVORITES) {
if (filterStatus.value == FILTERS_FAVORITES) {
R.string.auto_favorites
} else {
R.string.auto_chargers_closeby
}
)
)
if (prefs.placeSearchResultAndroidAutoName != null) {
searchLocation?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply {
if (prefs.placeSearchResultAndroidAutoName != null) {
setMarker(
PlaceMarker.Builder()
.setColor(CarColor.PRIMARY)
.build()
)
}
}.build())
}
} else {
location?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build())
}
}
searchLocation?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply {
if (prefs.placeSearchResultAndroidAutoName != null) {
setMarker(
PlaceMarker.Builder()
.setColor(CarColor.PRIMARY)
.build()
)
}
}.build())
} ?: setLoading(true)
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
chargerList.forEachIndexed { i, charger ->
builder.addItem(formatCharger(charger, showCity, isFavorite?.get(i) ?: false))
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger, showCity))
}
builder.setNoItemsMessage(
carContext.getString(
if (filterStatus == FILTERS_FAVORITES) {
if (filterStatus.value == FILTERS_FAVORITES) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
@@ -174,92 +151,31 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
builder.setOnItemsVisibilityChangedListener(this@MapScreen)
setItemList(builder.build())
} ?: run {
if (loadingError) {
val builder = ItemList.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.connection_error)
)
setItemList(builder.build())
} else if (locationError) {
val builder = ItemList.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.location_error)
)
setItemList(builder.build())
} else {
setLoading(true)
}
}
} ?: setLoading(true)
setCurrentLocationEnabled(true)
setHeaderAction(Action.APP_ICON)
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
filtersWithValue?.count {
val filtersCount = if (filterStatus.value == FILTERS_FAVORITES) 1 else {
filtersWithValue.value?.count {
!it.value.hasSameValueAs(it.filter.defaultValue())
}
}
setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
.addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_settings
)
).setTint(CarColor.DEFAULT).build()
)
.setOnClickListener {
screenManager.push(SettingsScreen(carContext, session))
screenManager.push(SettingsScreen(carContext))
session.mapScreen = null
}
.build())
.addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (prefs.placeSearchResultAndroidAuto != null) {
R.drawable.ic_search_off
} else {
R.drawable.ic_search
}
)
).build()
)
setOnClickListener {
if (prefs.placeSearchResultAndroidAuto != null) {
prefs.placeSearchResultAndroidAutoName = null
prefs.placeSearchResultAndroidAuto = null
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
chargers = null
isFavorite = null
loadChargers()
}
} else {
chargers = null
isFavorite = null
loadChargers()
}
} else {
screenManager.pushForResult(
PlaceSearchScreen(
carContext,
session
)
) {
chargers = null
isFavorite = null
loadChargers()
}
session.mapScreen = null
}
}
}.build())
.addAction(
Action.Builder()
.setIcon(
@@ -273,31 +189,24 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
.build()
)
.setOnClickListener {
screenManager.push(FilterScreen(carContext, session))
screenManager.pushForResult(FilterScreen(carContext, session)) {
filterStatus.value = prefs.filterStatus
}
session.mapScreen = null
}
.build())
.build())
if (carContext.carAppApiLevel >= 5 ||
(BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
) {
setOnContentRefreshListener(this@MapScreen)
}
setOnContentRefreshListener(this@MapScreen)
}.build()
}
private fun formatCharger(
charger: ChargeLocation,
showCity: Boolean,
isFavorite: Boolean
): Row {
val markerTint = getMarkerTint(charger)
val backgroundTint = if ((charger.maxPower ?: 0.0) > 100) {
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
val markerTint = if ((charger.maxPower ?: 0.0) > 100) {
R.color.charger_100kw_dark // slightly darker color for better contrast
} else {
markerTint
getMarkerTint(charger)
}
val color = ContextCompat.getColor(carContext, backgroundTint)
val color = ContextCompat.getColor(carContext, markerTint)
val place =
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
.setMarker(
@@ -307,32 +216,16 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
.build()
val icon = iconGen.getBitmap(
markerTint,
fault = charger.faultReport != null,
multi = charger.isMulti(),
fav = isFavorite
)
val iconSpan =
CarIconSpan.create(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build())
return Row.Builder().apply {
// only show the city if not all chargers are in the same city (-> showCity == true)
// and the city is not already contained in the charger name
val title = SpannableStringBuilder().apply {
append(" ", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
append(" ")
append(charger.name)
}
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
val titleWithCity = SpannableStringBuilder().apply {
append("", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
append(" ")
append("${charger.name} · ${charger.address.city}")
}
setTitle(CarText.Builder(titleWithCity).addVariant(title).build())
setTitle(
CarText.Builder("${charger.name} · ${charger.address.city}")
.addVariant(charger.name)
.build())
} else {
setTitle(title)
setTitle(charger.name)
}
val text = SpannableStringBuilder()
@@ -348,8 +241,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
DistanceSpan.create(
roundValueToDistance(
distanceMeters,
energyLevel?.distanceDisplayUnit?.value,
carContext
energyLevel?.distanceDisplayUnit?.value
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
@@ -357,7 +249,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
// power
val power = charger.maxPower
val power = charger.maxPower;
if (power != null) {
if (text.isNotEmpty()) text.append(" · ")
text.append("${power.roundToInt()} kW")
@@ -367,7 +259,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
availabilities[charger.id]?.second?.let { av ->
val status = av.status.values.flatten()
val available = availabilityText(status)
val total = charger.chargepoints.sumOf { it.count }
val total = charger.chargepoints.sumBy { it.count }
if (text.isNotEmpty()) text.append(" · ")
text.append(
@@ -397,10 +289,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
) {
return
}
val previousLocation = this.location
this.location = location
if (previousLocation == null) {
loadChargers()
if (updateCoroutine != null) {
// don't update while still loading last update
return
}
@@ -412,166 +303,83 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// update displayed distances
invalidate()
}
// if chargers are searched around current location, consider app-driven refresh
val searchLocation =
if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null
val distance = searchLocation?.let {
distanceBetween(
it.latitude, it.longitude, location.latitude, location.longitude
)
} ?: 0.0
if (supportsRefresh && (lastChargersUpdateTime == null ||
Duration.between(
lastChargersUpdateTime,
now
) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance)
) {
onContentRefreshRequested()
}
}
private fun loadChargers() {
val location = location ?: return
val referenceData = referenceData.value ?: return
val filters = filtersWithValue.value ?: return
val searchLocation =
prefs.placeSearchResultAndroidAuto ?: LatLng.fromLocation(location)
this.searchLocation = searchLocation
updateCoroutine = lifecycleScope.launch {
loadingError = false
try {
filterStatus = prefs.filterStatus
val filterValues =
db.filterValueDao().getFilterValuesAsync(filterStatus, prefs.dataSource)
val filters = repo.getFiltersAsync(carContext.stringProvider())
filtersWithValue = filtersWithValue(filters, filterValues)
val apiId = repo.api.value!!.id
// load chargers
if (filterStatus == FILTERS_FAVORITES) {
val chargers =
if (filterStatus.value == FILTERS_FAVORITES) {
chargers =
db.favoritesDao().getAllFavoritesAsync().map { it.charger }.sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
this@MapScreen.chargers = chargers
isFavorite = List(chargers.size) { true }
} else {
// try multiple search radii until we have enough chargers
var chargers: List<ChargeLocation>? = null
val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50)
for (radius in radiusValues) {
val response = repo.getChargepointsRadius(
searchLocation,
radius,
zoom = 16f,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
loadingError = true
this@MapScreen.chargers = null
invalidate()
return@launch
}
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
if (prefs.placeSearchResultAndroidAutoName == null) {
chargers = headingFilter(
chargers,
searchLocation
val response = api.getChargepointsRadius(
referenceData,
searchLocation,
searchRadius,
zoom = 16f,
filters
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < maxRows) {
// try again with larger radius
val response = api.getChargepointsRadius(
referenceData,
searchLocation,
searchRadius * 10,
zoom = 16f,
filters
)
}
if (chargers == null || chargers.size >= maxRows) {
break
chargers =
response.data?.filterIsInstance(ChargeLocation::class.java)
}
}
val isFavorite = chargers?.map {
db.favoritesDao().findFavorite(it.id, apiId) != null
}
this@MapScreen.chargers = chargers
this@MapScreen.isFavorite = isFavorite
}
updateCoroutine = null
lastChargersUpdateTime = Instant.now()
lastDistanceUpdateTime = Instant.now()
invalidate()
} catch (e: IOException) {
loadingError = true
invalidate()
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
/**
* Filters by heading if heading available and enabled
*/
private fun headingFilter(
chargers: List<ChargeLocation>?,
searchLocation: LatLng
): List<ChargeLocation>? {
// use compass heading if available, otherwise fall back to GPS
val location = location
val heading = heading?.orientations?.value?.get(0)
?: if (location?.hasBearing() == true) location.bearing else null
return heading?.let {
if (!prefs.showChargersAheadAndroidAuto) return@let chargers
chargers?.filter {
val bearing = bearingBetween(
searchLocation.latitude,
searchLocation.longitude,
it.coordinates.lat,
it.coordinates.lng
)
val diff = headingDiff(bearing, heading.toDouble())
abs(diff) < 30
}
} ?: chargers
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
val isUpdate = this.energyLevel == null
this.energyLevel = energyLevel
if (isUpdate) invalidate()
}
private fun onCompassUpdated(compass: Compass) {
this.heading = compass
}
override fun onStart(owner: LifecycleOwner) {
setupListeners()
session.requestLocationUpdates()
locationError = false
Handler(Looper.getMainLooper()).postDelayed({
if (location == null) {
locationError = true
invalidate()
}
}, 5000)
// Reloading chargers in onStart does not seem to count towards content limit.
// So let's do this so the user gets fresh chargers when re-entering the app.
if (prefs.dataSource != repo.api.value?.id) {
repo.api.value = createApi(prefs.dataSource, carContext)
}
invalidate()
loadChargers()
filtersWithValue.observe(this@MapScreen) {
loadChargers()
}
}
private fun setupListeners() {
val exec = ContextCompat.getMainExecutor(carContext)
if (supportsCarApiLevel3(carContext)) {
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCompassUpdated
)
}
if (!permissions.all {
ContextCompat.checkSelfPermission(
carContext,
@@ -582,7 +390,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
if (supportsCarApiLevel3(carContext)) {
println("Setting up energy level listener")
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
}
@@ -593,15 +402,13 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// (i.e. onGetTemplate is not called while the old data is still there)
chargers = null
availabilities.clear()
location = null
removeListeners()
}
private fun removeListeners() {
if (supportsCarApiLevel3(carContext)) {
println("Removing energy level listener")
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
carSensors.removeCompassListener(::onCompassUpdated)
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
}
}
@@ -618,7 +425,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
// when the list is scrolled, load corresponding availabilities
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
if (startIndex == visibleStart && endIndex == visibleEnd && !availabilities.isEmpty()) return
if (startIndex == -1 || endIndex == -1) return
if (availabilityUpdateCoroutine != null) return
@@ -647,7 +454,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
lifecycleScope.async {
val availability = availabilityRepo.getAvailability(it).data
val availability = getAvailability(it).data
val date = ZonedDateTime.now()
availabilities[it.id] = date to availability
}

View File

@@ -23,7 +23,6 @@ class PermissionScreen(
Action.Builder()
.setTitle(carContext.getString(R.string.grant_on_phone))
.setBackgroundColor(CarColor.PRIMARY)
.setFlags(Action.FLAG_PRIMARY)
.setOnClickListener(ParkedOnlyOnClickListener.create {
requestPermissions()
})
@@ -47,7 +46,7 @@ class PermissionScreen(
}
private fun requestPermissions() {
carContext.requestPermissions(permissions) { granted, _ ->
carContext.requestPermissions(permissions) { granted, rejected ->
if (granted.containsAll(permissions)) {
screenManager.pop()
} else {

View File

@@ -31,11 +31,7 @@ import java.io.IOException
import java.time.Instant
@ExperimentalCarApi
class PlaceSearchScreen(
ctx: CarContext,
val session: EVMapSession,
val initialSearch: String = ""
) : Screen(ctx),
class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
SearchTemplate.SearchCallback, LocationAwareScreen,
DefaultLifecycleObserver {
private val hardwareMan: CarHardwareManager by lazy {
@@ -68,15 +64,13 @@ class PlaceSearchScreen(
init {
lifecycle.addObserver(this)
update(initialSearch)
update("")
}
override fun onGetTemplate(): Template {
return SearchTemplate.Builder(this).apply {
setHeaderAction(Action.BACK)
setSearchHint(carContext.getString(R.string.search))
setInitialSearchText(initialSearch)
setShowKeyboardByDefault(initialSearch == "")
resultList?.let {
setItemList(buildItemList(it))
} ?: setLoading(true)
@@ -105,22 +99,21 @@ class PlaceSearchScreen(
DistanceSpan.create(
roundValueToDistance(
it,
energyLevel?.distanceDisplayUnit?.value,
carContext
energyLevel?.distanceDisplayUnit?.value
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
addText(text)
}
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
screenManager.popTo(MapScreen.MARKER)
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
screenManager.popTo(MapScreen.MARKER)
}
}
}
}.build())
@@ -155,7 +148,6 @@ class PlaceSearchScreen(
}
private suspend fun loadNewList(query: String) {
val location = location?.let { LatLng.fromLocation(it) }
for (provider in providers) {
try {
recentResults.clear()
@@ -169,7 +161,7 @@ class PlaceSearchScreen(
}
recentResults.addAll(recentPlaces)
resultList =
recentPlaces.map { it.asAutocompletePlace(location) }
recentPlaces.map { it.asAutocompletePlace(LatLng.fromLocation(location)) }
invalidate()
// if we already have enough results or the query is short, stop here
@@ -178,7 +170,7 @@ class PlaceSearchScreen(
// then search online
val recentIds = recentPlaces.map { it.id }
resultList = withContext(Dispatchers.IO) {
(resultList!! + provider.autocomplete(query, location)
(resultList!! + provider.autocomplete(query, LatLng.fromLocation(location))
.filter { !recentIds.contains(it.id) }).take(maxItems)
}
invalidate()

View File

@@ -7,38 +7,26 @@ import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import okio.IOException
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
SearchTemplate.SearchCallback {
protected var fullList: List<T>? = null
private var currentList: List<T> = emptyList()
private var query: String = ""
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
protected abstract val isMultiSelect: Boolean
protected abstract val shouldShowSelectAll: Boolean
override fun onGetTemplate(): Template {
if (fullList == null) {
lifecycleScope.launch {
try {
fullList = loadData()
filterList()
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.generic_connection_error,
CarToast.LENGTH_LONG
).show()
screenManager.pop()
}
}
fullList = loadData()
filterList()
invalidate()
}
}
@@ -100,7 +88,6 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
addItem(
Row.Builder()
.setTitle(getLabel(item))
.apply { getDetails(item)?.let { addText(it) } }
.setImage(if (isSelected(item)) checkedIcon else uncheckedIcon)
.setOnClickListener {
toggleSelected(item)
@@ -145,7 +132,5 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
abstract fun getLabel(it: T): String
open fun getDetails(it: T): String? = null
abstract suspend fun loadData(): List<T>
}

View File

@@ -1,51 +1,24 @@
package net.vonforst.evmap.auto
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
import android.net.Uri
import android.os.Bundle
import android.os.IInterface
import android.text.Html
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.TeslaOwnerApi
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.storage.PreferenceDataSource
import okhttp3.OkHttpClient
import java.io.IOException
import java.time.Instant
import kotlin.math.max
import kotlin.math.min
@ExperimentalCarApi
class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
class SettingsScreen(ctx: CarContext) : Screen(ctx) {
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.auto_settings))
@@ -96,44 +69,11 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(VehicleDataScreen(carContext, session))
screenManager.push(VehicleDataScreen(carContext))
}
.build()
)
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_ahead))
.setToggle(Toggle.Builder {
prefs.showChargersAheadAndroidAuto = it
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).setTint(CarColor.DEFAULT).build()
)
.build()
)
}
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.about))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_about
)
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(AboutScreen(carContext))
}
.build()
)
}.build())
}.build()
}
@@ -141,18 +81,18 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
val encryptedPrefs = EncryptedPreferenceDataStore(ctx)
val db = AppDatabase.getInstance(ctx)
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
val dataSourceValues = carContext.resources.getStringArray(R.array.pref_data_source_values)
val dataSourceDescriptions = listOf(
carContext.getString(R.string.data_source_goingelectric_desc),
carContext.getString(R.string.data_source_openchargemap_desc)
)
val searchProviderNames =
carContext.resources.getStringArray(R.array.pref_search_provider_names)
val searchProviderValues =
carContext.resources.getStringArray(R.array.pref_search_provider_values)
var teslaLoggingIn = false
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_data_sources))
@@ -168,9 +108,14 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
screenManager.push(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.CHARGER_DATA_SOURCE
)
)
R.string.pref_data_source,
dataSourceNames,
dataSourceValues,
prefs.dataSource,
dataSourceDescriptions
) {
prefs.dataSource = it
})
}
}.build())
addItem(Row.Builder().apply {
@@ -184,9 +129,13 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
screenManager.push(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.SEARCH_PROVIDER
)
)
R.string.pref_search_provider,
searchProviderNames,
searchProviderValues,
prefs.searchProvider
) {
prefs.searchProvider = it
})
}
}.build())
addItem(Row.Builder().apply {
@@ -202,210 +151,48 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
}
}
}.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()
}
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))
carContext.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(
ctx: CarContext,
val type: Type,
val initialChoice: Boolean = false,
@StringRes val extraDesc: Int? = null
@StringRes val title: Int,
val names: Array<String>,
val values: Array<String>,
val currentValue: String,
val descriptions: List<String>? = null,
val callback: (String) -> Unit
) : Screen(ctx) {
enum class Type {
CHARGER_DATA_SOURCE, SEARCH_PROVIDER
}
val prefs = PreferenceDataSource(carContext)
val title = when (type) {
Type.CHARGER_DATA_SOURCE -> R.string.pref_data_source
Type.SEARCH_PROVIDER -> R.string.pref_search_provider
}
val names = when (type) {
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_names)
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_names)
}
val values = when (type) {
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_values)
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_values)
}
val currentValue: String = when (type) {
Type.CHARGER_DATA_SOURCE -> prefs.dataSource
Type.SEARCH_PROVIDER -> prefs.searchProvider
}
val descriptions = when (type) {
Type.CHARGER_DATA_SOURCE -> listOf(
carContext.getString(R.string.data_source_goingelectric_desc),
carContext.getString(R.string.data_source_openchargemap_desc)
)
Type.SEARCH_PROVIDER -> null
}
val callback: (String) -> Unit = when (type) {
Type.CHARGER_DATA_SOURCE -> { it ->
prefs.dataSourceSet = true
prefs.dataSource = it
}
Type.SEARCH_PROVIDER -> { it ->
prefs.searchProvider = it
}
}
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(title))
setHeaderAction(if (initialChoice) Action.APP_ICON else Action.BACK)
val list = ItemList.Builder().apply {
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
for (i in names.indices) {
addItem(Row.Builder().apply {
setTitle(names[i])
descriptions?.let { addText(it[i]) }
if (initialChoice) {
setBrowsable(true)
setOnClickListener {
itemSelected(i)
}
}
}.build())
}
if (!initialChoice) {
setOnSelectedListener {
itemSelected(it)
}
setSelectedIndex(values.indexOf(currentValue))
setOnSelectedListener {
callback(values[it])
screenManager.pop()
}
}.build()
if (extraDesc != null) {
addSectionedList(SectionedItemList.create(list, carContext.getString(extraDesc)))
} else {
setSingleList(list)
}
setSelectedIndex(values.indexOf(currentValue))
}.build())
}.build()
}
private fun itemSelected(i: Int) {
callback(values[i])
screenManager.pop()
}
}
class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
@@ -524,8 +311,6 @@ class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<Chargepric
override fun getLabel(it: ChargepriceCar) = "${it.brand} ${it.name}"
override fun getDetails(it: ChargepriceCar) = it.formatSpecs()
override suspend fun loadData(): List<ChargepriceCar> {
return api.getVehicles()
}
@@ -722,193 +507,4 @@ class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
)
}.build()
}
}
class AboutScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
var developerOptionsCounter = 0
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.about))
setHeaderAction(Action.BACK)
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.version))
.addText(BuildConfig.VERSION_NAME)
.addText(
carContext.getString(R.string.copyright) + " " + carContext.getString(
R.string.copyright_summary
)
)
.setBrowsable(prefs.developerModeEnabled)
.setOnClickListener {
if (!prefs.developerModeEnabled) {
developerOptionsCounter += 1
if (developerOptionsCounter >= 7) {
prefs.developerModeEnabled = true
invalidate()
CarToast.makeText(
carContext,
carContext.getString(R.string.developer_mode_enabled),
CarToast.LENGTH_SHORT
).show()
}
} else {
screenManager.pushForResult(DeveloperOptionsScreen(carContext)) {
developerOptionsCounter = 0
invalidate()
}
}
}.build()
)
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.faq))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.faq_link))
}).build()
)
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.donate))
.addText(carContext.getString(R.string.donate_desc))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
if (BuildConfig.FLAVOR_automotive == "automotive") {
// we can't open the donation page on the phone in this case
openUrl(carContext, carContext.getString(R.string.donate_link))
} else {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_DONATE, true)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
}
}).build()
)
}.build(), carContext.getString(R.string.about)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.twitter))
.addText(carContext.getString(R.string.twitter_handle))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.twitter_url))
}).build()
)
if (maxRows > 6) {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.goingelectric_forum))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(
carContext,
carContext.getString(R.string.goingelectric_forum_url)
)
}).build()
)
}
}.build(), carContext.getString(R.string.contact)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.github_link_title))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.github_link))
}).build()
)
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.privacy))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.privacy_link))
}).build()
)
}.build(), carContext.getString(R.string.other)))
}.build()
}
}
class AcceptPrivacyScreen(ctx: CarContext) : 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, carContext.getString(R.string.privacy_link))
}).build()
)
}.build()
}
}
class DeveloperOptionsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.developer_options))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(
Row.Builder().apply {
setTitle("Car app API Level: ${carContext.carAppApiLevel}")
val hostPackage = carContext.hostInfo?.packageName
val hostVersion = hostPackage?.let {
try {
carContext.packageManager.getPackageInfoCompat(it).versionName
} catch (e: NameNotFoundException) {
null
}
}
addText("$hostPackage $hostVersion")
if (BuildConfig.FLAVOR_automotive == "automotive") {
addText(
"Sensor list: ${
(carContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager).getSensorList(
Sensor.TYPE_ALL
).map { it.type }.joinToString(",")
}"
)
}
}.build()
)
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.disable_developer_mode))
setOnClickListener {
prefs.developerModeEnabled = false
CarToast.makeText(
carContext,
carContext.getString(R.string.developer_mode_disabled),
CarToast.LENGTH_SHORT
).show()
screenManager.pop()
}
}.build())
}.build())
}.build()
}
}

View File

@@ -0,0 +1,155 @@
package net.vonforst.evmap.auto
import android.content.Context
import android.graphics.Bitmap
import androidx.car.app.CarContext
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.api.availability.ChargepointStatus
import java.util.*
import kotlin.math.roundToInt
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
val allFaulted = status.all { it == ChargepointStatus.FAULTED }
return if (unknown) {
CarColor.DEFAULT
} else if (available > 0) {
CarColor.GREEN
} else if (allFaulted) {
CarColor.RED
} else {
CarColor.BLUE
}
}
val CarContext.constraintManager
get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
val emptyCarIcon = Bitmap.createBitmap(
1,
1,
Bitmap.Config.ARGB_8888
).asCarIcon()
private const val kmPerMile = 1.609344
private const val ftPerMile = 5280
private const val ydPerMile = 1760
fun getDefaultDistanceUnit(): Int {
return if (usesImperialUnits(Locale.getDefault())) {
CarUnit.MILE
} else {
CarUnit.KILOMETER
}
}
fun usesImperialUnits(locale: Locale): Boolean {
return locale.country in listOf("US", "GB", "MM", "LR")
|| locale.country == "" && locale.language == "en"
}
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 {
if (value == null) return ""
return when (unit ?: getDefaultDistanceUnit()) {
// distance units: base unit is meters
CarUnit.METER -> "%.0f m".format(value)
CarUnit.KILOMETER -> "%.1f km".format(value / 1000)
CarUnit.MILLIMETER -> "%.0f mm".format(value * 1000) // whoever uses that...
CarUnit.MILE -> "%.1f mi".format(value / 1000 / kmPerMile)
else -> ""
}
}
fun formatCarUnitSpeed(value: Float?, unit: Int?): String {
if (value == null) return ""
return when (unit ?: getDefaultSpeedUnit()) {
// speed units: base unit is meters per second
CarUnit.METERS_PER_SEC -> "%.0f m/s".format(value)
CarUnit.KILOMETERS_PER_HOUR -> "%.0f km/h".format(value * 3.6)
CarUnit.MILES_PER_HOUR -> "%.0f mph".format(value * 3.6 / kmPerMile)
else -> ""
}
}
fun roundValueToDistance(value: Double, unit: Int? = null): Distance {
// value is in meters
when (unit ?: getDefaultDistanceUnit()) {
CarUnit.MILE -> {
// imperial system
val miles = value / 1000 / kmPerMile
val yards = miles * ydPerMile
val feet = miles * ftPerMile
return when (miles) {
in 0.0..0.1 -> if (Locale.getDefault().country == "UK") {
Distance.create(roundToMultipleOf(yards, 10.0), Distance.UNIT_YARDS)
} else {
Distance.create(roundToMultipleOf(feet, 10.0), Distance.UNIT_FEET)
}
in 0.1..10.0 -> Distance.create(
roundToMultipleOf(miles, 0.1),
Distance.UNIT_MILES_P1
)
else -> Distance.create(roundToMultipleOf(miles, 1.0), Distance.UNIT_MILES)
}
}
else -> {
// metric system
return when (value) {
in 0.0..999.0 -> Distance.create(
roundToMultipleOf(value, 10.0),
Distance.UNIT_METERS
)
in 1000.0..10000.0 -> Distance.create(
roundToMultipleOf(value / 1000, 0.1),
Distance.UNIT_KILOMETERS_P1
)
else -> Distance.create(
roundToMultipleOf(value / 1000, 1.0),
Distance.UNIT_KILOMETERS
)
}
}
}
}
private fun roundToMultipleOf(num: Double, step: Double): Double {
return (num / step).roundToInt() * step
}
fun getAndroidAutoVersion(ctx: Context): List<String> {
val info = ctx.packageManager.getPackageInfo("com.google.android.projection.gearhead", 0)
return info.versionName.split(".")
}
fun supportsCarApiLevel3(ctx: CarContext): Boolean {
if (ctx.carAppApiLevel < CarAppApiLevels.LEVEL_3) return false
ctx.hostInfo?.let { hostInfo ->
if (hostInfo.packageName == "com.google.android.projection.gearhead") {
val version = getAndroidAutoVersion(ctx)
// 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
if (version[0] < "6" || version[0] == "6" && version[1] < "7") {
return false
}
}
}
return true
}

View File

@@ -1,7 +1,6 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
@@ -17,13 +16,10 @@ import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.CompassNeedle
import net.vonforst.evmap.ui.Gauge
import net.vonforst.evmap.utils.formatDecimal
import kotlin.math.min
import kotlin.math.roundToInt
@androidx.car.app.annotations.ExperimentalCarApi
class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
LocationAwareScreen, DefaultLifecycleObserver {
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver {
private val carInfo =
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
private val carSensors = carContext.patchedCarSensors
@@ -31,7 +27,6 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
private var energyLevel: EnergyLevel? = null
private var speed: Speed? = null
private var heading: Compass? = null
private var location: Location? = null
private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
private var compass =
CompassNeedle((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
@@ -75,16 +70,12 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
val energyLevel = energyLevel
val model = model
val speed = speed
val location = location
val compassHeading = heading?.orientations?.value?.get(0)
val gpsHeading = if (location?.hasBearing() == true) location.bearing else null
val heading = compassHeading ?: gpsHeading
val heading = heading
return GridTemplate.Builder().apply {
setTitle(
if (model != null && model.manufacturer.value != null && model.name.value != null) {
"${getVehicleBrand(model.manufacturer.value)} ${
"${model.manufacturer.value} ${
getVehicleModel(
model.manufacturer.value,
model.name.value
@@ -139,8 +130,7 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
setText(
formatCarUnitDistance(
energyLevel.rangeRemainingMeters.value,
energyLevel.distanceDisplayUnit.value,
carContext
energyLevel.distanceDisplayUnit.value
)
)
setImage(
@@ -174,8 +164,7 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
setText(
formatCarUnitSpeed(
rawSpeed,
speed.speedDisplayUnit.value,
carContext
speed.speedDisplayUnit.value
)
)
setImage(
@@ -185,8 +174,7 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
setText(
formatCarUnitSpeed(
speed.displaySpeedMetersPerSecond.value,
speed.speedDisplayUnit.value,
carContext
speed.speedDisplayUnit.value
)
)
setImage(
@@ -204,30 +192,17 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
if (heading == null) {
setLoading(true)
} else {
val headingSource =
if (compassHeading != null) carContext.getString(R.string.compass) else carContext.getString(
R.string.gps
val heading = heading.orientations.value
if (heading != null) {
setText(
"${heading[0].roundToInt()}°"
)
setText("${heading.roundToInt()}° ($headingSource)")
} else {
setText(carContext.getString(R.string.auto_no_data))
}
setImage(
compass.draw(heading).asCarIcon()
)
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.coordinates))
if (location == null) {
setLoading(true)
} else {
val dms = location.formatDecimal(4)
setText(dms)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_location
)
).setTint(CarColor.DEFAULT).build()
compass.draw(heading?.get(0)).asCarIcon()
)
}
}.build())
@@ -254,7 +229,6 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
override fun onResume(owner: LifecycleOwner) {
setupListeners()
session.mapScreen = this
}
private fun setupListeners() {
@@ -279,7 +253,6 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
override fun onPause(owner: LifecycleOwner) {
removeListeners()
session.mapScreen = null
}
private fun removeListeners() {
@@ -296,8 +269,4 @@ class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
it
) == PackageManager.PERMISSION_GRANTED
}
override fun updateLocation(location: Location) {
this.location = location
}
}

View File

@@ -7,7 +7,6 @@ import android.text.style.StyleSpan
import com.car2go.maps.google.adapter.AnyMapAdapter
import com.car2go.maps.util.SphericalUtil
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.gms.tasks.Tasks.await
@@ -20,7 +19,6 @@ import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRe
import com.google.android.libraries.places.api.net.PlacesStatusCodes
import kotlinx.coroutines.tasks.await
import net.vonforst.evmap.R
import java.io.IOException
import java.util.concurrent.ExecutionException
import kotlin.math.sqrt
@@ -38,10 +36,10 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
): List<AutocompletePlace> {
val request = FindAutocompletePredictionsRequest.builder().apply {
if (location != null) {
locationBias = calcLocationBias(location)
origin = LatLng(location.latitude, location.longitude)
setLocationBias(calcLocationBias(location))
setOrigin(LatLng(location.latitude, location.longitude))
}
sessionToken = token
setSessionToken(token)
setQuery(query)
}.build()
try {
@@ -60,13 +58,6 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
if (cause is ApiException) {
if (cause.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
throw ApiUnavailableException()
} else if (cause.statusCode in listOf(
CommonStatusCodes.NETWORK_ERROR,
CommonStatusCodes.TIMEOUT, CommonStatusCodes.RECONNECTION_TIMED_OUT,
CommonStatusCodes.RECONNECTION_TIMED_OUT_DURING_UPDATE
)
) {
throw IOException(cause)
}
}
throw e
@@ -92,11 +83,10 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
}
}
override fun getAttributionString(): Int =
com.google.android.libraries.places.R.string.places_powered_by_google
override fun getAttributionString(): Int = R.string.places_powered_by_google
override fun getAttributionImage(dark: Boolean): Int =
if (dark) com.google.android.libraries.places.R.drawable.places_powered_by_google_dark else com.google.android.libraries.places.R.drawable.places_powered_by_google_light
if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light
private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds {
val radius = 100e3 // meters

View File

@@ -11,7 +11,6 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
@@ -19,17 +18,12 @@ import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DonationAdapter
import net.vonforst.evmap.adapter.SingleViewAdapter
import net.vonforst.evmap.databinding.FragmentDonateBinding
import net.vonforst.evmap.databinding.FragmentDonateHeaderBinding
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
import net.vonforst.evmap.viewmodel.DonateViewModel
class DonateFragment : Fragment() {
private lateinit var binding: FragmentDonateBinding
private val vm: DonateViewModel by viewModels()
private lateinit var header: FragmentDonateHeaderBinding
private lateinit var referrals: FragmentDonateReferralBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -46,9 +40,6 @@ class DonateFragment : Fragment() {
binding.lifecycleOwner = this
binding.vm = vm
header = FragmentDonateHeaderBinding.inflate(inflater, container, false)
referrals = FragmentDonateReferralBinding.inflate(inflater, container, false)
return binding.root
}
@@ -60,35 +51,25 @@ class DonateFragment : Fragment() {
(requireActivity() as MapsActivity).appBarConfiguration
)
val donationAdapter = DonationAdapter().apply {
onClickListener = {
vm.startPurchase(it, requireActivity())
}
}
binding.productsList.apply {
val joinedAdapter = ConcatAdapter(
SingleViewAdapter(header.root),
donationAdapter,
SingleViewAdapter(referrals.root)
)
adapter = joinedAdapter
adapter = DonationAdapter().apply {
onClickListener = {
vm.startPurchase(it, requireActivity())
}
}
layoutManager = LinearLayoutManager(context)
}
vm.products.observe(viewLifecycleOwner) {
donationAdapter.submitList(it.data)
print(it)
}
vm.purchaseSuccessful.observe(viewLifecycleOwner) {
vm.purchaseSuccessful.observe(viewLifecycleOwner, Observer {
Snackbar.make(view, R.string.donation_successful, Snackbar.LENGTH_LONG).show()
}
vm.purchaseFailed.observe(viewLifecycleOwner) {
})
vm.purchaseFailed.observe(viewLifecycleOwner, Observer {
Snackbar.make(view, R.string.donation_failed, Snackbar.LENGTH_LONG).show()
}
referrals.referralTesla.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(getString(R.string.tesla_referral_link))
}
})
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))

View File

@@ -0,0 +1,69 @@
package net.vonforst.evmap.fragment
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import net.vonforst.evmap.databinding.FragmentOnboardingAndroidAutoBinding
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 4
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> AndroidAutoFragment()
3 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}
class AndroidAutoFragment : OnboardingPageFragment() {
private lateinit var binding: FragmentOnboardingAndroidAutoBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentOnboardingAndroidAutoBinding.inflate(inflater, container, false)
binding.btnGetStarted.setOnClickListener {
parent.goToNext()
}
binding.imgAndroidAuto.alpha = 0f
return binding.root
}
@SuppressLint("Recycle")
override fun onResume() {
super.onResume()
val animators =
listOf(
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "translationY", -20f, 0f).apply {
interpolator = DecelerateInterpolator()
},
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "alpha", 0f, 1f).apply {
interpolator = DecelerateInterpolator()
}
)
AnimatorSet().apply {
playTogether(animators)
start()
}
}
override fun onPause() {
super.onPause()
binding.imgAndroidAuto.alpha = 0f
}
}

View File

@@ -3,8 +3,12 @@ package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.MultiSelectListPreference
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.RangeSliderPreference
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
import java.text.NumberFormat
class AndroidAutoSettingsFragment : BaseSettingsFragment() {
@@ -28,7 +32,7 @@ class AndroidAutoSettingsFragment : BaseSettingsFragment() {
setPreferencesFromResource(R.xml.settings_android_auto, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"chargeprice_battery_range_android_auto_min", "chargeprice_battery_range_android_auto_max" -> {
updateRangePreferenceSummary()

View File

@@ -5,7 +5,6 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import com.android.billingclient.api.*
import com.android.billingclient.api.BillingClient.ProductType
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.adapter.Equatable
@@ -15,12 +14,6 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
.setListener(this)
.enablePendingPurchases()
.build()
val products: MutableLiveData<Resource<List<DonationItem>>> by lazy {
MutableLiveData<Resource<List<DonationItem>>>().apply {
value = Resource.loading(null)
}
}
init {
billingClient.startConnection(object : BillingClientStateListener {
@@ -31,15 +24,10 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
loadProducts()
// consume pending purchases
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(ProductType.INAPP)
.build()
) { _, purchasesList ->
purchasesList.forEach {
if (!it.isAcknowledged) {
consumePurchase(it.purchaseToken, false)
}
val purchases = billingClient.queryPurchases(BillingClient.SkuType.INAPP)
purchases.purchasesList?.forEach {
if (!it.isAcknowledged) {
consumePurchase(it.purchaseToken, false)
}
}
}
@@ -48,26 +36,26 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
}
private fun loadProducts() {
val productIds = listOf(
"donate_1_eur", "donate_2_eur", "donate_5_eur", "donate_10_eur"
) + if (BuildConfig.DEBUG) {
listOf(
"android.test.purchased", "android.test.canceled",
"android.test.item_unavailable"
val params = SkuDetailsParams.newBuilder()
.setType(BillingClient.SkuType.INAPP)
.setSkusList(
listOf(
"donate_1_eur", "donate_2_eur", "donate_5_eur", "donate_10_eur"
) +
if (BuildConfig.DEBUG) {
listOf(
"android.test.purchased", "android.test.canceled",
"android.test.item_unavailable"
)
} else {
emptyList()
}
)
} else {
emptyList()
}
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productIds.map {
QueryProductDetailsParams.Product.newBuilder().setProductType(ProductType.INAPP)
.setProductId(it).build()
})
.build()
billingClient.queryProductDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
billingClient.querySkuDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK && details != null) {
products.postValue(Resource.success(details
.sortedBy { it.oneTimePurchaseOfferDetails!!.priceAmountMicros }
.sortedBy { it.priceAmountMicros }
.map { DonationItem(it) }
))
} else {
@@ -76,6 +64,12 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
}
}
val products: MutableLiveData<Resource<List<DonationItem>>> by lazy {
MutableLiveData<Resource<List<DonationItem>>>().apply {
value = Resource.loading(null)
}
}
val purchaseSuccessful = SingleLiveEvent<Nothing>()
val purchaseFailed = SingleLiveEvent<Nothing>()
@@ -103,13 +97,7 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
fun startPurchase(it: DonationItem, activity: Activity) {
val flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(it.product)
.build()
)
)
.setSkuDetails(it.sku)
.build()
val response = billingClient.launchBillingFlow(activity, flowParams)
if (response.responseCode != BillingClient.BillingResponseCode.OK) {
@@ -122,4 +110,4 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
}
}
data class DonationItem(val product: ProductDetails) : Equatable
data class DonationItem(val sku: SkuDetails) : Equatable

View File

@@ -35,16 +35,29 @@
</com.google.android.material.appbar.AppBarLayout>
<TextView
android:id="@+id/textView20"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/donations_info"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/products_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:data="@{vm.products.data}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar_container"
tools:itemCount="1"
tools:listitem="@layout/fragment_donate_preview" />
app:layout_constraintTop_toBottomOf="@+id/textView20"
tools:listitem="@layout/item_donation" />
<ProgressBar
android:id="@+id/progressBar3"

View File

@@ -1,10 +0,0 @@
<?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" />

View File

@@ -1,16 +0,0 @@
<?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>

View File

@@ -28,7 +28,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@{item.product.title}"
android:text="@{item.sku.title}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView21"
@@ -41,7 +41,7 @@
android:id="@+id/textView21"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.product.oneTimePurchaseOfferDetails.formattedPrice}"
android:text="@{item.sku.price}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent"

View File

@@ -1,5 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
<string name="auto_no_favorites_found">Keine Favoriten gefunden</string>
<string name="open_in_app">In App öffnen</string>
<string name="opened_on_phone">Auf dem Telefon geöffnet</string>
<string name="auto_location_permission_needed">Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
<string name="auto_vehicle_data_permission_needed">Für diese Funktion benötigt EVMap Zugriff auf Daten deines Fahrzeugs.</string>
<string name="grant_on_phone">Auf Telefon zulassen</string>
<string name="auto_chargers_closeby">In der Nähe</string>
<string name="auto_favorites">Favoriten</string>
<string name="auto_chargers_near_location">Nahe %s</string>
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
<string name="auto_prices">Preise</string>
<string name="auto_vehicle_data">Fahrzeugdaten</string>
<string name="auto_charging_level">Ladezustand</string>
<string name="auto_no_data">Nicht verfügbar</string>
<string name="auto_range">Reichweite</string>
<string name="auto_speed">Geschwindigkeit</string>
<string name="auto_heading">Fahrtrichtung</string>
<string name="auto_settings">Einstellungen</string>
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="sounds_cool">klingt cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
<string name="settings_android_auto_chargeprice_range">Ladebereich für Preisvergleich</string>
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap (Mapbox) für die Kartendaten wechseln.</string>
<string name="selecting_all">alle Einträge ausgewählt</string>
<string name="selecting_none">alle Einträge abgewählt</string>
</resources>

View File

@@ -3,5 +3,35 @@
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.
\n
\nGoogle prend 15% sur chaque don.</string>
<string name="auto_location_service">EVMap fonctionne sur Android Auto et utilise votre position.</string>
<string name="open_in_app">Ouvrir dans l\'application</string>
<string name="opened_on_phone">Ouvert sur le téléphone</string>
<string name="auto_location_permission_needed">Pour exécuter EVMap sur Android Auto, vous devez autoriser l\'accès à votre emplacement.</string>
<string name="grant_on_phone">Grant au téléphone</string>
<string name="auto_prices">Prix</string>
<string name="auto_vehicle_data">Données sur le véhicule</string>
<string name="auto_range">Autonomie</string>
<string name="auto_speed">Vitesse</string>
<string name="welcome_android_auto">Prise en charge dAndroid Auto</string>
<string name="sounds_cool">ça a l\'air cool</string>
<string name="auto_chargeprice_vehicle_unknown">Aucun des véhicules sélectionnés dans l\'application ne correspond à ce véhicule (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Plusieurs véhicules sélectionnés dans l\'application correspondent à ce véhicule (%1$s %2$s).</string>
<string name="selecting_all">tous les éléments sélectionnés</string>
<string name="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap (Mapbox) pour les données cartographiques.</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap n\'a pas pu déterminer le modèle de votre véhicule.</string>
<string name="auto_no_chargers_found">Aucun chargeur à proximité n\'a été trouvé</string>
<string name="auto_no_favorites_found">Pas de favoris trouvés</string>
<string name="auto_charging_level">Niveau de charge</string>
<string name="auto_chargers_closeby">Chargeurs à proximité</string>
<string name="auto_chargers_near_location">Près de %s</string>
<string name="auto_fault_report_date">⚠️ Rapport d\'anomalie (%s)</string>
<string name="auto_no_data">Indisponible</string>
<string name="auto_settings">Paramètres</string>
<string name="selecting_none">désélectionner tous les éléments</string>
<string name="auto_vehicle_data_permission_needed">Pour cette fonction, EVMap doit avoir accès aux données de votre véhicule.</string>
<string name="auto_heading">Direction</string>
<string name="auto_favorites">Favoris</string>
<string name="auto_no_refresh_possible">D\'autres mises à jour ne sont pas possibles. Veuillez revenir en arrière et redémarrer.</string>
<string name="settings_android_auto_chargeprice_range">Plage de charge pour la comparaison des prix</string>
<string name="welcome_android_auto_detail">Vous pouvez également utiliser EVMap à partir d\'Android Auto sur les voitures prises en charge. Il suffit de sélectionner l\'application EVMap dans le menu Android Auto.</string>
</resources>

View File

@@ -3,5 +3,35 @@
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende penger til utvikleren.
\n
\nGoogle tar 15% av alle donasjoner.</string>
<string name="auto_favorites">Favoritter</string>
<string name="auto_charging_level">Ladingsnivå</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap kunne ikke fastsette kjøretøymodellen.</string>
<string name="selecting_none">fravalgte alle elementer</string>
<string name="grant_on_phone">Innvilg på mobilenheten</string>
<string name="auto_chargers_closeby">Ladere i nærheten</string>
<string name="auto_prices">Pris</string>
<string name="auto_no_chargers_found">Ingen ladere i nærheten</string>
<string name="auto_no_favorites_found">Fant ikke noen favoritter</string>
<string name="open_in_app">Åpne i programmet</string>
<string name="auto_location_service">EVMap kjører på Android Auto og bruker posisjonen din.</string>
<string name="auto_heading">Fartsretning</string>
<string name="opened_on_phone">Åpnet på mobilenheten</string>
<string name="auto_location_permission_needed">Innvilg posisjonstilgang for å bruke EVMap på Android Auto.</string>
<string name="auto_chargers_near_location">Nær %s</string>
<string name="auto_fault_report_date">⚠️ Feilrapport (%s)</string>
<string name="auto_vehicle_data">Kjøretøydata</string>
<string name="auto_no_data">Utilgjengelig</string>
<string name="auto_speed">Hastighet</string>
<string name="auto_settings">Innstillinger</string>
<string name="auto_chargeprice_vehicle_unknown">Ingen av kjøretøyene valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
<string name="welcome_android_auto">Android Auto-støtte</string>
<string name="auto_chargeprice_vehicle_ambiguous">Flere kjøretøy valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
<string name="auto_vehicle_data_permission_needed">EvMap trenger tilgang til kjøretøydata for å bruke denne funksjonen.</string>
<string name="auto_no_refresh_possible">Videre oppdateringer er ikke mulig. Gå tilbake og start på ny.</string>
<string name="auto_range">Rekkevidde</string>
<string name="welcome_android_auto_detail">Du kan også bruke EVMap inne i Android Auto på bilder som støtter dette ved å velge det i Android Auto-menyen.</string>
<string name="settings_android_auto_chargeprice_range">Prissammenligning for laderekkevidde fordelt på pris</string>
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap (Mapbox) for kartdata.</string>
<string name="selecting_all">valgte alle elementene</string>
<string name="sounds_cool">den er grei</string>
</resources>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="gauge_active">#00e676</color>
<color name="gauge_middle">#087f23</color>
<color name="gauge_inactive">#9e9e9e</color>
<color name="charger_100kw_dark">#FBC02D</color>
</resources>

View File

@@ -1,5 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
<string name="auto_no_chargers_found">No nearby chargers found</string>
<string name="auto_no_favorites_found">No favorites found</string>
<string name="open_in_app">Open in app</string>
<string name="opened_on_phone">Opened on phone</string>
<string name="auto_location_permission_needed">To run EVMap on Android Auto, you need to grant access to your location.</string>
<string name="auto_vehicle_data_permission_needed">For this feature, EVMap needs access to your vehicle data.</string>
<string name="grant_on_phone">Grant on phone</string>
<string name="auto_chargers_closeby">Nearby chargers</string>
<string name="auto_favorites">Favorites</string>
<string name="auto_chargers_near_location">Near %s</string>
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
<string name="auto_prices">Pricing</string>
<string name="auto_vehicle_data">Vehicle data</string>
<string name="auto_charging_level">Charging level</string>
<string name="auto_no_data">Unavailable</string>
<string name="auto_range">Range</string>
<string name="auto_speed">Speed</string>
<string name="auto_heading">Heading</string>
<string name="auto_settings">Settings</string>
<string name="welcome_android_auto">Android Auto support</string>
<string name="welcome_android_auto_detail">You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
<string name="sounds_cool">sounds cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap could not determine your vehicle model.</string>
<string name="auto_chargeprice_vehicle_unknown">None of the vehicles selected in the app matches this vehicle (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Multiple vehicles selected in the app match this vehicle (%1$s %2$s).</string>
<string name="settings_android_auto_chargeprice_range">Charging range for price comparison</string>
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap (Mapbox) for the map data.</string>
<string name="selecting_all">selected all items</string>
<string name="selecting_none">deselected all items</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="CarAppTheme">
<item name="carColorPrimary">@color/colorPrimary</item>
<item name="carColorPrimaryDark">@color/colorPrimaryDark</item>
<item name="carColorSecondary">@color/colorSecondary</item>
<item name="carColorSecondaryDark">@color/colorSecondaryDark</item>
</style>
</resources>

View File

@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:fragment="net.vonforst.evmap.fragment.preference.AndroidAutoSettingsFragment"
android:title="@string/settings_android_auto"
android:icon="@drawable/ic_android_auto" />
</PreferenceScreen>

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
package="net.vonforst.evmap">
<uses-permission android:name="android.car.permission.CAR_INFO" />
<uses-permission android:name="android.car.permission.CAR_ENERGY" />
@@ -54,13 +55,6 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
<meta-data
android:name="distractionOptimized"

View File

@@ -1,16 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
package="net.vonforst.evmap">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
<queries>
<intent>
@@ -21,21 +15,11 @@
<action android:name="android.intent.action.VIEW" />
<data android:scheme="google.navigation" />
</intent>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
<package android:name="com.google.android.projection.gearhead" />
<package android:name="com.google.android.apps.automotive.templates.host" />
</queries>
<application
android:name=".EvMapApplication"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/backup_rules_api31"
android:fullBackupOnly="true"
android:backupAgent=".storage.BackupAgent"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -269,17 +253,6 @@
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Ungarn/..*/..*/..*/"
android:scheme="https" />
<data
android:host="openchargemap.org"
android:pathPattern="/site/poi/details/..*"
android:scheme="https" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -290,10 +263,6 @@
android:resource="@xml/shortcuts" />
</activity>
<activity android:name=".auto.OAuthLoginActivity">
</activity>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
@@ -302,51 +271,6 @@
android:name="autoStoreLocales"
android:value="true" />
</service>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- Remove WorkManagerInitializer as we implement getWorkManagerConfiguration in application class -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- Configuration for Android Auto app -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="androidx.car.app.theme"
android:resource="@style/CarAppTheme" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
<service
android:name=".auto.CarAppService"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:exported="true"
android:foregroundServiceType="location">
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.POI" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="net.vonforst.evmap" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -1,22 +1,17 @@
package net.vonforst.evmap
import android.app.Activity
import android.app.Application
import android.os.Build
import androidx.work.*
import net.vonforst.evmap.storage.CleanupCacheWorker
import com.facebook.stetho.Stetho
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
import org.acra.config.dialog
import org.acra.config.httpSender
import org.acra.config.limiter
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import java.time.Duration
class EvMapApplication : Application(), Configuration.Provider {
class EvMapApplication : Application() {
override fun onCreate() {
super.onCreate()
val prefs = PreferenceDataSource(this)
@@ -29,21 +24,16 @@ class EvMapApplication : Application(), Configuration.Provider {
prefs.language = null
}
Stetho.initializeWithDefaults(this);
init(applicationContext)
addDebugInterceptors(applicationContext)
if (!BuildConfig.DEBUG) {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST
// Vehicles often don't have an email app, so use HTTP to send instead
reportFormat = StringFormat.JSON
httpSender {
uri = getString(R.string.acra_backend_url)
val creds = getString(R.string.acra_credentials).split(":")
basicAuthLogin = creds[0]
basicAuthPassword = creds[1]
httpMethod = HttpSender.Method.POST
mailSender {
mailTo = "evmap+crashreport@vonforst.net"
}
dialog {
@@ -52,10 +42,6 @@ class EvMapApplication : Application(), Configuration.Provider {
commentPrompt = getString(R.string.crash_report_comment_prompt)
resIcon = R.drawable.ic_launcher_foreground
resTheme = R.style.AppTheme
if (BuildConfig.FLAVOR_automotive == "automotive") {
reportDialogClass =
Class.forName("androidx.car.app.activity.CarAppActivity") as Class<out Activity>?
}
}
limiter {
@@ -63,20 +49,5 @@ class EvMapApplication : Application(), Configuration.Provider {
}
}
}
val cleanupCacheRequest = PeriodicWorkRequestBuilder<CleanupCacheWorker>(Duration.ofDays(1))
.setConstraints(Constraints.Builder().apply {
setRequiresBatteryNotLow(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setRequiresDeviceIdle(true)
}
}.build()).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.REPLACE, cleanupCacheRequest
)
}
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder().build()
}
}

View File

@@ -8,10 +8,8 @@ import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen
@@ -40,7 +38,6 @@ const val EXTRA_CHARGER_ID = "chargerId"
const val EXTRA_LAT = "lat"
const val EXTRA_LON = "lon"
const val EXTRA_FAVORITES = "favorites"
const val EXTRA_DONATE = "donate"
class MapsActivity : AppCompatActivity(),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
@@ -55,8 +52,8 @@ class MapsActivity : AppCompatActivity(),
private lateinit var prefs: PreferenceDataSource
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()
setContentView(R.layout.activity_maps)
@@ -76,7 +73,7 @@ class MapsActivity : AppCompatActivity(),
val navView = findViewById<NavigationView>(R.id.nav_view)
navView.setupWithNavController(navController)
ViewCompat.setOnApplyWindowInsetsListener(navView) { _, insets ->
ViewCompat.setOnApplyWindowInsetsListener(navView) { v, insets ->
val header = navView.getHeaderView(0)
header.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
insets
@@ -107,10 +104,6 @@ class MapsActivity : AppCompatActivity(),
navGraph.setStartDestination(R.id.onboarding)
navController.graph = navGraph
return
} else if (!prefs.privacyAccepted) {
navGraph.setStartDestination(R.id.onboarding)
navController.graph = navGraph
return
} else {
navGraph.setStartDestination(R.id.map)
navController.setGraph(navGraph, MapFragmentArgs(appStart = true).toBundle())
@@ -128,7 +121,7 @@ class MapsActivity : AppCompatActivity(),
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
.createPendingIntent()
} else if (!query.isNullOrEmpty()) {
} else if (query != null && query.isNotEmpty()) {
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
@@ -138,69 +131,12 @@ class MapsActivity : AppCompatActivity(),
} 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()
.setDestination(R.id.map)
@@ -219,11 +155,6 @@ class MapsActivity : AppCompatActivity(),
.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()
@@ -237,7 +168,7 @@ class MapsActivity : AppCompatActivity(),
intent.data = Uri.parse("google.navigation:q=${coord.lat},${coord.lng}")
intent.`package` = "com.google.android.apps.maps"
if (prefs.navigateUseMaps && intent.resolveActivity(packageManager) != null) {
startActivity(intent)
startActivity(intent);
} else {
// fallback: generic geo intent
showLocation(charger)
@@ -253,7 +184,7 @@ class MapsActivity : AppCompatActivity(),
})"
)
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent)
startActivity(intent);
} else {
val cb = fragmentCallback ?: return
Snackbar.make(
@@ -265,7 +196,6 @@ class MapsActivity : AppCompatActivity(),
}
fun openUrl(url: String) {
val pkg = CustomTabsClient.getPackageName(this, null)
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
@@ -273,11 +203,6 @@ class MapsActivity : AppCompatActivity(),
.build()
)
.build()
pkg?.let {
// prefer to open URL in custom tab, even if native app
// available (such as EVMap itself)
intent.intent.setPackage(pkg)
}
try {
intent.launchUrl(this, Uri.parse(url))
} catch (e: ActivityNotFoundException) {
@@ -292,7 +217,7 @@ class MapsActivity : AppCompatActivity(),
fun shareUrl(url: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
setType("text/plain")
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(intent)

View File

@@ -1,17 +1,11 @@
package net.vonforst.evmap
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Typeface
import android.icu.util.LocaleData
import android.icu.util.ULocale
import android.os.Build
import android.os.Bundle
import android.text.*
import android.text.style.StyleSpan
import net.vonforst.evmap.storage.PreferenceDataSource
import java.util.*
fun Bundle.optDouble(name: String): Double? {
@@ -78,7 +72,7 @@ fun max(a: Int?, b: Int?): Int? {
* otherwise the non-null value or null
*/
return if (a != null && b != null) {
kotlin.math.max(a, b)
max(a, b)
} else {
a ?: b
}
@@ -91,30 +85,7 @@ fun Context.isDarkMode() =
const val kmPerMile = 1.609344
const val meterPerFt = 0.3048
const val ftPerMile = 5280
const val ydPerMile = 1760
fun shouldUseImperialUnits(ctx: Context): Boolean {
val prefs = PreferenceDataSource(ctx)
return when (prefs.units) {
"metric" -> false
"imperial" -> true
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
when (LocaleData.getMeasurementSystem(ULocale.getDefault())) {
LocaleData.MeasurementSystem.US, LocaleData.MeasurementSystem.UK -> true
LocaleData.MeasurementSystem.SI -> false
else -> false
}
} else {
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
}
}
}
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
}
fun shouldUseImperialUnits(): Boolean {
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
}

View File

@@ -1,6 +1,7 @@
package net.vonforst.evmap.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
@@ -90,7 +91,7 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
class ChargepriceAdapter() :
DataBindingAdapter<ChargePrice>() {
val viewPool = RecyclerView.RecycledViewPool()
val viewPool = RecyclerView.RecycledViewPool();
var meta: ChargepriceChargepointMeta? = null
set(value) {
field = value
@@ -160,12 +161,11 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
val binding = holder.binding as ItemConnectorButtonBinding
binding.enabled = enabledConnectors?.let { item.type in it } ?: true
val root = binding.root as CheckableConstraintLayout
root.setOnCheckedChangeListener { _, _ -> }
root.isChecked = checkedItem == position
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { _, checked: Boolean ->
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
if (checked) {
checkedItem = holder.bindingAdapterPosition.takeIf { it != -1 }
root.post {
@@ -204,7 +204,7 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { _, checked: Boolean ->
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
if (checked && item != checkedItem) {
checkedItem = item
root.post {

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