Compare commits

..

1 Commits
1.6.4 ... 0.9.0

Author SHA1 Message Date
johan12345
c2292ad7fa Release 0.9.0 2021-08-09 19:01:35 +02:00
510 changed files with 4392 additions and 22963 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +0,0 @@
github: johan12345
custom: 'https://paypal.me/johan98'

View File

@@ -1,8 +0,0 @@
<resources>
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">ci</string>
<string name="mapbox_key" translatable="false">ci</string>
<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>
</resources>

View File

@@ -1,78 +0,0 @@
on:
push:
tags:
- '*'
name: Release
jobs:
test:
name: Build and upload release
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Decrypt keystore
run: openssl aes-256-cbc -K ${{ secrets.encrypted_53968681344a_key }} -iv ${{ secrets.encrypted_53968681344a_iv }} -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
- name: Extract version code
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s\+[0-9]*" app/build.gradle | awk '{ print $2 }' | tr -d \''"\\')" >> $GITHUB_ENV
- name: Build app release
env:
GOINGELECTRIC_API_KEY: ${{ secrets.GOINGELECTRIC_API_KEY }}
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
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 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
run: ./gradlew assembleRelease --no-daemon
- name: release
uses: actions/create-release@v1
id: create_release
with:
draft: false
prerelease: false
release_name: ${{ steps.version.outputs.version }}
tag_name: ${{ github.ref }}
body_path: fastlane/metadata/android/en-US/changelogs/${{ env.VERSION_CODE }}.txt
env:
GITHUB_TOKEN: ${{ github.token }}
- name: upload Google 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/googleNormal/release/app-google-normal-release.apk
asset_name: app-google-normal-release.apk
asset_content_type: application/vnd.android.package-archive
- name: upload Foss 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/fossNormal/release/app-foss-normal-release.apk
asset_name: app-foss-normal-release.apk
asset_content_type: application/vnd.android.package-archive
- name: upload Google 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/googleAutomotive/release/app-google-automotive-release.apk
asset_name: app-google-automotive-release.apk
asset_content_type: application/vnd.android.package-archive

View File

@@ -1,36 +0,0 @@
on:
pull_request:
push:
branches:
- '*'
name: Tests
jobs:
test:
name: Build and Test (${{ matrix.buildvariant }})
runs-on: ubuntu-latest
strategy:
matrix:
buildvariant: [ FossNormal, GoogleNormal, GoogleAutomotive ]
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 17
distribution: 'zulu'
cache: 'gradle'
- name: Copy apikeys.xml
run: cp .github/workflows/apikeys-ci.xml app/src/main/res/values/apikeys.xml
- name: Build app
run: ./gradlew assemble${{ matrix.buildvariant }}Debug --no-daemon
- name: Run unit tests
run: ./gradlew test${{ matrix.buildvariant }}DebugUnitTest --no-daemon
- name: Run Android Lint
run: ./gradlew lint${{ matrix.buildvariant }}Debug --no-daemon

46
.travis.yml Normal file
View File

@@ -0,0 +1,46 @@
language: java
dist: focal
env:
global:
- secure: KYdFlMarsyXw+OHht1Atp+Kirbw9O09Ck14EjFuKb1eNtknurZ/tGEXuD+8xWh1W8W21kgHEG7s3rzru53t29buz+FW9f+ZmhEWXFP3OydyvXLw4BAVVOjm6xG2uHX/8MOGLJNM7cfaF25EPQ+kznHe84R29KaLH90mNRr2lPa4VnfbcnvDStiVaez/vJ72UoYSP5HICAzoF70yC3ZvvCK1hZv71UIysCbFE2IkxvMhG9OOGebdnRmFssaRCrvfRLjitobcLzkPWzZZIqdjNASf8/iAxX8VgGBYfVj8ID06AfMrtgXNJRCvcD0LICraQ+WPUbikMunRieGO8PNHSB5vKdPoC50aLUa0RoRb4G3QM1pR2A8xAFlIJFX2R7iY+2t24L9hRFqB98+QoQzutfkAI1T0rzem/wtpZpuan+bDawDJEHGCeYbE0aPDAl6lytgrEE9fRgV3c1jJLQzu0xIWG8YLl3iMg0hL+c0wCKXoeqrfCFS6kYmmG7W+rQp4tCZifvRbWfAXwIPQieffKxqdEuUwiUsYxdzCu9v9uU3nflEOLLuRgeMP3gV8mpur9b5GztpkfgfzcAqsF+NiY01kYgGtrgCYlMy0TxASE+UuALrtkQtU01wwhs9RH7Az0Ib3C+MT5DTjxHQCYETIViocmNEG2vfAbgHazCpGAhcY=
- secure: Hoko50vP+Mwm/O4CWvPvjMxd1gGhi+Bultjyy1WpludSmZFCfKz45Mj1EqzeYk6MeMLvGOEkSLB5wjdXdgJ9j5gEOF5K34k4vESJA7+DqDO3I7Xw9cnWgOXdFqB0qGHar0TVP3Dfg7ZcRgtmeX6t2uoELFLiS9GvTnbZXk4PCUybd979Xi8XHjEQV7+3EZbSjtsI4GFeIK1rrjd0I+UM88zYrWnz1KhdCjWvQb4iZjo+ib6NmGGEMqR7jJPRZz3KB01Y+n5h21qq9/Nv31zQIqt2B5nRxy3vBvvqKapgprIk+hVOpNnBU8w89uWUU6tYUeFk0t7z1TYWjgaBrMmGCM+aKkQ2q5F/ygNzDwB+KkpJx709Yhf0ZX8g2yvkdz+Ok7moYuvmrOPOf4E/U3BlfZxbGtRD2bGYbDgHLFYlTn6v5J2kJHDJAz31yvF5jvJejDaPp2IBVfoMRy7ZJFmGUNHGd9Se6bRwxS++AoobP5WDrBiUXNe2KKDMs3e7vbaO+hLbZ9XHpjeWIJGUfvtTee8EHZqF/8A3ju53V4/R0ehlOv2UZbpYNqcmwrsy9/R4pgMfDkG3Q054LmYrxD8DIC9b8excVMwWRP5aQ1TqZnxO2B1/vJU87RcnGl3jekeHTdHXbRq9BMV4dAdPfB9X3nGIi2GgV0iTBk+24xccOc0=
- secure: RsN/2nBVv0byMzwchuAhDti1AWKECg3Uzqk6Ahgaqg02zx8GHj60j0qNax7KuMUg70X3G4b3m8ZzndAU0wcJ98UyIku7ofUYgXHm0XYKTJwiyyhrKJ8pZ4qoeCoRkitIGIitlb84fSufVamcoLyLNbLUsO5OzL+Uscyhq/BwAhyOhj5FB9DM+GE9ntanQ4m3k4guMyMctR9CGS6Tk4LKJ1WCm0AnjTPalc+we+7yORY2J/9d6VHLKfaFXbpuWmrdnFfDZqxqcUODsxPVE0LuUtXyKQDTjjfQc8106Z/z19AJS+oLm/2UND84PD3MqsjX6EX0/9k3fY0OsoCuQAivDRmtevQ0bDQrTAyeBLcfnPCw/MYiJWyBcaYBAYK42EAfsFTDBRIduFB/Dpvg+8GuLZSdm4xVYpTlQ2pMtlGNWIGIRQsjk9LZ8swq8QBMiiF/bpMGKdfmnyQj8jkEOCWaAzkR9O+4E4Y7PuBENef9XuV0hLMryCrML2YXigxAKkEUOPhfnG+AbEY+g2kAMp+2EtXaG3tsGxoMhEYA+uRd3o2cacQMMwRpnePH5DYg6F05mrvdHPpt+P9UR1iHaHmjBPAYeksmrdP8bS088zcnePgiL6+6N0m3+l8Krihmxg5RyWjnH18IwX4RO+xg4x3cW+zaScCDbbotDMEYtChF+Hs=
- secure: LQHMdhaPUlCuJPFrCPpUphJSY6xzAFI/7RrcAVLtLcPhGdS+MeNifIkkAH7MeitTHroOC0dGkZ4bg/8/7bKfgwY4vPH9P50kZcnX5mI6zfBHgNYJzuthj+vJH9RAtkdQOW9Fe1uPIx8R9GUWUOVnkoJh0PQ1gDXdZW5fePqUtn1kYrcCCBE+Bhe3wz6QzTBqGS1nsVRTxQfSJNGi9uH1oi9kQGgQFuCCiJ/P0A6MIhSItkOfuggx/iorA+iASbhWkB4nXYQBbFe/ZhFJWbVfgYlOM0HtpKh8B2AqKw21Em32JoovCbUof4adkY7cH8/4Rt9SujC9YOw+a6oM+e//jJT0sie77V7zl670j+qODTuNvV4qVUwtoxShyc1Sfbd+Xb0xn/OC7DzBg97YuYCF/84yyuq12rl/cofynWE1L5YvGNSJk241XUw98Bvl0MK4VIfQvG9zJP0HnQZcWKt6kFOIEJSCRbmkd2tPPAZFBXBQf/bvpULOoKwneGJZBSapRoCyGwemM+EAzVB9UOXAqsXZ4FHkt1SSJVrTVwgxvXpCfmF6LZPhbz6nvouRWGsC/GdWjrHtdW5lEOvS27qKEL5rXwQ0o+71ZICGo8j4E0GOHXyi857qZhvO7cbOnts+iiawXiWzPXv2gGGabuqPwcU8JPEoWdaiIaeGUczfjBU=
- secure: fvPVjj3l+TZ7HF5aGn/pmrkipGIrz+MkKNy3I7pnCJSuD/oVp9nQ5ePP/dAhaRThaW+fQbq7hOmCquPAtfoN9CUnHNV2f2l9RavDQIxdqvpXqY13A0BFffZho6A6H2kO7k6kQQPQEhl4SMJjObnX12/YDaTVx3b7aIroEJ8DyY62xGTsjExtaAksuFwUEekjh0MoWICvyBoDfrYhpiEVI2721rGMHu7FIXwmE38+jj7wwZd3Bp37yI9NY/b3ZQ/HUKyYDuoAL0xl5/GaQlRepD0v2xWQUQ40NArHLfMoscXi55UaENuswCg7rt9os8jCcZ8FkZf1cVsQ71JrE0uxgs00Jfjy2QKM5u1XUZefl1Nw5cfCDTWXIEGsz9OGiidFLehWUupX/6C6wr1BStdlRt+6Pt/FXsYHxO/qog++cKqHjOJRXi+raGAb99HhQ/hLnLUMKl5DIWlKF9DImXiOpfYxrgCJc3y91vNX6noJyWYs6PvErMukTsXFHen+fM0NtfTFoKW682oILvXjoeFvuzKpk49+rcpkJbRi5+Zdo/duSPp/flwvC4LOMi0RZOO9TNMhWKdkyWweDr1HEpvQn6RS87rpHzQwRDvm85F+PkZLMMqyWpuxBWbJf0jVbew21KvTJWamuizsIgCebFh0SSxgObzmMbAIFCkzL0PRsms=
- ANDROID_HOME=$HOME/android-sdk
before_install:
- openssl aes-256-cbc -K $encrypted_53968681344a_key -iv $encrypted_53968681344a_iv -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
install:
# Download and unzip the Android command line tools (if not already there thanks to the cache mechanism)
# Latest version of this file available here: https://developer.android.com/studio/#command-tools
- if test ! -e $HOME/android-cmdline-tools/cmdline-tools.zip ; then curl https://dl.google.com/android/repository/commandlinetools-linux-6609375_latest.zip > $HOME/android-cmdline-tools/cmdline-tools.zip ; fi
- unzip -qq -n $HOME/android-cmdline-tools/cmdline-tools.zip -d $HOME/android-cmdline-tools
# Install or update Android SDK components (will not do anything if already up to date thanks to the cache mechanism)
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platform-tools' > /dev/null
# Latest version of build-tools available here: https://developer.android.com/studio/releases/build-tools.html
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'build-tools;29.0.3' > /dev/null
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platforms;android-29' > /dev/null
script:
- "./gradlew lintFossDebug testFossDebugUnitTest lintGoogleDebug testGoogleDebugUnitTest"
- "./gradlew assembleRelease"
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
cache:
directories:
- "$HOME/.gradle/caches/"
- "$HOME/.gradle/wrapper/"
- "$HOME/.android/build-cache"
- "$HOME/android-cmdline-tools"
- "$HOME/android-sdk"
deploy:
provider: releases
api_key:
secure: "XQR4GUrGkPKYVV0xMbJifX/ewKAnenBPlM/pPacQ9irAmYNYa/yEkySz4x1K6MP8cEnuJbxHFakcDqhNRCqD7Cq2NcnCi3qtTEXHK6ApLoVl/92eyiWxu/bYlidOEZb+YPcVNtTR253NiI8GYda+CrhLd4uCmsAgES+XPFJd/t2esMlDOSAp7xalZv/zFhhlB9+SevfPFMc6kkrqeHpKnMs9SK8ltVQmh3nch2KjtDvqgDW6d3nuwn7/HAer6/HY86hmA4Rh6Mo2cV6OloX0bdJ7hvA1GOT4p3+K3lWbTRxzE0o1DXAtT7+D158iKvxHFPuF3h+CTjSlLeiss6kQZL9nFjw/KhAvu+GJOp37PcMoI++mpMiFoWPlzKpp17BVKIDinYbgi8kiU4zG+QHhe2cY85SbfAplXUaysq7uzxEZwEUYHSAHNahshVooXRqvuzkthcH0/nvinfeXrzx2xDvQ3if1NENMRgttwewU0kvU61iKUwpcf/UN2bHK3DaPes0VzSH4PTHAGjoRpksDfqUwb7S8YxbYr+44aMbSPYN8Lbjda0BxPSKWwHM5/pi7FBJN1a1w3t7sV/EiACWUWr8OovmX4ljyCybbR0w9cPzRC1zAYeSUHslLXMTW2Pp9h594RnYh3q3VfeYlFCikFvuvrafwXmTkz35uhLb+2ws="
file:
- app/build/outputs/apk/foss/release/app-foss-release.apk
- app/build/outputs/apk/google/release/app-google-release.apk
on:
repo: johan12345/EVMap
tags: true
skip_cleanup: 'true'

View File

