Compare commits

..

1 Commits

Author SHA1 Message Date
johan12345
10ce6a0b5f AA/AAOS: implement TabTemplate for detail view 2023-09-02 22:21:07 +02:00
65 changed files with 766 additions and 1398 deletions

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Set up Java environment
uses: actions/setup-java@v3
uses: actions/setup-java@v2
with:
java-version: 17
distribution: 'zulu'
@@ -22,7 +22,7 @@ jobs:
- 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*=\s*[0-9]\+" app/build.gradle.kts | awk '{ print $3 }' | tr -d \''"\\')" >> $GITHUB_ENV
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:
@@ -32,7 +32,6 @@ jobs:
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}

View File

@@ -16,10 +16,10 @@ jobs:
buildvariant: [ FossNormal, FossAutomotive, GoogleNormal, GoogleAutomotive ]
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Set up Java environment
uses: actions/setup-java@v3
uses: actions/setup-java@v2
with:
java-version: 17
distribution: 'zulu'

298
app/build.gradle Normal file
View File

@@ -0,0 +1,298 @@
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 {
defaultConfig {
applicationId "net.vonforst.evmap"
compileSdk 34
minSdkVersion 21
targetSdkVersion 34
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 194
versionName "1.6.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(',')
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
}
signingConfigs {
release {
def isRunningOnCI = System.getenv("CI") == "true"
if (isRunningOnCI) {
// configure keystore
storeFile = file("../_ci/keystore.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEYSTORE_ALIAS")
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}
flavorDimensions "dependencies", "automotive"
productFlavors {
foss {
dimension "dependencies"
}
google {
dimension "dependencies"
versionNameSuffix "-google"
}
normal {
dimension "automotive"
}
automotive {
dimension "automotive"
versionNameSuffix "-automotive"
versionCode defaultConfig.versionCode + 1
minSdkVersion 29
}
}
compileOptions {
coreLibraryDesugaringEnabled true
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs).configureEach {
kotlinOptions {
jvmTarget = "1.8"
}
}
buildFeatures {
dataBinding = true
viewBinding true
}
lint {
disable 'NullSafeMutableLiveData'
warning 'MissingTranslation'
}
testOptions {
unitTests.includeAndroidResources true
}
resourcePlaceholders {
files = ['xml/shortcuts.xml']
}
namespace 'net.vonforst.evmap'
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all { variant ->
ext.env = System.getenv()
def goingelectricKey = env.GOINGELECTRIC_API_KEY ?: project.findProperty("GOINGELECTRIC_API_KEY")
if (goingelectricKey != null) {
variant.resValue "string", "goingelectric_key", goingelectricKey
}
def openchargemapKey = env.OPENCHARGEMAP_API_KEY ?: project.findProperty("OPENCHARGEMAP_API_KEY")
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
openchargemapKey = decode(project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (openchargemapKey != null) {
variant.resValue "string", "openchargemap_key", openchargemapKey
}
def googleMapsKey = env.GOOGLE_MAPS_API_KEY ?: project.findProperty("GOOGLE_MAPS_API_KEY")
if (googleMapsKey != null && variant.flavorName.startsWith('google')) {
variant.resValue "string", "google_maps_key", googleMapsKey
}
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(project.findProperty("MAPBOX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (mapboxKey != null) {
variant.resValue "string", "mapbox_key", mapboxKey
}
def chargepriceKey = env.CHARGEPRICE_API_KEY ?: project.findProperty("CHARGEPRICE_API_KEY")
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
chargepriceKey = decode(project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (chargepriceKey != null) {
variant.resValue "string", "chargeprice_key", chargepriceKey
}
def fronyxKey = env.FRONYX_API_KEY ?: project.findProperty("FRONYX_API_KEY")
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
fronyxKey = decode(project.findProperty("FRONYX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (fronyxKey != null) {
variant.resValue "string", "fronyx_key", fronyxKey
}
def acraKey = env.ACRA_CRASHREPORT_CREDENTIALS ?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
acraKey = decode(project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (acraKey != null) {
variant.resValue "string", "acra_credentials", acraKey
}
}
packagingOptions {
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
}
}
configurations {
googleNormalImplementation {}
googleAutomotiveImplementation {}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation "androidx.activity:activity-ktx:1.7.2"
implementation "androidx.fragment:fragment-ktx:1.6.1"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.browser:browser:1.6.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation "androidx.work:work-runtime-ktx:2.8.1"
implementation 'com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.11.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
implementation 'com.squareup.moshi:moshi-adapters:1.15.0'
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
implementation 'io.coil-kt:coil:2.4.0'
implementation 'com.github.ev-map:StfalconImageViewer:5082ebd392'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:4.1.0'
implementation 'io.michaelrocks.bimap:bimap:1.1.0'
implementation 'com.google.guava:guava:29.0-android'
implementation 'com.github.pengrad:mapscaleview:1.6.0'
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
def carAppVersion = '1.4.0-beta01'
implementation "androidx.car.app:app:$carAppVersion"
normalImplementation "androidx.car.app:app-projected:$carAppVersion"
automotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
// AnyMaps
def anyMapsVersion = '8f1226e1c5'
implementation "com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion"
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
exclude group: 'com.google.android.gms', module: 'play-services-location'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-core'
}
// original version of mapbox-android-core
googleImplementation 'com.mapbox.mapboxsdk:mapbox-android-core:2.0.1'
// patched version that removes build-time dependency on GMS (-> no Google location services)
fossImplementation 'com.github.ev-map:mapbox-events-android:a21c324501'
// Google Places
googleImplementation 'com.google.android.libraries.places:places:3.2.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.1'
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
// navigation library
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.6.0-beta01"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation 'com.github.anboralabs:spatia-room:0.2.7'
// billing library
def billing_version = "6.0.1"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
// ACRA (crash reporting)
def acraVersion = "5.11.1"
implementation("ch.acra:acra-mail:$acraVersion")
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
debugImplementation 'com.facebook.flipper:flipper:0.190.0'
debugImplementation 'com.facebook.soloader:soloader:0.10.5'
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.190.0'
// testing
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
testImplementation 'org.robolectric:robolectric:4.10.3'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.arch.core:core-testing:2.2.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.15.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
}
private static String decode(String s, String key) {
return new String(xorWithKey(s.decodeBase64(), key.getBytes()), "UTF-8");
}
private static byte[] xorWithKey(byte[] a, byte[] key) {
byte[] out = new byte[a.length];
for (int i = 0; i < a.length; i++) {
out[i] = (byte) (a[i] ^ key[i%key.length]);
}
return out;
}

View File

@@ -1,353 +0,0 @@
import java.util.Base64
plugins {
id("com.adarshr.test-logger") version "3.1.0"
id("com.android.application")
id("kotlin-android")
id("kotlin-parcelize")
id("kotlin-kapt")
id("androidx.navigation.safeargs.kotlin")
id("com.mikepenz.aboutlibraries.plugin")
id("pt.jcosta.resourceplaceholders")
}
val supportedLocales = "en,de,fr,nb-rNO,nl,pt,ro"
android {
defaultConfig {
applicationId = "net.vonforst.evmap"
compileSdk = 34
minSdk = 21
targetSdk = 34
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode = 206
versionName = "1.7.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resourceConfigurations += supportedLocales.split(",")
buildConfigField("String", "supportedLocales", "\"$supportedLocales\"")
}
signingConfigs {
create("release") {
val isRunningOnCI = System.getenv("CI") == "true"
if (isRunningOnCI) {
// configure keystore
storeFile = file("../_ci/keystore.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEYSTORE_ALIAS")
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
debug {
applicationIdSuffix = ".debug"
isDebuggable = true
}
}
flavorDimensions += listOf("dependencies", "automotive")
productFlavors {
create("foss") {
dimension = "dependencies"
}
create("google") {
dimension = "dependencies"
versionNameSuffix = "-google"
}
create("normal") {
dimension = "automotive"
}
create("automotive") {
dimension = "automotive"
versionNameSuffix = "-automotive"
versionCode = defaultConfig.versionCode!! + 1
minSdk = 29
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs>().configureEach {
kotlinOptions {
jvmTarget = "1.8"
}
}
buildFeatures {
dataBinding = true
viewBinding = true
}
lint {
disable += listOf("NullSafeMutableLiveData")
warning += listOf("MissingTranslation")
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
resourcePlaceholders {
files("xml/shortcuts.xml")
}
namespace = "net.vonforst.evmap"
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all {
val goingelectricKey =
System.getenv("GOINGELECTRIC_API_KEY") ?: project.findProperty("GOINGELECTRIC_API_KEY")
?.toString()
if (goingelectricKey != null) {
resValue("string", "goingelectric_key", goingelectricKey)
}
var openchargemapKey =
System.getenv("OPENCHARGEMAP_API_KEY") ?: project.findProperty("OPENCHARGEMAP_API_KEY")
?.toString()
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
openchargemapKey = decode(
project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (openchargemapKey != null) {
resValue("string", "openchargemap_key", openchargemapKey)
}
val googleMapsKey =
System.getenv("GOOGLE_MAPS_API_KEY") ?: project.findProperty("GOOGLE_MAPS_API_KEY")
?.toString()
if (googleMapsKey != null && flavorName.startsWith("google")) {
resValue("string", "google_maps_key", googleMapsKey)
}
var mapboxKey =
System.getenv("MAPBOX_API_KEY") ?: project.findProperty("MAPBOX_API_KEY")?.toString()
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(
project.findProperty("MAPBOX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (mapboxKey != null) {
resValue("string", "mapbox_key", mapboxKey)
}
var chargepriceKey =
System.getenv("CHARGEPRICE_API_KEY") ?: project.findProperty("CHARGEPRICE_API_KEY")
?.toString()
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
chargepriceKey = decode(
project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (chargepriceKey != null) {
resValue("string", "chargeprice_key", chargepriceKey)
}
var fronyxKey =
System.getenv("FRONYX_API_KEY") ?: project.findProperty("FRONYX_API_KEY")?.toString()
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
fronyxKey = decode(
project.findProperty("FRONYX_API_KEY_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (fronyxKey != null) {
resValue("string", "fronyx_key", fronyxKey)
}
var acraKey = System.getenv("ACRA_CRASHREPORT_CREDENTIALS")
?: project.findProperty("ACRA_CRASHREPORT_CREDENTIALS")?.toString()
if (acraKey == null && project.hasProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED")) {
acraKey = decode(
project.findProperty("ACRA_CRASHREPORT_CREDENTIALS_ENCRYPTED").toString(),
"FmK.d,-f*p+rD+WK!eds"
)
}
if (acraKey != null) {
resValue("string", "acra_credentials", acraKey)
}
}
packaging {
jniLibs {
pickFirsts.addAll(
listOf(
"lib/x86/libc++_shared.so",
"lib/arm64-v8a/libc++_shared.so",
"lib/x86_64/libc++_shared.so",
"lib/armeabi-v7a/libc++_shared.so"
)
)
}
}
}
configurations {
create("googleNormalImplementation") {}
create("googleAutomotiveImplementation") {}
}
aboutLibraries {
allowedLicenses = arrayOf(
"Apache-2.0", "mit", "BSD-2-Clause",
"asdkl", // Android SDK
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
"Google Maps Platform Terms of Service" // Google Maps SDK
)
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
}
dependencies {
val kotlinVersion: String by rootProject.extra
val aboutLibsVersion: String by rootProject.extra
val navVersion: String by rootProject.extra
val normalImplementation by configurations
val googleImplementation by configurations
val automotiveImplementation by configurations
val fossImplementation by configurations
val testGoogleImplementation by configurations
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.activity:activity-ktx:1.8.0")
implementation("androidx.fragment:fragment-ktx:1.6.2")
implementation("androidx.cardview:cardview:1.0.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.google.android.material:material:1.10.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.browser:browser:1.6.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.11.0")
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
implementation("com.markomilos.jsonapi:jsonapi-retrofit:1.1.0")
implementation("io.coil-kt:coil:2.4.0")
implementation("com.github.ev-map:StfalconImageViewer:5082ebd392")
implementation("com.mikepenz:aboutlibraries-core:$aboutLibsVersion")
implementation("com.mikepenz:aboutlibraries:$aboutLibsVersion")
implementation("com.airbnb.android:lottie:4.1.0")
implementation("io.michaelrocks.bimap:bimap:1.1.0")
implementation("com.google.guava:guava:29.0-android")
implementation("com.github.pengrad:mapscaleview:1.6.0")
implementation("com.github.romandanylyk:PageIndicatorView:b1bad589b5")
// Android Auto
val carAppVersion = "1.4.0-rc01"
implementation("androidx.car.app:app:$carAppVersion")
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
// AnyMaps
val 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.2.0")
implementation("com.github.ev-map.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-accounts")
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-telemetry")
exclude(group = "com.google.android.gms", module = "play-services-location")
exclude(group = "com.mapbox.mapboxsdk", module = "mapbox-android-core")
}
// original version of mapbox-android-core
googleImplementation("com.mapbox.mapboxsdk:mapbox-android-core:2.0.1")
// patched version that removes build-time dependency on GMS (-> no Google location services)
fossImplementation("com.github.ev-map:mapbox-events-android:a21c324501")
// Google Places
googleImplementation("com.google.android.libraries.places:places:3.2.0")
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Mapbox Geocoding
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0")
// navigation library
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
// viewmodel library
val lifecycle_version = "2.6.2"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
// room library
val room_version = "2.6.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
val billing_version = "6.0.1"
googleImplementation("com.android.billingclient:billing:$billing_version")
googleImplementation("com.android.billingclient:billing-ktx:$billing_version")
// ACRA (crash reporting)
val acraVersion = "5.11.1"
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
debugImplementation("com.facebook.flipper:flipper:0.190.0")
debugImplementation("com.facebook.soloader:soloader:0.10.5")
debugImplementation("com.facebook.flipper:flipper-network-plugin:0.190.0")
// testing
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.11.0")
//noinspection GradleDependency
testImplementation("org.json:json:20080701")
testImplementation("org.robolectric:robolectric:4.10.3")
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.arch.core:core-testing:2.2.0")
// testing for car app
testGoogleImplementation("androidx.car.app:app-testing:$carAppVersion")
testGoogleImplementation("androidx.test:core:1.5.0")
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.4")
}
fun decode(s: String, key: String): String {
return String(xorWithKey(Base64.getDecoder().decode(s), key.toByteArray()), Charsets.UTF_8)
}
fun xorWithKey(a: ByteArray, key: ByteArray): ByteArray {
val out = ByteArray(a.size)
for (i in a.indices) {
out[i] = (a[i].toInt() xor key[i % key.size].toInt()).toByte()
}
return out
}

View File

@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

View File

@@ -3,12 +3,10 @@ package net.vonforst.evmap
import android.app.Activity
import android.content.Context
@Suppress("UNUSED_PARAMETER")
fun init(context: Context) {
}
@Suppress("UNUSED_PARAMETER")
fun checkPlayServices(activity: Activity): Boolean {
return true
}

View File

@@ -7,7 +7,6 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
@@ -290,10 +289,6 @@
android:resource="@xml/shortcuts" />
</activity>
<activity android:name=".auto.OAuthLoginActivity">
</activity>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"

View File

@@ -11,6 +11,7 @@ import net.vonforst.evmap.ui.updateNightMode
import org.acra.config.dialog
import org.acra.config.httpSender
import org.acra.config.limiter
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
@@ -36,14 +37,21 @@ class EvMapApplication : Application(), Configuration.Provider {
initAcra {
buildConfigClass = BuildConfig::class.java
// Vehicles often don't have an email app, so use HTTP to send instead
reportFormat = StringFormat.JSON
httpSender {
uri = getString(R.string.acra_backend_url)
val creds = getString(R.string.acra_credentials).split(":")
basicAuthLogin = creds[0]
basicAuthPassword = creds[1]
httpMethod = HttpSender.Method.POST
if (BuildConfig.FLAVOR_automotive == "automotive") {
// Vehicles often don't have an email app, so use HTTP to send instead
httpSender {
uri = getString(R.string.acra_backend_url)
val creds = getString(R.string.acra_credentials).split(":")
basicAuthLogin = creds[0]
basicAuthPassword = creds[1]
httpMethod = HttpSender.Method.POST
}
} else {
mailSender {
mailTo = "evmap+crashreport@vonforst.net"
}
}
dialog {
@@ -72,7 +80,7 @@ class EvMapApplication : Application(), Configuration.Provider {
}
}.build()).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.UPDATE, cleanupCacheRequest
"CleanupCacheWorker", ExistingPeriodicWorkPolicy.REPLACE, cleanupCacheRequest
)
}

View File

@@ -30,7 +30,6 @@ abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback(getKey)) {
var onClickListener: ((T) -> Unit)? = null
var onLongClickListener: ((T) -> Boolean)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> {
val layoutInflater = LayoutInflater.from(parent.context)
@@ -55,12 +54,6 @@ abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
listener(item)
}
}
if (onLongClickListener != null) {
holder.binding.root.setOnLongClickListener {
val listener = onLongClickListener ?: return@setOnLongClickListener false
return@setOnLongClickListener listener(item)
}
}
}
class DiffCallback<T : Equatable>(val getKey: ((T) -> Any)?) : DiffUtil.ItemCallback<T>() {
@@ -216,8 +209,8 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
checkedItem = item
root.post {
notifyDataSetChanged()
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}

View File

@@ -139,7 +139,7 @@ fun buildDetails(
)
}
fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
teslaPricing.memberRates?.activePricebook?.parking?.let { parkingFee ->
ctx.getString(
R.string.tesla_pricing_blocking_fee,
@@ -147,7 +147,7 @@ fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
)
}
fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
private fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
buildSpannedString {
teslaPricing.memberRates?.let { memberRates ->
append(

View File

@@ -170,11 +170,9 @@ class AvailabilityRepository(context: Context) {
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
private val teslaAvailabilityDetector =
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context))
private val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
teslaAvailabilityDetector,
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
@@ -201,10 +199,4 @@ class AvailabilityRepository(context: Context) {
}
return value ?: Resource.error(null, null)
}
fun isSupercharger(charger: ChargeLocation) =
teslaAvailabilityDetector.isChargerSupported(charger)
fun isTeslaSupported(charger: ChargeLocation) =
teslaAvailabilityDetector.isChargerSupported(charger) && teslaAvailabilityDetector.isSignedIn()
}

View File

@@ -105,19 +105,20 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
var markers =
api.getMarkers(lng - coordRange, lng + coordRange, lat - coordRange, lat + coordRange)
markers = markers.flatMap {
if (it.grouped) {
api.getMarkers(
it.viewPort.lowerLeftLon,
it.viewPort.upperRightLon,
it.viewPort.lowerLeftLat,
it.viewPort.upperRightLat
)
} else {
listOf(it)
while (markers.any { it.grouped }) {
markers = markers.flatMap {
if (it.grouped) {
api.getMarkers(
it.viewPort.lowerLeftLon,
it.viewPort.upperRightLon,
it.viewPort.lowerLeftLat,
it.viewPort.upperRightLat
)
} else {
listOf(it)
}
}
}
if (markers.any { it.grouped }) throw AvailabilityDetectorException("markers still grouped")
val nearest = markers.minByOrNull { marker ->
distanceBetween(marker.lat, marker.lon, lat, lng)

View File

@@ -1,6 +1,5 @@
package net.vonforst.evmap.api.availability
import android.net.Uri
import android.util.Base64
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
@@ -103,18 +102,6 @@ interface TeslaAuthenticationApi {
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
}
fun buildSignInUri(codeChallenge: String): Uri =
Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
.appendQueryParameter("client_id", "ownerapi")
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid email offline_access")
.appendQueryParameter("state", "123").build()
val resultUrlPrefix = "https://auth.tesla.com/void/callback"
}
}
@@ -287,7 +274,7 @@ interface TeslaGraphQlApi {
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation?)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation)
@JsonClass(generateAdapter = true)
data class ChargingSiteInformation(
@@ -316,13 +303,13 @@ interface TeslaGraphQlApi {
@JsonClass(generateAdapter = true)
data class ChargerId(
val id: Text,
val label: Value<String>?,
val label: Value<String>,
val name: String?
) {
val labelNumber
get() = label?.value?.replace(Regex("""\D"""), "")?.toInt()
get() = label.value.replace(Regex("""\D"""), "").toInt()
val labelLetter
get() = label?.value?.replace(Regex("""\d"""), "")
get() = label.value.replace(Regex("""\d"""), "")
}
@JsonClass(generateAdapter = true)
@@ -445,9 +432,6 @@ interface TeslaGraphQlApi {
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
APPROXIMATELY_20_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_GREATER_THAN_25_MINUTES")
GREATER_THAN_25_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
UNKNOWN
}
@@ -547,7 +531,7 @@ class TeslaAvailabilityDetector(
TeslaGraphQlApi.VehicleMakeType.NON_TESLA
)
)
).data.charging.site ?: throw AvailabilityDetectorException("no candidates found.")
).data.charging.site
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
@@ -570,16 +554,8 @@ class TeslaAvailabilityDetector(
"charger has unknown connectors"
)
var statusSorted = details.siteDynamic.chargerDetails
.sortedBy { c ->
c.charger.labelLetter
?: details.siteStatic.chargers.find { it.id == c.charger.id }?.labelLetter
}
.sortedBy { c ->
c.charger.labelNumber
?: details.siteStatic.chargers.find { it.id == c.charger.id }?.labelNumber
}
.map { it.availability }
var statusSorted = details.siteDynamic.chargerDetails.sortedBy { it.charger.labelLetter }
.sortedBy { it.charger.labelNumber }.map { it.availability }
if (statusSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
// apparently some connectors are missing in Tesla data
// If we have just one type of charger, we can still match
@@ -666,6 +642,4 @@ class TeslaAvailabilityDetector(
}
}
fun isSignedIn() = tokenStore.teslaRefreshToken != null
}

View File

@@ -1,188 +0,0 @@
package net.vonforst.evmap.api.fronyx
import android.content.Context
import com.squareup.moshi.JsonDataException
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.Resource
import retrofit2.HttpException
import java.io.IOException
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
data class PredictionData(
val predictionGraph: Map<ZonedDateTime, Double>?,
val maxValue: Double,
val predictedChargepoints: List<Chargepoint>,
val isPercentage: Boolean,
val description: String?
)
class PredictionRepository(private val context: Context) {
private val predictionApi = FronyxApi(context.getString(R.string.fronyx_key))
private val prefs = PreferenceDataSource(context)
suspend fun getPredictionData(
charger: ChargeLocation,
availability: ChargeLocationStatus?,
filteredConnectors: Set<String>? = null
): PredictionData {
val fronyxPrediction = availability?.evseIds?.let { evseIds ->
getFronyxPrediction(charger, evseIds, filteredConnectors)
}?.data
val graph = buildPredictionGraph(availability, fronyxPrediction)
val predictedChargepoints = getPredictedChargepoints(charger, filteredConnectors)
val maxValue = getPredictionMaxValue(availability, fronyxPrediction, predictedChargepoints)
val isPercentage = predictionIsPercentage(availability, fronyxPrediction)
val description = getDescription(charger, predictedChargepoints)
return PredictionData(
graph, maxValue, predictedChargepoints, isPercentage, description
)
}
private suspend fun getFronyxPrediction(
charger: ChargeLocation,
evseIds: Map<Chargepoint, List<String>>,
filteredConnectors: Set<String>?
): Resource<List<FronyxEvseIdResponse>> {
if (!prefs.predictionEnabled) return Resource.success(null)
val allEvseIds =
evseIds.filterKeys {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors?.let { filtered ->
equivalentPlugTypes(
it.type
).any { filtered.contains(it) }
} ?: true
}.flatMap { it.value }
if (allEvseIds.isEmpty()) {
return Resource.success(emptyList())
}
try {
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
if (result.size == allEvseIds.size) {
return Resource.success(result)
} else {
return Resource.error("not all EVSEIDs found", null)
}
} catch (e: IOException) {
e.printStackTrace()
return Resource.error(e.message, null)
} catch (e: HttpException) {
e.printStackTrace()
return Resource.error(e.message, null)
} catch (e: AvailabilityDetectorException) {
e.printStackTrace()
return Resource.error(e.message, null)
} catch (e: JsonDataException) {
// malformed JSON response from fronyx API
e.printStackTrace()
return Resource.error(e.message, null)
}
}
private fun buildPredictionGraph(
availability: ChargeLocationStatus?,
prediction: List<FronyxEvseIdResponse>?
): Map<ZonedDateTime, Double>? {
val congestionHistogram = availability?.congestionHistogram
return if (congestionHistogram != null && prediction == null) {
congestionHistogram.mapIndexed { i, value ->
LocalTime.of(i, 0).atDate(LocalDate.now())
.atZone(ZoneId.systemDefault()) to value
}.toMap()
} else {
prediction?.let { responses ->
if (responses.isEmpty()) {
null
} else {
val evseIds = responses.map { it.evseId }
val groupByTimestamp = responses.flatMap { response ->
response.predictions.map {
Triple(
it.timestamp,
response.evseId,
it.status
)
}
}
.groupBy { it.first } // group by timestamp
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
groupByTimestamp.mapValues {
it.value.count {
it.second == FronyxStatus.UNAVAILABLE
}.toDouble()
}.ifEmpty { null }
}
}
}
}
private fun getPredictedChargepoints(
charger: ChargeLocation,
filteredConnectors: Set<String>?
) =
charger.chargepoints.filter {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors?.let { filtered ->
equivalentPlugTypes(it.type).any {
filtered.contains(
it
)
}
} ?: true
}
private fun getPredictionMaxValue(
availability: ChargeLocationStatus?,
prediction: List<FronyxEvseIdResponse>?,
predictedChargepoints: List<Chargepoint>
): Double = if (availability?.congestionHistogram != null && prediction == null) {
1.0
} else {
predictedChargepoints.sumOf { it.count }.toDouble()
}
private fun predictionIsPercentage(
availability: ChargeLocationStatus?,
prediction: List<FronyxEvseIdResponse>?
) =
availability?.congestionHistogram != null && prediction == null
private fun getDescription(
charger: ChargeLocation,
predictedChargepoints: List<Chargepoint>
): String? {
val allChargepoints = charger.chargepoints
val predictedChargepointTypes = predictedChargepoints.map { it.type }.distinct()
return if (allChargepoints == predictedChargepoints) {
null
} else if (predictedChargepointTypes.size == 1) {
context.getString(
R.string.prediction_only,
nameForPlugType(context.stringProvider(), predictedChargepointTypes[0])
)
} else {
context.getString(
R.string.prediction_only,
context.getString(R.string.prediction_dc_plugs_only)
)
}
}
}

View File

@@ -64,8 +64,8 @@ data class OCMChargepoint(
addressInfo.toAddress(refData),
connections.map { it.convert(refData) },
operatorInfo?.title,
"https://map.openchargemap.io/?id=$id",
"https://map.openchargemap.io/?id=$id",
"https://openchargemap.org/site/poi/details/$id",
"https://openchargemap.org/site/poi/edit/$id",
convertFaultReport(),
recentlyVerified,
null,

View File

@@ -45,15 +45,13 @@ interface LocationAwareScreen {
class CarAppService : androidx.car.app.CarAppService() {
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
private var foregroundStarted = false
fun ensureForegroundService() {
override fun onCreate() {
super.onCreate()
// we want to run as a foreground service to make sure we can use location
if (!foregroundStarted) {
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
foregroundStarted = true
}
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
}
private fun createNotificationChannel() {
@@ -224,7 +222,6 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
@SuppressLint("MissingPermission")
fun requestLocationUpdates() {
if (!locationPermissionGranted()) return
cas.ensureForegroundService()
Log.i(TAG, "Requesting location updates")
requestCarHardwareLocationUpdates()
requestPhoneLocationUpdates()

View File

@@ -10,9 +10,10 @@ 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.model.*
import androidx.core.content.ContextCompat
import androidx.car.app.model.TabTemplate.TabCallback
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.scale
import androidx.core.text.HtmlCompat
@@ -24,16 +25,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.adapter.formatTeslaParkingFee
import net.vonforst.evmap.adapter.formatTeslaPricing
import net.vonforst.evmap.api.availability.AvailabilityRepository
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.fronyx.FronyxApi
import net.vonforst.evmap.api.fronyx.PredictionData
import net.vonforst.evmap.api.fronyx.PredictionRepository
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
@@ -52,26 +47,22 @@ import net.vonforst.evmap.viewmodel.awaitFinished
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
@ExperimentalCarApi
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
private val TAB_MAIN = "main"
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
private var prediction: PredictionData? = null
private var fronyxSupported = false
private var teslaSupported = false
val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val availabilityRepo = AvailabilityRepository(ctx)
private val predictionRepo = PredictionRepository(ctx)
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val imageSize = 128 // images should be 128dp according to docs
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
@@ -82,6 +73,8 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
private val largeImageSupported =
ctx.carAppApiLevel >= 4 // since API 4, Row.setImage is supported
private val tabsSupported = ctx.carAppApiLevel >= 6
private var currentTab = TAB_MAIN
private var favorite: Favorite? = null
private var favoriteUpdateJob: Job? = null
@@ -93,6 +86,45 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
override fun onGetTemplate(): Template {
if (charger == null) loadCharger()
if (tabsSupported) {
return generateTabs()
} else {
return generateMainPane()
}
}
private fun generateTabs(): TabTemplate {
return TabTemplate.Builder(object : TabCallback {
override fun onTabSelected(tabContentId: String) {
currentTab = tabContentId
invalidate()
}
}).apply {
charger?.let {
addTab(
Tab.Builder()
.setTitle(carContext.getString(R.string.general_info))
.setIcon(CarIcon.APP_ICON)
.setContentId(TAB_MAIN).build()
)
addTab(
Tab.Builder()
.setTitle("bla")
.setIcon(CarIcon.APP_ICON)
.setContentId("bla").build()
)
val contents = when (currentTab) {
TAB_MAIN -> generateMainPane()
else -> throw IllegalArgumentException("invalid tab")
}
setTabContents(TabContents.Builder(contents).build())
setActiveTabContentId(currentTab)
} ?: setLoading(true)
setHeaderAction(Action.APP_ICON)
}.build()
}
private fun generateMainPane(): PaneTemplate {
return PaneTemplate.Builder(
Pane.Builder().apply {
charger?.let { charger ->
@@ -114,28 +146,28 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
)
.setTitle(carContext.getString(R.string.navigate))
.setFlags(Action.FLAG_PRIMARY)
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
navigateToCharger(charger)
}
.build())
if (ChargepriceApi.isChargerSupported(charger)) {
addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
)
.setTitle(carContext.getString(R.string.auto_prices))
.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())
}
}
} ?: setLoading(true)
}.build()
).apply {
@@ -304,106 +336,9 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}.build())
}
}
if (rows.count() < maxRows && charger.generalInformation != null) {
rows.add(Row.Builder().apply {
setTitle(carContext.getString(R.string.general_info))
addText(charger.generalInformation)
}.build())
}
if (rows.count() < maxRows && charger.amenities != null) {
rows.add(Row.Builder().apply {
setTitle(carContext.getString(R.string.amenities))
addText(charger.amenities)
}.build())
}
if (rows.count() < maxRows && ((fronyxSupported && prefs.predictionEnabled) || teslaSupported)) {
rows.add(1, Row.Builder().apply {
setTitle(
if (fronyxSupported) {
carContext.getString(R.string.utilization_prediction) + " (" + carContext.getString(
R.string.powered_by_fronyx
) + ")"
} else carContext.getString(R.string.average_utilization)
)
generatePredictionGraph()?.let { addText(it) }
?: addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
}.build())
}
if (rows.count() < maxRows && teslaSupported) {
val teslaPricing = availability?.extraData as? TeslaGraphQlApi.Pricing
rows.add(3, Row.Builder().apply {
setTitle(carContext.getString(R.string.cost))
teslaPricing?.let {
var text = formatTeslaPricing(teslaPricing, carContext) as CharSequence
formatTeslaParkingFee(teslaPricing, carContext)?.let { text += "\n\n" + it }
addText(text)
} ?: {
addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
}
}.build())
}
return rows
}
private fun generatePredictionGraph(): CharSequence? {
val predictionData = prediction ?: return null
val graphData = predictionData.predictionGraph?.toList() ?: return null
val maxValue = predictionData.maxValue
val maxWidth = if (BuildConfig.FLAVOR_automotive == "automotive") 25 else 18
val step = maxOf(graphData.size.toFloat() / maxWidth, 1f)
val values = graphData.map { it.second }
val graph = buildGraph(values, step, maxValue, predictionData.isPercentage)
val measurer = TextMeasurer(carContext)
val width = measurer.measureText(graph)
val startTime = timeFormat.format(graphData[0].first)
val endTime = timeFormat.format(graphData.last().first)
val baseWidth = measurer.measureText(startTime + endTime)
val spaceWidth = measurer.measureText(" ")
val numSpaces = floor((width - baseWidth) / spaceWidth).toInt()
val legend = startTime + " ".repeat(numSpaces) + endTime
return graph + "\n" + legend
}
private fun buildGraph(
values: List<Double>,
step: Float,
maxValue: Double,
isPercentage: Boolean
): CharSequence {
val sparklines = "▁▂▃▄▅▆▇█"
val graph = SpannableStringBuilder()
var i = 0f
while (i.roundToInt() < values.size) {
val v = values[i.roundToInt()]
val fraction = v / maxValue
val sparkline = sparklines[(fraction * (sparklines.length - 1)).roundToInt()].toString()
val color = if (isPercentage) {
when (v) {
in 0.0..0.5 -> CarColor.GREEN
in 0.5..0.8 -> CarColor.YELLOW
else -> CarColor.RED
}
} else {
if (v < maxValue) CarColor.GREEN else CarColor.RED
}
graph.append(
sparkline,
ForegroundCarColorSpan.create(color),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
i += step
}
return graph
}
private fun generateCostStatusText(cost: Cost): CharSequence {
val string = SpannableString(cost.getStatusText(carContext, emoji = true))
// replace emoji with CarIcon
@@ -482,10 +417,8 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
} else {
append(nameForPlugType(carContext.stringProvider(), cp.type))
}
cp.formatPower()?.let {
append(" ")
append(it)
}
append(" ")
append(cp.formatPower())
}
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
@@ -520,7 +453,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val intent =
Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:${coord.lat},${coord.lng}")
Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
)
carContext.startCarApp(intent)
}
@@ -576,23 +509,12 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
)
this@ChargerDetailScreen.photo = outImg
}
fronyxSupported = charger.chargepoints.any {
FronyxApi.isChargepointSupported(
charger,
it
)
} && !availabilityRepo.isSupercharger(charger)
teslaSupported = availabilityRepo.isTeslaSupported(charger)
invalidate()
availability = availabilityRepo.getAvailability(charger).data
invalidate()
prediction = predictionRepo.getPredictionData(charger, availability)
invalidate()
} else {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)

View File

@@ -41,9 +41,7 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
page = 0
} else {
val index =
paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
page = index.takeUnless { it == -1 } ?: 0
page = paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
}
invalidate()
}
@@ -227,7 +225,7 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_item,
R.string.deleted_filterprofile,
it.name
),
CarToast.LENGTH_SHORT
@@ -344,7 +342,7 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_item,
R.string.deleted_filterprofile,
currentProfile.name
),
CarToast.LENGTH_SHORT

