From 4f3157a0ac18a534654b3c813731733cec6bad9e Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sat, 17 Jul 2021 12:47:47 +0200 Subject: [PATCH 01/28] update Car app library to 1.1.0-alpha1 --- app/build.gradle | 2 +- app/src/google/AndroidManifest.xml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 6cdc6e3c..670b92a1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -136,7 +136,7 @@ dependencies { implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5' // Android Auto - googleImplementation 'androidx.car.app:app:1.0.0' + googleImplementation 'androidx.car.app:app:1.1.0-alpha01' // AnyMaps def anyMapsVersion = '95ddd6c083' diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml index 10a8b42b..c1b26bcd 100644 --- a/app/src/google/AndroidManifest.xml +++ b/app/src/google/AndroidManifest.xml @@ -21,6 +21,10 @@ android:name="androidx.car.app.theme" android:resource="@style/CarAppTheme" /> + + Date: Thu, 29 Jul 2021 17:45:38 +0200 Subject: [PATCH 02/28] use CarAppService.requestPermission() instead of custom PermissionActivity --- app/src/google/AndroidManifest.xml | 2 - .../vonforst/evmap/auto/PermissionActivity.kt | 72 ------------------- .../vonforst/evmap/auto/PermissionScreen.kt | 49 +++++-------- 3 files changed, 19 insertions(+), 104 deletions(-) delete mode 100644 app/src/google/java/net/vonforst/evmap/auto/PermissionActivity.kt diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml index c1b26bcd..9899dae2 100644 --- a/app/src/google/AndroidManifest.xml +++ b/app/src/google/AndroidManifest.xml @@ -41,7 +41,5 @@ android:name=".auto.CarLocationService" android:foregroundServiceType="location" android:enabled="true" /> - - \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/auto/PermissionActivity.kt b/app/src/google/java/net/vonforst/evmap/auto/PermissionActivity.kt deleted file mode 100644 index 13a5a468..00000000 --- a/app/src/google/java/net/vonforst/evmap/auto/PermissionActivity.kt +++ /dev/null @@ -1,72 +0,0 @@ -package net.vonforst.evmap.auto - -import android.Manifest -import android.app.Activity -import android.content.pm.PackageManager -import android.os.Bundle -import android.os.ResultReceiver -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat - - -class PermissionActivity : Activity() { - companion object { - const val EXTRA_RESULT_RECEIVER = "result_receiver"; - const val RESULT_GRANTED = "granted" - } - - private lateinit var resultReceiver: ResultReceiver - private val permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) - private val requestCode = 1 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (intent != null) { - resultReceiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER)!! - if (!hasPermissions(permissions)) { - ActivityCompat.requestPermissions(this, permissions, requestCode) - } else { - onComplete( - requestCode, - permissions, - intArrayOf(PackageManager.PERMISSION_GRANTED) - ) - } - } else { - finish() - } - } - - private fun onComplete(requestCode: Int, permissions: Array?, grantResults: IntArray) { - val bundle = Bundle() - bundle.putBoolean( - RESULT_GRANTED, - grantResults.all { it == PackageManager.PERMISSION_GRANTED }) - resultReceiver.send(requestCode, bundle) - finish() - } - - private fun hasPermissions(permissions: Array): Boolean { - var result = true - for (permission in permissions) { - if (ContextCompat.checkSelfPermission( - this, - permission - ) != PackageManager.PERMISSION_GRANTED - ) { - result = false - break - } - } - return result - } - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - onComplete(requestCode, permissions, grantResults) - } -} \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt index 09517273..6a5aadb3 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt @@ -1,10 +1,7 @@ package net.vonforst.evmap.auto -import android.content.Intent -import android.os.Bundle -import android.os.ResultReceiver +import android.Manifest import androidx.car.app.CarContext -import androidx.car.app.CarToast import androidx.car.app.Screen import androidx.car.app.model.* import net.vonforst.evmap.R @@ -23,32 +20,7 @@ class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) .setTitle(carContext.getString(R.string.grant_on_phone)) .setBackgroundColor(CarColor.PRIMARY) .setOnClickListener(ParkedOnlyOnClickListener.create { - val intent = Intent(carContext, PermissionActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra( - PermissionActivity.EXTRA_RESULT_RECEIVER, - object : ResultReceiver(null) { - override fun onReceiveResult( - resultCode: Int, - resultData: Bundle? - ) { - if (resultData!!.getBoolean(PermissionActivity.RESULT_GRANTED)) { - session.bindLocationService() - screenManager.push( - WelcomeScreen( - carContext, - session - ) - ) - } - } - }) - carContext.startActivity(intent) - CarToast.makeText( - carContext, - R.string.opened_on_phone, - CarToast.LENGTH_LONG - ).show() + requestPermissions() }) .build() ) @@ -62,4 +34,21 @@ class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) ) .build() } + + private fun requestPermissions() { + val permission = Manifest.permission.ACCESS_FINE_LOCATION + carContext.requestPermissions(listOf(permission)) { granted, rejected -> + if (granted.contains(permission)) { + session.bindLocationService() + screenManager.push( + WelcomeScreen( + carContext, + session + ) + ) + } else { + requestPermissions() + } + } + } } \ No newline at end of file From 139c02ef7056f10a3512e9d1f6514d73597298be Mon Sep 17 00:00:00 2001 From: Johan von Forstner Date: Thu, 29 Jul 2021 17:53:41 +0200 Subject: [PATCH 03/28] use ConstraintManager to dynamically get maximum number of items to be shown on Android Auto list --- app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt | 6 ++++-- app/src/google/java/net/vonforst/evmap/auto/Utils.kt | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt index ccd8590b..daad6911 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt @@ -6,6 +6,7 @@ import android.text.Spanned import androidx.car.app.CarContext import androidx.car.app.CarToast import androidx.car.app.Screen +import androidx.car.app.constraints.ConstraintManager import androidx.car.app.model.* import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat @@ -59,7 +60,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole private val availabilityUpdateThreshold = Duration.ofMinutes(1) private var availabilities: MutableMap> = HashMap() - private val maxRows = 6 + private val maxRows = + ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST) private val referenceData = api.getReferenceData(lifecycleScope, carContext) private val filterStatus = MutableLiveData().apply { @@ -268,7 +270,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole ) chargers = response.data?.filterIsInstance(ChargeLocation::class.java) chargers?.let { - if (it.size < 6) { + if (it.size < maxRows) { // try again with larger radius val response = api.getChargepointsRadius( referenceData, diff --git a/app/src/google/java/net/vonforst/evmap/auto/Utils.kt b/app/src/google/java/net/vonforst/evmap/auto/Utils.kt index 47392f92..e14a2b38 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/Utils.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/Utils.kt @@ -1,5 +1,7 @@ package net.vonforst.evmap.auto +import androidx.car.app.CarContext +import androidx.car.app.constraints.ConstraintManager import androidx.car.app.model.CarColor import net.vonforst.evmap.api.availability.ChargepointStatus @@ -17,4 +19,7 @@ fun carAvailabilityColor(status: List): CarColor { } else { CarColor.BLUE } -} \ No newline at end of file +} + +val CarContext.constraintManager + get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager \ No newline at end of file From 04fc17d73ce6622d0211a660ecd255c84be736f9 Mon Sep 17 00:00:00 2001 From: Johan von Forstner Date: Thu, 29 Jul 2021 17:54:14 +0200 Subject: [PATCH 04/28] increase image size corresponding to updated Android Auto docs --- .../java/net/vonforst/evmap/auto/ChargerDetailScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt index 60fbe9a7..2ea8e97c 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt @@ -48,7 +48,10 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : } private val referenceData = api.getReferenceData(lifecycleScope, carContext) - private val iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64) + private val imageSize = 128 // images should be 128dp according to docs + + private val iconGen = + ChargerIconGenerator(carContext, null, oversize = 1.4f, height = imageSize) init { referenceData.observe(this) { From bcee975124c912a9afbac463a649312e4dea7e43 Mon Sep 17 00:00:00 2001 From: Johan von Forstner Date: Thu, 29 Jul 2021 17:55:37 +0200 Subject: [PATCH 05/28] remove now unneeded @ExperimentalCarApi annotations --- app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt | 2 -- app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt | 1 - app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt | 1 - app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt | 1 - 4 files changed, 5 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt index a92ecb8b..d2085a44 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt @@ -21,7 +21,6 @@ interface LocationAwareScreen { fun updateLocation(location: Location) } -@androidx.car.app.annotations.ExperimentalCarApi class CarAppService : androidx.car.app.CarAppService() { override fun createHostValidator(): HostValidator { return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) { @@ -38,7 +37,6 @@ class CarAppService : androidx.car.app.CarAppService() { } } -@androidx.car.app.annotations.ExperimentalCarApi class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver { var mapScreen: LocationAwareScreen? = null set(value) { diff --git a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt index daad6911..7de85d43 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt @@ -40,7 +40,6 @@ import kotlin.math.roundToInt /** * Main map screen showing either nearby chargers or favorites */ -@androidx.car.app.annotations.ExperimentalCarApi class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) : Screen(ctx), LocationAwareScreen { private var updateCoroutine: Job? = null diff --git a/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt index 6a5aadb3..01457f8c 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt @@ -9,7 +9,6 @@ import net.vonforst.evmap.R /** * Screen to grant location permission */ -@androidx.car.app.annotations.ExperimentalCarApi class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) { override fun onGetTemplate(): Template { return MessageTemplate.Builder(carContext.getString(R.string.auto_location_permission_needed)) diff --git a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt index bc2bc06f..28d0996d 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt @@ -10,7 +10,6 @@ import net.vonforst.evmap.R /** * Welcome screen with selection between favorites and nearby chargers */ -@androidx.car.app.annotations.ExperimentalCarApi class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen { private var location: Location? = null From 630178bfcf86af1e4e4cc2ac0159b3b31c25c48c Mon Sep 17 00:00:00 2001 From: Johan von Forstner Date: Thu, 22 Jul 2021 13:08:03 +0200 Subject: [PATCH 06/28] Update car app library to 1.1.0-alpha02 --- app/build.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 670b92a1..f1d83d64 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -136,7 +136,8 @@ dependencies { implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5' // Android Auto - googleImplementation 'androidx.car.app:app:1.1.0-alpha01' + googleImplementation 'androidx.car.app:app:1.1.0-alpha02' + googleImplementation 'androidx.car.app:app-projected:1.1.0-alpha02' // AnyMaps def anyMapsVersion = '95ddd6c083' From 65189cd798d2d0e2258ebd8b9d814289aea1efd1 Mon Sep 17 00:00:00 2001 From: Johan von Forstner Date: Thu, 22 Jul 2021 14:32:00 +0200 Subject: [PATCH 07/28] Android Auto: create a VehicleDataScreen showing state of charge --- app/src/google/AndroidManifest.xml | 3 +- .../vonforst/evmap/auto/VehicleDataScreen.kt | 43 +++++++++++++++++++ .../net/vonforst/evmap/auto/WelcomeScreen.kt | 16 +++++++ app/src/google/res/drawable/ic_car.xml | 10 +++++ app/src/google/res/values-de/values.xml | 1 + app/src/google/res/values/values.xml | 1 + 6 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt create mode 100644 app/src/google/res/drawable/ic_car.xml diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml index 9899dae2..cda57b0e 100644 --- a/app/src/google/AndroidManifest.xml +++ b/app/src/google/AndroidManifest.xml @@ -5,8 +5,9 @@ + - + { + val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager + var energyLevel: EnergyLevel? = null + + init { + hardwareMan.carInfo.addEnergyLevelListener(ContextCompat.getMainExecutor(ctx), this) + } + + override fun onGetTemplate(): Template { + val energy = energyLevel ?: return GridTemplate.Builder().setLoading(true).build() + + return GridTemplate.Builder().setSingleList( + ItemList.Builder().apply { + energy.batteryPercent.value?.let { percent -> + addItem( + GridItem.Builder() + .setTitle("Battery") + .setText("%.1f".format(percent)) + .build() + ) + } + }.build() + ).build() + } + + override fun onCarDataAvailable(data: EnergyLevel) { + this.energyLevel = energyLevel + invalidate() + } +} \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt index 28d0996d..15b12bd1 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt @@ -6,6 +6,7 @@ import androidx.car.app.Screen import androidx.car.app.model.* import androidx.core.graphics.drawable.IconCompat import net.vonforst.evmap.R +import net.vonforst.evmap.auto.screens.VehicleDataScreen /** * Welcome screen with selection between favorites and nearby chargers @@ -55,6 +56,21 @@ class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), L screenManager.push(MapScreen(carContext, session, favorites = true)) } .build()) + addItem( + Row.Builder() + .setTitle(carContext.getString(R.string.auto_vehicle_data)) + .setImage( + CarIcon.Builder( + IconCompat.createWithResource(carContext, R.drawable.ic_car) + ).setTint(CarColor.DEFAULT).build() + ) + .setBrowsable(true) + .setOnClickListener { + session.mapScreen = null + screenManager.push(VehicleDataScreen(carContext)) + } + .build() + ) }.build()) setCurrentLocationEnabled(true) setHeaderAction(Action.APP_ICON) diff --git a/app/src/google/res/drawable/ic_car.xml b/app/src/google/res/drawable/ic_car.xml new file mode 100644 index 00000000..dc2afc1f --- /dev/null +++ b/app/src/google/res/drawable/ic_car.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/google/res/values-de/values.xml b/app/src/google/res/values-de/values.xml index bcc63a28..87a1fb3c 100644 --- a/app/src/google/res/values-de/values.xml +++ b/app/src/google/res/values-de/values.xml @@ -20,6 +20,7 @@ Favoriten ⚠️ Störungsmeldung (%s) Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten. + Fahrzeugdaten Android Auto-Unterstützung Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto. klingt cool diff --git a/app/src/google/res/values/values.xml b/app/src/google/res/values/values.xml index 13380609..5f243afe 100644 --- a/app/src/google/res/values/values.xml +++ b/app/src/google/res/values/values.xml @@ -30,6 +30,7 @@ Favorites ⚠️ Fault report (%s) Further updates not possible. Please go back and restart. + Vehicle data Android Auto support You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu. sounds cool From ab0c37cb820418d1cf98368c742ac71c2fdcb778 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sat, 14 Aug 2021 18:53:21 +0200 Subject: [PATCH 08/28] make PermissionScreen reusable --- .../net/vonforst/evmap/auto/CarAppService.kt | 8 +- .../vonforst/evmap/auto/PermissionScreen.kt | 26 ++-- .../net/vonforst/evmap/auto/WelcomeScreen.kt | 135 +++++++++++------- 3 files changed, 95 insertions(+), 74 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt index d2085a44..e5eb297d 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt @@ -63,14 +63,10 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver { } override fun onCreateScreen(intent: Intent): Screen { - return if (locationPermissionGranted()) { - WelcomeScreen(carContext, this) - } else { - PermissionScreen(carContext, this) - } + return WelcomeScreen(carContext, this) } - private fun locationPermissionGranted() = + fun locationPermissionGranted() = ContextCompat.checkSelfPermission( carContext, Manifest.permission.ACCESS_FINE_LOCATION diff --git a/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt index 01457f8c..72b8add9 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt @@ -1,17 +1,22 @@ package net.vonforst.evmap.auto -import android.Manifest +import androidx.annotation.StringRes import androidx.car.app.CarContext import androidx.car.app.Screen import androidx.car.app.model.* import net.vonforst.evmap.R /** - * Screen to grant location permission + * Screen to grant permission */ -class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) { +class PermissionScreen( + ctx: CarContext, + val session: EVMapSession, + @StringRes val message: Int, + val permissions: List +) : Screen(ctx) { override fun onGetTemplate(): Template { - return MessageTemplate.Builder(carContext.getString(R.string.auto_location_permission_needed)) + return MessageTemplate.Builder(carContext.getString(message)) .setTitle(carContext.getString(R.string.app_name)) .setHeaderAction(Action.APP_ICON) .addAction( @@ -35,16 +40,9 @@ class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) } private fun requestPermissions() { - val permission = Manifest.permission.ACCESS_FINE_LOCATION - carContext.requestPermissions(listOf(permission)) { granted, rejected -> - if (granted.contains(permission)) { - session.bindLocationService() - screenManager.push( - WelcomeScreen( - carContext, - session - ) - ) + carContext.requestPermissions(permissions) { granted, rejected -> + if (granted.containsAll(permissions)) { + screenManager.pop() } else { requestPermissions() } diff --git a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt index 15b12bd1..0969def4 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt @@ -1,12 +1,14 @@ package net.vonforst.evmap.auto +import android.Manifest import android.location.Location +import android.os.Handler +import android.os.Looper import androidx.car.app.CarContext import androidx.car.app.Screen import androidx.car.app.model.* import androidx.core.graphics.drawable.IconCompat import net.vonforst.evmap.R -import net.vonforst.evmap.auto.screens.VehicleDataScreen /** * Welcome screen with selection between favorites and nearby chargers @@ -15,64 +17,89 @@ class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), L private var location: Location? = null override fun onGetTemplate(): Template { + if (!session.locationPermissionGranted()) { + Handler(Looper.getMainLooper()).post { + screenManager.pushForResult( + PermissionScreen( + carContext, + session, + R.string.auto_location_permission_needed, + listOf(Manifest.permission.ACCESS_FINE_LOCATION) + ) + ) { + session.bindLocationService() + } + } + } + session.mapScreen = this return PlaceListMapTemplate.Builder().apply { setTitle(carContext.getString(R.string.app_name)) - location?.let { - setAnchor(Place.Builder(CarLocation.create(it)).build()) - } - setItemList(ItemList.Builder().apply { - addItem( - Row.Builder() - .setTitle(carContext.getString(R.string.auto_chargers_closeby)) - .setImage( - CarIcon.Builder( - IconCompat.createWithResource( - carContext, - R.drawable.ic_address - ) - ) - .setTint(CarColor.DEFAULT).build() - ) - .setBrowsable(true) - .setOnClickListener { - screenManager.push(MapScreen(carContext, session, favorites = false)) - } - .build()) - addItem( - Row.Builder() - .setTitle(carContext.getString(R.string.auto_favorites)) - .setImage( - CarIcon.Builder( - IconCompat.createWithResource( - carContext, - R.drawable.ic_fav + if (!session.locationPermissionGranted()) { + setLoading(true) + } else { + location?.let { + setAnchor(Place.Builder(CarLocation.create(it)).build()) + } + setItemList(ItemList.Builder().apply { + addItem( + Row.Builder() + .setTitle(carContext.getString(R.string.auto_chargers_closeby)) + .setImage( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_address + ) ) + .setTint(CarColor.DEFAULT).build() ) - .setTint(CarColor.DEFAULT).build() - ) - .setBrowsable(true) - .setOnClickListener { - screenManager.push(MapScreen(carContext, session, favorites = true)) - } - .build()) - addItem( - Row.Builder() - .setTitle(carContext.getString(R.string.auto_vehicle_data)) - .setImage( - CarIcon.Builder( - IconCompat.createWithResource(carContext, R.drawable.ic_car) - ).setTint(CarColor.DEFAULT).build() - ) - .setBrowsable(true) - .setOnClickListener { - session.mapScreen = null - screenManager.push(VehicleDataScreen(carContext)) - } - .build() - ) - }.build()) - setCurrentLocationEnabled(true) + .setBrowsable(true) + .setOnClickListener { + screenManager.push( + MapScreen( + carContext, + session, + favorites = false + ) + ) + } + .build()) + addItem( + Row.Builder() + .setTitle(carContext.getString(R.string.auto_favorites)) + .setImage( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_fav + ) + ) + .setTint(CarColor.DEFAULT).build() + ) + .setBrowsable(true) + .setOnClickListener { + screenManager.push(MapScreen(carContext, session, favorites = true)) + } + .build()) + addItem( + Row.Builder() + .setTitle(carContext.getString(R.string.auto_vehicle_data)) + .setImage( + CarIcon.Builder( + IconCompat.createWithResource(carContext, R.drawable.ic_car) + ).setTint(CarColor.DEFAULT).build() + ) + .setBrowsable(true) + .setOnClickListener { + session.mapScreen = null + screenManager.push(VehicleDataScreen(carContext)) + } + .build() + ) + }.build()) + setCurrentLocationEnabled(true) + } setHeaderAction(Action.APP_ICON) build() }.build() From 71f1ee8d7b6c462a42409a85ade9098397e040eb Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sat, 14 Aug 2021 19:51:49 +0200 Subject: [PATCH 09/28] make VehicleDataScreen request permissions and work correctly --- .../vonforst/evmap/auto/PermissionScreen.kt | 1 - .../vonforst/evmap/auto/VehicleDataScreen.kt | 111 ++++++++++++++---- .../net/vonforst/evmap/auto/WelcomeScreen.kt | 1 - app/src/google/res/values-de/values.xml | 1 + app/src/google/res/values/values.xml | 1 + 5 files changed, 87 insertions(+), 28 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt index 72b8add9..8ed475fb 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/PermissionScreen.kt @@ -11,7 +11,6 @@ import net.vonforst.evmap.R */ class PermissionScreen( ctx: CarContext, - val session: EVMapSession, @StringRes val message: Int, val permissions: List ) : Screen(ctx) { diff --git a/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt index df30ccbd..401d2185 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt @@ -1,43 +1,102 @@ package net.vonforst.evmap.auto +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper import androidx.car.app.CarContext import androidx.car.app.Screen import androidx.car.app.hardware.CarHardwareManager -import androidx.car.app.hardware.common.OnCarDataAvailableListener import androidx.car.app.hardware.info.EnergyLevel -import androidx.car.app.model.GridItem -import androidx.car.app.model.GridTemplate -import androidx.car.app.model.ItemList -import androidx.car.app.model.Template +import androidx.car.app.hardware.info.Model +import androidx.car.app.model.* import androidx.core.content.ContextCompat +import net.vonforst.evmap.R -class VehicleDataScreen(ctx: CarContext) : Screen(ctx), OnCarDataAvailableListener { - val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager - var energyLevel: EnergyLevel? = null +class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { + private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager + private var model: Model? = null + private var energyLevel: EnergyLevel? = null - init { - hardwareMan.carInfo.addEnergyLevelListener(ContextCompat.getMainExecutor(ctx), this) - } + private val permissions = listOf( + "com.google.android.gms.permission.CAR_FUEL" + ) override fun onGetTemplate(): Template { - val energy = energyLevel ?: return GridTemplate.Builder().setLoading(true).build() - - return GridTemplate.Builder().setSingleList( - ItemList.Builder().apply { - energy.batteryPercent.value?.let { percent -> - addItem( - GridItem.Builder() - .setTitle("Battery") - .setText("%.1f".format(percent)) - .build() + if (permissionsGranted()) { + setupListeners() + } else { + Handler(Looper.getMainLooper()).post { + screenManager.pushForResult( + PermissionScreen( + carContext, + R.string.auto_location_permission_needed, + permissions ) + ) { + setupListeners() } - }.build() - ).build() + } + } + + val energyLevel = energyLevel + val model = model + + return GridTemplate.Builder().apply { + setTitle( + if (model != null && model.manufacturer.value != null && model.name.value != null) { + "${model.manufacturer.value} ${model.name.value}" + } else { + carContext.getString(R.string.auto_vehicle_data) + } + ) + setHeaderAction(Action.BACK) + if (!permissionsGranted()) { + setLoading(true) + } else { + setSingleList( + ItemList.Builder().apply { + addItem( + GridItem.Builder().apply { + setTitle("Battery") + energyLevel?.batteryPercent?.value?.let { percent -> + setText("%.1f".format(percent)) + setImage(CarIcon.APP_ICON) + } ?: setLoading(true) + }.build() + ) + addItem( + GridItem.Builder().apply { + setTitle("Fuel") + energyLevel?.fuelPercent?.value?.let { percent -> + setText("%.1f".format(percent)) + setImage(CarIcon.APP_ICON) + } ?: setLoading(true) + }.build() + ) + }.build() + ) + } + }.build() } - override fun onCarDataAvailable(data: EnergyLevel) { - this.energyLevel = energyLevel - invalidate() + private fun setupListeners() { + val exec = ContextCompat.getMainExecutor(carContext) + hardwareMan.carInfo.addEnergyLevelListener(exec) { + this.energyLevel = it + invalidate() + } + + hardwareMan.carInfo.fetchModel(exec) { + this.model = it + invalidate() + } } + + private fun permissionsGranted(): Boolean = + permissions.all { + ContextCompat.checkSelfPermission( + carContext, + it + ) == PackageManager.PERMISSION_GRANTED + } } \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt index 0969def4..0afb7072 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt @@ -22,7 +22,6 @@ class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), L screenManager.pushForResult( PermissionScreen( carContext, - session, R.string.auto_location_permission_needed, listOf(Manifest.permission.ACCESS_FINE_LOCATION) ) diff --git a/app/src/google/res/values-de/values.xml b/app/src/google/res/values-de/values.xml index 87a1fb3c..3f5225cd 100644 --- a/app/src/google/res/values-de/values.xml +++ b/app/src/google/res/values-de/values.xml @@ -15,6 +15,7 @@ In App öffnen Auf dem Telefon geöffnet Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort. + Für diese Funktion benötigt EVMap Zugriff auf Daten deines Fahrzeugs. Auf Telefon zulassen In der Nähe Favoriten diff --git a/app/src/google/res/values/values.xml b/app/src/google/res/values/values.xml index 5f243afe..101b3dff 100644 --- a/app/src/google/res/values/values.xml +++ b/app/src/google/res/values/values.xml @@ -25,6 +25,7 @@ Open in app Opened on phone To run EVMap on Android Auto, you need to grant access to your location. + For this feature, EVMap needs access to your vehicle data. Grant on phone Nearby chargers Favorites From 6c2243078bc71c299ccf6f2f9afa0cef514115fd Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 15 Aug 2021 15:36:52 +0200 Subject: [PATCH 10/28] Vehicle data screen: Add speed and range + gauge icons --- app/src/google/AndroidManifest.xml | 1 + .../net/vonforst/evmap/auto/FilterScreen.kt | 14 +- .../java/net/vonforst/evmap/auto/Utils.kt | 48 +++++- .../vonforst/evmap/auto/VehicleDataScreen.kt | 152 +++++++++++++++--- .../java/net/vonforst/evmap/ui/Gauge.kt | 74 +++++++++ app/src/google/res/values-de/values.xml | 4 + app/src/google/res/values/colors.xml | 6 + app/src/google/res/values/values.xml | 4 + 8 files changed, 269 insertions(+), 34 deletions(-) create mode 100644 app/src/google/java/net/vonforst/evmap/ui/Gauge.kt create mode 100644 app/src/google/res/values/colors.xml diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml index cda57b0e..bfed96af 100644 --- a/app/src/google/AndroidManifest.xml +++ b/app/src/google/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt index cae3a35b..d6a51886 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt @@ -26,15 +26,11 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) { init { val size = (ctx.resources.displayMetrics.density * 24).roundToInt() - emptyIcon = CarIcon.Builder( - IconCompat.createWithBitmap( - Bitmap.createBitmap( - size, - size, - Bitmap.Config.ARGB_8888 - ) - ) - ).build() + emptyIcon = Bitmap.createBitmap( + size, + size, + Bitmap.Config.ARGB_8888 + ).asCarIcon() } init { diff --git a/app/src/google/java/net/vonforst/evmap/auto/Utils.kt b/app/src/google/java/net/vonforst/evmap/auto/Utils.kt index e14a2b38..69b16723 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/Utils.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/Utils.kt @@ -1,9 +1,14 @@ package net.vonforst.evmap.auto +import android.graphics.Bitmap import androidx.car.app.CarContext import androidx.car.app.constraints.ConstraintManager +import androidx.car.app.hardware.common.CarUnit import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.core.graphics.drawable.IconCompat import net.vonforst.evmap.api.availability.ChargepointStatus +import java.util.* fun carAvailabilityColor(status: List): CarColor { val unknown = status.any { it == ChargepointStatus.UNKNOWN } @@ -22,4 +27,45 @@ fun carAvailabilityColor(status: List): CarColor { } val CarContext.constraintManager - get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager \ No newline at end of file + get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager + +fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build() + +private const val kmPerMile = 1.609344 + +fun getDefaultDistanceUnit(): Int { + return when (Locale.getDefault().country) { + "US", "GB", "MM", "LR" -> CarUnit.MILE + else -> CarUnit.KILOMETER + } +} + +fun getDefaultSpeedUnit(): Int { + return when (Locale.getDefault().country) { + "US", "GB", "MM", "LR" -> CarUnit.MILES_PER_HOUR + else -> CarUnit.KILOMETERS_PER_HOUR + } +} + +fun formatCarUnitDistance(value: Float?, unit: Int?): String { + if (value == null) return "" + return when (unit ?: getDefaultDistanceUnit()) { + // distance units: base unit is meters + CarUnit.METER -> "%.0f m".format(value) + CarUnit.KILOMETER -> "%.1f km".format(value / 1000) + CarUnit.MILLIMETER -> "%.0f mm".format(value * 1000) // whoever uses that... + CarUnit.MILE -> "%.1f mi".format(value / 1000 / kmPerMile) + else -> "" + } +} + +fun formatCarUnitSpeed(value: Float?, unit: Int?): String { + if (value == null) return "" + return when (unit ?: getDefaultSpeedUnit()) { + // speed units: base unit is meters per second + CarUnit.METERS_PER_SEC -> "%.0f m/s".format(value) + CarUnit.KILOMETERS_PER_HOUR -> "%.0f km/h".format(value * 3.6) + CarUnit.MILES_PER_HOUR -> "%.0f mph".format(value * 3.6 / kmPerMile) + else -> "" + } +} \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt index 401d2185..01404500 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt @@ -8,17 +8,28 @@ import androidx.car.app.Screen import androidx.car.app.hardware.CarHardwareManager import androidx.car.app.hardware.info.EnergyLevel import androidx.car.app.hardware.info.Model +import androidx.car.app.hardware.info.Speed import androidx.car.app.model.* import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.OnLifecycleEvent import net.vonforst.evmap.R +import net.vonforst.evmap.ui.Gauge +import kotlin.math.min +import kotlin.math.roundToInt class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager private var model: Model? = null private var energyLevel: EnergyLevel? = null + private var speed: Speed? = null + private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx) + private val maxSpeed = 160f / 3.6f // m/s, speed gauge will show max if speed is higher private val permissions = listOf( - "com.google.android.gms.permission.CAR_FUEL" + "com.google.android.gms.permission.CAR_FUEL", + "com.google.android.gms.permission.CAR_SPEED" ) override fun onGetTemplate(): Template { @@ -29,7 +40,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { screenManager.pushForResult( PermissionScreen( carContext, - R.string.auto_location_permission_needed, + R.string.auto_vehicle_data_permission_needed, permissions ) ) { @@ -40,6 +51,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { val energyLevel = energyLevel val model = model + val speed = speed return GridTemplate.Builder().apply { setTitle( @@ -55,36 +67,122 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { } else { setSingleList( ItemList.Builder().apply { - addItem( - GridItem.Builder().apply { - setTitle("Battery") - energyLevel?.batteryPercent?.value?.let { percent -> - setText("%.1f".format(percent)) - setImage(CarIcon.APP_ICON) - } ?: setLoading(true) - }.build() - ) - addItem( - GridItem.Builder().apply { - setTitle("Fuel") - energyLevel?.fuelPercent?.value?.let { percent -> - setText("%.1f".format(percent)) - setImage(CarIcon.APP_ICON) - } ?: setLoading(true) - }.build() - ) + addItem(GridItem.Builder().apply { + setTitle(carContext.getString(R.string.auto_charging_level)) + if (energyLevel == null) { + setLoading(true) + } else if (energyLevel.batteryPercent.value != null && energyLevel.fuelPercent.value != null) { + // both battery and fuel (Plug-in hybrid) + setText( + "\uD83D\uDD0C %.0f %% ⛽ %.0f %%".format( + energyLevel.batteryPercent.value, + energyLevel.fuelPercent.value + ) + ) + setImage( + gauge.draw( + energyLevel.batteryPercent.value, + energyLevel.fuelPercent.value + ).asCarIcon() + ) + } else if (energyLevel.batteryPercent.value != null) { + // BEV + setText("%.0f %%".format(energyLevel.batteryPercent.value)) + setImage(gauge.draw(energyLevel.batteryPercent.value).asCarIcon()) + } else if (energyLevel.fuelPercent.value != null) { + // ICE + setText("⛽ %.0f %%".format(energyLevel.fuelPercent.value)) + setImage(gauge.draw(energyLevel.fuelPercent.value).asCarIcon()) + } else { + setText(carContext.getString(R.string.auto_no_data)) + setImage(gauge.draw(0f).asCarIcon()) + } + }.build()) + addItem(GridItem.Builder().apply { + setTitle(carContext.getString(R.string.auto_range)) + if (energyLevel == null) { + setLoading(true) + } else if (energyLevel.rangeRemainingMeters.value != null) { + setText( + formatCarUnitDistance( + energyLevel.rangeRemainingMeters.value, + energyLevel.distanceDisplayUnit.value + ) + ) + setImage( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_car + ) + ).build() + ) + } else { + setText(carContext.getString(R.string.auto_no_data)) + setImage( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_car + ) + ).build() + ) + } + }.build()) + addItem(GridItem.Builder().apply { + setTitle(carContext.getString(R.string.auto_speed)) + if (speed == null) { + setLoading(true) + } else { + val rawSpeed = speed.rawSpeedMetersPerSecond.value + val displaySpeed = speed.displaySpeedMetersPerSecond.value + if (rawSpeed != null) { + setText( + formatCarUnitSpeed( + rawSpeed, + speed.speedDisplayUnit.value + ) + ) + setImage( + gauge.draw(min(rawSpeed / maxSpeed * 100, 100f)).asCarIcon() + ) + } else if (displaySpeed != null) { + setText( + formatCarUnitSpeed( + speed.displaySpeedMetersPerSecond.value, + speed.speedDisplayUnit.value + ) + ) + setImage( + gauge.draw(min(displaySpeed / maxSpeed * 100, 100f)) + .asCarIcon() + ) + } else { + setText(carContext.getString(R.string.auto_no_data)) + setImage(gauge.draw(0f).asCarIcon()) + } + } + }.build()) }.build() ) } }.build() } + private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) { + this.energyLevel = energyLevel + invalidate() + } + + private fun onSpeedUpdated(speed: Speed) { + this.speed = speed + invalidate() + } + private fun setupListeners() { val exec = ContextCompat.getMainExecutor(carContext) - hardwareMan.carInfo.addEnergyLevelListener(exec) { - this.energyLevel = it - invalidate() - } + hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated) + hardwareMan.carInfo.addSpeedListener(exec, ::onSpeedUpdated) hardwareMan.carInfo.fetchModel(exec) { this.model = it @@ -92,6 +190,12 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { } } + @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) + private fun removeListeners() { + hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated) + hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated) + } + private fun permissionsGranted(): Boolean = permissions.all { ContextCompat.checkSelfPermission( diff --git a/app/src/google/java/net/vonforst/evmap/ui/Gauge.kt b/app/src/google/java/net/vonforst/evmap/ui/Gauge.kt new file mode 100644 index 00000000..f0bb93f6 --- /dev/null +++ b/app/src/google/java/net/vonforst/evmap/ui/Gauge.kt @@ -0,0 +1,74 @@ +package net.vonforst.evmap.ui + +import android.content.Context +import android.graphics.* +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import net.vonforst.evmap.R +import kotlin.math.max +import kotlin.math.min + +class Gauge(val size: Int, ctx: Context) { + val arcPaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = size * 0.15f + } + val gaugePaint = Paint() + val activeColor = ContextCompat.getColor(ctx, R.color.gauge_active) + val middleColor = ContextCompat.getColor(ctx, R.color.gauge_middle) + val inactiveColor = ContextCompat.getColor(ctx, R.color.gauge_inactive) + + fun draw(valuePercent: Float?, secondValuePercent: Float? = null): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + + val angle = valuePercent?.let { 180f * it / 100 } ?: 0f + val secondAngle = secondValuePercent?.let { 180f * it / 100 } + + drawArc(angle, secondAngle, canvas) + if (secondAngle != null) drawGauge(secondAngle, inactiveColor, canvas) + drawGauge(angle, Color.WHITE, canvas) + return bitmap + } + + private fun drawGauge(angle: Float, @ColorInt color: Int, canvas: Canvas) { + gaugePaint.color = color + canvas.save() + canvas.rotate(angle - 90, size / 2f, 3 * size / 4f) + canvas.drawCircle(size / 2f, 3 * size / 4f, size * 0.1F, gaugePaint) + canvas.drawRect(size * 0.48f, 3 * size / 4f, size * 0.53f, size * 0.325f, gaugePaint) + canvas.restore() + } + + private fun drawArc(angle: Float, secondAngle: Float?, canvas: Canvas) { + val (angle1, angle2) = if (secondAngle != null) { + min(angle, secondAngle) to max(angle, secondAngle) + } else { + angle to null + } + + arcPaint.color = activeColor + val arcBounds = RectF( + arcPaint.strokeWidth / 2, + size / 4f + arcPaint.strokeWidth / 2, + size - arcPaint.strokeWidth / 2, + 5 * size / 4f - arcPaint.strokeWidth / 2 + ) + + canvas.drawArc(arcBounds, 180f, angle1, false, arcPaint) + if (angle2 != null) { + arcPaint.color = middleColor + canvas.drawArc(arcBounds, 180f + angle1, angle2 - angle1, false, arcPaint) + } + arcPaint.color = inactiveColor + canvas.drawArc( + arcBounds, + 180f + (angle2 ?: angle1), + 180f - (angle2 ?: angle1), + false, + arcPaint + ) + } +} \ No newline at end of file diff --git a/app/src/google/res/values-de/values.xml b/app/src/google/res/values-de/values.xml index 3f5225cd..ab60fd0a 100644 --- a/app/src/google/res/values-de/values.xml +++ b/app/src/google/res/values-de/values.xml @@ -22,6 +22,10 @@ ⚠️ Störungsmeldung (%s) Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten. Fahrzeugdaten + Ladezustand + Nicht verfügbar + Reichweite + Geschwindigkeit Android Auto-Unterstützung Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto. klingt cool diff --git a/app/src/google/res/values/colors.xml b/app/src/google/res/values/colors.xml new file mode 100644 index 00000000..34fdac36 --- /dev/null +++ b/app/src/google/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #00e676 + #087f23 + #9e9e9e + \ No newline at end of file diff --git a/app/src/google/res/values/values.xml b/app/src/google/res/values/values.xml index 101b3dff..d4b7b014 100644 --- a/app/src/google/res/values/values.xml +++ b/app/src/google/res/values/values.xml @@ -32,6 +32,10 @@ ⚠️ Fault report (%s) Further updates not possible. Please go back and restart. Vehicle data + Charging level + Unavailable + Range + Speed Android Auto support You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu. sounds cool From fe4db387986366d624ace287d55c2d586e00e757 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 15 Aug 2021 15:37:58 +0200 Subject: [PATCH 11/28] show vehicle data screen only if API level available --- .../net/vonforst/evmap/auto/WelcomeScreen.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt index 0afb7072..65dd54d8 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/WelcomeScreen.kt @@ -7,6 +7,7 @@ import android.os.Looper import androidx.car.app.CarContext import androidx.car.app.Screen import androidx.car.app.model.* +import androidx.car.app.versioning.CarAppApiLevels import androidx.core.graphics.drawable.IconCompat import net.vonforst.evmap.R @@ -81,21 +82,23 @@ class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), L screenManager.push(MapScreen(carContext, session, favorites = true)) } .build()) - addItem( - Row.Builder() - .setTitle(carContext.getString(R.string.auto_vehicle_data)) - .setImage( - CarIcon.Builder( - IconCompat.createWithResource(carContext, R.drawable.ic_car) - ).setTint(CarColor.DEFAULT).build() - ) - .setBrowsable(true) - .setOnClickListener { - session.mapScreen = null - screenManager.push(VehicleDataScreen(carContext)) - } - .build() - ) + if (carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_3) { + addItem( + Row.Builder() + .setTitle(carContext.getString(R.string.auto_vehicle_data)) + .setImage( + CarIcon.Builder( + IconCompat.createWithResource(carContext, R.drawable.ic_car) + ).setTint(CarColor.DEFAULT).build() + ) + .setBrowsable(true) + .setOnClickListener { + session.mapScreen = null + screenManager.push(VehicleDataScreen(carContext)) + } + .build() + ) + } }.build()) setCurrentLocationEnabled(true) } From 31ad748796d3d481fadef26012c4f7fd50f140bc Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 15 Aug 2021 15:49:23 +0200 Subject: [PATCH 12/28] use car hardware location data if available --- .../net/vonforst/evmap/auto/CarAppService.kt | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt index e5eb297d..44ecc04b 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt @@ -6,15 +6,19 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.location.Location import android.os.IBinder +import androidx.car.app.CarContext import androidx.car.app.Screen import androidx.car.app.Session -import androidx.car.app.model.* +import androidx.car.app.hardware.CarHardwareManager +import androidx.car.app.hardware.info.CarHardwareLocation +import androidx.car.app.hardware.info.CarSensors import androidx.car.app.validation.HostValidator +import androidx.car.app.versioning.CarAppApiLevels import androidx.core.content.ContextCompat -import androidx.lifecycle.* +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent import androidx.localbroadcastmanager.content.LocalBroadcastManager -import kotlinx.coroutines.* -import net.vonforst.evmap.* interface LocationAwareScreen { @@ -45,6 +49,8 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver { } private var location: Location? = null private var locationService: CarLocationService? = null + private val hardwareMan = + carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, ibinder: IBinder) { @@ -75,29 +81,50 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver { private val locationReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location? - val mapScreen = this@EVMapSession.mapScreen - if (location != null && mapScreen != null) { - mapScreen.updateLocation(location) - } - this@EVMapSession.location = location + updateLocation(location) } } + private fun updateLocation(location: Location?) { + val mapScreen = mapScreen + if (location != null && mapScreen != null) { + mapScreen.updateLocation(location) + } + this.location = location + } + + private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) { + updateLocation(loc.location.value) + } + @OnLifecycleEvent(Lifecycle.Event.ON_START) fun bindLocationService() { if (!locationPermissionGranted()) return - cas.bindService( - Intent(cas, CarLocationService::class.java), - serviceConnection, - Context.BIND_AUTO_CREATE - ) + if (carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_3) { + val exec = ContextCompat.getMainExecutor(carContext) + hardwareMan.carSensors.addCarHardwareLocationListener( + CarSensors.UPDATE_RATE_NORMAL, + exec, + ::onCarHardwareLocationReceived + ) + } else { + cas.bindService( + Intent(cas, CarLocationService::class.java), + serviceConnection, + Context.BIND_AUTO_CREATE + ) + } } @OnLifecycleEvent(Lifecycle.Event.ON_STOP) private fun unbindLocationService() { - locationService?.let { service -> - service.removeLocationUpdates() - cas.unbindService(serviceConnection) + if (carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_3) { + locationService?.let { service -> + service.removeLocationUpdates() + cas.unbindService(serviceConnection) + } + } else { + hardwareMan.carSensors.removeCarHardwareLocationListener(::onCarHardwareLocationReceived) } } From 17a40127e6fa10a402e65a4abadf8d4306cd0261 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 15 Aug 2021 18:06:09 +0200 Subject: [PATCH 13/28] add Chargeprice to Android Auto fixes #80 --- .../vonforst/evmap/auto/ChargepriceScreen.kt | 203 ++++++++++++++++++ .../evmap/auto/ChargerDetailScreen.kt | 51 +++-- .../java/net/vonforst/evmap/auto/MapScreen.kt | 2 +- app/src/google/res/values-de/values.xml | 4 + app/src/google/res/values/values.xml | 4 + .../evmap/api/chargeprice/ChargepriceApi.kt | 14 ++ .../evmap/api/chargeprice/ChargepriceModel.kt | 21 ++ .../evmap/viewmodel/ChargepriceViewModel.kt | 30 +-- 8 files changed, 283 insertions(+), 46 deletions(-) create mode 100644 app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt diff --git a/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt new file mode 100644 index 00000000..89c9fdba --- /dev/null +++ b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt @@ -0,0 +1,203 @@ +package net.vonforst.evmap.auto + +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.hardware.CarHardwareManager +import androidx.car.app.hardware.info.Model +import androidx.car.app.model.* +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import moe.banana.jsonapi2.HasOne +import net.vonforst.evmap.R +import net.vonforst.evmap.api.chargeprice.* +import net.vonforst.evmap.model.ChargeLocation +import net.vonforst.evmap.storage.AppDatabase +import net.vonforst.evmap.storage.PreferenceDataSource +import net.vonforst.evmap.ui.currency + +class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(ctx) { + private val prefs = PreferenceDataSource(ctx) + private val db = AppDatabase.getInstance(carContext) + private val api by lazy { + ChargepriceApi.create(carContext.getString(R.string.chargeprice_key)) + } + private var prices: List? = null + private var meta: ChargepriceChargepointMeta? = null + private val maxRows = 6 + private var errorMessage: String? = null + private val batteryRange = listOf(20.0, 80.0) + + override fun onGetTemplate(): Template { + if (prices == null) loadData() + + return ListTemplate.Builder().apply { + setTitle( + carContext.getString( + R.string.chargeprice_battery_range, + batteryRange[0], + batteryRange[1] + ) + " · " + carContext.getString(R.string.powered_by_chargeprice) + ) + setHeaderAction(Action.BACK) + if (prices == null && errorMessage == null) { + setLoading(true) + } else { + setSingleList(ItemList.Builder().apply { + setNoItemsMessage( + errorMessage ?: carContext.getString(R.string.chargeprice_no_tariffs_found) + ) + prices?.take(maxRows)?.forEach { price -> + addItem(Row.Builder().apply { + setTitle(formatProvider(price)) + addText(formatPrice(price)) + }.build()) + } + }.build()) + } + }.build() + } + + private fun formatProvider(price: ChargePrice): String { + if (!price.tariffName.startsWith(price.provider)) { + return price.provider + " " + price.tariffName + } else { + return price.tariffName + } + } + + private fun formatPrice(price: ChargePrice): String { + val totalPrice = carContext.getString( + R.string.charge_price_format, + price.chargepointPrices.first().price, + currency(price.currency) + ) + val kwhPrice = if (price.chargepointPrices.first().price > 0f) { + carContext.getString( + if (price.chargepointPrices[0].priceDistribution.isOnlyKwh) { + R.string.charge_price_kwh_format + } else { + R.string.charge_price_average_format + }, + price.chargepointPrices.get(0).price / meta!!.energy, + currency(price.currency) + ) + } else null + val monthlyFees = if (price.totalMonthlyFee > 0 || price.monthlyMinSales > 0) { + price.formatMonthlyFees(carContext) + } else null + var text = totalPrice + if (kwhPrice != null && monthlyFees != null) { + text += " ($kwhPrice, $monthlyFees)" + } else if (kwhPrice != null) { + text += " ($kwhPrice)" + } else if (monthlyFees != null) { + text += " ($monthlyFees)" + } + return text + } + + private fun loadData() { + val exec = ContextCompat.getMainExecutor(carContext) + val hardwareMan = + carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager + hardwareMan.carInfo.fetchModel(exec) { model -> + loadPrices(model) + } + } + + private fun loadPrices(model: Model) { + val dataAdapter = when (charger.dataSource) { + "goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC + "openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP + else -> return + } + val manufacturer = model.manufacturer.value + val modelName = model.name.value + lifecycleScope.launch { + var vehicles = api.getVehicles().filter { + it.id in prefs.chargepriceMyVehicles + } + if (vehicles.isEmpty()) { + errorMessage = carContext.getString(R.string.chargeprice_select_car_first) + return@launch + } else if (vehicles.size > 1) { + if (manufacturer != null && modelName != null) { + vehicles = vehicles.filter { + it.brand == manufacturer && it.name.startsWith(modelName) + } + if (vehicles.isEmpty()) { + errorMessage = carContext.getString( + R.string.auto_chargeprice_vehicle_unknown, + manufacturer, + modelName + ) + return@launch + } else if (vehicles.size > 1) { + errorMessage = carContext.getString( + R.string.auto_chargeprice_vehicle_ambiguous, + manufacturer, + modelName + ) + return@launch + } + } else { + errorMessage = + carContext.getString(R.string.auto_chargeprice_vehicle_unavailable) + return@launch + } + } + val car = vehicles[0] + + val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors) + val result = api.getChargePrices(ChargepriceRequest().apply { + this.dataAdapter = dataAdapter + station = cpStation + vehicle = HasOne(car) + options = ChargepriceOptions( + batteryRange = batteryRange, + providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs, + maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null, + currency = prefs.chargepriceCurrency + ) + }, ChargepriceApi.getChargepriceLanguage()) + + val myTariffs = prefs.chargepriceMyTariffs + + // choose the highest power chargepoint compatible with the car + val chargepoint = cpStation.chargePoints.filterIndexed { i, cp -> + charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors + }.maxByOrNull { it.power } + if (chargepoint == null) { + errorMessage = carContext.getString(R.string.chargeprice_no_compatible_connectors) + return@launch + } + meta = + (result.meta.get(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta).chargePoints.filterIndexed { i, cp -> + charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors + }.maxByOrNull { + it.power + } + + prices = result.map { cp -> + val filteredPrices = + cp.chargepointPrices.filter { + it.plug == chargepoint.plug && it.power == chargepoint.power + } + if (filteredPrices.isEmpty()) { + null + } else { + cp.clone().apply { + chargepointPrices = filteredPrices + } + } + }.filterNotNull() + .sortedBy { it.chargepointPrices.first().price } + .sortedByDescending { + prefs.chargepriceMyTariffsAll || + myTariffs != null && it.tariff?.get()?.id in myTariffs + } + invalidate() + } + } +} \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt index 2ea8e97c..12a55bb8 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt @@ -150,29 +150,46 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : navigateToCharger(charger) } .build()) - addAction( - Action.Builder() - .setTitle(carContext.getString(R.string.open_in_app)) - .setOnClickListener(ParkedOnlyOnClickListener.create { - val intent = Intent(carContext, MapsActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(EXTRA_CHARGER_ID, charger.id) - .putExtra(EXTRA_LAT, charger.coordinates.lat) - .putExtra(EXTRA_LON, charger.coordinates.lng) - carContext.startActivity(intent) - CarToast.makeText( + addAction(Action.Builder() + .setIcon( + CarIcon.Builder( + IconCompat.createWithResource( carContext, - R.string.opened_on_phone, - CarToast.LENGTH_LONG - ).show() - }) - .build() - ) + R.drawable.ic_chargeprice + ) + ).build() + ) + .setTitle(carContext.getString(R.string.auto_prices)) + .setOnClickListener { + screenManager.push(ChargepriceScreen(carContext, charger)) + } + .build()) + } ?: setLoading(true) }.build() ).apply { setTitle(chargerSparse.name) setHeaderAction(Action.BACK) + setActionStrip( + ActionStrip.Builder().addAction( + Action.Builder() + .setTitle(carContext.getString(R.string.open_in_app)) + .setOnClickListener(ParkedOnlyOnClickListener.create { + val intent = Intent(carContext, MapsActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_CHARGER_ID, chargerSparse.id) + .putExtra(EXTRA_LAT, chargerSparse.coordinates.lat) + .putExtra(EXTRA_LON, chargerSparse.coordinates.lng) + carContext.startActivity(intent) + CarToast.makeText( + carContext, + R.string.opened_on_phone, + CarToast.LENGTH_LONG + ).show() + }) + .build() + ).build() + ) }.build() } diff --git a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt index 7de85d43..02567509 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt @@ -44,7 +44,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole Screen(ctx), LocationAwareScreen { private var updateCoroutine: Job? = null private var numUpdates = 0 - private val maxNumUpdates = 3 + private val maxNumUpdates = 2 private var location: Location? = null private var lastUpdateLocation: Location? = null diff --git a/app/src/google/res/values-de/values.xml b/app/src/google/res/values-de/values.xml index ab60fd0a..1db7c465 100644 --- a/app/src/google/res/values-de/values.xml +++ b/app/src/google/res/values-de/values.xml @@ -21,6 +21,7 @@ Favoriten ⚠️ Störungsmeldung (%s) Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten. + Preise Fahrzeugdaten Ladezustand Nicht verfügbar @@ -29,4 +30,7 @@ Android Auto-Unterstützung Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto. klingt cool + EVMap konnte das Fahrzeugmodell nicht erkennen. + Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%s %s). Bitte wähle in der App ein passendes Fahrzeug aus. + Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%s %s). Bitte wähle nur ein passendes Fahrzeug in der App aus. \ No newline at end of file diff --git a/app/src/google/res/values/values.xml b/app/src/google/res/values/values.xml index d4b7b014..94b84215 100644 --- a/app/src/google/res/values/values.xml +++ b/app/src/google/res/values/values.xml @@ -31,6 +31,7 @@ Favorites ⚠️ Fault report (%s) Further updates not possible. Please go back and restart. + Pricing Vehicle data Charging level Unavailable @@ -39,4 +40,7 @@ Android Auto support You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu. sounds cool + EVMap could not determine your vehicle model. + None of the vehicles selected in the app matches this vehicle (%s %s). Please select a matching vehicle from the app. + Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%s %s). Bitte wähle nur ein passendes Fahrzeug in der App aus. \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/api/chargeprice/ChargepriceApi.kt b/app/src/main/java/net/vonforst/evmap/api/chargeprice/ChargepriceApi.kt index 07110b6e..4745e8d6 100644 --- a/app/src/main/java/net/vonforst/evmap/api/chargeprice/ChargepriceApi.kt +++ b/app/src/main/java/net/vonforst/evmap/api/chargeprice/ChargepriceApi.kt @@ -15,6 +15,7 @@ import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST +import java.util.* interface ChargepriceApi { @POST("charge_prices") @@ -33,6 +34,9 @@ interface ChargepriceApi { private val cacheSize = 1L * 1024 * 1024 // 1MB val supportedLanguages = setOf("de", "en", "fr", "nl") + val DATA_SOURCE_GOINGELECTRIC = "going_electric" + val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map" + private val jsonApiAdapterFactory = ResourceAdapterFactory.builder() .add(ChargepriceRequest::class.java) .add(ChargepriceTariff::class.java) @@ -75,6 +79,16 @@ interface ChargepriceApi { return retrofit.create(ChargepriceApi::class.java) } + + fun getChargepriceLanguage(): String { + val locale = Locale.getDefault().language + return if (supportedLanguages.contains(locale)) { + locale + } else { + "en" + } + } + @JvmStatic fun isCountrySupported(country: String, dataSource: String): Boolean = when (dataSource) { // list of countries updated 2021/08/24 diff --git a/app/src/main/java/net/vonforst/evmap/api/chargeprice/ChargepriceModel.kt b/app/src/main/java/net/vonforst/evmap/api/chargeprice/ChargepriceModel.kt index b4a7da0f..cc30a5df 100644 --- a/app/src/main/java/net/vonforst/evmap/api/chargeprice/ChargepriceModel.kt +++ b/app/src/main/java/net/vonforst/evmap/api/chargeprice/ChargepriceModel.kt @@ -11,6 +11,7 @@ import net.vonforst.evmap.R import net.vonforst.evmap.adapter.Equatable import net.vonforst.evmap.api.equivalentPlugTypes import net.vonforst.evmap.model.ChargeLocation +import net.vonforst.evmap.model.Chargepoint import net.vonforst.evmap.ui.currency import kotlin.math.ceil import kotlin.math.floor @@ -148,6 +149,26 @@ class ChargepriceCar : Resource(), Equatable { result = 31 * result + manufacturer.hashCode() return result } + + private val acConnectors = listOf( + Chargepoint.CEE_BLAU, + Chargepoint.CEE_ROT, + Chargepoint.SCHUKO, + Chargepoint.TYPE_1, + Chargepoint.TYPE_2_UNKNOWN, + Chargepoint.TYPE_2_SOCKET, + Chargepoint.TYPE_2_PLUG + ) + private val plugMapping = mapOf( + "ccs" to Chargepoint.CCS_UNKNOWN, + "tesla_suc" to Chargepoint.SUPERCHARGER, + "tesla_ccs" to Chargepoint.CCS_UNKNOWN, + "chademo" to Chargepoint.CHADEMO + ) + val compatibleEvmapConnectors: List + get() = dcChargePorts.map { + plugMapping[it] + }.filterNotNull().plus(acConnectors) } @JsonApi(type = "brand") diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/ChargepriceViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/ChargepriceViewModel.kt index 8fd72131..ab932024 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/ChargepriceViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/ChargepriceViewModel.kt @@ -49,27 +49,10 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String) MutableLiveData() } - private val acConnectors = listOf( - Chargepoint.CEE_BLAU, - Chargepoint.CEE_ROT, - Chargepoint.SCHUKO, - Chargepoint.TYPE_1, - Chargepoint.TYPE_2_UNKNOWN, - Chargepoint.TYPE_2_SOCKET, - Chargepoint.TYPE_2_PLUG - ) - private val plugMapping = mapOf( - "ccs" to Chargepoint.CCS_UNKNOWN, - "tesla_suc" to Chargepoint.SUPERCHARGER, - "tesla_ccs" to Chargepoint.CCS_UNKNOWN, - "chademo" to Chargepoint.CHADEMO - ) val vehicleCompatibleConnectors: LiveData> by lazy { MediatorLiveData>().apply { addSource(vehicle) { - value = it?.dcChargePorts?.map { - plugMapping[it] - }?.filterNotNull()?.plus(acConnectors) + value = it?.compatibleEvmapConnectors } } } @@ -245,7 +228,7 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String) maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null, currency = prefs.chargepriceCurrency ) - }, getChargepriceLanguage()) + }, ChargepriceApi.getChargepriceLanguage()) val meta = result.meta.get(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta chargePrices.value = Resource.success(result) @@ -272,13 +255,4 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String) } } } - - private fun getChargepriceLanguage(): String { - val locale = Locale.getDefault().language - return if (ChargepriceApi.supportedLanguages.contains(locale)) { - locale - } else { - "en" - } - } } \ No newline at end of file From 4ae16df064a5e3d3fefcf34b0a62c46395a24412 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 15 Aug 2021 18:14:50 +0200 Subject: [PATCH 14/28] add Chargeprice icon --- .../net/vonforst/evmap/auto/ChargepriceScreen.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt index 89c9fdba..fbaacc4e 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt @@ -6,6 +6,7 @@ import androidx.car.app.hardware.CarHardwareManager import androidx.car.app.hardware.info.Model import androidx.car.app.model.* import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import moe.banana.jsonapi2.HasOne @@ -55,6 +56,18 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c } }.build()) } + setActionStrip( + ActionStrip.Builder().addAction( + Action.Builder().setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_chargeprice + ) + ).build() + ).build() + ).build() + ) }.build() } From 066b7c085e77ac0701b9e27636a0e5c69a5af81f Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 15 Aug 2021 18:30:48 +0200 Subject: [PATCH 15/28] add link to Chargeprice website --- .../vonforst/evmap/auto/ChargepriceScreen.kt | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt index fbaacc4e..8d91e373 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt @@ -1,6 +1,12 @@ package net.vonforst.evmap.auto +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent import androidx.car.app.CarContext +import androidx.car.app.CarToast import androidx.car.app.Screen import androidx.car.app.hardware.CarHardwareManager import androidx.car.app.hardware.info.Model @@ -10,7 +16,7 @@ import androidx.core.graphics.drawable.IconCompat import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import moe.banana.jsonapi2.HasOne -import net.vonforst.evmap.R +import net.vonforst.evmap.* import net.vonforst.evmap.api.chargeprice.* import net.vonforst.evmap.model.ChargeLocation import net.vonforst.evmap.storage.AppDatabase @@ -65,7 +71,37 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c R.drawable.ic_chargeprice ) ).build() - ).build() + ).setOnClickListener { + val intent = CustomTabsIntent.Builder() + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor( + ContextCompat.getColor( + carContext, + R.color.colorPrimary + ) + ) + .build() + ) + .build().intent + intent.data = + Uri.parse("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter()}") + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + carContext.startActivity(intent) + CarToast.makeText( + carContext, + R.string.opened_on_phone, + CarToast.LENGTH_LONG + ).show() + } catch (e: ActivityNotFoundException) { + CarToast.makeText( + carContext, + R.string.no_browser_app_found, + CarToast.LENGTH_LONG + ).show() + } + }.build() ).build() ) }.build() @@ -120,11 +156,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c } private fun loadPrices(model: Model) { - val dataAdapter = when (charger.dataSource) { - "goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC - "openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP - else -> return - } + val dataAdapter = getDataAdapter() ?: return val manufacturer = model.manufacturer.value val modelName = model.name.value lifecycleScope.launch { @@ -213,4 +245,10 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c invalidate() } } + + private fun getDataAdapter(): String? = when (charger.dataSource) { + "goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC + "openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP + else -> null + } } \ No newline at end of file From dc5ffb148d9371a425013dbc5042977a3b6c0a07 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 15 Aug 2021 19:28:50 +0200 Subject: [PATCH 16/28] Chargeprice: check car API level --- .../vonforst/evmap/auto/ChargepriceScreen.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt index 8d91e373..cba41e68 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt @@ -11,6 +11,7 @@ import androidx.car.app.Screen import androidx.car.app.hardware.CarHardwareManager import androidx.car.app.hardware.info.Model import androidx.car.app.model.* +import androidx.car.app.versioning.CarAppApiLevels import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat import androidx.lifecycle.lifecycleScope @@ -147,18 +148,22 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c } private fun loadData() { - val exec = ContextCompat.getMainExecutor(carContext) - val hardwareMan = - carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager - hardwareMan.carInfo.fetchModel(exec) { model -> - loadPrices(model) + if (carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_3) { + val exec = ContextCompat.getMainExecutor(carContext) + val hardwareMan = + carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager + hardwareMan.carInfo.fetchModel(exec) { model -> + loadPrices(model) + } + } else { + loadPrices(null) } } - private fun loadPrices(model: Model) { + private fun loadPrices(model: Model?) { val dataAdapter = getDataAdapter() ?: return - val manufacturer = model.manufacturer.value - val modelName = model.name.value + val manufacturer = model?.manufacturer?.value + val modelName = model?.name?.value lifecycleScope.launch { var vehicles = api.getVehicles().filter { it.id in prefs.chargepriceMyVehicles From ae0a84db4c2517fbb40a08290543dfa343104682 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 5 Sep 2021 15:37:55 +0200 Subject: [PATCH 17/28] VehicleDataScreen: setup listeners with lifecycle events --- .../vonforst/evmap/auto/VehicleDataScreen.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt index 01404500..21be0af8 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt @@ -13,13 +13,14 @@ import androidx.car.app.model.* import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.OnLifecycleEvent import net.vonforst.evmap.R import net.vonforst.evmap.ui.Gauge import kotlin.math.min import kotlin.math.roundToInt -class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { +class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver { private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager private var model: Model? = null private var energyLevel: EnergyLevel? = null @@ -32,10 +33,12 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { "com.google.android.gms.permission.CAR_SPEED" ) + init { + lifecycle.addObserver(this) + } + override fun onGetTemplate(): Template { - if (permissionsGranted()) { - setupListeners() - } else { + if (!permissionsGranted()) { Handler(Looper.getMainLooper()).post { screenManager.pushForResult( PermissionScreen( @@ -179,7 +182,12 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { invalidate() } + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) private fun setupListeners() { + if (!permissionsGranted()) return + + println("Setting up energy level listener") + val exec = ContextCompat.getMainExecutor(carContext) hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated) hardwareMan.carInfo.addSpeedListener(exec, ::onSpeedUpdated) @@ -192,6 +200,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) { @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) private fun removeListeners() { + println("Removing energy level listener") hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated) hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated) } From 07be77c5735fc2878214b54b619b09b914536ec0 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 5 Sep 2021 16:35:55 +0200 Subject: [PATCH 18/28] ChargepriceScreen: fix showing error messages --- .../google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt index cba41e68..fd0384d9 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt @@ -170,6 +170,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c } if (vehicles.isEmpty()) { errorMessage = carContext.getString(R.string.chargeprice_select_car_first) + invalidate() return@launch } else if (vehicles.size > 1) { if (manufacturer != null && modelName != null) { @@ -182,6 +183,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c manufacturer, modelName ) + invalidate() return@launch } else if (vehicles.size > 1) { errorMessage = carContext.getString( @@ -189,11 +191,13 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c manufacturer, modelName ) + invalidate() return@launch } } else { errorMessage = carContext.getString(R.string.auto_chargeprice_vehicle_unavailable) + invalidate() return@launch } } @@ -220,6 +224,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c }.maxByOrNull { it.power } if (chargepoint == null) { errorMessage = carContext.getString(R.string.chargeprice_no_compatible_connectors) + invalidate() return@launch } meta = From fb0a2cfa1ca224e799c3fcbee5f3c2024125537b Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 5 Sep 2021 16:49:12 +0200 Subject: [PATCH 19/28] internal test release --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index f1d83d64..36169e0f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,7 +13,7 @@ android { applicationId "net.vonforst.evmap" minSdkVersion 21 targetSdkVersion 30 - versionCode 57 + versionCode 58 versionName "0.9.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" From 2167a63321e92d326c76d1d8bd7c82942d6ba221 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 5 Sep 2021 19:43:31 +0200 Subject: [PATCH 20/28] only use ConstraintManager if car API level >= 2 refs a562ee6c --- app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt index 02567509..f179685c 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt @@ -59,8 +59,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole private val availabilityUpdateThreshold = Duration.ofMinutes(1) private var availabilities: MutableMap> = HashMap() - private val maxRows = + private val maxRows = if (ctx.carAppApiLevel >= 2) { ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST) + } else 6 private val referenceData = api.getReferenceData(lifecycleScope, carContext) private val filterStatus = MutableLiveData().apply { From b2c29b647b77dded61aded9f74fa6d9dd5ff2ca5 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 5 Sep 2021 21:08:59 +0200 Subject: [PATCH 21/28] upgrade car app library to 1.1.0-beta01 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 36169e0f..e12e1e50 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -136,8 +136,8 @@ dependencies { implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5' // Android Auto - googleImplementation 'androidx.car.app:app:1.1.0-alpha02' - googleImplementation 'androidx.car.app:app-projected:1.1.0-alpha02' + googleImplementation 'androidx.car.app:app:1.1.0-beta01' + googleImplementation 'androidx.car.app:app-projected:1.1.0-beta01' // AnyMaps def anyMapsVersion = '95ddd6c083' From 2576bc4854c65c2749f5d198adb1e4b932996322 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 5 Sep 2021 21:15:32 +0200 Subject: [PATCH 22/28] upgrade compileSdk to Android 12 required for new car app library --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index e12e1e50..8c47b577 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,7 +6,7 @@ apply plugin: 'androidx.navigation.safeargs.kotlin' apply plugin: 'com.mikepenz.aboutlibraries.plugin' android { - compileSdkVersion 30 + compileSdkVersion 31 buildToolsVersion "30.0.3" defaultConfig { From ff75594b376f062ae9a8359f0da4ece3b3d2e3b7 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 5 Sep 2021 21:21:30 +0200 Subject: [PATCH 23/28] get CarHardwareManager lazily --- app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt index 44ecc04b..d51d7632 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt @@ -49,8 +49,9 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver { } private var location: Location? = null private var locationService: CarLocationService? = null - private val hardwareMan = + private val hardwareMan: CarHardwareManager by lazy { carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager + } private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, ibinder: IBinder) { From 08cd4eb84935d6d243684bfdb4e318e338a2c99d Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 5 Sep 2021 21:29:21 +0200 Subject: [PATCH 24/28] Android Auto: do not update the map when location changes to avoid running into template restrictions --- .../google/java/net/vonforst/evmap/auto/MapScreen.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt index f179685c..230f9c19 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt @@ -44,7 +44,11 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole Screen(ctx), LocationAwareScreen { private var updateCoroutine: Job? = null private var numUpdates = 0 - private val maxNumUpdates = 2 + + /* Updating map contents is disabled - if the user uses Chargeprice from the charger + detail screen, this already means 4 steps, after which the app would crash. + follow https://issuetracker.google.com/issues/176694222 for updates how to solve this. */ + private val maxNumUpdates = 1 private var location: Location? = null private var lastUpdateLocation: Location? = null @@ -246,8 +250,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole numUpdates++ println(numUpdates) if (numUpdates > maxNumUpdates) { - CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG) - .show() + /*CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG) + .show()*/ return } updateCoroutine = lifecycleScope.launch { From 6fd737f6e948ca85f188f4868978036e7dbf2e5d Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 5 Sep 2021 21:39:05 +0200 Subject: [PATCH 25/28] Android Auto: Disable Chargeprice in unsupported countries see also: cf6c6628, #117 --- .../evmap/auto/ChargerDetailScreen.kt | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt index 12a55bb8..e1e1c791 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.withContext import net.vonforst.evmap.* import net.vonforst.evmap.api.availability.ChargeLocationStatus import net.vonforst.evmap.api.availability.getAvailability +import net.vonforst.evmap.api.chargeprice.ChargepriceApi import net.vonforst.evmap.api.createApi import net.vonforst.evmap.api.nameForPlugType import net.vonforst.evmap.api.stringProvider @@ -150,21 +151,24 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : navigateToCharger(charger) } .build()) - addAction(Action.Builder() - .setIcon( - CarIcon.Builder( - IconCompat.createWithResource( - carContext, - R.drawable.ic_chargeprice + charger.chargepriceData?.country?.let { country -> + if (ChargepriceApi.isCountrySupported(country, charger.dataSource)) { + addAction(Action.Builder() + .setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_chargeprice + ) + ).build() ) - ).build() - ) - .setTitle(carContext.getString(R.string.auto_prices)) - .setOnClickListener { - screenManager.push(ChargepriceScreen(carContext, charger)) + .setTitle(carContext.getString(R.string.auto_prices)) + .setOnClickListener { + screenManager.push(ChargepriceScreen(carContext, charger)) + } + .build()) } - .build()) - + } } ?: setLoading(true) }.build() ).apply { From b3c5fe788d30ba4c5c1c718d20d06720d464b251 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Mon, 6 Sep 2021 20:03:22 +0200 Subject: [PATCH 26/28] suppress lint error --- app/src/main/java/net/vonforst/evmap/storage/Database.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/net/vonforst/evmap/storage/Database.kt b/app/src/main/java/net/vonforst/evmap/storage/Database.kt index 0655c0a7..8b5db0b8 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/Database.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/Database.kt @@ -1,5 +1,6 @@ package net.vonforst.evmap.storage +import android.annotation.SuppressLint import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase @@ -256,6 +257,7 @@ abstract class AppDatabase : RoomDatabase() { } private val MIGRATION_13 = object : Migration(12, 13) { + @SuppressLint("Range") override fun migrate(db: SupportSQLiteDatabase) { db.beginTransaction() try { From 8b241e3f6fe3413bf88ff8b2e5cca729affb4329 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sat, 11 Sep 2021 11:09:08 +0200 Subject: [PATCH 27/28] new screenshots - English and German - Mapbox and Google Maps - + Android Auto --- .gitattributes | 2 ++ .../android_auto/de/11_android_auto_map.png | 3 +++ .../de/12_android_auto_detail.png | 3 +++ .../de/13_android_auto_prices.png | 3 +++ .../android_auto/de/14_vehicle_data.png | 3 +++ .../android_auto/en/11_android_auto_map.png | 3 +++ .../en/12_android_auto_detail.png | 3 +++ .../en/13_android_auto_prices.png | 3 +++ .../android_auto/en/14_vehicle_data.png | 3 +++ _img/screenshots/phone/01_main.png | Bin 931071 -> 0 bytes _img/screenshots/phone/02_detail.png | Bin 965842 -> 0 bytes .../phone/11_android_auto_detail.png | Bin 82016 -> 0 bytes .../screenshots/phone/11_android_auto_map.png | Bin 161678 -> 0 bytes _img/screenshots/phone/de/google/01_map.png | 3 +++ .../screenshots/phone/de/google/02_detail.png | 3 +++ .../screenshots/phone/de/google/03_prices.png | 3 +++ .../phone/de/google/04_favorites.png | 3 +++ .../phone/de/google/05_filters.png | 3 +++ _img/screenshots/phone/de/mapbox/01_map.png | 3 +++ .../screenshots/phone/de/mapbox/02_detail.png | 3 +++ .../screenshots/phone/de/mapbox/03_prices.png | 3 +++ .../phone/de/mapbox/04_favorites.png | 3 +++ .../phone/de/mapbox/05_filters.png | 3 +++ _img/screenshots/phone/en/google/01_map.png | 3 +++ .../screenshots/phone/en/google/02_detail.png | 3 +++ .../screenshots/phone/en/google/03_prices.png | 3 +++ .../phone/en/google/04_favorites.png | 3 +++ .../phone/en/google/05_filters.png | 3 +++ _img/screenshots/phone/en/mapbox/01_map.png | 3 +++ .../screenshots/phone/en/mapbox/02_detail.png | 3 +++ .../screenshots/phone/en/mapbox/03_prices.png | 3 +++ .../phone/en/mapbox/04_favorites.png | 3 +++ .../phone/en/mapbox/05_filters.png | 3 +++ .../android/de-DE/images/featureGraphic.png | Bin 72855 -> 130 bytes .../metadata/android/de-DE/images/icon.png | Bin 25044 -> 130 bytes .../de-DE/images/phoneScreenshots/01_map.png | 3 +++ .../images/phoneScreenshots/02_detail.png | 3 +++ .../images/phoneScreenshots/03_prices.png | 3 +++ .../images/phoneScreenshots/04_favorites.png | 3 +++ .../images/phoneScreenshots/05_filters.png | 3 +++ .../de-DE/images/phoneScreenshots/1_de-DE.png | Bin 931071 -> 0 bytes .../de-DE/images/phoneScreenshots/2_de-DE.png | Bin 965842 -> 0 bytes .../android/en-US/images/featureGraphic.png | 3 +++ .../metadata/android/en-US/images/icon.png | 3 +++ .../en-US/images/phoneScreenshots/01_map.png | 3 +++ .../images/phoneScreenshots/02_detail.png | 3 +++ .../images/phoneScreenshots/03_prices.png | 3 +++ .../images/phoneScreenshots/04_favorites.png | 3 +++ .../images/phoneScreenshots/05_filters.png | 3 +++ 49 files changed, 122 insertions(+) create mode 100644 .gitattributes create mode 100644 _img/screenshots/android_auto/de/11_android_auto_map.png create mode 100644 _img/screenshots/android_auto/de/12_android_auto_detail.png create mode 100644 _img/screenshots/android_auto/de/13_android_auto_prices.png create mode 100644 _img/screenshots/android_auto/de/14_vehicle_data.png create mode 100644 _img/screenshots/android_auto/en/11_android_auto_map.png create mode 100644 _img/screenshots/android_auto/en/12_android_auto_detail.png create mode 100644 _img/screenshots/android_auto/en/13_android_auto_prices.png create mode 100644 _img/screenshots/android_auto/en/14_vehicle_data.png delete mode 100644 _img/screenshots/phone/01_main.png delete mode 100644 _img/screenshots/phone/02_detail.png delete mode 100644 _img/screenshots/phone/11_android_auto_detail.png delete mode 100644 _img/screenshots/phone/11_android_auto_map.png create mode 100644 _img/screenshots/phone/de/google/01_map.png create mode 100644 _img/screenshots/phone/de/google/02_detail.png create mode 100644 _img/screenshots/phone/de/google/03_prices.png create mode 100644 _img/screenshots/phone/de/google/04_favorites.png create mode 100644 _img/screenshots/phone/de/google/05_filters.png create mode 100644 _img/screenshots/phone/de/mapbox/01_map.png create mode 100644 _img/screenshots/phone/de/mapbox/02_detail.png create mode 100644 _img/screenshots/phone/de/mapbox/03_prices.png create mode 100644 _img/screenshots/phone/de/mapbox/04_favorites.png create mode 100644 _img/screenshots/phone/de/mapbox/05_filters.png create mode 100644 _img/screenshots/phone/en/google/01_map.png create mode 100644 _img/screenshots/phone/en/google/02_detail.png create mode 100644 _img/screenshots/phone/en/google/03_prices.png create mode 100644 _img/screenshots/phone/en/google/04_favorites.png create mode 100644 _img/screenshots/phone/en/google/05_filters.png create mode 100644 _img/screenshots/phone/en/mapbox/01_map.png create mode 100644 _img/screenshots/phone/en/mapbox/02_detail.png create mode 100644 _img/screenshots/phone/en/mapbox/03_prices.png create mode 100644 _img/screenshots/phone/en/mapbox/04_favorites.png create mode 100644 _img/screenshots/phone/en/mapbox/05_filters.png create mode 100644 fastlane/metadata/android/de-DE/images/phoneScreenshots/01_map.png create mode 100644 fastlane/metadata/android/de-DE/images/phoneScreenshots/02_detail.png create mode 100644 fastlane/metadata/android/de-DE/images/phoneScreenshots/03_prices.png create mode 100644 fastlane/metadata/android/de-DE/images/phoneScreenshots/04_favorites.png create mode 100644 fastlane/metadata/android/de-DE/images/phoneScreenshots/05_filters.png delete mode 100644 fastlane/metadata/android/de-DE/images/phoneScreenshots/1_de-DE.png delete mode 100644 fastlane/metadata/android/de-DE/images/phoneScreenshots/2_de-DE.png create mode 100644 fastlane/metadata/android/en-US/images/featureGraphic.png create mode 100644 fastlane/metadata/android/en-US/images/icon.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/01_map.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/02_detail.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/03_prices.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/04_favorites.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/05_filters.png diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..58a75a66 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +_img/screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text +fastlane/metadata/android/**/images/**/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/_img/screenshots/android_auto/de/11_android_auto_map.png b/_img/screenshots/android_auto/de/11_android_auto_map.png new file mode 100644 index 00000000..304149b6 --- /dev/null +++ b/_img/screenshots/android_auto/de/11_android_auto_map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0731d286fe0dd41c068cba6b32b55c6560c2ce9e04f89837a91af4fb76c57861 +size 198603 diff --git a/_img/screenshots/android_auto/de/12_android_auto_detail.png b/_img/screenshots/android_auto/de/12_android_auto_detail.png new file mode 100644 index 00000000..5ef5387a --- /dev/null +++ b/_img/screenshots/android_auto/de/12_android_auto_detail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63e95826a0206522c83ec61084715866076b19dd6d29812e7b50abb0ca248a58 +size 95959 diff --git a/_img/screenshots/android_auto/de/13_android_auto_prices.png b/_img/screenshots/android_auto/de/13_android_auto_prices.png new file mode 100644 index 00000000..3f971af7 --- /dev/null +++ b/_img/screenshots/android_auto/de/13_android_auto_prices.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:649ec77837aa0322583a83fbd675f62b771032aa3690a7de7bb5e4b4e97da31e +size 90238 diff --git a/_img/screenshots/android_auto/de/14_vehicle_data.png b/_img/screenshots/android_auto/de/14_vehicle_data.png new file mode 100644 index 00000000..4000ea76 --- /dev/null +++ b/_img/screenshots/android_auto/de/14_vehicle_data.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc3f077c912439554cd2e5bea86621985c50721aaa7ac445bc7d6cfb5b47bde8 +size 46030 diff --git a/_img/screenshots/android_auto/en/11_android_auto_map.png b/_img/screenshots/android_auto/en/11_android_auto_map.png new file mode 100644 index 00000000..2d771735 --- /dev/null +++ b/_img/screenshots/android_auto/en/11_android_auto_map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11de773f36770cbd0249dee34f965d1b6568d53bb73cc8671824be2bc82b294e +size 248261 diff --git a/_img/screenshots/android_auto/en/12_android_auto_detail.png b/_img/screenshots/android_auto/en/12_android_auto_detail.png new file mode 100644 index 00000000..55c3f359 --- /dev/null +++ b/_img/screenshots/android_auto/en/12_android_auto_detail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:575eb7389a2334e579457ab3aa939ce300862d4df137384cd68b9d279915930a +size 91867 diff --git a/_img/screenshots/android_auto/en/13_android_auto_prices.png b/_img/screenshots/android_auto/en/13_android_auto_prices.png new file mode 100644 index 00000000..676f3956 --- /dev/null +++ b/_img/screenshots/android_auto/en/13_android_auto_prices.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db0d2ca1156283aa0ae81a958994fe6224d91d8002bf50fbf362bcd56efd56eb +size 90128 diff --git a/_img/screenshots/android_auto/en/14_vehicle_data.png b/_img/screenshots/android_auto/en/14_vehicle_data.png new file mode 100644 index 00000000..44941762 --- /dev/null +++ b/_img/screenshots/android_auto/en/14_vehicle_data.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0fe97239d8421babbf81a6828e37f154443c42472465c9e723f38eeff0cc2e +size 42451 diff --git a/_img/screenshots/phone/01_main.png b/_img/screenshots/phone/01_main.png deleted file mode 100644 index 3eb6b280afc5333dc138c42dd9aacc204bebf0e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 931071 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
  • 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