@@ -113,7 +113,7 @@ GEM
http-cookie (1.0.3)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.1)
jmespath (1.4.0)
json (2.3.1)
jwt (2.2.1)
memoist (0.16.2)

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2023 Johan von Forstner and contributors
Copyright (c) 2020-2021 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://travis-ci.org/johan12345/EVMap.svg?branch=master)](https://travis-ci.org/johan12345/EVMap)
=====
<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.
@@ -20,8 +19,8 @@ Features
- Search for places
- Advanced filtering options, including saved filter profiles
- Favorites list, also with availability information
- Integrated price comparison using [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Android Auto & Android Automotive OS integration
- Integrated price comparison using Chargeprice.app [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Android Auto integration
- No ads, fully open source
- Compatible with Android 5.0 and above
- Can use Google Maps or Mapbox (OpenStreetMap) as map backends - the version available on F-Droid only uses Mapbox.
@@ -29,37 +28,38 @@ 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/01_main.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/02_detail.png" width=250 alt="Screenshot 2"/>
Development setup
-----------------
The App is developed using Android Studio and should pretty much work out-of-the-box when you clone
the Git repository and open the project with Android Studio.
The App is developed using Android Studio.
The only exception is that you need to obtain some free API keys for the different data sources that
EVMap uses and put them into the app in the form of a resource file called `apikeys.xml` under
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
features and how they can be obtained in our [documentation page](doc/api_keys.md).
For testing the app, you need to obtain free API Keys for the
[GoingElectric API](https://www.goingelectric.de/stromtankstellen/api/),
the [Chargeprice API](https://github.com/chargeprice/chargeprice-api-docs),
the [OpenChargeMap API](https://openchargemap.org/site/profile/appedit),
as well as for [Google APIs](https://console.developers.google.com/)
("Maps SDK for Android" and "Places API" need to be activated) and/or [Mapbox](https://www.mapbox.com/). These API keys need to be put into the
app in the form of a resource file called `apikeys.xml` under `app/src/main/res/values`, with the
following content:
There are three different build flavors, `googleNormal`, `fossNormal` and `googleAutomotive`.
- The `foss` variant only uses Mapbox data and should run on most Android devices, even without
Google Play Services.
- The `google` variants also include access to Google Maps data.
- `googleNormal` is intended to run on smartphones and tablets, and also includes the Android
Auto app for use on the car display.
- `googleAutomotive` variant is intended to be installed directly on car infotainment systems
using the Google-flavored Android Automotive OS. It does not provide the usual smartphone UI.
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
app.
Translations
------------
You can use our [Weblate page](https://hosted.weblate.org/projects/evmap/) to help translate EVMap
into new languages.
<a href="https://hosted.weblate.org/engage/evmap/">
<img src="https://hosted.weblate.org/widgets/evmap/-/open-graph.png" width="500" alt="Translation status" />
</a>
```xml
<resources>
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">
insert your Google Maps key here
</string>
<string name="mapbox_key" translatable="false">
insert your Mapbox key here
</string>
<string name="goingelectric_key" translatable="false">
insert your GoingElectric key here
</string>
<string name="chargeprice_key" translatable="false">
insert your Chargeprice key here
</string>
<string name="openchargemap_key" translatable="false">
insert your OpenChargeMap key here
</string>
</resources>
```

View File

Binary file not shown.

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,23 +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 120 120" style="enable-background:new 0 0 120 120;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<g>
<path class="st0"
d="M27.1,88.3l-2.2-19.2l-3.3,0.3l2.2,19.2L27.1,88.3z M39,86.9l-2.2-19.2l-3.3,0.3l2.2,19.2L39,86.9z" />
<path class="st0" d="M45.2,113c-1,1.3-1.8,2.1-2,2.2c-3,2.4-5.4,3.1-7.4,2.2c-3.5-1.7-3.2-8.2-3.1-8.9l2.4,0.1
c-0.1,1.8,0.2,5.8,1.8,6.6c0.9,0.5,2.5-0.1,4.6-1.8l0,0c0,0,6.7-6.7,5.3-12c-1.6-6.4,5.8-15.5,8.2-18.6l0.3-0.3l2,1.5l-0.3,0.5
c-7.5,9.2-8.3,14-7.7,16.4C50.5,105.4,47.4,110.4,45.2,113z" />
<path class="st0" d="M19.7,88.1l0.9,7.9l7.3,4.9l9.8-1l6-6.4l-0.9-7.9L19.7,88.1z" />
<g>
<path class="st0"
d="M37.6,99.7l-9.8,1l2.1,8.7l7.7-0.9V99.7L37.6,99.7z M44.6,79l0.8,7.2l-28.2,3.2l-0.8-7.2L44.6,79z" />
</g>
</g>
<path class="st0" d="M66.7,0C46.5,0,30.1,16.4,30.1,36.6c0,27.6,30.8,42,34.5,81.4c0.1,1.2,1,2,2.2,2c1.2,0,2.1-0.8,2.2-2
c3.7-39.4,34.5-53.8,34.5-81.4C103.3,16.2,86.9,0,66.7,0z M78.4,34.7L64.3,59V40.8h-6V18.7c0,0,20.2,0,20.1-0.1l-8.1,16.2H78.4z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 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,25 +1,31 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 23.0.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 233.8 368.4" style="enable-background:new 0 0 233.8 368.4;" xml:space="preserve">
viewBox="0 0 233.8 368.4" style="enable-background:new 0 0 233.8 368.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#808080;}
.st1{display:none;}
.st2{display:inline;fill:#802C27;}
.st3{fill:#808080;}
.st4{display:none;fill:#802C27;}
</style>
<g>
<g>
<path class="st0" d="M117,367.4c-0.4-0.3-0.8-0.6-1.2-0.9c-1.6-1.2-3.1-2.3-4.2-3.7c-2.9-26.9-9.6-51.7-20.1-74
c-12.4-27.3-30.1-52.4-47.1-75.8c-8.7-12-19.8-27.9-28.8-45.2C2.3,143.6-2.1,115.9,3.2,89.9c4.3-20.4,15-40,30.3-55.2
C53.6,15.1,81.5,2.8,109.9,1l13.5,0c34.4,1.9,66.9,18.9,86.9,45.4c12.8,16.3,20.8,37.5,22.5,59.8l0,8
c-0.7,38.8-23.7,70.9-45.9,101.9c-1.7,2.3-3.3,4.6-5,6.9c-24.4,34.5-50.3,76.1-57.3,123.3c-0.5,2-0.7,4.3-0.9,6.5
C123.3,359,122.8,364.9,117,367.4z" />
<path class="st1" d="M123.3,2c34.1,1.9,66.3,18.8,86.2,45c12.6,16.1,20.5,37.1,22.3,59.1l0,8c-0.7,38.5-23.6,70.5-45.7,101.3
c-1.7,2.3-3.3,4.6-5,6.9c-24.5,34.6-50.5,76.3-57.4,123.7c-0.5,2.1-0.7,4.4-0.9,6.7c-0.5,5.9-1,11-5.8,13.4
c-0.2-0.2-0.5-0.4-0.7-0.5c-1.5-1.1-2.9-2-3.8-3.3c-2.9-26.9-9.7-51.8-20.1-74C80,261,62.3,235.8,45.2,212.4
c-8.7-11.9-19.8-27.8-28.8-45.1C3.3,143.3-1,115.9,4.2,90.1c4.2-20.2,14.9-39.6,30-54.7C54.2,16,81.7,3.8,109.9,2H123.3 M123.4,0
h-13.6c-28.7,1.8-56.5,14-77,34C17.6,49.1,6.6,68.6,2.2,89.7c-5.4,26.7-0.5,54.8,12.5,78.6C23,184.2,33,199,43.6,213.6
c17.5,24,34.7,48.5,47,75.6c10.9,23.2,17.3,48.4,20,73.9c1.5,2.3,4,3.7,6.2,5.4c9.3-3.5,7.1-14.3,8.9-22
c6.7-45.6,30.9-85.9,57.1-122.9c23.3-32.8,50.2-67.3,51-109.4v-8.1c-1.7-21.7-9.2-43.1-22.7-60.3C190.5,18.5,157.3,1.9,123.4,0
L123.4,0z" />
<g>
<path class="st0" d="M109.8,0h13.6c33.9,1.9,67.1,18.5,87.7,45.8c13.5,17.2,21,38.6,22.7,60.3v8.1c-0.8,42.1-27.7,76.6-51,109.4
c-26.2,37-50.4,77.3-57.1,122.9c-1.8,7.7,0.4,18.5-8.9,22c-2.2-1.7-4.7-3.1-6.2-5.4c-2.7-25.5-9.1-50.7-20-73.9
c-12.3-27.1-29.5-51.6-47-75.6C33,199,23,184.2,14.7,168.3c-13-23.8-17.9-51.9-12.5-78.6c4.4-21.1,15.4-40.6,30.6-55.7
C53.3,14,81.1,1.8,109.8,0z" />
</g>
</g>
<g class="st1">
<path class="st2" d="M107.2,74.1c18.9-4.8,40.4,5.5,47.7,23.7c6.1,14.5,1.9,32.5-9.9,42.9c-12.6,11.5-32.4,14-47.5,6
c-13.9-6.8-23-22.6-21.3-38.1C77.6,92,91.1,77.7,107.2,74.1z" />
</g>
</g>
<path class="st1"
d="M90.9,57.3v68.2h18.6v55.8l43.4-74.4h-24.8l24.8-49.6C152.9,57.3,90.9,57.3,90.9,57.3z" />
<path class="st3" d="M90.9,57.3v68.2h18.6v55.8l43.4-74.4h-24.8l24.8-49.6H90.9z" />
<path class="st4" d="M159,85.3L159,85.3l-20.8-20.9l-5.9,5.9l11.8,11.8c-5.3,2-9,7.1-9,13.1c0,7.7,6.3,14,14,14c2,0,3.9-0.4,5.6-1.2
v40.4c0,3.1-2.5,5.6-5.6,5.6s-5.6-2.5-5.6-5.6v-25.2c0-6.2-5-11.2-11.2-11.2h-5.6V72.8c0-6.2-5-11.2-11.2-11.2H81.8
c-6.2,0-11.2,5-11.2,11.2v89.7h56.1v-42.1h8.4v28c0,7.7,6.3,14,14,14s14-6.3,14-14V95.2C163.1,91.3,161.6,87.8,159,85.3
M149.1,100.8c-3.1,0-5.6-2.5-5.6-5.6c0-3.1,2.5-5.6,5.6-5.6s5.6,2.5,5.6,5.6C154.7,98.3,152.2,100.8,149.1,100.8 M93.1,145.6v-25.2
H81.8l22.4-42.1v28h11.2L93.1,145.6z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,28 +1,27 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 23.0.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 233.8 368.4" style="enable-background:new 0 0 233.8 368.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#808080;}
.st2{fill:#B5B5B5;}
.st1{fill:#B5B5B5;}
.st2{fill:#808080;}
</style>
<g>
<path class="st0" d="M117,367.4c-0.4-0.3-0.8-0.6-1.2-0.9c-1.6-1.2-3.1-2.3-4.2-3.7c-2.9-26.9-9.6-51.7-20.1-74
c-12.4-27.3-30.1-52.4-47.1-75.8c-8.7-12-19.8-27.9-28.8-45.2C2.3,143.6-2.1,115.9,3.2,89.9c4.3-20.4,15-40,30.3-55.2
C53.6,15.1,81.5,2.8,109.9,1l13.5,0c34.4,1.9,66.9,18.9,86.9,45.4c12.8,16.3,20.8,37.5,22.5,59.8l0,8
c-0.7,38.8-23.7,70.9-45.9,101.9c-1.7,2.3-3.3,4.6-5,6.9c-24.4,34.5-50.3,76.1-57.3,123.3c-0.5,2-0.7,4.3-0.9,6.5
C123.3,359,122.8,364.9,117,367.4z" />
<path class="st1" d="M123.3,2c34.1,1.9,66.3,18.8,86.2,45c12.6,16.1,20.5,37.1,22.3,59.1l0,8c-0.7,38.5-23.6,70.5-45.7,101.3
c-1.7,2.3-3.3,4.6-5,6.9c-24.5,34.6-50.5,76.3-57.4,123.7c-0.5,2.1-0.7,4.4-0.9,6.7c-0.5,5.9-1,11-5.8,13.4
c-0.2-0.2-0.5-0.4-0.7-0.5c-1.5-1.1-2.9-2-3.8-3.3c-2.9-26.9-9.7-51.8-20.1-74C80,261,62.3,235.8,45.2,212.4
c-8.7-11.9-19.8-27.8-28.8-45.1C3.3,143.3-1,115.9,4.2,90.1c4.2-20.2,14.9-39.6,30-54.7C54.2,16,81.7,3.8,109.9,2H123.3 M123.4,0
h-13.6c-28.7,1.8-56.5,14-77,34C17.6,49.1,6.6,68.6,2.2,89.7c-5.4,26.7-0.5,54.8,12.5,78.6C23,184.2,33,199,43.6,213.6
c17.5,24,34.7,48.5,47,75.6c10.9,23.2,17.3,48.4,20,73.9c1.5,2.3,4,3.7,6.2,5.4c9.3-3.5,7.1-14.3,8.9-22
c6.7-45.6,30.9-85.9,57.1-122.9c23.3-32.8,50.2-67.3,51-109.4v-8.1c-1.7-21.7-9.2-43.1-22.7-60.3C190.5,18.5,157.3,1.9,123.4,0
L123.4,0z" />
<g>
<g>
<path class="st0" d="M109.8,0h13.6c33.9,1.9,67.1,18.5,87.7,45.8c13.5,17.2,21,38.6,22.7,60.3v8.1c-0.8,42.1-27.7,76.6-51,109.4
c-26.2,37-50.4,77.3-57.1,122.9c-1.8,7.7,0.4,18.5-8.9,22c-2.2-1.7-4.7-3.1-6.2-5.4c-2.7-25.5-9.1-50.7-20-73.9
c-12.3-27.1-29.5-51.6-47-75.6C33,199,23,184.2,14.7,168.3c-13-23.8-17.9-51.9-12.5-78.6C6.6,68.6,17.6,49.1,32.8,34
C53.3,14,81.1,1.8,109.8,0z" />
</g>
</g>
</g>
<g>
<polygon class="st1"
points="143.2,109.4 123.5,143.2 123.5,181.3 166.9,106.9 144.7,106.9 " />
<path class="st1"
d="M122.2,101.9h16.7h5.7l22.3-44.6c0,0-10.2,0-22.4,0l-1.1,2.2L122.2,101.9z" />
<path class="st2" d="M138.9,57.3c-9.7,0-19.8,0-26.4,0c-2.5,0-5.1,0-7.6,0c-8.2,0-16.1,0-21.4,0c-4.1,0-6.6,0-6.6,0v68.2h18.6v55.8
l43.4-74.4h-24.8L138.9,57.3z" />
</g>
<polygon class="st2" points="143.2,109.4 123.5,143.2 123.5,181.3 166.9,106.9 144.7,106.9 " />
<path class="st2" d="M122.2,101.9h16.7h5.7l22.3-44.6c0,0-10.2,0-22.4,0l-1.1,2.2L122.2,101.9z" />
<path class="st1" d="M138.9,57.3c-9.7,0-19.8,0-26.4,0c-2.5,0-5.1,0-7.6,0c-8.2,0-16.1,0-21.4,0c-4.1,0-6.6,0-6.6,0v68.2h18.6v55.8
l43.4-74.4h-24.8L138.9,57.3z" />
</svg>

Before

Width:  |  Height:  |  Size: 2.0 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: 194 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 886 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1005 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -1,43 +1,33 @@
plugins {
id 'com.adarshr.test-logger' version '3.1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlin-kapt'
apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
apply plugin: 'pt.jcosta.resourceplaceholders'
def supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
android {
compileSdkVersion 33
compileSdkVersion 30
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 33
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 188
versionName "1.6.4"
targetSdkVersion 30
versionCode 53
versionName "0.9.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(',')
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
}
signingConfigs {
release {
def isRunningOnCI = System.getenv("CI") == "true"
if (isRunningOnCI) {
def isRunningOnTravis = System.getenv("CI") == "true"
if (isRunningOnTravis) {
// configure keystore
storeFile = file("../_ci/keystore.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEYSTORE_ALIAS")
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
storePassword = System.getenv("keystore_password")
keyAlias = System.getenv("keystore_alias")
keyPassword = System.getenv("keystore_alias_password")
}
}
}
@@ -54,7 +44,7 @@ android {
}
}
flavorDimensions "dependencies", "automotive"
flavorDimensions "dependencies"
productFlavors {
foss {
dimension "dependencies"
@@ -63,22 +53,6 @@ android {
dimension "dependencies"
versionNameSuffix "-google"
}
normal {
dimension "automotive"
}
automotive {
dimension "automotive"
versionNameSuffix "-automotive"
versionCode defaultConfig.versionCode + 1
minSdkVersion 29
}
}
variantFilter { variant ->
def names = variant.flavors*.name
// Android Automotive OS app is always based on Google variant
if (names.contains("automotive") && !names.contains("google")) {
setIgnore(true)
}
}
compileOptions {
@@ -91,29 +65,10 @@ 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
}
lint {
disable 'NullSafeMutableLiveData'
warning 'MissingTranslation'
}
testOptions {
unitTests.includeAndroidResources true
}
resourcePlaceholders {
files = ['xml/shortcuts.xml']
}
namespace 'net.vonforst.evmap'
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all { variant ->
@@ -130,13 +85,10 @@ android {
variant.resValue "string", "openchargemap_key", openchargemapKey
}
def googleMapsKey = env.GOOGLE_MAPS_API_KEY ?: project.findProperty("GOOGLE_MAPS_API_KEY")
if (googleMapsKey != null && variant.flavorName.startsWith('google')) {
if (googleMapsKey != null && variant.flavorName == 'google') {
variant.resValue "string", "google_maps_key", googleMapsKey
}
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(project.findProperty("MAPBOX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (mapboxKey != null) {
variant.resValue "string", "mapbox_key", mapboxKey
}
@@ -147,144 +99,105 @@ 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
}
}
packagingOptions {
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
}
}
configurations {
googleNormalImplementation {}
googleAutomotiveImplementation {}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation "androidx.activity:activity-ktx:1.7.2"
implementation "androidx.fragment:fragment-ktx:1.5.7"
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'androidx.core:core-ktx:1.5.0'
implementation "androidx.activity:activity-ktx:1.2.3"
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.browser:browser:1.5.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation "androidx.work:work-runtime-ktx:2.8.1"
implementation 'com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.recyclerview:recyclerview:1.2.0'
implementation 'androidx.browser:browser:1.3.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
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 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.12.0'
implementation 'com.squareup.moshi:moshi-adapters:1.12.0'
implementation 'moe.banana:moshi-jsonapi:3.5.0'
implementation 'moe.banana:moshi-jsonapi-retrofit-converter:3.5.0'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
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'
implementation 'com.airbnb.android:lottie:3.4.0'
implementation 'io.michaelrocks.bimap:bimap:1.1.0'
implementation 'com.mapzen.android:lost:3.0.2'
implementation 'com.google.guava:guava:29.0-android'
implementation 'com.github.pengrad:mapscaleview:1.6.0'
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
def carAppVersion = '1.3.0-rc01'
googleImplementation "androidx.car.app:app:$carAppVersion"
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
googleImplementation 'androidx.car.app:app:1.0.0'
// AnyMaps
def anyMapsVersion = '8f1226e1c5'
implementation "com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion"
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
def anyMapsVersion = '95ddd6c083'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
// Google Maps v3 Beta
googleImplementation 'com.google.android.libraries.maps:maps:3.1.0-beta'
googleImplementation name:'places-maps-sdk-3.1.0-beta', ext:'aar'
googleImplementation 'com.android.volley:volley:1.2.0'
googleImplementation 'com.google.android.gms:play-services-base:17.5.0'
googleImplementation 'com.google.android.gms:play-services-basement:17.5.0'
googleImplementation 'com.google.android.gms:play-services-gcm:17.0.0'
googleImplementation 'com.google.android.gms:play-services-location:17.1.0'
googleImplementation 'com.google.android.gms:play-services-tasks:17.2.0'
googleImplementation 'com.google.auto.value:auto-value-annotations:1.6.3'
googleImplementation 'com.google.code.gson:gson:2.8.6'
googleImplementation 'com.google.android.datatransport:transport-runtime:2.2.5'
googleImplementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// Mapbox places (autocomplete)
// forked this library and included through JitPack to fix https://github.com/mapbox/mapbox-plugins-android/issues/1011
implementation('com.github.johan12345.mapbox-plugins-android:mapbox-android-plugin-places-v9:922bf877f6') {
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
exclude group: 'com.google.android.gms', module: 'play-services-location'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-core'
}
// original version of mapbox-android-core
googleImplementation 'com.mapbox.mapboxsdk:mapbox-android-core:2.0.1'
// patched version that removes build-time dependency on GMS (-> no Google location services)
fossImplementation 'com.github.ev-map:mapbox-events-android:a21c324501'
// Google Places
googleImplementation 'com.google.android.libraries.places:places:3.1.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4'
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
// navigation library
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.6.1"
def lifecycle_version = "2.3.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.5.1"
def room_version = "2.3.0"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation 'com.github.anboralabs:spatia-room:0.2.7'
// billing library
def billing_version = "6.0.0"
def billing_version = "4.0.0"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
// ACRA (crash reporting)
def acraVersion = "5.8.4"
implementation("ch.acra:acra-mail:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
debugImplementation 'com.facebook.flipper:flipper:0.190.0'
debugImplementation 'com.facebook.soloader:soloader:0.10.5'
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.190.0'
// testing
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
implementation 'com.facebook.stetho:stetho:1.5.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'
testImplementation 'junit:junit:4.13'
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
testImplementation 'org.robolectric:robolectric:4.9.2'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'org.robolectric:robolectric:4.9.2'
testGoogleImplementation 'androidx.test:core:1.5.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.12.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.arch.core:core-testing:2.2.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
}
private static String decode(String s, String key) {
@@ -297,4 +210,4 @@ private static byte[] xorWithKey(byte[] a, byte[] key) {
out[i] = (byte) (a[i] ^ key[i%key.length]);
}
return out;
}
}

View File

Binary file not shown.

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,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

@@ -1,107 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="198.3471"
android:viewportHeight="198.3471">
<group
android:translateX="3.1735537"
android:translateY="3.1735537">
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:pathData="M106.2,74.3h-7"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#212121"
android:fillAlpha="0.2" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:pathData="M106.2,60.3c0,0 -17.5,0 -17.5,0"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#212121"
android:fillAlpha="0.2" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:pathData="M93.9,79.5L88.7,79.5"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:fillAlpha="0.2" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:strokeWidth="1"
android:pathData="M94,79v16.2"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:strokeColor="#212121"
android:fillAlpha="0.2"
android:strokeLineCap="round" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:strokeWidth="1"
android:pathData="M106.2,60.3L99.2,74.3"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:fillAlpha="0.2"
android:strokeLineCap="round" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:strokeWidth="1"
android:pathData="M106.2,74.3L93.9,95.2"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:fillAlpha="0.2"
android:strokeLineCap="round" />
</group>
<path
android:pathData="M67.6,120.6L65.7,104l-2.9,0.3l1.9,16.6L67.6,120.6zM77.9,119.4l-1.9,-16.6l-2.9,0.3l1.9,16.6L77.9,119.4z"
android:fillColor="#808080" />
<path
android:pathData="M83.3,142c-0.9,1.1 -1.6,1.8 -1.7,1.9c-2.6,2.1 -4.7,2.7 -6.4,1.9c-3,-1.5 -2.8,-7.1 -2.7,-7.7l2.1,0.1c-0.1,1.6 0.2,5 1.6,5.7c0.8,0.4 2.2,-0.1 4,-1.6l0,0c0,0 5.8,-5.8 4.6,-10.4c-1.4,-5.5 5,-13.4 7.1,-16.1l0.3,-0.3l1.7,1.3l-0.3,0.4c-6.5,8 -7.2,12.1 -6.7,14.2C87.9,135.4 85.2,139.7 83.3,142z"
android:fillColor="#9e9e9e" />
<path
android:pathData="M61.2,120.4l0.8,6.8l6.3,4.2l8.5,-0.9l5.2,-5.5l-0.8,-6.8L61.2,120.4z"
android:fillColor="#9e9e9e" />
<path
android:pathData="M76.7,130.5l-8.5,0.9l1.8,7.5l6.7,-0.8L76.7,130.5L76.7,130.5zM82.8,112.5l0.7,6.2l-24.4,2.8l-0.7,-6.2L82.8,112.5z"
android:fillColor="#666666" />
<path
android:pathData="M101.9,44.1c-17.5,0 -31.7,14.2 -31.7,31.7c0,23.9 26.7,36.4 29.9,70.5c0.1,1 0.9,1.7 1.9,1.7s1.8,-0.7 1.9,-1.7c3.2,-34.1 29.9,-46.6 29.9,-70.5C133.6,58.2 119.4,44.1 101.9,44.1z"
android:fillColor="#737373" />
<path
android:pathData="M101.9,44.8c17.4,0 31.5,14 31.7,31.3c0,-0.1 0,-0.2 0,-0.3c0,-17.5 -14.2,-31.7 -31.7,-31.7S70.2,58.2 70.2,75.8c0,0.1 0,0.2 0,0.3C70.4,58.8 84.5,44.8 101.9,44.8L101.9,44.8z"
android:fillColor="#FFFFFF"
android:fillAlpha="0.2" />
<path
android:pathData="M103.8,145.5c-0.1,1 -0.9,1.7 -1.9,1.7s-1.8,-0.7 -1.9,-1.7c-3.1,-34 -29.6,-46.5 -29.8,-70.1c0,0.2 0,0.3 0,0.5c0,23.9 26.7,36.4 29.9,70.5c0.1,1 0.9,1.7 1.9,1.7s1.8,-0.7 1.9,-1.7c3.2,-34.1 29.9,-46.6 29.9,-70.5c0,-0.2 0,-0.3 0,-0.5C133.4,99 106.9,111.5 103.8,145.5L103.8,145.5z"
android:fillColor="#303030"
android:fillAlpha="0.2" />
<path
android:fillColor="#FF000000"
android:pathData="M94.6,60.3v19.2h5.2v15.7l12.2,-21h-7l7,-14C112.1,60.3 94.6,60.3 94.6,60.3z"
android:strokeAlpha="0.45"
android:fillAlpha="0.45" />
</group>
</vector>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chargeprice_api_url">https://staging-api.chargeprice.app/v1/</string>
<string name="chargeprice_key">20c0d68918c9dc96c564784b711a6570</string>
</resources>

View File

@@ -1,5 +0,0 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
fun getAutocompleteProviders(context: Context) = listOf(MapboxAutocompleteProvider(context))

View File

@@ -8,7 +8,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.FragmentDonateBinding
@@ -16,17 +15,11 @@ import net.vonforst.evmap.databinding.FragmentDonateBinding
class DonateFragment : Fragment() {
private lateinit var binding: FragmentDonateBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
): View? {
binding = FragmentDonateBinding.inflate(inflater, container, false)
return binding.root
}
@@ -34,8 +27,9 @@ class DonateFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
val navController = findNavController()
binding.toolbar.setupWithNavController(
findNavController(),
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)

View File

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

View File

@@ -23,7 +23,7 @@
<Button
android:id="@+id/btnDonate"
style="@style/Widget.Material3.Button.Icon"
style="@style/Widget.MaterialComponents.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"

View File

@@ -1,6 +1,8 @@
<?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.</string>
<string-array name="pref_map_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<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 30% Gebühren ab.</string>
<string name="donate_paypal">Mit PayPal spenden</string>
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap (Mapbox).</string>
</resources>

View File

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

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donate_paypal">Doner med PayPal</string>
<string name="data_sources_hint">Kartdata i programmet tilbys av OpenStreetMap (Mapbox).</string>
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende en slant til utvikleren.</string>
</resources>

View File

@@ -1,6 +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

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

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
</resources>

View File

@@ -1,6 +0,0 @@
<?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.</string>
<string name="donate_paypal">Donate with PayPal</string>
<string name="data_sources_hint">Map data in the app is provided by OpenStreetMap (Mapbox).</string>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_map_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
<string name="donate_paypal">Donate with PayPal</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
</resources>

View File

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

View File

@@ -1,18 +1,12 @@
<?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.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
<uses-sdk tools:overrideLibrary="androidx.car.app,androidx.car.app.projected" />
<queries>
<package android:name="com.google.android.projection.gearhead" />
<package android:name="com.google.android.apps.automotive.templates.host" />
</queries>
<uses-sdk tools:overrideLibrary="androidx.car.app" />
<application>
<meta-data
@@ -27,10 +21,6 @@
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"
@@ -39,15 +29,15 @@
<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" />
android:category="androidx.car.app.category.CHARGING" />
</intent-filter>
</service>
<service
android:name=".auto.CarLocationService"
android:foregroundServiceType="location"
android:enabled="true" />
<activity android:name=".auto.PermissionActivity" />
</application>
</manifest>

View File

@@ -5,12 +5,10 @@ import android.content.Context
import android.util.Log
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.maps.MapsInitializer
import com.google.android.libraries.places.api.Places
fun init(context: Context) {
Places.initialize(context, context.getString(R.string.google_maps_key))
MapsInitializer.initialize(context, MapsInitializer.Renderer.LATEST, null)
Places.initialize(context, context.getString(R.string.google_maps_key));
}
fun checkPlayServices(activity: Activity): Boolean {

View File

@@ -1,86 +1,28 @@
package net.vonforst.evmap.auto
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.content.*
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.location.Location
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.car.app.CarContext
import android.os.IBinder
import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.Session
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.model.*
import androidx.car.app.validation.HostValidator
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
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 androidx.lifecycle.*
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.*
import net.vonforst.evmap.*
interface LocationAwareScreen {
fun updateLocation(location: Location)
}
@ExperimentalCarApi
@androidx.car.app.annotations.ExperimentalCarApi
class CarAppService : androidx.car.app.CarAppService() {
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
override fun onCreate() {
super.onCreate()
// we want to run as a foreground service to make sure we can use location
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
}
private fun createNotificationChannel() {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Android O requires a Notification Channel.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name: CharSequence = getString(R.string.app_name)
// Create the channel for the notification
val mChannel =
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
// Set the Notification Channel for the Notification Manager.
notificationManager.createNotificationChannel(mChannel)
}
}
private fun getNotification(): Notification {
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentText(getString(R.string.auto_location_service))
.setContentTitle(getString(R.string.app_name))
.setOngoing(true)
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
.setSmallIcon(R.drawable.ic_appicon_notification)
.setColor(ContextCompat.getColor(this, R.color.colorPrimary))
.setTicker(getString(R.string.auto_location_service))
.setWhen(System.currentTimeMillis())
return builder.build()
}
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
@@ -96,25 +38,26 @@ class CarAppService : androidx.car.app.CarAppService() {
}
}
@ExperimentalCarApi
class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver {
private val TAG = "EVMapSession"
@androidx.car.app.annotations.ExperimentalCarApi
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
var mapScreen: LocationAwareScreen? = null
set(value) {
field = value
location?.let { value?.updateLocation(it) }
}
private var location: Location? = null
private val locationEngine: LocationEngine by lazy {
FusionEngine(carContext)
}
private var locationService: CarLocationService? = null
private val hardwareMan: CarHardwareManager by lazy {
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, ibinder: IBinder) {
val binder: CarLocationService.LocalBinder = ibinder as CarLocationService.LocalBinder
locationService = binder.service
locationService?.requestLocationUpdates()
}
private val prefs: PreferenceDataSource by lazy {
PreferenceDataSource(carContext)
override fun onServiceDisconnected(name: ComponentName?) {
locationService = null
}
}
init {
@@ -122,150 +65,59 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
}
override fun onCreateScreen(intent: Intent): Screen {
handleActionsIntent(intent)
val mapScreen = MapScreen(carContext, this)
val screens = mutableListOf<Screen>(mapScreen)
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
)
)
)
return if (locationPermissionGranted()) {
WelcomeScreen(carContext, this)
} else {
PermissionScreen(carContext, this)
}
}
if (screens.size > 1) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
for (i in 0 until screens.size - 1) {
screenManager.push(screens[i])
private fun locationPermissionGranted() =
ContextCompat.checkSelfPermission(
carContext,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
private val locationReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location?
val mapScreen = this@EVMapSession.mapScreen
if (location != null && mapScreen != null) {
mapScreen.updateLocation(location)
}
this@EVMapSession.location = location
}
return screens.last()
}
private fun handleActionsIntent(intent: Intent): Boolean {
intent.data?.let {
if (it.host == "find_charger") {
val lat = it.getQueryParameter("latitude")?.toDouble()
val lon = it.getQueryParameter("longitude")?.toDouble()
val name = it.getQueryParameter("name")
if (lat != null && lon != null) {
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
return true
} else if (name != null) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
screenManager.push(PlaceSearchScreen(carContext, this, name))
return true
}
}
}
return false
}
override fun onNewIntent(intent: Intent) {
handleActionsIntent(intent)
}
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
private fun updateLocation(location: Location?) {
Log.d(TAG, "Received location: $location")
val mapScreen = mapScreen
if (location != null && mapScreen != null) {
mapScreen.updateLocation(location)
}
this.location = location
}
override fun onStart(owner: LifecycleOwner) {
requestLocationUpdates()
}
override fun onStop(owner: LifecycleOwner) {
removeLocationUpdates()
}
@SuppressLint("MissingPermission")
fun requestLocationUpdates() {
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun bindLocationService() {
if (!locationPermissionGranted()) return
Log.i(TAG, "Requesting location updates")
requestCarHardwareLocationUpdates()
requestPhoneLocationUpdates()
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION])
private fun requestCarHardwareLocationUpdates() {
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carSensors.addCarHardwareLocationListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCarHardwareLocationReceived
)
}
}
private val phoneLocationListener = LocationListenerCompat {
this.updateLocation(it)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
private fun requestPhoneLocationUpdates() {
val location = locationEngine.getLastKnownLocation()
updateLocation(location)
locationEngine.requestLocationUpdates(
Priority.HIGH_ACCURACY,
1000,
phoneLocationListener
cas.bindService(
Intent(cas, CarLocationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
@SuppressLint("MissingPermission")
private fun removeLocationUpdates() {
if (!locationPermissionGranted()) return
removeCarHardwareLocationUpdates()
removePhoneLocationUpdates()
}
private fun removeCarHardwareLocationUpdates() {
if (supportsCarApiLevel3(carContext)) {
hardwareMan.carSensors.removeCarHardwareLocationListener(::onCarHardwareLocationReceived)
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun unbindLocationService() {
locationService?.let { service ->
service.removeLocationUpdates()
cas.unbindService(serviceConnection)
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
private fun removePhoneLocationUpdates() {
locationEngine.removeUpdates(phoneLocationListener)
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun registerBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).registerReceiver(
locationReceiver,
IntentFilter(CarLocationService.ACTION_BROADCAST)
);
}
@SuppressLint("MissingPermission")
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
if (loc.location.status == CarValue.STATUS_SUCCESS && loc.location.value != null) {
updateLocation(loc.location.value)
// we successfully received a location from the car hardware,
// so we don't need the smartphone location anymore.
removePhoneLocationUpdates()
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun unregisterBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).unregisterReceiver(locationReceiver)
}
}

View File

@@ -0,0 +1,163 @@
package net.vonforst.evmap.auto
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.location.Location
import android.os.*
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.gms.location.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
class CarLocationService : Service() {
private lateinit var serviceHandler: Handler
private lateinit var locationRequest: LocationRequest
private lateinit var notificationManager: NotificationManager
private lateinit var locationCallback: LocationCallback
private lateinit var fusedLocationClient: FusedLocationProviderClient
private val binder: IBinder = LocalBinder(this)
private var location: Location? = null
private val UPDATE_INTERVAL_IN_MILLISECONDS = 10000L
private val FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS = UPDATE_INTERVAL_IN_MILLISECONDS / 2
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
private val TAG = "CarLocationService"
companion object {
const val ACTION_BROADCAST: String = BuildConfig.APPLICATION_ID + ".car_location_broadcast"
const val EXTRA_LOCATION: String = BuildConfig.APPLICATION_ID + ".location"
}
override fun onCreate() {
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
super.onLocationResult(locationResult)
onNewLocation(locationResult.lastLocation)
}
}
createLocationRequest()
getLastLocation()
val handlerThread = HandlerThread(TAG)
handlerThread.start()
serviceHandler = Handler(handlerThread.looper)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Android O requires a Notification Channel.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name: CharSequence = getString(R.string.app_name)
// Create the channel for the notification
val mChannel =
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
// Set the Notification Channel for the Notification Manager.
notificationManager.createNotificationChannel(mChannel)
}
startForeground(NOTIFICATION_ID, getNotification())
}
/**
* Returns the [NotificationCompat] used as part of the foreground service.
*/
private fun getNotification(): Notification {
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentText(getString(R.string.auto_location_service))
.setContentTitle(getString(R.string.app_name))
.setOngoing(true)
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
.setSmallIcon(R.mipmap.ic_launcher)
.setTicker(getString(R.string.auto_location_service))
.setWhen(System.currentTimeMillis())
return builder.build()
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
private fun createLocationRequest() {
locationRequest = LocationRequest()
locationRequest.interval = UPDATE_INTERVAL_IN_MILLISECONDS
locationRequest.fastestInterval = FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS
locationRequest.priority = LocationRequest.PRIORITY_HIGH_ACCURACY
}
private fun onNewLocation(location: Location) {
Log.i(TAG, "New location: $location")
this.location = location
// Notify anyone listening for broadcasts about the new location.
val intent = Intent(ACTION_BROADCAST)
intent.putExtra(EXTRA_LOCATION, location)
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
}
private fun getLastLocation() {
try {
fusedLocationClient.lastLocation
.addOnCompleteListener { task ->
if (task.isSuccessful && task.result != null) {
location = task.result
} else {
Log.w(TAG, "Failed to get location.")
}
}
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission.$unlikely")
}
}
/**
* Makes a request for location updates. Note that in this sample we merely log the
* [SecurityException].
*/
fun requestLocationUpdates() {
Log.i(TAG, "Requesting location updates")
startService(Intent(applicationContext, CarLocationService::class.java))
try {
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback, Looper.myLooper()
)
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission. Could not request updates. $unlikely")
}
}
/**
* Removes location updates. Note that in this sample we merely log the
* [SecurityException].
*/
fun removeLocationUpdates() {
Log.i(TAG, "Removing location updates")
try {
fusedLocationClient.removeLocationUpdates(locationCallback)
stopSelf()
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission. Could not remove updates. $unlikely")
}
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
Log.i(TAG, "Service started")
// Tells the system to not try to recreate the service after it has been killed.
return START_NOT_STICKY
}
override fun onDestroy() {
serviceHandler.removeCallbacksAndMessages(null)
}
class LocalBinder(val service: CarLocationService) : Binder()
}

View File

@@ -1,22 +0,0 @@
package net.vonforst.evmap.auto
/**
* This file lists known mappings between the vehicle model provided by Android Auto's CarInfo API
* and human-readable vehicle models as listed by Chargeprice in their vehicle database.
*/
private val models = mapOf(
"Audi" to mapOf(
"516 (G4x)" to "e-tron"
),
"Renault" to mapOf(
"BCB" to "Megane E-Tech"
)
)
fun getVehicleModel(manufacturer: String?, model: String?) =
if (manufacturer != null && model != null) {
models[manufacturer]?.get(model) ?: model
} else {
null
}

View File

@@ -1,140 +0,0 @@
package net.vonforst.evmap.auto
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import androidx.car.app.CarContext
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.common.OnCarDataAvailableListener
import androidx.car.app.hardware.info.*
import androidx.car.app.hardware.info.CarSensors.UpdateRate
import net.vonforst.evmap.BuildConfig
import java.util.concurrent.Executor
/**
* CarSensors is not yet implemented for Android Automotive OS
* (see docs at https://developer.android.com/reference/androidx/car/app/hardware/info/CarSensors)
* so we provide our own implementation based on SensorManager APIs.
*/
val CarContext.patchedCarSensors: CarSensors
get() = if (BuildConfig.FLAVOR_automotive != "automotive") {
(this.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carSensors
} else {
CarSensorsWrapper(this)
}
class CarSensorsWrapper(carContext: CarContext) :
CarSensors {
private val sensorManager = carContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val compassListeners: MutableMap<OnCarDataAvailableListener<Compass>, SensorEventListener> =
mutableMapOf()
override fun addAccelerometerListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<Accelerometer>
) {
TODO("Not yet implemented")
}
override fun removeAccelerometerListener(listener: OnCarDataAvailableListener<Accelerometer>) {
TODO("Not yet implemented")
}
override fun addGyroscopeListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<Gyroscope>
) {
TODO("Not yet implemented")
}
override fun removeGyroscopeListener(listener: OnCarDataAvailableListener<Gyroscope>) {
TODO("Not yet implemented")
}
override fun addCompassListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<Compass>
) {
val magSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
val accSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (magSensor == null) {
executor.execute {
listener.onCarDataAvailable(Compass(CarValue(null, 0, CarValue.STATUS_UNAVAILABLE)))
}
return
}
val sensorListener = object : SensorEventListener {
var magValues: FloatArray? = null
// AAOS cars may not provide an acceleration sensor, so we assume acceleration based on
// Earth's gravity. May not be correct when driving on other planets.
var accValues = floatArrayOf(0f, 0f, SensorManager.GRAVITY_EARTH)
val rotMatrix = FloatArray(9)
val orientation = FloatArray(3)
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor) {
magSensor -> magValues = event.values
accSensor -> accValues = event.values
}
if (magValues == null) return
SensorManager.getRotationMatrix(rotMatrix, null, accValues, magValues)
SensorManager.getOrientation(rotMatrix, orientation)
val compassDegrees = orientation.map { Math.toDegrees(it.toDouble()).toFloat() }
executor.execute {
listener.onCarDataAvailable(
Compass(
CarValue(
compassDegrees,
event.timestamp,
CarValue.STATUS_SUCCESS
)
)
)
}
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
}
}
compassListeners[listener] = sensorListener
sensorManager.registerListener(sensorListener, magSensor, mapRate(rate))
accSensor?.let { sensorManager.registerListener(sensorListener, it, mapRate(rate)) }
}
private fun mapRate(@UpdateRate rate: Int): Int {
return when (rate) {
CarSensors.UPDATE_RATE_NORMAL -> SensorManager.SENSOR_DELAY_NORMAL
CarSensors.UPDATE_RATE_UI -> SensorManager.SENSOR_DELAY_UI
CarSensors.UPDATE_RATE_FASTEST -> SensorManager.SENSOR_DELAY_FASTEST
else -> throw IllegalArgumentException()
}
}
override fun removeCompassListener(listener: OnCarDataAvailableListener<Compass>) {
compassListeners[listener]?.let {
sensorManager.unregisterListener(it)
}
}
override fun addCarHardwareLocationListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<CarHardwareLocation>
) {
TODO("Not yet implemented")
}
override fun removeCarHardwareLocationListener(listener: OnCarDataAvailableListener<CarHardwareLocation>) {
TODO("Not yet implemented")
}
}

View File

@@ -1,372 +0,0 @@
package net.vonforst.evmap.auto
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.Model
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Dispatchers
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)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
}
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 var errorMessage: String? = null
private val batteryRange = prefs.chargepriceBatteryRangeAndroidAuto
override fun onGetTemplate(): Template {
if (prices == null) loadData()
return ListTemplate.Builder().apply {
setTitle(
carContext.getString(
R.string.chargeprice_battery_range,
batteryRange[0],
batteryRange[1]
) + " · " + carContext.getString(R.string.powered_by_chargeprice)
)
setHeaderAction(Action.BACK)
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
)
}"
}
}
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)
}
}
}
setActionStrip(
ActionStrip.Builder().addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
).setOnClickListener {
openUrl(carContext, ChargepriceApi.getPoiUrl(charger))
}.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
} else {
return price.tariffName
}
}
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,
currency(price.currency)
)
val kwhPrice = if (amount > 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,
currency(price.currency)
)
} else null
val monthlyFees = if (price.totalMonthlyFee > 0 || price.monthlyMinSales > 0) {
price.formatMonthlyFees(carContext)
} else null
var text = totalPrice
if (kwhPrice != null && monthlyFees != null) {
text += " ($kwhPrice, $monthlyFees)"
} else if (kwhPrice != null) {
text += " ($kwhPrice)"
} else if (monthlyFees != null) {
text += " ($monthlyFees)"
}
return text
}
private fun loadData() {
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
val hardwareMan =
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
hardwareMan.carInfo.fetchModel(exec) { model ->
loadPrices(model)
}
} else {
loadPrices(null)
}
}
private fun loadPrices(model: Model?) {
val dataAdapter = ChargepriceApi.getDataAdapter(charger)
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,
station = cpStation,
vehicle = car,
options = ChargepriceOptions(
batteryRange = batteryRange.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
showPriceUnavailable = true
),
relationships = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
Relationships(
"tariffs" to Relationship.ToMany(
myTariffs.map {
ResourceIdentifier(
"tariff",
id = it
)
},
meta = Meta.from(
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS),
ChargepriceApi.moshi
)
)
)
} else null
), ChargepriceApi.getChargepriceLanguage()
)
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]
if (chargepoint == null) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
val metaMapped =
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.maxByOrNull { it.power }
prices = result.data!!.mapNotNull { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
}
if (filteredPrices.isEmpty()) {
null
} else {
cp.copy(
chargepointPrices = filteredPrices
)
}
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariffId in myTariffs
}
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.chargeprice_connection_error,
CarToast.LENGTH_LONG
)
.show()
}
} catch (e: NoVehicleSelectedException) {
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
invalidate()
} catch (e: VehicleUnknownException) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_unknown,
manufacturer,
modelName
)
invalidate()
} catch (e: VehicleAmbiguousException) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_ambiguous,
manufacturer,
modelName
)
invalidate()
} catch (e: VehicleUnavailableException) {
errorMessage =
carContext.getString(R.string.auto_chargeprice_vehicle_unavailable)
invalidate()
}
}
}
private class NoVehicleSelectedException : Exception()
private class VehicleUnknownException : Exception()
private class VehicleAmbiguousException : Exception()
private class VehicleUnavailableException : Exception()
private suspend fun determineVehicle(
manufacturer: String?,
modelName: String?
): ChargepriceCar {
var vehicles = api.getVehicles().filter {
it.id in prefs.chargepriceMyVehicles
}
if (vehicles.isEmpty()) {
throw NoVehicleSelectedException()
} else if (vehicles.size > 1) {
if (manufacturer != null) {
vehicles = vehicles.filter {
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())
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()
} else if (vehicles.size > 1) {
throw VehicleAmbiguousException()
}
} else {
throw VehicleAmbiguousException()
}
}
} else {
throw VehicleUnavailableException()
}
}
return vehicles[0]
}
}