View File

@@ -1,31 +0,0 @@
package net.vonforst.evmap.auto
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.add
import androidx.fragment.app.commit
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import net.vonforst.evmap.R
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
class OAuthLoginActivity : AppCompatActivity(R.layout.activity_oauth_login) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
add<OAuthLoginFragment>(R.id.fragment_container_view, args = intent.extras)
}
}
LocalBroadcastManager.getInstance(this).registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
finish()
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
}
}

View File

@@ -116,7 +116,7 @@ class PlaceSearchScreen(
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id) ?: return@launch
val placeDetails = getDetails(place.id)
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
@@ -226,9 +226,9 @@ class PlaceSearchScreen(
}
}
suspend fun getDetails(id: String): PlaceWithBounds? {
suspend fun getDetails(id: String): PlaceWithBounds {
val provider = currentProvider!!
val result = resultList?.find { it.id == id } ?: return null
val result = resultList!!.find { it.id == id }!!
val recentPlace = recentResults.find { it.id == id }
if (recentPlace != null) return recentPlace.asPlaceWithBounds()

View File

@@ -7,11 +7,8 @@ import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import okio.IOException
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
SearchTemplate.SearchCallback {
@@ -25,20 +22,9 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
override fun onGetTemplate(): Template {
if (fullList == null) {
lifecycleScope.launch {
try {
fullList = loadData()
filterList()
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.generic_connection_error,
CarToast.LENGTH_LONG
).show()
screenManager.pop()
}
}
fullList = loadData()
filterList()
invalidate()
}
}

View File

@@ -1,15 +1,10 @@
package net.vonforst.evmap.auto
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
import android.net.Uri
import android.os.Bundle
import android.os.IInterface
import android.text.Html
import androidx.annotation.StringRes
import androidx.car.app.CarContext
@@ -18,27 +13,16 @@ import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.TeslaOwnerApi
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
import net.vonforst.evmap.storage.PreferenceDataSource
import okhttp3.OkHttpClient
import java.io.IOException
import java.time.Instant
import kotlin.math.max
import kotlin.math.min
@@ -141,7 +125,6 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
val encryptedPrefs = EncryptedPreferenceDataStore(ctx)
val db = AppDatabase.getInstance(ctx)
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
@@ -151,8 +134,6 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
val searchProviderValues =
carContext.resources.getStringArray(R.array.pref_search_provider_values)
var teslaLoggingIn = false
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_data_sources))
@@ -202,122 +183,9 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
}
}
}.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.pref_prediction_enabled))
.addText(carContext.getString(R.string.pref_prediction_enabled_summary))
.setToggle(Toggle.Builder {
prefs.predictionEnabled = it
}.setChecked(prefs.predictionEnabled).build())
.build()
)
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_tesla_account))
addText(
if (encryptedPrefs.teslaRefreshToken != null) {
carContext.getString(
R.string.pref_tesla_account_enabled,
encryptedPrefs.teslaEmail
)
} else if (teslaLoggingIn) {
carContext.getString(R.string.logging_in)
} else {
carContext.getString(R.string.pref_tesla_account_disabled)
}
)
if (encryptedPrefs.teslaRefreshToken != null) {
setOnClickListener {
teslaLogout()
}
} else {
setOnClickListener(ParkedOnlyOnClickListener.create {
teslaLogin()
})
}
}.build())
}.build())
}.build()
}
private fun teslaLogin() {
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
val args = OAuthLoginFragmentArgs(
uri.toString(),
TeslaAuthenticationApi.resultUrlPrefix,
"#000000"
).toBundle()
val intent = Intent(carContext, OAuthLoginActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtras(args)
LocalBroadcastManager.getInstance(carContext)
.registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val url = IntentCompat.getParcelableExtra(
intent,
OAuthLoginFragment.EXTRA_URL,
Uri::class.java
)
teslaGetAccessToken(url!!, codeVerifier)
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
carContext.startActivity(intent)
if (BuildConfig.FLAVOR_automotive != "automotive") {
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
}
}
private fun teslaGetAccessToken(url: Uri, codeVerifier: String) {
teslaLoggingIn = true
invalidate()
val code = url.getQueryParameter("code") ?: return
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
lifecycleScope.launch {
try {
val time = Instant.now().epochSecond
val response =
TeslaAuthenticationApi.create(okhttp).getToken(request)
val userResponse =
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
encryptedPrefs.teslaEmail = userResponse.response.email
encryptedPrefs.teslaAccessToken = response.accessToken
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
encryptedPrefs.teslaRefreshToken = response.refreshToken
} catch (e: IOException) {
CarToast.makeText(
carContext,
R.string.generic_connection_error,
CarToast.LENGTH_SHORT
).show()
} finally {
teslaLoggingIn = false
}
invalidate()
}
}
private fun teslaLogout() {
// sign out
encryptedPrefs.teslaRefreshToken = null
encryptedPrefs.teslaAccessToken = null
encryptedPrefs.teslaAccessTokenExpiry = -1
encryptedPrefs.teslaEmail = null
CarToast.makeText(carContext, R.string.logged_out, CarToast.LENGTH_SHORT).show()
invalidate()
}
}
class ChooseDataSourceScreen(

View File

@@ -4,9 +4,7 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Typeface
import android.net.Uri
import android.text.TextPaint
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
@@ -260,17 +258,4 @@ class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
.build()
}
}
class TextMeasurer(ctx: CarContext) {
val textPaint = TextPaint()
init {
textPaint.textSize = ctx.resources.displayMetrics.density * 24
textPaint.typeface = Typeface.DEFAULT
}
fun measureText(text: CharSequence): Float {
return textPaint.measureText(text, 0, text.length)
}
}