View File

@@ -1,53 +1,41 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.*
import android.graphics.Bitmap
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
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.scale
import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
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.roundToInt
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
@@ -55,25 +43,17 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
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 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
private val iconGen =
ChargerIconGenerator(carContext, null, height = imageSize)
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
private val largeImageSupported =
ctx.carAppApiLevel >= 4 // since API 4, Row.setImage is supported
private var favorite: Favorite? = null
private var favoriteUpdateJob: Job? = null
private val iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64)
init {
loadCharger()
referenceData.observe(this) {
loadCharger()
}
}
override fun onGetTemplate(): Template {
@@ -82,326 +62,117 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
return PaneTemplate.Builder(
Pane.Builder().apply {
charger?.let { charger ->
if (largeImageSupported) {
photo?.let {
setImage(CarIcon.Builder(IconCompat.createWithBitmap(it)).build())
}
}
generateRows(charger).forEach { addRow(it) }
addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
addRow(Row.Builder().apply {
setTitle(charger.address.toString())
val icon = iconGen.getBitmap(
tint = getMarkerTint(charger),
fault = charger.faultReport != null,
multi = charger.isMulti()
)
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(icon)).build(),
Row.IMAGE_TYPE_LARGE
)
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
if (i > 0) chargepointsText.append(" · ")
chargepointsText.append(
"${cp.count}× ${
nameForPlugType(
carContext.stringProvider(),
cp.type
)
).build()
} ${cp.formatPower()}"
)
.setTitle(carContext.getString(R.string.navigate))
.setFlags(Action.FLAG_PRIMARY)
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
addText(chargepointsText)
}.build())
addRow(Row.Builder().apply {
photo?.let {
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
Row.IMAGE_TYPE_LARGE
)
}
val operatorText = StringBuilder().apply {
charger.operator?.let { append(it) }
charger.network?.let {
if (isNotEmpty()) append(" · ")
append(it)
}
}.ifEmpty {
carContext.getString(R.string.unknown_operator)
}
setTitle(operatorText)
charger.cost?.let { addText(it.getStatusText(carContext, emoji = true)) }
charger.faultReport?.created?.let {
addText(
carContext.getString(
R.string.auto_fault_report_date,
it.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
}
/*val types = charger.chargepoints.map { it.type }.distinct()
if (types.size == 1) {
setImage(
CarIcon.of(IconCompat.createWithResource(carContext, iconForPlugType(types[0]))),
Row.IMAGE_TYPE_ICON)
}*/
}.build())
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).build()
)
.setTitle(carContext.getString(R.string.navigate))
.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))
.setOnClickListener {
screenManager.push(ChargepriceScreen(carContext, charger))
}
.build())
}
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, charger.id)
.putExtra(EXTRA_LAT, charger.coordinates.lat)
.putExtra(EXTRA_LON, charger.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
} ?: setLoading(true)
}.build()
).apply {
setTitle(chargerSparse.name)
setHeaderAction(Action.BACK)
charger?.let { charger ->
setActionStrip(
ActionStrip.Builder().apply {
if (BuildConfig.FLAVOR_automotive != "automotive") {
// show "Open in app" action if not running on Android Automotive
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, chargerSparse.id)
.putExtra(EXTRA_LAT, chargerSparse.coordinates.lat)
.putExtra(EXTRA_LON, chargerSparse.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
}
// show fav action
addAction(Action.Builder()
.setOnClickListener {
favorite?.let {
deleteFavorite(it)
} ?: run {
insertFavorite(charger)
}
}
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (favorite != null) {
R.drawable.ic_fav
} else {
R.drawable.ic_fav_no
}
)
)
.setTint(CarColor.DEFAULT).build()
)
.build())
.build()
}.build()
)
}
}.build()
}
private fun insertFavorite(charger: ChargeLocation) {
if (favoriteUpdateJob?.isCompleted == false) return
favoriteUpdateJob = lifecycleScope.launch {
db.chargeLocationsDao().insert(charger)
val fav = Favorite(
chargerId = charger.id,
chargerDataSource = charger.dataSource
)
val id = db.favoritesDao().insert(fav)[0]
favorite = fav.copy(favoriteId = id)
invalidate()
}
}
private fun deleteFavorite(fav: Favorite) {
if (favoriteUpdateJob?.isCompleted == false) return
favoriteUpdateJob = lifecycleScope.launch {
db.favoritesDao().delete(fav)
favorite = null
invalidate()
}
}
private fun generateRows(charger: ChargeLocation): List<Row> {
val rows = mutableListOf<Row>()
val photo = photo
// Row 1: address + chargepoints
rows.add(Row.Builder().apply {
setTitle(charger.address.toString())
if (photo == null) {
// show just the icon
val icon = iconGen.getBitmap(
tint = getMarkerTint(charger),
fault = charger.faultReport != null,
multi = charger.isMulti()
)
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(icon)).build(),
Row.IMAGE_TYPE_LARGE
)
} else if (!largeImageSupported) {
// show the photo with icon
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
Row.IMAGE_TYPE_LARGE
)
}
addText(generateChargepointsText(charger))
}.build())
if (maxRows <= 3) {
// row 2: operator + cost + fault report
rows.add(Row.Builder().apply {
if (photo != null && !largeImageSupported) {
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
Row.IMAGE_TYPE_LARGE
)
}
val operatorText = generateOperatorText(charger)
setTitle(operatorText)
charger.cost?.let { addText(generateCostStatusText(it)) }
charger.faultReport?.let { fault ->
addText(generateFaultReportTitle(fault))
}
}.build())
} else {
// row 2: operator + cost + cost description
rows.add(Row.Builder().apply {
if (photo != null && !largeImageSupported) {
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
Row.IMAGE_TYPE_LARGE
)
}
val operatorText = generateOperatorText(charger)
setTitle(operatorText)
charger.cost?.let {
addText(generateCostStatusText(it))
it.getDetailText()?.let { addText(it) }
}
}.build())
// row 3: fault report (if exists)
charger.faultReport?.let { fault ->
rows.add(Row.Builder().apply {
setTitle(generateFaultReportTitle(fault))
fault.description?.let {
addText(
HtmlCompat.fromHtml(
it.replace("\n", " · "),
HtmlCompat.FROM_HTML_MODE_LEGACY
)
)
}
}.build())
}
// row 4: opening hours + location description
charger.openinghours?.let { hours ->
val title =
hours.getStatusText(carContext).ifEmpty { carContext.getString(R.string.hours) }
rows.add(Row.Builder().apply {
setTitle(title)
hours.description?.let { addText(it) }
charger.locationDescription?.let { addText(it) }
}.build())
}
}
return rows
}
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
)
} else {
append(nameForPlugType(carContext.stringProvider(), cp.type))
}
append(" ")
append(cp.formatPower())
}
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
return chargepointsText
}
private fun generateOperatorText(charger: ChargeLocation) =
if (charger.operator != null && charger.network != null) {
if (charger.operator.contains(charger.network)) {
charger.operator
} else if (charger.network.contains(charger.operator)) {
charger.network
} else {
"${charger.operator} · ${charger.network}"
}
} else if (charger.operator != null) {
charger.operator
} else if (charger.network != null) {
charger.network
} else {
carContext.getString(R.string.unknown_operator)
}
private fun navigateToCharger(charger: ChargeLocation) {
val coord = charger.coordinates
val intent =
@@ -413,60 +184,22 @@ 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()
charger = response.data!!
val photo = charger.photos?.firstOrNull()
val photo = charger?.photos?.firstOrNull()
photo?.let {
val density = carContext.resources.displayMetrics.density
val size =
(density * if (largeImageSupported) imageSizeLarge else imageSize).roundToInt()
val size = (carContext.resources.displayMetrics.density * 64).roundToInt()
val url = photo.getUrl(size = size)
val request = ImageRequest.Builder(carContext).data(url).build()
val img =
this@ChargerDetailScreen.photo =
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
// draw icon on top of image
val icon = iconGen.getBitmap(
tint = getMarkerTint(charger),
fault = charger.faultReport != null,
multi = charger.isMulti()
)
val outImg = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
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
)
canvas.drawBitmap(
iconSmall,
0f,
(size - iconSmall.height * 1.1).toFloat(),
null
)
this@ChargerDetailScreen.photo = outImg
}
invalidate()
availability = availabilityRepo.getAvailability(charger).data
availability = charger?.let { getAvailability(it).data }
invalidate()
} else {

View File

@@ -1,603 +1,90 @@
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 android.graphics.Bitmap
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.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.*
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.FilterViewModel
import kotlin.math.roundToInt
@androidx.car.app.annotations.ExperimentalCarApi
class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
class FilterScreen(ctx: CarContext) : 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 = 6
private val checkIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check)).build()
private val emptyIcon: CarIcon
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
private var page = 0
init {
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
emptyIcon = CarIcon.Builder(
IconCompat.createWithBitmap(
Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888
)
)
).build()
}
init {
filterProfiles.observe(this) {
val filterStatus = prefs.filterStatus
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
page = 0
} else {
page = paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
}
invalidate()
}
}
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.take(maxRows), prefs.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,
R.drawable.ic_edit
)
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
lifecycleScope.launch {
db.filterValueDao()
.copyFiltersToCustom(filterStatus, prefs.dataSource)
screenManager.push(EditFiltersScreen(carContext))
}
})
}.build())
}.build()
)
}.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 {
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())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.no_filters))
if (FILTERS_DISABLED == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyIcon)
}
}
paginatedProfiles[page].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) }
}.build())
}
if (page < paginatedProfiles.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()
}
private fun onItemClick(id: Long) {
prefs.filterStatus = id
screenManager.pop()
}
}
@androidx.car.app.annotations.ExperimentalCarApi
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)
}
init {
paginatedFilters.observe(this) {
vm.filterProfile.observe(this) {
invalidate()
}
}
}
override fun onGetTemplate(): Template {
val currentProfileName = vm.filterProfile.value?.name
return ListTemplate.Builder().apply {
paginatedFilters.value?.let { paginatedFilters ->
setSingleList(buildFiltersList(paginatedFilters))
} ?: setLoading(true)
var title = currentProfileName?.let {
carContext.getString(
R.string.edit_filter_profile,
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)
setHeaderAction(Action.BACK)
setActionStrip(ActionStrip.Builder().apply {
val currentProfile = vm.filterProfile.value
if (currentProfile != null) {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_delete
)
).build()
)
setOnClickListener {
lifecycleScope.launch {
vm.deleteCurrentProfile()
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
currentProfile.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
screenManager.pop()
}
}
}.build())
setOnClickListener {
prefs.filterStatus = FILTERS_DISABLED
screenManager.pop()
}
addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_save
)
).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
}
invalidate()
}
.build()
)
}
.build())
}.build()
}
private fun buildFiltersList(paginatedFilters: List<FilterValues>): ItemList {
return ItemList.Builder().apply {
if (page > 0) {
}.build())
profiles.forEach {
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
)
setTitle(it.name)
if (it.id == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyIcon)
}
setOnClickListener {
page -= 1
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())
}
paginatedFilters[page].forEach {
val filter = it.filter
val value = it.value
addItem(Row.Builder().apply {
setTitle(filter.name)
when (filter) {
is BooleanFilter -> {
setToggle(Toggle.Builder {
(value as BooleanFilterValue).value = it
lifecycleScope.launch { vm.saveFilterValues() }
}.setChecked((value as BooleanFilterValue).value).build())
}
is MultipleChoiceFilter -> {
setBrowsable(true)
setOnClickListener {
screenManager.pushForResult(
MultipleChoiceFilterScreen(
carContext,
filter,
value as MultipleChoiceFilterValue
)
) {
lifecycleScope.launch { vm.saveFilterValues() }
}
}
addText(
if ((value as MultipleChoiceFilterValue).all) {
carContext.getString(R.string.all_selected)
} else {
carContext.getString(
R.string.number_selected,
value.values.size
)
}
)
}
is SliderFilter -> {
setBrowsable(true)
addText((value as SliderFilterValue).value.toString() + " " + filter.unit)
setOnClickListener {
screenManager.pushForResult(
SliderFilterScreen(
carContext,
filter,
value
)
) {
lifecycleScope.launch { vm.saveFilterValues() }
}
}
}
}
}.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()
}
prefs.filterStatus = it.id
screenManager.pop()
}
}.build())
}
setNoItemsMessage(carContext.getString(R.string.filterprofiles_empty_state))
}.build()
}
}
class MultipleChoiceFilterScreen(
ctx: CarContext,
val filter: MultipleChoiceFilter,
val value: MultipleChoiceFilterValue
) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
override val isMultiSelect = true
override val shouldShowSelectAll = true
override fun isSelected(it: Pair<String, String>): Boolean =
value.all || value.values.contains(it.first)
override fun toggleSelected(item: Pair<String, String>) {
if (isSelected(item)) {
val values = if (value.all) filter.choices.keys else value.values
value.values = values.minus(item.first).toMutableSet()
value.all = false
} else {
value.values.add(item.first)
if (value.values == filter.choices.keys) {
value.all = true
}
}
}
override fun selectAll() {
value.all = true
super.selectAll()
}
override fun selectNone() {
value.all = false
value.values = mutableSetOf()
super.selectNone()
}
override fun getLabel(it: Pair<String, String>): String = it.second
override suspend fun loadData(): List<Pair<String, String>> {
return filter.choices.entries.map { it.toPair() }
}
}
class SliderFilterScreen(
ctx: CarContext,
val filter: SliderFilter,
val value: SliderFilterValue
) : Screen(ctx) {
override fun onGetTemplate(): Template {
return PaneTemplate.Builder(
Pane.Builder().apply {
addRow(Row.Builder().apply {
setTitle(filter.name)
addText(value.value.toString() + " " + filter.unit)
addText(generateSlider())
}.build())
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener(::decrease)
}.build())
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener(::increase)
}.build())
}.build()
).apply {
setHeaderAction(Action.BACK)
}.build()
}
private fun generateSlider(): CharSequence {
val bar = ""
val dot = ""
val length = 30
val position =
((filter.inverseMapping(value.value) - filter.min) / (filter.max - filter.min).toDouble() * length).roundToInt()
val text = SpannableStringBuilder()
text.append(
bar.repeat(position),
ForegroundCarColorSpan.create(CarColor.SECONDARY),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
text.append(
dot,
ForegroundCarColorSpan.create(CarColor.SECONDARY),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
text.append(bar.repeat(length - position))
return text
}
private fun increase() {
var valueInternal = filter.inverseMapping(value.value)
if (valueInternal < filter.max) valueInternal += 1
value.value = filter.mapping(valueInternal)
invalidate()
}
private fun decrease() {
var valueInternal = filter.inverseMapping(value.value)
if (valueInternal > filter.min) valueInternal -= 1
value.value = filter.mapping(valueInternal)
invalidate()
}
}

View File

@@ -1,289 +1,151 @@
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.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.MutableLiveData
import androidx.lifecycle.lifecycleScope
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.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
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.getFilters
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
/**
* Main map screen showing either nearby chargers or favorites
*/
@androidx.car.app.annotations.ExperimentalCarApi
class MapScreen(ctx: CarContext, val session: EVMapSession) :
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
ItemList.OnItemVisibilityChangedListener, DefaultLifecycleObserver {
companion object {
val MARKER = "map"
}
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
Screen(ctx), LocationAwareScreen {
private var updateCoroutine: Job? = null
private var availabilityUpdateCoroutine: Job? = null
private var visibleStart: Int? = null
private var visibleEnd: Int? = null
private var numUpdates = 0
private val maxNumUpdates = 3
private var location: Location? = null
private var lastDistanceUpdateTime: Instant? = null
private var lastChargersUpdateTime: Instant? = null
private var lastUpdateLocation: Location? = null
private var chargers: List<ChargeLocation>? = 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 updateThreshold = 2000 // meters
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?>> =
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 = 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.takeUnless { it == FILTERS_CUSTOM } ?: FILTERS_DISABLED
}
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",
"android.car.permission.CAR_ENERGY_PORTS",
"android.car.permission.READ_CAR_DISPLAY_UNITS",
)
} else {
listOf(
"com.google.android.gms.permission.CAR_FUEL"
)
}
private var searchLocation: LatLng? = null
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
private val filters = api.getFilters(referenceData, carContext.stringProvider())
private val filtersWithValue = filtersWithValue(filters, filterValues)
init {
lifecycle.addObserver(this)
marker = MARKER
filtersWithValue.observe(this) {
loadChargers()
}
}
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
prefs.placeSearchResultAndroidAutoName?.let {
carContext.getString(R.string.auto_chargers_near_location, it)
} ?: carContext.getString(
if (filterStatus == FILTERS_FAVORITES) {
carContext.getString(
if (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())
}
}
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).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
val showCity = chargerList.map { it.address.city }.distinct().size > 1
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger, showCity))
}
builder.setNoItemsMessage(
carContext.getString(
if (filterStatus == FILTERS_FAVORITES) {
if (favorites) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
}
)
)
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 {
setHeaderAction(Action.BACK)
if (!favorites) {
val filtersCount = filtersWithValue.value?.count {
!it.value.hasSameValueAs(it.filter.defaultValue())
}
}
setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_settings
)
).setTint(CarColor.DEFAULT).build()
setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_filter
)
)
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
)
.setOnClickListener {
screenManager.push(SettingsScreen(carContext, session))
screenManager.pushForResult(FilterScreen(carContext)) {
chargers = null
numUpdates = 0
filterStatus.value = prefs.filterStatus
}
session.mapScreen = null
}
.build())
.addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (prefs.placeSearchResultAndroidAuto != null) {
R.drawable.ic_search_off
} else {
R.drawable.ic_search
}
)
).build()
)
setOnClickListener {
if (prefs.placeSearchResultAndroidAuto != null) {
prefs.placeSearchResultAndroidAutoName = null
prefs.placeSearchResultAndroidAuto = null
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
chargers = null
loadChargers()
}
} else {
chargers = null
loadChargers()
}
} else {
screenManager.pushForResult(
PlaceSearchScreen(
carContext,
session
)
) {
chargers = null
loadChargers()
}
session.mapScreen = null
}
}
}.build())
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_filter
)
)
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
)
.setOnClickListener {
screenManager.push(FilterScreen(carContext, session))
session.mapScreen = null
}
.build())
.build())
if (carContext.carAppApiLevel >= 5 ||
(BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
) {
setOnContentRefreshListener(this@MapScreen)
}
build()
}.build()
}
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 {
getMarkerTint(charger)
}
val color = ContextCompat.getColor(carContext, markerTint)
val color = ContextCompat.getColor(carContext, getMarkerTint(charger))
val place =
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
.setMarker(
@@ -296,7 +158,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
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
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
if (showCity && charger.address.city != null && charger.address.city !in charger.name) {
setTitle(
CarText.Builder("${charger.name} · ${charger.address.city}")
.addVariant(charger.name)
@@ -309,34 +171,26 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// distance
location?.let {
val distanceMeters = distanceBetween(
val distance = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
)
) / 1000
text.append(
"distance",
DistanceSpan.create(
roundValueToDistance(
distanceMeters,
energyLevel?.distanceDisplayUnit?.value
)
),
DistanceSpan.create(Distance.create(distance, Distance.UNIT_KILOMETERS)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
// power
val power = charger.maxPower
if (power != null) {
if (text.isNotEmpty()) text.append(" · ")
text.append("${power.roundToInt()} kW")
}
if (text.isNotEmpty()) text.append(" · ")
text.append("${charger.maxPower.roundToInt()} kW")
// availability
availabilities[charger.id]?.second?.let { av ->
val status = av.status.values.flatten()
val available = availabilityText(status)
val total = charger.chargepoints.sumOf { it.count }
val total = charger.chargepoints.sumBy { it.count }
if (text.isNotEmpty()) text.append(" · ")
text.append(
@@ -355,270 +209,105 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
setOnClickListener {
screenManager.push(ChargerDetailScreen(carContext, charger))
session.mapScreen = null
}
}.build()
}
override fun updateLocation(location: Location) {
if (location.latitude == this.location?.latitude
&& location.longitude == this.location?.longitude
) {
return
}
val previousLocation = this.location
this.location = location
if (previousLocation == null) {
loadChargers()
if (updateCoroutine != null) {
// don't update while still loading last update
return
}
val now = Instant.now()
if (lastDistanceUpdateTime == null ||
Duration.between(lastDistanceUpdateTime, now) > distanceUpdateThreshold
) {
lastDistanceUpdateTime = now
// update displayed distances
invalidate()
}
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)
if (lastUpdateLocation == null ||
location.distanceTo(lastUpdateLocation) > updateThreshold
) {
onContentRefreshRequested()
lastUpdateLocation = location
// update displayed chargers
loadChargers()
}
}
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
numUpdates++
println(numUpdates)
if (numUpdates > maxNumUpdates) {
CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
.show()
return
}
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)
// load chargers
if (filterStatus == FILTERS_FAVORITES) {
chargers =
db.favoritesDao().getAllFavoritesAsync().map { it.charger }.sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
if (favorites) {
chargers = db.chargeLocationsDao().getAllChargeLocationsAsync().sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
} 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,
LatLng.fromLocation(location),
searchRadius,
zoom = 16f,
filters
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < 6) {
// try again with larger radius
val response = api.getChargepointsRadius(
referenceData,
LatLng.fromLocation(location),
searchRadius * 10,
zoom = 16f,
filters
)
}
if (chargers == null || chargers.size >= maxRows) {
break
chargers =
response.data?.filterIsInstance(ChargeLocation::class.java)
}
}
this@MapScreen.chargers = chargers
}
// remove outdated availabilities
availabilities = availabilities.filter {
Duration.between(
it.value.first,
ZonedDateTime.now()
) > availabilityUpdateThreshold
}.toMutableMap()
// update availabilities
chargers?.take(maxRows)?.map {
lifecycleScope.async {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
val date = ZonedDateTime.now()
val availability = getAvailability(it).data
if (availability != null) {
availabilities[it.id] = date to availability
}
}
}
}?.awaitAll()
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()
}
private fun setupListeners() {
val exec = ContextCompat.getMainExecutor(carContext)
if (supportsCarApiLevel3(carContext)) {
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCompassUpdated
)
}
if (!permissions.all {
ContextCompat.checkSelfPermission(
carContext,
it
) == PackageManager.PERMISSION_GRANTED
})
return
if (supportsCarApiLevel3(carContext)) {
println("Setting up energy level listener")
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
}
override fun onStop(owner: LifecycleOwner) {
// Reloading chargers in onStart does not seem to count towards content limit.
// So let's do this so the user gets fresh chargers when re-entering the app.
// Deleting the data already in onStop makes sure that we show a loading screen directly
// (i.e. onGetTemplate is not called while the old data is still there)
chargers = null
availabilities.clear()
location = null
removeListeners()
}
private fun removeListeners() {
if (supportsCarApiLevel3(carContext)) {
println("Removing energy level listener")
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
carSensors.removeCompassListener(::onCompassUpdated)
}
}
override fun onContentRefreshRequested() {
loadChargers()
availabilities.clear()
val start = visibleStart
val end = visibleEnd
if (start != null && end != null) {
onItemVisibilityChanged(start, end)
}
}
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
// when the list is scrolled, load corresponding availabilities
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
if (startIndex == -1 || endIndex == -1) return
if (availabilityUpdateCoroutine != null) return
visibleEnd = endIndex
visibleStart = startIndex
// remove outdated availabilities
availabilities = availabilities.filter {
Duration.between(
it.value.first,
ZonedDateTime.now()
) <= availabilityUpdateThreshold
}.toMutableMap()
// update availabilities
availabilityUpdateCoroutine = lifecycleScope.launch {
delay(300L)
val chargers = chargers ?: return@launch
if (chargers.isEmpty()) return@launch
val tasks = chargers.subList(
min(startIndex, chargers.size - 1),
min(endIndex, chargers.size - 1)
).mapNotNull {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
lifecycleScope.async {
val availability = availabilityRepo.getAvailability(it).data
val date = ZonedDateTime.now()
availabilities[it.id] = date to availability
}
} else null
}
if (tasks.isNotEmpty()) {
tasks.awaitAll()
invalidate()
}
availabilityUpdateCoroutine = null
}
}
}