View File

@@ -32,7 +32,6 @@ import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
import net.vonforst.evmap.databinding.FragmentChargepriceHeaderBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
import net.vonforst.evmap.viewmodel.Status
@@ -82,7 +81,7 @@ class ChargepriceFragment : Fragment() {
}
.setPositiveButton(R.string.donate) { di, _ ->
di.dismiss()
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToDonateFragment())
findNavController().navigate(R.id.action_chargeprice_to_donateFragment)
}
.show()
}
@@ -168,7 +167,7 @@ class ChargepriceFragment : Fragment() {
chargepriceAdapter.myTariffsAll = it
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
chargepriceAdapter.submitList(it?.data ?: emptyList())
it?.data?.let { chargepriceAdapter.submitList(it) }
}
val connectorsAdapter = CheckableConnectorAdapter()
@@ -198,7 +197,7 @@ class ChargepriceFragment : Fragment() {
}
binding.btnSettings.setOnClickListener {
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToChargepriceSettingsFragment())
findNavController().navigate(R.id.action_chargeprice_to_chargepriceSettingsFragment)
}
headerBinding.batteryRange.setLabelFormatter { value: Float ->

View File

@@ -28,6 +28,7 @@ import net.vonforst.evmap.databinding.FragmentFavoritesBinding
import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.model.FavoriteWithDetail
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.viewmodel.FavoritesViewModel
@@ -36,6 +37,7 @@ import net.vonforst.evmap.viewmodel.viewModelFactory
class FavoritesFragment : Fragment() {
private lateinit var binding: FragmentFavoritesBinding
private lateinit var locationEngine: LocationEngine
private var toDelete: Favorite? = null
private var deleteSnackbar: Snackbar? = null
private lateinit var adapter: FavoritesAdapter
@@ -111,32 +113,6 @@ class FavoritesFragment : Fragment() {
binding.swipeRefresh.isRefreshing = false
}
}
vm.deletedFavorite.observe(viewLifecycleOwner) { fav ->
if (fav == null) {
deleteSnackbar?.dismiss()
return@observe
}
val snackbar = Snackbar.make(
requireView(),
getString(
R.string.deleted_item,
fav.charger.name
),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
vm.undoDeletion()
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
// if undo was not clicked, actually delete
if (event == DISMISS_EVENT_TIMEOUT || event == DISMISS_EVENT_SWIPE) {
vm.deletedFavorite.value = null
}
}
})
deleteSnackbar = snackbar
snackbar.show()
}
}
override fun onStart() {
@@ -151,7 +127,41 @@ class FavoritesFragment : Fragment() {
}
fun delete(fav: FavoriteWithDetail) {
vm.deleteFavoriteWithUndo(fav)
val position =
vm.listData.value?.indexOfFirst { it.fav.favorite.favoriteId == fav.favorite.favoriteId }
?: return
// if there is already a profile to delete, delete it now
actuallyDelete()
deleteSnackbar?.dismiss()
toDelete = fav.favorite
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fav.charger.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
toDelete = null
adapter.notifyItemChanged(position)
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
// if undo was not clicked, actually delete
if (event == DISMISS_EVENT_TIMEOUT || event == DISMISS_EVENT_SWIPE) {
actuallyDelete()
}
}
})
deleteSnackbar = snackbar
snackbar.show()
} ?: run {
actuallyDelete()
}
}
private fun actuallyDelete() {
toDelete?.let { vm.deleteFavorite(it) }
toDelete = null
}
private fun createTouchHelper(): ItemTouchHelper {

View File

@@ -230,7 +230,7 @@ class FilterProfilesFragment : Fragment() {
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_item, fp.name),
getString(R.string.deleted_filterprofile, fp.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
toDelete = null

View File

@@ -3,12 +3,9 @@ package net.vonforst.evmap.fragment
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.text.method.KeyListener
import android.view.*
@@ -75,11 +72,11 @@ import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.bold
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.fragment.preference.DataSettingsFragmentArgs
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.model.*
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.*
@@ -87,7 +84,6 @@ import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.utils.checkFineLocationPermission
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.utils.formatDecimal
import net.vonforst.evmap.viewmodel.*
import java.io.IOException
import kotlin.collections.component1
@@ -281,7 +277,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
try {
findNavController().safeNavigate(MapFragmentDirections.actionMapToOpensourceDonations())
findNavController().navigate(R.id.action_map_to_opensource_donations)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
} catch (ignored: IllegalStateException) {
@@ -290,7 +286,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
/*if (!prefs.update060AndroidAutoDialogShown) {
try {
navController.safeNavigate(MapFragmentDirections.actionMapToUpdate060AndroidAuto())
navController.navigate(R.id.action_map_to_update_060_androidauto)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
}
@@ -379,9 +375,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val charger = vm.charger.value?.data ?: return@setOnClickListener
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().safeNavigate(
MapFragmentDirections.actionMapToChargepriceFragment(charger),
extras
findNavController().navigate(
R.id.action_map_to_chargepriceFragment,
ChargepriceFragmentArgs(charger).toBundle(),
null, extras
)
}
binding.detailView.btnChargerWebsite.setOnClickListener {
@@ -389,8 +386,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
binding.detailView.btnLogin.setOnClickListener {
findNavController().safeNavigate(
MapFragmentDirections.actionMapToDataSettings(true)
findNavController().navigate(
R.id.settings_data,
DataSettingsFragmentArgs(true).toBundle()
)
}
binding.detailView.imgPredictionSource.setOnClickListener {
@@ -826,69 +824,15 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
R.drawable.ic_fault_report -> {
(activity as? MapsActivity)?.openUrl(charger.url)
}
R.drawable.ic_payment -> {
showPaymentMethodsDialog(charger)
}
R.drawable.ic_network -> {
charger.networkUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
}
}
}
onLongClickListener = {
val charger = vm.chargerDetails.value?.data
val clipboardManager =
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
if (charger != null) {
when (it.icon) {
R.drawable.ic_address -> {
if (charger.address != null) {
val clip = ClipData.newPlainText(
getString(R.string.address),
charger.address.toString()
)
clipboardManager.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Snackbar.make(
requireView(),
R.string.copied,
Toast.LENGTH_SHORT
)
.show()
}
true
} else {
false
}
}
R.drawable.ic_location -> {
val clip = ClipData.newPlainText(
getString(R.string.coordinates),
charger.coordinates.formatDecimal()
)
clipboardManager.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Snackbar.make(
requireView(),
R.string.copied,
Toast.LENGTH_SHORT
)
.show()
}
true
}
else -> false
}
} else {
false
}
}
}
itemAnimator = null
layoutManager =
@@ -1099,7 +1043,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.search.requestFocus()
binding.search.setSelection(locationName.length)
}
if (context.checkAnyLocationPermission() && prefs.currentMapMyLocationEnabled) {
if (context.checkAnyLocationPermission()) {
enableLocation(!positionSet, false)
positionSet = true
}
@@ -1281,29 +1225,26 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
MenuCompat.setGroupDividerEnabled(popup.menu, true)
popup.setForceShowIcon(true)
popup.setOnMenuItemClickListener {
val navController = requireView().findNavController()
when (it.itemId) {
R.id.menu_edit_filters -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
lifecycleScope.launch {
vm.copyFiltersToCustom()
navController.safeNavigate(
MapFragmentDirections.actionMapToFilterFragment()
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
)
}
true
}
R.id.menu_manage_filter_profiles -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
navController.safeNavigate(
MapFragmentDirections.actionMapToFilterProfilesFragment()
requireView().findNavController().navigate(
R.id.action_map_to_filterProfilesFragment
)
true
}
else -> {
val profileId = profilesMap.inverse[it]
if (profileId != null) {
@@ -1384,6 +1325,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
filterView?.setOnLongClickListener {
// enable/disable filters
vm.toggleFilters()
// haptic feedback
filterView.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS,
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
)
// show snackbar
Snackbar.make(
requireView(), if (vm.filterStatus.value != FILTERS_DISABLED) {
@@ -1454,9 +1400,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
prefs.currentMapLocation = it.bounds.center
prefs.currentMapZoom = it.zoom
}
vm.myLocationEnabled.value?.let {
prefs.currentMapMyLocationEnabled = it
}
}
override fun onDestroy() {

View File

@@ -14,7 +14,6 @@ import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.viewpager2.adapter.FragmentStateAdapter
@@ -22,7 +21,6 @@ import androidx.viewpager2.widget.ViewPager2
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.*
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
class OnboardingFragment : Fragment() {
@@ -84,7 +82,7 @@ class OnboardingFragment : Fragment() {
fun goToNext() {
if (binding.viewPager.currentItem == adapter.itemCount - 1) {
findNavController().safeNavigate(OnboardingFragmentDirections.actionOnboardingToMap())
findNavController().navigate(R.id.action_onboarding_to_map)
} else {
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
}
@@ -227,12 +225,7 @@ class DataSourceSelectFragment : OnboardingPageFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.cbAcceptPrivacy.text =
HtmlCompat.fromHtml(
getString(
R.string.accept_privacy,
getString(R.string.privacy_link)
), HtmlCompat.FROM_HTML_MODE_LEGACY
)
Html.fromHtml(getString(R.string.accept_privacy, getString(R.string.privacy_link)))
binding.cbAcceptPrivacy.linksClickable = true
binding.cbAcceptPrivacy.movementMethod = LinkMovementMethod.getInstance()
binding.btnGetStarted.visibility = View.INVISIBLE

View File

@@ -1,7 +1,6 @@
package net.vonforst.evmap.fragment.oauth
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
@@ -14,25 +13,17 @@ import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import java.lang.IllegalStateException
class OAuthLoginFragment : Fragment() {
companion object {
val ACTION_OAUTH_RESULT = "oauth_result"
val EXTRA_URL = "url"
}
private lateinit var webView: WebView
override fun onCreate(savedInstanceState: Bundle?) {
@@ -52,24 +43,10 @@ class OAuthLoginFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
val navController = try {
findNavController()
} catch (e: IllegalStateException) {
null
// standalone in OAuthLoginActivity
}
if (navController != null) {
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
} else {
toolbar.title = getString(R.string.login)
toolbar.navigationIcon =
AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_back)
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
}
toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
val uri = Uri.parse(args.url)
@@ -91,12 +68,7 @@ class OAuthLoginFragment : Fragment() {
val result = Bundle()
result.putString("url", url.toString())
setFragmentResult(args.url, result)
context?.let {
LocalBroadcastManager.getInstance(it).sendBroadcast(
Intent(ACTION_OAUTH_RESULT).putExtra(EXTRA_URL, url)
)
}
navController?.popBackStack()
findNavController().popBackStack()
}
return url.host != uri.host

View File

@@ -16,7 +16,6 @@ import com.mikepenz.aboutlibraries.LibsBuilder
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -102,17 +101,18 @@ class AboutFragment : PreferenceFragmentCompat() {
.withAboutVersionShown(false)
.withAboutIconShown(false)
.withActivityTitle(getString(R.string.oss_licenses))
.withExcludedLibraries()
.start(requireActivity())
true
}
"donate" -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToDonateFragment())
findNavController().navigate(R.id.action_about_to_donateFragment)
true
}
"github_sponsors" -> {
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToGithubSponsors())
findNavController().navigate(R.id.action_about_to_github_sponsors)
true
}
"twitter" -> {

View File

@@ -141,11 +141,18 @@ class DataSettingsFragment : BaseSettingsFragment() {
private fun teslaLogin() {
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
val uri = Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
.appendQueryParameter("client_id", "ownerapi")
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid email offline_access")
.appendQueryParameter("state", "123").build()
val args = OAuthLoginFragmentArgs(
uri.toString(),
TeslaAuthenticationApi.resultUrlPrefix,
"https://auth.tesla.com/void/callback",
"#000000"
).toBundle()
@@ -177,8 +184,7 @@ class DataSettingsFragment : BaseSettingsFragment() {
encryptedPrefs.teslaRefreshToken = response.refreshToken
} catch (e: IOException) {
view?.let {
Snackbar.make(it, R.string.generic_connection_error, Snackbar.LENGTH_SHORT)
.show()
Snackbar.make(it, R.string.connection_error, Snackbar.LENGTH_SHORT).show()
}
}
refreshTeslaAccountStatus()

View File

@@ -7,7 +7,6 @@ import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.MaterialDialogFragment
@@ -31,11 +30,11 @@ class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
}
binding.btnDonate.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToDonate())
findNavController().navigate(R.id.action_opensource_donations_to_donate)
}
binding.btnGithubSponsors.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToGithubSponsors())
findNavController().navigate(R.id.action_opensource_donations_to_github_sponsors)
}
}

View File

@@ -1,17 +0,0 @@
package net.vonforst.evmap.navigation
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.Navigator
fun NavController.safeNavigate(
direction: NavDirections,
navigatorExtras: Navigator.Extras? = null
) {
currentDestination?.getAction(direction.actionId) ?: return
if (navigatorExtras != null) {
navigate(direction, navigatorExtras)
} else {
navigate(direction)
}
}

View File

@@ -261,11 +261,8 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putBoolean("show_chargers_ahead_android_auto", value).apply()
}
var predictionEnabled: Boolean
val predictionEnabled: Boolean
get() = sp.getBoolean("prediction_enabled", true)
set(value) {
sp.edit().putBoolean("prediction_enabled", value).apply()
}
var developerModeEnabled: Boolean
get() = sp.getBoolean("dev_mode_enabled", false)
@@ -294,12 +291,6 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putFloat("current_map_zoom", value).apply()
}
var currentMapMyLocationEnabled: Boolean
get() = sp.getBoolean("current_map_my_location_enabled", false)
set(value) {
sp.edit().putBoolean("current_map_my_location_enabled", value).apply()
}
var privacyAccepted: Boolean
get() = sp.getBoolean("privacy_accepted", false)
set(value) {

View File

@@ -9,7 +9,6 @@ import android.text.TextPaint
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
@@ -28,6 +27,7 @@ import kotlin.math.roundToInt
class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) {
private val dp = context.resources.displayMetrics.density
private val sp = context.resources.displayMetrics.scaledDensity
var zeroHeight = 4 * dp
var barWidth = 16 * dp
var barMargin = 2 * dp
@@ -35,9 +35,7 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
var legendLineLength = 4 * dp
var legendLineWidth = 1 * dp
var dashLength = 4 * dp
var bubbleTextSize =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, context.resources.displayMetrics)
.roundToInt()
var bubbleTextSize = (12 * sp).roundToInt()
var bubblePadding = (6 * dp).roundToInt()
var selectedBar: Int = 0
var bubbleStrokeWidth = 1 * dp

View File

@@ -116,7 +116,6 @@ class ChargepriceViewModel(
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
value = state["chargePrices"] ?: Resource.loading(null)
listOf(
vehicle,
batteryRange,
batteryRangeSliderDragging,
vehicleCompatibleConnectors,

View File

@@ -33,7 +33,7 @@ class FavoritesViewModel(application: Application) :
MediatorLiveData<Map<Long, Resource<ChargeLocationStatus>>>().apply {
addSource(favorites) { favorites ->
if (favorites != null) {
reloadAvailability(forceReload = false)
reloadAvailability()
} else {
value = null
}
@@ -41,10 +41,9 @@ class FavoritesViewModel(application: Application) :
}
}
fun reloadAvailability(forceReload: Boolean = true, callback: (() -> Unit)? = null) {
fun reloadAvailability(callback: (() -> Unit)? = null) {
val favorites = favorites.value ?: return
val chargers = favorites.map { it.charger }
val previous = availability.value
viewModelScope.launch {
val data = hashMapOf<Long, Resource<ChargeLocationStatus>>()
@@ -55,13 +54,8 @@ class FavoritesViewModel(application: Application) :
chargers.map { charger ->
async {
if (!forceReload && previous?.get(charger.id)?.status == Status.SUCCESS) {
data[charger.id] = previous[charger.id]!!
availability.value = data
} else {
data[charger.id] = availabilityRepo.getAvailability(charger)
availability.value = data
}
data[charger.id] = availabilityRepo.getAvailability(charger)
availability.value = data
}
}.awaitAll()
callback?.invoke()
@@ -123,24 +117,6 @@ class FavoritesViewModel(application: Application) :
}
}
val deletedFavorite: MutableLiveData<FavoriteWithDetail?> by lazy {
MutableLiveData<FavoriteWithDetail?>()
}
fun deleteFavoriteWithUndo(fav: FavoriteWithDetail) {
deletedFavorite.value = fav
deleteFavorite(fav.favorite)
}
fun undoDeletion() {
deletedFavorite.value?.let {
viewModelScope.launch {
insertFavorite(it.charger)
}
deletedFavorite.value = null
}
}
fun deleteFavorite(fav: Favorite) {
viewModelScope.launch {
db.favoritesDao().delete(fav)

View File

@@ -24,8 +24,6 @@ import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.fronyx.FronyxApi
import net.vonforst.evmap.api.fronyx.FronyxEvseIdResponse
import net.vonforst.evmap.api.fronyx.FronyxStatus
import net.vonforst.evmap.api.fronyx.PredictionData
import net.vonforst.evmap.api.fronyx.PredictionRepository
import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.openchargemap.OCMConnection
@@ -252,12 +250,155 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
it.data?.extraData as? TeslaGraphQlApi.Pricing
}
private val predictionRepository = PredictionRepository(application)
val predictionApi = FronyxApi(application.getString(R.string.fronyx_key))
val predictionData: LiveData<PredictionData> = availability.switchMap { av ->
liveData {
val charger = charger.value?.data ?: return@liveData
emit(predictionRepository.getPredictionData(charger, av.data, filteredConnectors.value))
val prediction: LiveData<Resource<List<FronyxEvseIdResponse>>> by lazy {
availability.switchMap { av ->
if (!prefs.predictionEnabled) return@switchMap null
av.data?.evseIds?.let { evseIds ->
liveData {
emit(Resource.loading(null))
val charger = charger.value?.data ?: return@liveData
val allEvseIds =
evseIds.filterKeys {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors.value?.let { filtered ->
equivalentPlugTypes(
it.type
).any { filtered.contains(it) }
} ?: true
}.flatMap { it.value }
if (allEvseIds.isEmpty()) {
emit(Resource.success(emptyList()))
return@liveData
}
try {
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
if (result.size == allEvseIds.size) {
emit(Resource.success(result))
} else {
emit(Resource.error("not all EVSEIDs found", null))
}
} catch (e: IOException) {
emit(Resource.error(e.message, null))
e.printStackTrace()
} catch (e: HttpException) {
emit(Resource.error(e.message, null))
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
emit(Resource.error(e.message, null))
e.printStackTrace()
} catch (e: JsonDataException) {
// malformed JSON response from fronyx API
emit(Resource.error(e.message, null))
e.printStackTrace()
}
}
} ?: liveData { emit(Resource.success(null)) }
}
}
val predictionGraph: LiveData<Map<ZonedDateTime, Double>?> =
MediatorLiveData<Map<ZonedDateTime, Double>?>().apply {
listOf(prediction, availability).forEach {
addSource(it) {
val congestionHistogram = availability.value?.data?.congestionHistogram
val prediction = prediction.value?.data
value = if (congestionHistogram != null && prediction == null) {
congestionHistogram.mapIndexed { i, value ->
LocalTime.of(i, 0).atDate(LocalDate.now())
.atZone(ZoneId.systemDefault()) to value
}.toMap()
} else {
prediction?.let { responses ->
if (responses.isEmpty()) {
null
} else {
val evseIds = responses.map { it.evseId }
val groupByTimestamp = responses.flatMap { response ->
response.predictions.map {
Triple(
it.timestamp,
response.evseId,
it.status
)
}
}
.groupBy { it.first } // group by timestamp
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
groupByTimestamp.mapValues {
it.value.count {
it.second == FronyxStatus.UNAVAILABLE
}.toDouble()
}.ifEmpty { null }
}
}
}
}
}
}
private val predictedChargepoints = charger.map {
it.data?.let { charger ->
charger.chargepoints.filter {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors.value?.let { filtered ->
equivalentPlugTypes(it.type).any {
filtered.contains(
it
)
}
} ?: true
}
}
}
val predictionMaxValue: LiveData<Double> = MediatorLiveData<Double>().apply {
listOf(prediction, availability).forEach {
addSource(it) {
value =
if (availability.value?.data?.congestionHistogram != null && prediction.value?.data == null) {
1.0
} else {
(predictedChargepoints.value?.sumOf { it.count } ?: 0).toDouble()
}
}
}
}
val predictionIsPercentage: LiveData<Boolean> = MediatorLiveData<Boolean>().apply {
listOf(prediction, availability).forEach {
addSource(it) {
value =
availability.value?.data?.congestionHistogram != null && prediction.value?.data == null
}
}
}
val predictionDescription: LiveData<String?> by lazy {
predictedChargepoints.map { predictedChargepoints ->
if (predictedChargepoints == null) return@map null
val allChargepoints = charger.value?.data?.chargepoints ?: return@map null
val predictedChargepointTypes = predictedChargepoints.map { it.type }.distinct()
if (allChargepoints == predictedChargepoints) {
null
} else if (predictedChargepointTypes.size == 1) {
application.getString(
R.string.prediction_only,
nameForPlugType(application.stringProvider(), predictedChargepointTypes[0])
)
} else {
application.getString(
R.string.prediction_only,
application.getString(R.string.prediction_dc_plugs_only)
)
}
}
}

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_container_view"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:fitsSystemWindows="true" />

View File

@@ -37,8 +37,6 @@
<import type="java.time.Duration" />
<import type="net.vonforst.evmap.api.fronyx.PredictionData" />
<variable
name="charger"
type="Resource&lt;ChargeLocation&gt;" />
@@ -52,8 +50,20 @@
type="Resource&lt;ChargeLocationStatus&gt;" />
<variable
name="predictionData"
type="PredictionData" />
name="predictionGraph"
type="Map&lt;ZonedDateTime, Double&gt;" />
<variable
name="predictionMaxValue"
type="Double" />
<variable
name="predictionIsPercentage"
type="Boolean" />
<variable
name="predictionDescription"
type="String" />
<variable
name="filteredAvailability"
@@ -357,11 +367,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@{predictionData.isPercentage ? @string/average_utilization : @string/utilization_prediction}"
android:text="@{predictionIsPercentage ? @string/average_utilization : @string/utilization_prediction}"
tools:text="@string/utilization_prediction"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:goneUnless="@{predictionGraph != null}"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider2" />
@@ -371,9 +381,9 @@
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="@{predictionData.description}"
android:text="@{predictionDescription}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
app:layout_constraintStart_toEndOf="@+id/textView8"
@@ -385,7 +395,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/help"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:icon="@drawable/ic_help"
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView8"
@@ -397,13 +407,13 @@
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_marginTop="8dp"
app:data="@{predictionData.predictionGraph}"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:data="@{predictionGraph}"
app:goneUnless="@{predictionGraph != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView8"
app:maxValue="@{predictionData.maxValue}"
app:isPercentage="@{predictionData.isPercentage}"
app:maxValue="@{predictionMaxValue}"
app:isPercentage="@{predictionIsPercentage}"
tools:itemCount="3"
tools:layoutManager="LinearLayoutManager"
tools:listitem="@layout/item_connector"
@@ -417,7 +427,7 @@
android:adjustViewBounds="true"
android:background="?selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
app:goneUnless="@{predictionData.predictionGraph != null &amp;&amp; !predictionData.isPercentage}"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toBottomOf="@+id/prediction"
app:srcCompat="@drawable/ic_powered_by_fronyx"
@@ -429,7 +439,7 @@
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:goneUnless="@{predictionData.predictionGraph != null}"
app:goneUnless="@{predictionGraph != null}"
app:layout_constraintTop_toBottomOf="@+id/imgPredictionSource" />
<ImageView

View File

@@ -197,7 +197,10 @@
app:charger="@{vm.charger}"
app:availability="@{vm.availability}"
app:filteredAvailability="@{vm.filteredAvailability}"
app:predictionData="@{vm.predictionData}"
app:predictionGraph="@{vm.predictionGraph}"
app:predictionMaxValue="@{vm.predictionMaxValue}"
app:predictionIsPercentage="@{vm.predictionIsPercentage}"
app:predictionDescription="@{vm.predictionDescription}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}"

View File

@@ -32,9 +32,6 @@
<action
android:id="@+id/action_map_to_opensource_donations"
app:destination="@id/opensource_donations" />
<action
android:id="@+id/action_map_to_data_settings"
app:destination="@id/settings_data" />
<argument
android:name="locationName"
android:defaultValue="@null"

View File

@@ -161,7 +161,7 @@
<string name="donation_dialog_detail">EVMap ist kostenlos und Open Source. Über GitHub kann jeder zur Weiterentwicklung der App beitragen. Um die laufenden Kosten für die Datenquellen zu decken, freut sich der Entwickler über Spenden mit einem Betrag deiner Wahl.</string>
<string name="chargeprice_donation_dialog_title">Du bist ein richtiger Sparfuchs!</string>
<string name="chargeprice_donation_dialog_detail">Anscheinend nutzt du den Preisvergleich sehr gern. Mit einer Spende für EVMap kannst du helfen, die Kosten für den Datenzugriff zu decken.</string>
<string name="deleted_item">„%s” gelöscht</string>
<string name="deleted_filterprofile">„%s” gelöscht</string>
<string name="undo">Rückgängig</string>
<string name="rename">Umbenennen</string>
<string name="charging_barrierfree">Ohne Vertrag / Registrierung nutzbar</string>
@@ -275,7 +275,6 @@
<string name="about_contributors">Mitwirkende</string>
<string name="about_contributors_text">Dank an alle Mitwirkenden für ihre Beiträge von Code und Übersetzungen für EVMap:</string>
<string name="utilization_prediction">Auslastungsprognose</string>
<string name="powered_by_fronyx">powered by fronyx</string>
<string name="prediction_help">Die Prognose basiert auf Faktoren wie Wochentag, Uhrzeit und Nutzung in der Vergangenheit. So kannst du stark ausgelastete Ladesäulen vermeiden. Keine Garantie.</string>
<string name="prediction_time_colon">%s Uhr:</string>
<plurals name="prediction_number_available">
@@ -305,8 +304,8 @@
<string name="logging_in">Anmelden…</string>
<string name="log_out">Abmelden</string>
<string name="logged_out">Abgemeldet</string>
<string name="login">Anmelden</string>
<string name="login_error">Anmeldung fehlgeschlagen</string>
<string name="login">Login</string>
<string name="login_error">Login fehlgeschlagen</string>
<string name="tesla_pricing_owners">Nur Tesla-Fahrzeuge:</string>
<string name="tesla_pricing_members">Tesla-Fahrzeuge &amp; Mitglieder:</string>
<string name="tesla_pricing_others">Andere Kunden:</string>
@@ -326,7 +325,7 @@
<string name="settings_cache_count">Cache-Größe</string>
<string name="settings_cache_clear">Cache leeren</string>
<string name="settings_cache_clear_summary">Löscht alle gespeicherten Ladestationen außer Favoriten</string>
<string name="settings_cache_count_summary">%1$d Ladestationen gespeichert, %2$.1f MB</string>
<string name="settings_cache_count_summary">%d Ladestationen gespeichert, %.1f MB</string>
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
<string name="auto_no_favorites_found">Keine Favoriten gefunden</string>
@@ -349,7 +348,7 @@
<string name="auto_heading">Fahrtrichtung</string>
<string name="auto_settings">Einstellungen</string>
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
<string name="welcome_android_auto_detail">Auf unterstützten Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="sounds_cool">Klingt cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%1$s %2$s).</string>
@@ -360,12 +359,10 @@
<string name="selecting_none">alle Einträge abgewählt</string>
<string name="loading">Lade…</string>
<string name="auto_multipage_goto">Seite %d</string>
<string name="auto_multipage">(%1$d/%2$d)</string>
<string name="auto_multipage">(%d/%d)</string>
<string name="reload">Neu laden</string>
<string name="accept_privacy"><![CDATA[Ich habe die <a href=\"%s\">Datenschutzerklärung</a> von EVMap gelesen und bin damit einverstanden.]]></string>
<string name="referrals">Empfehlungslinks</string>
<string name="referrals_info">Du kannst auch einen der Empfehlungslinks unten benutzen, um den Entwickler mit deinem Kauf zu unterstützen.</string>
<string name="referral_tesla">Tesla</string>
<string name="generic_connection_error">Daten konnten nicht geladen werden</string>
<string name="copied">In Zwischenablage kopiert</string>
</resources>

View File

@@ -75,7 +75,7 @@
<string name="welcome_2_detail">Cela peut également être vu dans \"À propos\" → \"Foire aux questions\"</string>
<string name="donation_dialog_title">Merci d\'utiliser EVMap</string>
<string name="chargeprice_donation_dialog_title">Vous êtes un vrai chasseur de bonnes affaires !</string>
<string name="deleted_item">\"%s\" supprimé</string>
<string name="deleted_filterprofile">\"%s\" supprimé</string>
<string name="undo">Annuler</string>
<string name="rename">Renommer</string>
<string name="verified">vérifié</string>
@@ -330,5 +330,5 @@
<string name="welcome_android_auto_detail">Vous pouvez également utiliser EVMap à partir d\'Android Auto sur les voitures prises en charge. Il suffit de sélectionner l\'application EVMap dans le menu Android Auto.</string>
<string name="loading">Chargement…</string>
<string name="auto_multipage_goto">Page %d</string>
<string name="auto_multipage">(%1$d/%2$d)</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

@@ -144,7 +144,7 @@
<string name="save_profile_enter_name">Skriv inn navnet på filterprofilen:</string>
<string name="filterprofiles_empty_state">Du har ikke noen lagrede filterprofiler</string>
<string name="chargeprice_donation_dialog_title">Du er en sann gjerrigknark.</string>
<string name="deleted_item">Slettet «%s»</string>
<string name="deleted_filterprofile">Slettet «%s»</string>
<string name="charging_barrierfree">Kan brukes uten registrering</string>
<string name="welcome_1">Finn kjøretøyladere der du er</string>
<string name="welcome_2">Hver laders farge samsvarer med dens høyeste ladeeffekt</string>
@@ -330,19 +330,5 @@
<string name="auto_chargers_ahead">Kun ladere i kjøreretningen</string>
<string name="loading">Laster inn …</string>
<string name="auto_multipage_goto">Side %d</string>
<string name="auto_multipage">(%1$d/%2$d)</string>
<string name="charge_price_minute_format">%2$s%1$.2f/min</string>
<string name="website">Nettside</string>
<string name="pref_tesla_account_enabled">Innlogget som %s</string>
<string name="pref_units">Enheter</string>
<string name="log_out">Logg ut</string>
<string name="logging_in">Logger inn …</string>
<string name="realtime_data_login_needed">Tesla-konto kreves for sanntidsdata</string>
<string name="generic_connection_error">Kunne ikke laste inn data</string>
<string name="logged_out">Utlogget</string>
<string name="pref_tesla_account">Tesla-konto</string>
<string name="tesla_pricing_others">Andre kunder:</string>
<string name="referral_tesla">Tesla</string>
<string name="pref_units_default">Enhetsforvalg</string>
<string name="login">Logg inn</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

@@ -145,7 +145,7 @@
<string name="donation_dialog_title">Bedankt om EVMap te gebruiken</string>
<string name="chargeprice_donation_dialog_title">Jij bent een echte koopjeszoeker!</string>
<string name="chargeprice_donation_dialog_detail">Blijkbaar maak je dankbaar gebruik van de prijsvergelijkingen. Met een donatie kan je de kosten voor deze data helpen dragen.</string>
<string name="deleted_item">“%s” verwijderd</string>
<string name="deleted_filterprofile">“%s” verwijderd</string>
<string name="undo">Ongedaan maken</string>
<string name="rename">Hernoem</string>
<plurals name="charge_cards_compatible_num">
@@ -327,6 +327,6 @@
<string name="selecting_none">alle items gedeselecteerd</string>
<string name="loading">Laden…</string>
<string name="auto_multipage_goto">Pagina %d</string>
<string name="auto_multipage">(%1$d/%2$d)</string>
<string name="auto_multipage">(%d/%d)</string>
<string name="auto_chargeprice_vehicle_unknown">Geen enkel voertuig geselecteerd in de app komt overeen met dit voertuig (%1$s %2$s).</string>
</resources>

View File

@@ -8,12 +8,12 @@
<string name="welcome_2_detail">Também pode encontrar esta informação em \"Sobre\" → \"Perguntas frequentes\"</string>
<string name="chargeprice_donation_dialog_title">Você é um verdadeiro caçador de pechinchas!</string>
<string name="donation_dialog_title">Obrigado por usar o EVMap</string>
<string name="deleted_item">“%s” removido</string>
<string name="deleted_filterprofile">“%s” removido</string>
<string name="undo">Refazer</string>
<string name="rename">Renomear</string>
<string name="chargeprice_donation_dialog_detail">Você faz grande uso da comparação de preços. Ajude a cobrir os custos de acesso à informação apoiando o EVMap com uma doação.</string>
<string name="verified">verificado</string>
<string name="chargeprice_select_connector">Escolha o conector</string>
<string name="chargeprice_select_connector">Escolhe o conector</string>
<string name="verified_desc">O carregador foi marcado como funcional por um membro da comunidade %s</string>
<string name="charge_price_format">%2$s%1$.2f</string>
<string name="charge_price_average_format">⌀ %2$s%1$.2f/kWh</string>
@@ -58,7 +58,7 @@
<string name="fault_report_date">Com problemas (atualizado: %s)</string>
<string name="filter_chargecards">Formas de pagamento</string>
<string name="pref_language">Língua da app</string>
<string name="all_selected">Todos selecionados</string>
<string name="all_selected">Todas selecionadas</string>
<string name="edit">editar</string>
<string name="pref_darkmode">Modo escuro</string>
<string name="connection_error">Não foi possível carregar a lista de carregadores</string>
@@ -76,7 +76,7 @@
<string name="category_public_authorities">Autoridades públicas</string>
<string name="category_private_charger">Carregador privado</string>
<string name="category_rest_area">Área de descanso</string>
<string name="edit_at_datasource">Editar em %s</string>
<string name="edit_at_datasource">Editado em %s</string>
<string name="categories">Categorias</string>
<string name="category_service_on_motorway">Área de serviço (autoestrada)</string>
<string name="category_service_off_motorway">Área de serviço (fora da autoestrada)</string>
@@ -96,7 +96,7 @@
<string name="save_profile_enter_name">Insira o nome do perfil com este filtro:</string>
<string name="save_as_profile">Guardar como perfil</string>
<string name="filterprofiles_empty_state">Não existem filtros guardados</string>
<string name="welcome_2">Cada cor corresponde à potência máxima do carregador</string>
<string name="welcome_2">Cada cor corresponde a potência máxima do carregador</string>
<string name="welcome_to_evmap">Bem-vindo(a) ao EVMap</string>
<string name="pref_darkmode_always_off">Sempre desligado</string>
<string name="welcome_2_title">Escolha a potência</string>
@@ -153,14 +153,14 @@
<string name="lets_go">Vamos lá</string>
<string name="crash_report_text">O EVMap encontrou um problema. Por favor envie um relatório do erro para o criador da app.</string>
<string name="crash_report_comment_prompt">Pode adicionar um comentário abaixo:</string>
<string name="pref_search_provider">Provedor da pesquisa</string>
<string name="pref_search_provider">Fornecedor da pesquisa</string>
<string name="powered_by_mapbox">via Mapbox</string>
<string name="github_sponsors">GitHub Sponsors</string>
<string name="donate_desc">Apoie o desenvolvimento do EVMap com uma única doação</string>
<string name="pref_map_rotate_gestures_on">Use dois dedos para girar o mapa</string>
<string name="pref_map_rotate_gestures_off">Rotação desligada (norte sempre para cima)</string>
<string name="refresh_live_data">atualizar estado em tempo real</string>
<string name="pref_search_provider_info">As pesquisas são caras, especialmente se o Google Maps for utilizado. Por favor considere doar através de \"Sobre\" → \"Doar\".</string>
<string name="pref_search_provider_info">As pesquisas são caras, especialmente quando o Google Maps é usado. Por favor considere doar através de \"Sobre\" → \"Doar\".</string>
<string name="github_sponsors_desc">Apoie o EVMap através do GitHub</string>
<string name="unnamed_filter_profile">Filtro sem nome</string>
<string name="deleted_recent_search_results">As pesquisas recentes foram eliminadas</string>
@@ -323,9 +323,9 @@
<string name="data_retrieved_at">Informação atualizada %s</string>
<string name="settings_cache_count">Tamanho da cache</string>
<string name="settings_cache_clear">Limpar cache</string>
<string name="settings_cache_count_summary">%1$d carregadores na base de dados, %2$.1f MB</string>
<string name="settings_cache_count_summary">%d carregadores na base de dados, %.1f MB</string>
<string name="settings_caching">Caching (base de dados local)</string>
<string name="settings_cache_clear_summary">Elimina todos os carregadores guardados localmente, com a exceção dos seus favoritos</string>
<string name="settings_cache_clear_summary">Elimina todos os carregadores guardados na base de dados local, com a exceção dos seus favoritos</string>
<string name="auto_no_chargers_found">Não foram encontrados carregadores próximo de si</string>
<string name="auto_no_favorites_found">Nenhum favorito encontrado</string>
<string name="opened_on_phone">Aberto no telefone</string>
@@ -341,7 +341,7 @@
<string name="selecting_all">todos os items selecionados</string>
<string name="loading">Carregando…</string>
<string name="auto_multipage_goto">Página %d</string>
<string name="auto_multipage">(%1$d/%2$d)</string>
<string name="auto_multipage">(%d/%d)</string>
<string name="settings_android_auto_chargeprice_range">Escala de carregamento para comparação de preços</string>
<string name="auto_location_service">O EVMap está a funcionar no Android Auto e usando a sua localização.</string>
<string name="auto_fault_report_date">⚠️ Problemas (%s)</string>
@@ -362,14 +362,4 @@
<string name="sounds_cool">Continuar</string>
<string name="reload">Recarregar</string>
<string name="accept_privacy"><![CDATA[Li e aceito a <a href=\"%s\">política de privacidade</a> do EVMap.]]></string>
<string name="referrals">Links de afiliado</string>
<string name="referral_tesla">Tesla</string>
<string name="pref_units">Unidades</string>
<string name="pref_map_scale_meters_and_miles">Milhas e metros na barra de escala do mapa</string>
<string name="pref_units_default">Padrão do dispositivo</string>
<string name="pref_units_metric">Métrico</string>
<string name="pref_units_imperial">Imperial</string>
<string name="referrals_info">Também pode usar um dos seguintes links de afiliado para apoiar o desenvolvedor da app com a sua compra.</string>
<string name="generic_connection_error">Não foi possível carregar a informação</string>
<string name="powered_by_fronyx">previsão feita por fronyx</string>
</resources>

View File

@@ -160,7 +160,7 @@
<string name="donation_dialog_detail">EVMap este libera si gratuita. Contributiile pe GitHub sunt apreciate. Pentru a acoperi costurile pentru acces la date, va rugam sa donati orice suma pentru dezvoltator.</string>
<string name="chargeprice_donation_dialog_title">Stii sa cauti ofertele cele mai bune!</string>
<string name="chargeprice_donation_dialog_detail">Stii sa folosesti optiunea de comparare preturi. Puteti ajuta pentru a acoperi costurile pentru accesul la aceste date donand pentru EVMap.</string>
<string name="deleted_item">“%s” a fost sters</string>
<string name="deleted_filterprofile">“%s” a fost sters</string>
<string name="undo">Anuleaza</string>
<string name="rename">Redenumeste</string>
<string name="charging_barrierfree">Utilizabile fara inregistrare</string>

View File

@@ -28,8 +28,7 @@
pt2121\n
nautilusx\n
Bobby Galati\n
programmin1\n
Jean-BaptisteC
programmin1
</string>
<string name="hide_on_scroll_fab_behavior">net.vonforst.evmap.ui.HideOnScrollFabBehavior</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>

View File

@@ -161,7 +161,7 @@
<string name="donation_dialog_detail">EVMap is open source and free of charge. Code contributions on GitHub are much appreciated. To help cover the running costs for data access, please consider donating an amount of your choice to the developer.</string>
<string name="chargeprice_donation_dialog_title">You\'re a real bargain hunter!</string>
<string name="chargeprice_donation_dialog_detail">You make great use of the price comparison feature. Please help cover the costs for this data by supporting EVMap with a donation.</string>
<string name="deleted_item">Deleted “%s”</string>
<string name="deleted_filterprofile">Deleted “%s”</string>
<string name="undo">Undo</string>
<string name="rename">Rename</string>
<string name="charging_barrierfree">Usable without registration</string>
@@ -275,7 +275,6 @@
<string name="about_contributors">Contributors</string>
<string name="about_contributors_text">Thanks to all contributors for their coding and translation contributions to EVMap:</string>
<string name="utilization_prediction">Utilization prediction</string>
<string name="powered_by_fronyx">powered by fronyx</string>
<string name="prediction_help">The prediction is based on factors like day of the week, time of day and past usage, so that you can avoid overcrowded chargers. No guarantee.</string>
<string name="prediction_time_colon">%s:</string>
<plurals name="prediction_number_available">
@@ -305,7 +304,7 @@
<string name="logging_in">Logging in…</string>
<string name="log_out">Log out</string>
<string name="logged_out">Logged out</string>
<string name="login">Log in</string>
<string name="login">Login</string>
<string name="login_error">Login failed</string>
<string name="tesla_pricing_owners">Tesla vehicles only:</string>
<string name="tesla_pricing_members">Tesla vehicles &amp; members:</string>
@@ -326,7 +325,7 @@
<string name="settings_cache_count">Cache size</string>
<string name="settings_cache_clear">Clear cache</string>
<string name="settings_cache_clear_summary">Deletes all cached chargers except favorites</string>
<string name="settings_cache_count_summary">%1$d chargers cached, %2$.1f MB</string>
<string name="settings_cache_count_summary">%d chargers cached, %.1f MB</string>
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
<string name="auto_no_chargers_found">No nearby chargers found</string>
<string name="auto_no_favorites_found">No favorites found</string>
@@ -360,12 +359,10 @@
<string name="selecting_none">deselected all items</string>
<string name="loading">Loading…</string>
<string name="auto_multipage_goto">Page %d</string>
<string name="auto_multipage">(%1$d/%2$d)</string>
<string name="auto_multipage">(%d/%d)</string>
<string name="reload">Reload</string>
<string name="accept_privacy"><![CDATA[I have read and accepted EVMap\'s <a href=\"%s\">privacy policy</a>.]]></string>
<string name="referrals">Referral links</string>
<string name="referrals_info">You can also use one of the referral links below to support the developer with your purchase.</string>
<string name="referral_tesla">Tesla</string>
<string name="generic_connection_error">Could not load data</string>
<string name="copied">Copied to clipboard</string>
</resources>

View File

@@ -15,12 +15,11 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.internal.DoNotInstrument
@RunWith(RobolectricTestRunner::class)
@DoNotInstrument
@Config(sdk = [33]) // Robolectric does not yet support SDK 34
@Ignore("Disabled because Robolectric does not yet support API 34")
class CarAppTest {
private val testCarContext =
TestCarContext.createCarContext(ApplicationProvider.getApplicationContext()).apply {

35
build.gradle Normal file
View File

@@ -0,0 +1,35 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.9.0'
ext.about_libs_version = '8.9.4'
ext.nav_version = '2.7.1'
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
classpath "pt.jcosta.resourceplaceholders:plugin:0.7"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
mavenCentral()
//noinspection JcenterRepositoryObsolete
maven { url 'https://jitpack.io' }
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@@ -1,35 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
val kotlinVersion by extra("1.9.10")
val aboutLibsVersion by extra("10.9.1")
val navVersion by extra("2.7.5")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
dependencies {
classpath("com.android.tools.build:gradle:8.1.2")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$aboutLibsVersion")
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:$navVersion")
classpath("pt.jcosta.resourceplaceholders:plugin:0.7")
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
mavenCentral()
//noinspection JcenterRepositoryObsolete
maven { setUrl("https://jitpack.io") }
}
}
tasks.register("clean", Delete::class) {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -23,7 +23,9 @@ Install Android Auto
If you haven't already, install the
[Android Auto](https://play.google.com/store/apps/details?id=com.google.android.projection.gearhead)
app on your test device from the Google Play Store.
and
[Android Auto for phone screens](https://play.google.com/store/apps/details?id=com.google.android.projection.gearhead.phonescreen)
apps on your test device from the Google Play Store.
If you are using the Android Emulator, the Play Store may show the Android Auto app as incompatible.
In that case, download the APK for the newest version from a site like
@@ -31,12 +33,12 @@ In that case, download the APK for the newest version from a site like
(choosing the correct architecture for your emulator - x86_64, x86 or ARM)
and drag it onto the running emulator window to install.
Starting and connecting to the DHU
----------------------------------
Starting the DHU
----------------
(see also the corresponding section on
the [Android Developers site](https://developer.android.com/training/cars/testing#running-dhu))
1. Go to Android Auto settings (Settings app -> Connected devices -> Connection preferences -> Android Auto)
1. Start the Android Auto for phone screens app, tap the menu icon on the top left to go to settings
2. Scroll all the way down to the app version, tap it 10 times
3. Click *OK* in the dialog that appears to enable developer mode
4. In the menu on the top left, tap *Start head unit server*

View File

@@ -1,8 +0,0 @@
Verbesserungen:
- Neue Einstellung für Maßeinheiten
- Anpassungen für Android 14
- Android Auto: Weitere Detailbeschreibungen zu den Ladestationen
- Android Auto: Löschbutton in der Filterliste
Fehler behoben:
- Fehler beim Laden der EnBW Echtzeitdaten

View File

@@ -1,5 +0,0 @@
Verbesserungen:
- Beim Start der App wird nun der zuletzt gesehene Kartenausschnitt gezeigt
Fehler behoben:
- Abstürze behoben

View File

@@ -1,5 +0,0 @@
Neue Funktionen:
- Auslastungsprognose auch unter Android Auto verfügbar
Fehler behoben:
- Abstürze behoben

View File

@@ -1,11 +0,0 @@
Neue Funktionen:
- Adresse oder Koordinaten gedrückt halten um sie in die Zwischenablage zu kopieren
Verbesserungen:
- OpenChargeMap: Links verweisen nun auf die mobile Website
Fehler behoben:
- Favoriten löschen funktionierte nicht bei Rotieren des Bildschirms
- Fehler beim Wechsel zwischen mehreren Fahrzeugen im Preisvergleich
- Android Auto: Adresse wurde nicht korrekt an TomTom GO-Navigation übergeben
- Abstürze behoben

View File

@@ -1,8 +0,0 @@
Improvements:
- New setting for units of measurement
- Adjustments for Android 14
- Android Auto: More detailed descriptions of chargers
- Android Auto: Delete button in filter list
Bugfixes:
- Errors loading realtime data from EnBW

View File

@@ -1,5 +0,0 @@
Improvements:
- When starting the app, the last viewed map area will be shown
Bugfixes:
- Fixed crashes

View File

@@ -1,5 +0,0 @@
New features:
- Availability prediction also available on Android Auto
Bugfixes:
- Fixed crashes

View File

@@ -1,11 +0,0 @@
New features:
- Copy address or coordinates using long press
Improvements:
- OpenChargeMap: links now refer to mobile website
Bugfixes:
- Deleting favorites did not work when rotating the screen
- Price comparison: error when switching between different vehicles
- Android Auto: Address was not correctly passed to TomTom GO navigation
- Fixed crashes

View File

@@ -1,6 +1,6 @@
#Sat Aug 06 15:33:46 CEST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

2
settings.gradle Normal file
View File

@@ -0,0 +1,2 @@
rootProject.name='EVMap'
include ':app'

View File

@@ -1,2 +0,0 @@
rootProject.name="EVMap"
include (":app")