View File

@@ -0,0 +1,72 @@
package net.vonforst.evmap.auto
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.ResultReceiver
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
class PermissionActivity : Activity() {
companion object {
const val EXTRA_RESULT_RECEIVER = "result_receiver";
const val RESULT_GRANTED = "granted"
}
private lateinit var resultReceiver: ResultReceiver
private val permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
private val requestCode = 1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent != null) {
resultReceiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER)!!
if (!hasPermissions(permissions)) {
ActivityCompat.requestPermissions(this, permissions, requestCode)
} else {
onComplete(
requestCode,
permissions,
intArrayOf(PackageManager.PERMISSION_GRANTED)
)
}
} else {
finish()
}
}
private fun onComplete(requestCode: Int, permissions: Array<String>?, grantResults: IntArray) {
val bundle = Bundle()
bundle.putBoolean(
RESULT_GRANTED,
grantResults.all { it == PackageManager.PERMISSION_GRANTED })
resultReceiver.send(requestCode, bundle)
finish()
}
private fun hasPermissions(permissions: Array<String>): Boolean {
var result = true
for (permission in permissions) {
if (ContextCompat.checkSelfPermission(
this,
permission
) != PackageManager.PERMISSION_GRANTED
) {
result = false
break
}
}
return result
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
onComplete(requestCode, permissions, grantResults)
}
}

View File

@@ -1,22 +1,21 @@
package net.vonforst.evmap.auto
import androidx.annotation.StringRes
import android.content.Intent
import android.os.Bundle
import android.os.ResultReceiver
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.*
import net.vonforst.evmap.R
/**
* Screen to grant permission
* Screen to grant location permission
*/
class PermissionScreen(
ctx: CarContext,
@StringRes val message: Int,
val permissions: List<String>,
val finishApp: Boolean = true
) : Screen(ctx) {
@androidx.car.app.annotations.ExperimentalCarApi
class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(message))
return MessageTemplate.Builder(carContext.getString(R.string.auto_location_permission_needed))
.setTitle(carContext.getString(R.string.app_name))
.setHeaderAction(Action.APP_ICON)
.addAction(
@@ -24,7 +23,32 @@ class PermissionScreen(
.setTitle(carContext.getString(R.string.grant_on_phone))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener(ParkedOnlyOnClickListener.create {
requestPermissions()
val intent = Intent(carContext, PermissionActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(
PermissionActivity.EXTRA_RESULT_RECEIVER,
object : ResultReceiver(null) {
override fun onReceiveResult(
resultCode: Int,
resultData: Bundle?
) {
if (resultData!!.getBoolean(PermissionActivity.RESULT_GRANTED)) {
session.bindLocationService()
screenManager.push(
WelcomeScreen(
carContext,
session
)
)
}
}
})
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
@@ -32,26 +56,10 @@ class PermissionScreen(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
if (finishApp) {
carContext.finishCarApp()
} else {
// pop twice to get away from the screen that requires the permission
screenManager.pop()
screenManager.pop()
}
carContext.finishCarApp()
}
.build(),
)
.build()
}
private fun requestPermissions() {
carContext.requestPermissions(permissions) { granted, _ ->
if (granted.containsAll(permissions)) {
screenManager.pop()
} else {
requestPermissions()
}
}
}
}

View File

@@ -1,241 +0,0 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
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.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.iconForPlaceType
import net.vonforst.evmap.adapter.isSpecialPlace
import net.vonforst.evmap.autocomplete.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.RecentAutocompletePlace
import java.io.IOException
import java.time.Instant
@ExperimentalCarApi
class PlaceSearchScreen(
ctx: CarContext,
val session: EVMapSession,
val initialSearch: String = ""
) : Screen(ctx),
SearchTemplate.SearchCallback, LocationAwareScreen,
DefaultLifecycleObserver {
private val hardwareMan: CarHardwareManager by lazy {
ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private var resultList: List<AutocompletePlace>? = null
private var recentResults = mutableListOf<RecentAutocompletePlace>()
private var currentProvider: AutocompleteProvider? = null
private val providers = getAutocompleteProviders(ctx)
private val recents = AppDatabase.getInstance(ctx).recentAutocompletePlaceDao()
private val maxItems = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private var location: Location? = null
private var energyLevel: EnergyLevel? = null
private var updateJob: Job? = null
private val prefs = PreferenceDataSource(ctx)
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
"android.car.permission.CAR_ENERGY",
"android.car.permission.CAR_ENERGY_PORTS",
"android.car.permission.READ_CAR_DISPLAY_UNITS",
)
} else {
listOf(
"com.google.android.gms.permission.CAR_FUEL"
)
}
init {
lifecycle.addObserver(this)
update(initialSearch)
}
override fun onGetTemplate(): Template {
return SearchTemplate.Builder(this).apply {
setHeaderAction(Action.BACK)
setSearchHint(carContext.getString(R.string.search))
setInitialSearchText(initialSearch)
setShowKeyboardByDefault(initialSearch == "")
resultList?.let {
setItemList(buildItemList(it))
} ?: setLoading(true)
}.build()
}
private fun buildItemList(results: List<AutocompletePlace>): ItemList {
return ItemList.Builder().apply {
results.forEach { place ->
addItem(Row.Builder().apply {
setTitle(place.primaryText)
addText(place.secondaryText)
val icon = iconForPlaceType(place.types)
setImage(
CarIcon.Builder(IconCompat.createWithResource(carContext, icon))
.setTint(if (isSpecialPlace(place.types)) CarColor.PRIMARY else CarColor.DEFAULT)
.build()
)
// distance
place.distanceMeters?.let {
val text = SpannableStringBuilder()
text.append(
"distance",
DistanceSpan.create(
roundValueToDistance(
it,
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)
}
}
}.build())
}
}.build()
}
override fun onSearchTextChanged(searchText: String) {
update(searchText)
}
override fun onSearchSubmitted(searchText: String) {
update(searchText)
}
private fun update(searchText: String) {
updateJob?.cancel()
updateJob = lifecycleScope.launch {
if (prefs.searchProvider == "mapbox" && !isShortQuery(searchText)) {
delay(500L)
}
try {
loadNewList(searchText)
} catch (e: IOException) {
CarToast.makeText(
carContext,
R.string.autocomplete_connection_error,
CarToast.LENGTH_SHORT
).show()
}
}
}
private suspend fun loadNewList(query: String) {
val location = location?.let { LatLng.fromLocation(it) }
for (provider in providers) {
try {
recentResults.clear()
currentProvider = provider
// first search in recent places
val recentPlaces = if (query.isEmpty()) {
recents.getAllAsync(provider.id, limit = maxItems)
} else {
recents.searchAsync(query, provider.id, limit = maxItems)
}
recentResults.addAll(recentPlaces)
resultList =
recentPlaces.map { it.asAutocompletePlace(location) }
invalidate()
// if we already have enough results or the query is short, stop here
if (isShortQuery(query) || recentResults.size >= maxItems) break
// then search online
val recentIds = recentPlaces.map { it.id }
resultList = withContext(Dispatchers.IO) {
(resultList!! + provider.autocomplete(query, location)
.filter { !recentIds.contains(it.id) }).take(maxItems)
}
invalidate()
break
} catch (e: ApiUnavailableException) {
e.printStackTrace()
}
}
}
private fun isShortQuery(query: CharSequence) = query.length < 3
override fun updateLocation(location: Location) {
this.location = location
}
override fun onResume(owner: LifecycleOwner) {
session.requestLocationUpdates()
session.mapScreen = this
if (supportsCarApiLevel3(carContext) && permissions.all {
ContextCompat.checkSelfPermission(
carContext,
it
) == PackageManager.PERMISSION_GRANTED
}) {
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
val isUpdate = this.energyLevel == null
this.energyLevel = energyLevel
if (isUpdate) invalidate()
}
override fun onPause(owner: LifecycleOwner) {
session.mapScreen = null
if (supportsCarApiLevel3(carContext)) {
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
}
}
suspend fun getDetails(id: String): PlaceWithBounds {
val provider = currentProvider!!
val result = resultList!!.find { it.id == id }!!
val recentPlace = recentResults.find { it.id == id }
if (recentPlace != null) return recentPlace.asPlaceWithBounds()
val details = provider.getDetails(id)
recents.insert(RecentAutocompletePlace(result, details, provider.id, Instant.now()))
return details
}
}

View File

@@ -1,137 +0,0 @@
package net.vonforst.evmap.auto
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.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
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)
protected abstract val isMultiSelect: Boolean
protected abstract val shouldShowSelectAll: Boolean
override fun onGetTemplate(): Template {
if (fullList == null) {
lifecycleScope.launch {
fullList = loadData()
filterList()
invalidate()
}
}
return SearchTemplate.Builder(this).apply {
setHeaderAction(Action.BACK)
fullList?.let {
setItemList(buildItemList())
} ?: run {
setLoading(true)
}
if (isMultiSelect) {
setActionStrip(ActionStrip.Builder().apply {
addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_select_all
)
).build()
).setOnClickListener(::selectAll).build()
)
addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_select_none
)
).build()
).setOnClickListener(::selectNone).build()
)
}.build())
}
}.build()
}
private fun filterList() {
currentList = fullList?.let {
it.sortedBy { getLabel(it).lowercase() }
.sortedBy { !isSelected(it) }
.filter { getLabel(it).lowercase().contains(query.lowercase()) }
.take(maxRows)
} ?: emptyList()
}
private val checkedIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_checkbox_checked))
.setTint(CarColor.PRIMARY)
.build()
private val uncheckedIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_checkbox_unchecked))
.setTint(CarColor.PRIMARY)
.build()
private fun buildItemList(): ItemList {
return ItemList.Builder().apply {
currentList.forEach { item ->
addItem(
Row.Builder()
.setTitle(getLabel(item))
.apply { getDetails(item)?.let { addText(it) } }
.setImage(if (isSelected(item)) checkedIcon else uncheckedIcon)
.setOnClickListener {
toggleSelected(item)
if (isMultiSelect) {
invalidate()
} else {
setResult(item)
screenManager.pop()
}
}
.build()
)
}
}.build()
}
override fun onSearchTextChanged(searchText: String) {
query = searchText
filterList()
invalidate()
}
override fun onSearchSubmitted(searchText: String) {
query = searchText
filterList()
invalidate()
}
abstract fun toggleSelected(item: T)
open fun selectAll() {
CarToast.makeText(carContext, R.string.selecting_all, CarToast.LENGTH_SHORT).show()
invalidate()
}
open fun selectNone() {
CarToast.makeText(carContext, R.string.selecting_none, CarToast.LENGTH_SHORT).show()
invalidate()
}
abstract fun isSelected(it: T): Boolean
abstract fun getLabel(it: T): String
open fun getDetails(it: T): String? = null
abstract suspend fun loadData(): List<T>
}

View File

@@ -1,752 +0,0 @@
package net.vonforst.evmap.auto
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
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.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.*
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.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.max
import kotlin.math.min
@ExperimentalCarApi
class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.auto_settings))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_data_sources))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_settings_data_source
)
).setTint(
CarColor.DEFAULT
).build()
)
setBrowsable(true)
setOnClickListener {
screenManager.push(DataSettingsScreen(carContext))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_chargeprice))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).setTint(
CarColor.DEFAULT
).build()
)
setBrowsable(true)
setOnClickListener {
screenManager.push(ChargepriceSettingsScreen(carContext))
}
}.build())
if (supportsCarApiLevel3(carContext)) {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_vehicle_data))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(carContext, R.drawable.ic_car)
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(VehicleDataScreen(carContext, session))
}
.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()
}
}
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(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 searchProviderNames =
carContext.resources.getStringArray(R.array.pref_search_provider_names)
val searchProviderValues =
carContext.resources.getStringArray(R.array.pref_search_provider_values)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_data_sources))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_data_source))
setBrowsable(true)
val dataSourceId = prefs.dataSource
val dataSourceDesc = dataSourceNames[dataSourceValues.indexOf(dataSourceId)]
addText(dataSourceDesc)
setOnClickListener {
screenManager.push(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.CHARGER_DATA_SOURCE
)
)
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_provider))
setBrowsable(true)
val searchProviderId = prefs.searchProvider
val searchProviderDesc =
searchProviderNames[searchProviderValues.indexOf(searchProviderId)]
addText(searchProviderDesc)
setOnClickListener {
screenManager.push(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.SEARCH_PROVIDER
)
)
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_delete_recent))
setOnClickListener {
lifecycleScope.launch {
db.recentAutocompletePlaceDao().deleteAll()
CarToast.makeText(
carContext,
R.string.deleted_recent_search_results,
CarToast.LENGTH_SHORT
).show()
}
}
}.build())
}.build())
}.build()
}
}
class ChooseDataSourceScreen(
ctx: CarContext,
val type: Type,
val initialChoice: Boolean = false,
@StringRes val extraDesc: Int? = null
) : 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 {
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))
}
}.build()
if (extraDesc != null) {
addSectionedList(SectionedItemList.create(list, carContext.getString(extraDesc)))
} else {
setSingleList(list)
}
}.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)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_chargeprice))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_vehicle))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectVehiclesScreen(carContext))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_tariffs))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectTariffsScreen(carContext))
}
addText(
if (prefs.chargepriceMyTariffsAll) {
carContext.getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
carContext.resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + carContext.resources.getQuantityString(
R.plurals.pref_my_tariffs_summary,
n
)
}
)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
setBrowsable(true)
val range = prefs.chargepriceBatteryRangeAndroidAuto
addText(
carContext.getString(
R.string.chargeprice_battery_range,
range[0],
range[1]
)
)
setOnClickListener {
screenManager.push(SelectChargingRangeScreen(carContext))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_currency))
val names =
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
val values =
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
val index = values.indexOf(prefs.chargepriceCurrency)
addText(if (index >= 0) names[index] else "")
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectCurrencyScreen(carContext))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_no_base_fee))
setToggle(Toggle.Builder {
prefs.chargepriceNoBaseFee = it
}.setChecked(prefs.chargepriceNoBaseFee).build())
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
setToggle(Toggle.Builder {
prefs.chargepriceShowProviderCustomerTariffs = it
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
}.build())
if (maxRows > 6) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load))
addText(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load_summary))
setToggle(Toggle.Builder {
prefs.chargepriceAllowUnbalancedLoad = it
}.setChecked(prefs.chargepriceAllowUnbalancedLoad).build())
}.build())
}
}.build())
}.build()
}
}
class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceCar>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = false
override fun isSelected(it: ChargepriceCar): Boolean {
return prefs.chargepriceMyVehicles.contains(it.id)
}
override fun toggleSelected(item: ChargepriceCar) {
if (isSelected(item)) {
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.minus(item.id)
} else {
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.plus(item.id)
}
}
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()
}
}
class SelectTariffsScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceTariff>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = true
override fun isSelected(it: ChargepriceTariff): Boolean {
return prefs.chargepriceMyTariffsAll or (prefs.chargepriceMyTariffs?.contains(it.id)
?: false)
}
override fun toggleSelected(item: ChargepriceTariff) {
val tariffs = prefs.chargepriceMyTariffs ?: if (prefs.chargepriceMyTariffsAll) {
fullList!!.map { it.id }.toSet()
} else {
emptySet()
}
if (isSelected(item)) {
prefs.chargepriceMyTariffs = tariffs.minus(item.id)
prefs.chargepriceMyTariffsAll = false
} else {
prefs.chargepriceMyTariffs = tariffs.plus(item.id)
if (prefs.chargepriceMyTariffs == fullList!!.map { it.id }.toSet()) {
prefs.chargepriceMyTariffsAll = true
}
}
}
override fun selectAll() {
prefs.chargepriceMyTariffsAll = true
super.selectAll()
}
override fun selectNone() {
prefs.chargepriceMyTariffsAll = false
prefs.chargepriceMyTariffs = emptySet()
super.selectNone()
}
override fun getLabel(it: ChargepriceTariff): String {
return if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
"${it.provider} ${it.name}"
} else {
it.name
}
}
override suspend fun loadData(): List<ChargepriceTariff> {
return api.getTariffs()
}
}
class SelectCurrencyScreen(ctx: CarContext) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
private val prefs = PreferenceDataSource(carContext)
override val isMultiSelect = false
override val shouldShowSelectAll = false
override fun isSelected(it: Pair<String, String>): Boolean =
prefs.chargepriceCurrency == it.second
override fun toggleSelected(item: Pair<String, String>) {
prefs.chargepriceCurrency = item.second
}
override fun getLabel(it: Pair<String, String>): String = it.first
override suspend fun loadData(): List<Pair<String, String>> {
val names = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
val values = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
return names.zip(values)
}
}
class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
private val maxItems = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_GRID)
} else 6
override fun onGetTemplate(): Template {
return GridTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
setHeaderAction(Action.BACK)
setSingleList(
ItemList.Builder().apply {
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.chargeprice_battery_range_from))
setText(
carContext.getString(
R.string.percent_format,
prefs.chargepriceBatteryRangeAndroidAuto[0]
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[0] = min(this[1] - 5, this[0] + 5)
}
invalidate()
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.chargeprice_battery_range_to))
setText(
carContext.getString(
R.string.percent_format,
prefs.chargepriceBatteryRangeAndroidAuto[1]
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[1] = min(100f, this[1] + 5)
}
invalidate()
}
}.build())
val nSpacers = when {
maxItems % 3 == 0 -> 1
maxItems == 100 -> 0 // AA has increased the limit to 100 and changed the way items are laid out
maxItems % 4 == 0 -> 2
else -> 0
}
for (i in 0..nSpacers) {
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(emptyCarIcon)
}.build())
}
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[0] = max(0f, this[0] - 5)
}
invalidate()
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[1] = max(this[0] + 5, this[1] - 5)
}
invalidate()
}
}.build())
}.build()
)
}.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.paypal_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 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

@@ -1,63 +0,0 @@
package net.vonforst.evmap.auto
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.car.app.model.signin.InputSignInMethod
import androidx.car.app.model.signin.SignInTemplate
import net.vonforst.evmap.R
class TextPromptScreen(
ctx: CarContext,
@StringRes val title: Int,
@StringRes val prompt: Int,
val initialValue: String? = null,
val cancelable: Boolean = true
) : Screen(ctx),
InputCallback {
private var inputText = ""
override fun onGetTemplate(): Template {
val signInMethod = InputSignInMethod.Builder(this).apply {
initialValue?.let {
setDefaultValue(it)
inputText = initialValue
}
setShowKeyboardByDefault(true)
}.build()
return SignInTemplate.Builder(signInMethod).apply {
setHeaderAction(Action.BACK)
setInstructions(carContext.getString(prompt))
setTitle(carContext.getString(title))
if (cancelable) {
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener(ParkedOnlyOnClickListener.create {
screenManager.pop()
})
.build()
)
}
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.ok))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener(ParkedOnlyOnClickListener.create {
onInputSubmitted(inputText)
})
.build()
)
}.build()
}
override fun onInputTextChanged(text: String) {
inputText = text
}
override fun onInputSubmitted(text: String) {
setResult(text)
screenManager.pop()
}
}

View File

@@ -1,27 +1,7 @@
package net.vonforst.evmap.auto
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
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
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.model.*
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import androidx.car.app.model.CarColor
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.getPackageInfoCompat
import java.util.*
import kotlin.math.roundToInt
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
@@ -37,228 +17,4 @@ fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
} else {
CarColor.BLUE
}
}
val CarContext.constraintManager
get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
fun CarContext.getContentLimit(id: Int) = if (carAppApiLevel >= 2) {
constraintManager.getContentLimit(id)
} else {
when (id) {
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 6
ConstraintManager.CONTENT_LIMIT_TYPE_LIST -> 6
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST -> 6
ConstraintManager.CONTENT_LIMIT_TYPE_ROUTE_LIST -> 3
else -> throw IllegalArgumentException("unknown limit ID")
}
}
val CarContext.isAppDrivenRefreshSupported
@androidx.car.app.annotations.ExperimentalCarApi
get() = if (carAppApiLevel >= 6) constraintManager.isAppDrivenRefreshEnabled else false
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
val emptyCarIcon: CarIcon by lazy {
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
}
/**
* Paginates data based on specific limits for each page.
* If the data fits on a single page, this page can have a maximum size nSingle. Otherwise, the
* first page has maximum nFirst items, the last page nLast items, and all intermediate pages nOther
* items.
*/
fun <T> List<T>.paginate(nSingle: Int, nFirst: Int, nOther: Int, nLast: Int): List<List<T>> {
if (nOther > nLast) {
throw IllegalArgumentException("nLast has to be larger than or equal to nOther")
}
return if (size <= nSingle) {
listOf(this)
} else {
val result = mutableListOf<List<T>>()
var i = 0
var page = 0
while (true) {
val remaining = size - i
if (page == 0) {
result.add(subList(i, i + nFirst))
i += nFirst
} else if (remaining <= nLast) {
result.add(subList(i, size))
break
} else {
result.add(subList(i, i + nOther))
i += nOther
}
page++
}
result
}
}
fun getAndroidAutoVersion(ctx: Context): List<String> {
val info = ctx.packageManager.getPackageInfoCompat("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
}
fun openUrl(carContext: CarContext, url: String) {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
R.color.colorPrimary
)
)
.build()
)
.build().intent
intent.data = Uri.parse(url)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
if (BuildConfig.FLAVOR_automotive != "automotive") {
// only show the toast "opened on phone" if we're running on a phone
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()
}
}
class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
/*
Dummy screen to get around template refresh limitations.
It immediately pops back to the previous screen.
*/
override fun onGetTemplate(): Template {
screenManager.pop()
return MessageTemplate.Builder(carContext.getString(R.string.loading)).setLoading(true)
.build()
}
}

View File

@@ -1,300 +0,0 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.*
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 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 {
private val carInfo =
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
private val carSensors = carContext.patchedCarSensors
private var model: Model? = null
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)
private val maxSpeed = 160f / 3.6f // m/s, speed gauge will show max if speed is higher
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
"android.car.permission.CAR_INFO",
"android.car.permission.CAR_ENERGY",
"android.car.permission.CAR_ENERGY_PORTS",
"android.car.permission.READ_CAR_DISPLAY_UNITS",
"android.car.permission.CAR_SPEED"
)
} else {
listOf(
"com.google.android.gms.permission.CAR_FUEL",
"com.google.android.gms.permission.CAR_SPEED"
)
}
init {
lifecycle.addObserver(this)
}
override fun onGetTemplate(): Template {
if (!permissionsGranted()) {
Handler(Looper.getMainLooper()).post {
screenManager.pushForResult(
PermissionScreen(
carContext,
R.string.auto_vehicle_data_permission_needed,
permissions,
finishApp = false
)
) {
setupListeners()
}
}
}
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
return GridTemplate.Builder().apply {
setTitle(
if (model != null && model.manufacturer.value != null && model.name.value != null) {
"${model.manufacturer.value} ${
getVehicleModel(
model.manufacturer.value,
model.name.value
)
}"
} else {
carContext.getString(R.string.auto_vehicle_data)
}
)
setHeaderAction(Action.BACK)
if (!permissionsGranted()) {
setLoading(true)
} else {
setSingleList(
ItemList.Builder().apply {
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_charging_level))
if (energyLevel == null) {
setLoading(true)
} else if (energyLevel.batteryPercent.value != null && energyLevel.fuelPercent.value != null) {
// both battery and fuel (Plug-in hybrid)
setText(
"\uD83D\uDD0C %.0f %% ⛽ %.0f %%".format(
energyLevel.batteryPercent.value,
energyLevel.fuelPercent.value
)
)
setImage(
gauge.draw(
energyLevel.batteryPercent.value,
energyLevel.fuelPercent.value
).asCarIcon()
)
} else if (energyLevel.batteryPercent.value != null) {
// BEV
setText("%.0f %%".format(energyLevel.batteryPercent.value))
setImage(gauge.draw(energyLevel.batteryPercent.value).asCarIcon())
} else if (energyLevel.fuelPercent.value != null) {
// ICE
setText("⛽ %.0f %%".format(energyLevel.fuelPercent.value))
setImage(gauge.draw(energyLevel.fuelPercent.value).asCarIcon())
} else {
setText(carContext.getString(R.string.auto_no_data))
setImage(gauge.draw(0f).asCarIcon())
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_range))
if (energyLevel == null) {
setLoading(true)
} else if (energyLevel.rangeRemainingMeters.value != null) {
setText(
formatCarUnitDistance(
energyLevel.rangeRemainingMeters.value,
energyLevel.distanceDisplayUnit.value
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_car
)
).build()
)
} else {
setText(carContext.getString(R.string.auto_no_data))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_car
)
).build()
)
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_speed))
if (speed == null) {
setLoading(true)
} else {
val rawSpeed = speed.rawSpeedMetersPerSecond.value
val displaySpeed = speed.displaySpeedMetersPerSecond.value
if (rawSpeed != null) {
setText(
formatCarUnitSpeed(
rawSpeed,
speed.speedDisplayUnit.value
)
)
setImage(
gauge.draw(min(rawSpeed / maxSpeed * 100, 100f)).asCarIcon()
)
} else if (displaySpeed != null) {
setText(
formatCarUnitSpeed(
speed.displaySpeedMetersPerSecond.value,
speed.speedDisplayUnit.value
)
)
setImage(
gauge.draw(min(displaySpeed / maxSpeed * 100, 100f))
.asCarIcon()
)
} else {
setText(carContext.getString(R.string.auto_no_data))
setImage(gauge.draw(0f).asCarIcon())
}
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_heading))
if (heading == null) {
setLoading(true)
} else {
val headingSource =
if (compassHeading != null) carContext.getString(R.string.compass) else carContext.getString(
R.string.gps
)
setText("${heading.roundToInt()}° ($headingSource)")
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()
)
}
}.build())
}.build()
)
}
}.build()
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
this.energyLevel = energyLevel
invalidate()
}
private fun onSpeedUpdated(speed: Speed) {
this.speed = speed
invalidate()
}
private fun onCompassUpdated(compass: Compass) {
this.heading = compass
invalidate()
}
override fun onResume(owner: LifecycleOwner) {
setupListeners()
session.mapScreen = this
}
private fun setupListeners() {
if (!permissionsGranted()) return
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
carInfo.addSpeedListener(exec, ::onSpeedUpdated)
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCompassUpdated
)
carInfo.fetchModel(exec) {
this.model = it
invalidate()
}
}
override fun onPause(owner: LifecycleOwner) {
removeListeners()
session.mapScreen = null
}
private fun removeListeners() {
println("Removing energy level listener")
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
carInfo.removeSpeedListener(::onSpeedUpdated)
carSensors.removeCompassListener(::onCompassUpdated)
}
private fun permissionsGranted(): Boolean =
permissions.all {
ContextCompat.checkSelfPermission(
carContext,
it
) == PackageManager.PERMISSION_GRANTED
}
override fun updateLocation(location: Location) {
this.location = location
}
}

View File

@@ -0,0 +1,70 @@
package net.vonforst.evmap.auto
import android.location.Location
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.R
/**
* Welcome screen with selection between favorites and nearby chargers
*/
@androidx.car.app.annotations.ExperimentalCarApi
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
private var location: Location? = null
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(carContext.getString(R.string.app_name))
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
}
setItemList(ItemList.Builder().apply {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_address
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = false))
}
.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_favorites))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = true))
}
.build())
}.build())
setCurrentLocationEnabled(true)
setHeaderAction(Action.APP_ICON)
build()
}.build()
}
override fun updateLocation(location: Location) {
this.location = location
invalidate()
}
}

View File

@@ -1,11 +0,0 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import net.vonforst.evmap.storage.PreferenceDataSource
fun getAutocompleteProviders(context: Context) =
if (PreferenceDataSource(context).searchProvider == "google") {
listOf(GooglePlacesAutocompleteProvider(context), MapboxAutocompleteProvider(context))
} else {
listOf(MapboxAutocompleteProvider(context), GooglePlacesAutocompleteProvider(context))
}

View File

@@ -1,122 +0,0 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import android.graphics.Typeface
import android.text.style.CharacterStyle
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
import com.google.android.libraries.places.api.Places
import com.google.android.libraries.places.api.model.AutocompleteSessionToken
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.api.model.RectangularBounds
import com.google.android.libraries.places.api.net.FetchPlaceRequest
import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest
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
class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvider {
private var token = AutocompleteSessionToken.newInstance()
private val client = Places.createClient(context)
private val bold: CharacterStyle = StyleSpan(Typeface.BOLD)
override val id = "google"
override fun autocomplete(
query: String,
location: com.car2go.maps.model.LatLng?
): List<AutocompletePlace> {
val request = FindAutocompletePredictionsRequest.builder().apply {
if (location != null) {
locationBias = calcLocationBias(location)
origin = LatLng(location.latitude, location.longitude)
}
sessionToken = token
setQuery(query)
}.build()
try {
val result =
await(client.findAutocompletePredictions(request)).autocompletePredictions
return result.map {
AutocompletePlace(
it.getPrimaryText(bold),
it.getSecondaryText(bold),
it.placeId,
it.distanceMeters?.toDouble(),
it.placeTypes.map { AutocompletePlaceType.valueOf(it.name) })
}
} catch (e: ExecutionException) {
val cause = e.cause
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
}
}
override suspend fun getDetails(id: String): PlaceWithBounds {
val request =
FetchPlaceRequest.builder(id, listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)).build()
try {
val place = client.fetchPlace(request).await().place
token = AutocompleteSessionToken.newInstance()
return PlaceWithBounds(
AnyMapAdapter.adapt(place.latLng),
AnyMapAdapter.adapt(place.viewport)
)
} catch (e: ApiException) {
if (e.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
throw ApiUnavailableException()
} else {
throw e
}
}
}
override fun getAttributionString(): Int =
com.google.android.libraries.places.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
private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds {
val radius = 100e3 // meters
val northEast =
SphericalUtil.computeOffset(
location,
radius * sqrt(2.0),
45.0
)
val southWest =
SphericalUtil.computeOffset(
location,
radius * sqrt(2.0),
225.0
)
return RectangularBounds.newInstance(
LatLngBounds(
AnyMapAdapter.adapt(southWest),
AnyMapAdapter.adapt(northEast)
)
)
}
}

View File

@@ -5,6 +5,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -12,9 +13,7 @@ import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DonationAdapter
@@ -25,29 +24,24 @@ class DonateFragment : Fragment() {
private lateinit var binding: FragmentDonateBinding
private val vm: DonateViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_donate, container, false)
binding.lifecycleOwner = this
binding.vm = vm
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
binding.toolbar.setupWithNavController(
findNavController(),
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
@@ -70,8 +64,5 @@ class DonateFragment : Fragment() {
vm.purchaseFailed.observe(viewLifecycleOwner, Observer {
Snackbar.make(view, R.string.donation_failed, Snackbar.LENGTH_LONG).show()
})
// 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

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

View File

@@ -1,43 +0,0 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.RangeSliderPreference
import java.text.NumberFormat
class AndroidAutoSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
private lateinit var rangePreference: RangeSliderPreference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
rangePreference = findPreference("chargeprice_battery_range_android_auto")!!
rangePreference.labelFormatter = { value: Float ->
val fmt = NumberFormat.getNumberInstance()
fmt.maximumFractionDigits = 0
fmt.format(value.toDouble()) + "%"
}
updateRangePreferenceSummary()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_android_auto, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"chargeprice_battery_range_android_auto_min", "chargeprice_battery_range_android_auto_max" -> {
updateRangePreferenceSummary()
}
}
}
private fun updateRangePreferenceSummary() {
val range = prefs.chargepriceBatteryRangeAndroidAuto
rangePreference.summary = getString(R.string.chargeprice_battery_range, range[0], range[1])
}
}

View File

@@ -1,33 +0,0 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.PorterDuff
import androidx.core.content.ContextCompat
import net.vonforst.evmap.R
class CompassNeedle(val size: Int, ctx: Context) {
val image = ContextCompat.getDrawable(ctx, R.drawable.ic_navigation)!!
init {
image.setTint(Color.WHITE)
image.setBounds(0, 0, size, size)
}
fun draw(angle: Float?): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
if (angle != null) {
canvas.save()
canvas.rotate(-angle, size / 2f, size / 2f)
image.draw(canvas)
canvas.restore()
}
return bitmap
}
}

View File

@@ -1,74 +0,0 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.graphics.*
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import net.vonforst.evmap.R
import kotlin.math.max
import kotlin.math.min
class Gauge(val size: Int, ctx: Context) {
val arcPaint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = size * 0.15f
}
val gaugePaint = Paint()
val activeColor = ContextCompat.getColor(ctx, R.color.gauge_active)
val middleColor = ContextCompat.getColor(ctx, R.color.gauge_middle)
val inactiveColor = ContextCompat.getColor(ctx, R.color.gauge_inactive)
fun draw(valuePercent: Float?, secondValuePercent: Float? = null): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
val angle = valuePercent?.let { 180f * it / 100 } ?: 0f
val secondAngle = secondValuePercent?.let { 180f * it / 100 }
drawArc(angle, secondAngle, canvas)
if (secondAngle != null) drawGauge(secondAngle, inactiveColor, canvas)
drawGauge(angle, Color.WHITE, canvas)
return bitmap
}
private fun drawGauge(angle: Float, @ColorInt color: Int, canvas: Canvas) {
gaugePaint.color = color
canvas.save()
canvas.rotate(angle - 90, size / 2f, 3 * size / 4f)
canvas.drawCircle(size / 2f, 3 * size / 4f, size * 0.1F, gaugePaint)
canvas.drawRect(size * 0.48f, 3 * size / 4f, size * 0.53f, size * 0.325f, gaugePaint)
canvas.restore()
}
private fun drawArc(angle: Float, secondAngle: Float?, canvas: Canvas) {
val (angle1, angle2) = if (secondAngle != null) {
min(angle, secondAngle) to max(angle, secondAngle)
} else {
angle to null
}
arcPaint.color = activeColor
val arcBounds = RectF(
arcPaint.strokeWidth / 2,
size / 4f + arcPaint.strokeWidth / 2,
size - arcPaint.strokeWidth / 2,
5 * size / 4f - arcPaint.strokeWidth / 2
)
canvas.drawArc(arcBounds, 180f, angle1, false, arcPaint)
if (angle2 != null) {
arcPaint.color = middleColor
canvas.drawArc(arcBounds, 180f + angle1, angle2 - angle1, false, arcPaint)
}
arcPaint.color = inactiveColor
canvas.drawArc(
arcBounds,
180f + (angle2 ?: angle1),
180f - (angle2 ?: angle1),
false,
arcPaint
)
}
}

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

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#FF000000"
android:pathData="m22.78,17.91c0.16,0.25 0.22,0.51 0.22,0.79 0,0.38 -0.13,0.69 -0.43,0.94s-0.63,0.36 -1.01,0.36h-2.48l-6.66,-12h-0.84l-6.66,12h-2.53c-0.47,0 -0.86,-0.2 -1.17,-0.62s-0.33,-0.88 -0.05,-1.38l9.61,-16.31c0.31,-0.47 0.72,-0.69 1.22,-0.69 0.53,0 0.92,0.22 1.17,0.69zM4.78,22.31 L12,9.38 19.22,22.31 18.5,23 12,20.34 5.44,23z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99zM6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16zM17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM5,11l1.5,-4.5h11L19,11L5,11z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
</vector>

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