Compare commits

...

15 Commits
0.8.2 ... 0.8.4

Author SHA1 Message Date
Johan von Forstner
2da7ea4c05 Release 0.8.4 2021-07-31 20:09:05 +02:00
Johan von Forstner
20c4274c55 Chargeprice: prevent same value for start and end state of charge 2021-07-31 20:05:41 +02:00
Johan von Forstner
748212189f add missing import 2021-07-29 17:47:25 +02:00
Johan von Forstner
d86a49beb7 move Android Auto screen classes to separate files 2021-07-29 17:13:33 +02:00
Johan von Forstner
f8b1a20d1a OpenChargeMap: Comments are optional 2021-07-29 14:31:00 +02:00
Johan von Forstner
14edb6f0cd release 0.8.3 2021-07-27 22:11:52 +02:00
Johan von Forstner
7726088f91 update AnyMaps 2021-07-27 22:09:18 +02:00
Johan von Forstner
cbc7c5a6d8 MapViewModel: cancel loading charger details when another charger is selected 2021-07-25 19:23:20 +02:00
Johan von Forstner
d510d81914 SettingsFragment: move appBarConfiguration to onResume to fix crash when changing dark mode setting 2021-07-25 19:14:23 +02:00
Johan von Forstner
9f5abd6c91 apparently we need @ExperimentalCarApi all classes that create a MapScreen as well 2021-07-22 13:57:31 +02:00
Johan von Forstner
966f62ac3d move @ExperimentalCarApi annotation to the whole MapScreen class 2021-07-22 13:03:40 +02:00
Johan von Forstner
91caf40bdb Android Auto: show city next to charger name
if there is enough room, the name does not already contain the city, and not all chargers on the list are in the same city
fixes #102
2021-07-22 12:41:55 +02:00
Johan von Forstner
72c0293365 update AnyMaps
New version uses Mapbox's legacy Marker API instead of the annotation plugin. This might be a fix for #91
2021-07-22 11:45:18 +02:00
johan12345
ca9dc9629f fix a coroutine crash when no internet available 2021-07-20 20:18:08 +02:00
johan12345
438e529257 fix crash in Android Auto 2021-07-20 19:43:37 +02:00
16 changed files with 734 additions and 636 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 30
versionCode 50
versionName "0.8.2"
versionCode 52
versionName "0.8.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -139,7 +139,7 @@ dependencies {
googleImplementation 'androidx.car.app:app:1.0.0'
// AnyMaps
def anyMapsVersion = '1f050d860f'
def anyMapsVersion = '95ddd6c083'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"

View File

@@ -4,62 +4,24 @@ import android.Manifest
import android.content.*
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.location.Location
import android.net.Uri
import android.os.Bundle
import android.os.IBinder
import android.os.ResultReceiver
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.model.*
import androidx.car.app.model.Distance.UNIT_KILOMETERS
import androidx.car.app.validation.HostValidator
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.*
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import coil.imageLoader
import coil.request.ImageRequest
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.GEReferenceDataRepository
import net.vonforst.evmap.storage.OCMReferenceDataRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.distanceBetween
import java.io.IOException
import java.time.Duration
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.roundToInt
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) {
@@ -76,6 +38,7 @@ class CarAppService : androidx.car.app.CarAppService() {
}
}
@androidx.car.app.annotations.ExperimentalCarApi
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
var mapScreen: LocationAwareScreen? = null
set(value) {
@@ -158,566 +121,3 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
}
}
/**
* Welcome screen with selection between favorites and nearby chargers
*/
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
private var location: Location? = null
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(carContext.getString(R.string.app_name))
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
}
setItemList(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_address
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = false))
}
.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_favorites))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = true))
}
.build())
}.build())
setCurrentLocationEnabled(true)
setHeaderAction(Action.APP_ICON)
build()
}.build()
}
override fun updateLocation(location: Location) {
this.location = location
invalidate()
}
}
/**
* Screen to grant location permission
*/
class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(R.string.auto_location_permission_needed))
.setTitle(carContext.getString(R.string.app_name))
.setHeaderAction(Action.APP_ICON)
.addAction(
Action.Builder()
.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()
})
.build()
)
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
carContext.finishCarApp()
}
.build(),
)
.build()
}
}
/**
* Main map screen showing either nearby chargers or favorites
*/
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
Screen(ctx), LocationAwareScreen {
private var updateCoroutine: Job? = null
private var numUpdates = 0
private val maxNumUpdates = 3
private var location: Location? = null
private var lastUpdateLocation: Location? = null
private var chargers: List<ChargeLocation>? = null
private var prefs = PreferenceDataSource(ctx)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val searchRadius = 5 // kilometers
private val updateThreshold = 2000 // meters
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
HashMap()
private val maxRows = 6
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
carContext.getString(
if (favorites) {
R.string.auto_favorites
} else {
R.string.auto_chargers_closeby
}
)
)
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
} ?: setLoading(true)
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger))
}
builder.setNoItemsMessage(
carContext.getString(
if (favorites) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
}
)
)
setItemList(builder.build())
} ?: setLoading(true)
setCurrentLocationEnabled(true)
setHeaderAction(Action.BACK)
build()
}.build()
}
private fun formatCharger(charger: ChargeLocation): Row {
val color = ContextCompat.getColor(carContext, getMarkerTint(charger))
val place =
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
.setMarker(
PlaceMarker.Builder()
.setColor(CarColor.createCustom(color, color))
.build()
)
.build()
return Row.Builder().apply {
setTitle(charger.name)
val text = SpannableStringBuilder()
// distance
location?.let {
val distance = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
) / 1000
text.append(
"distance",
DistanceSpan.create(Distance.create(distance, UNIT_KILOMETERS)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
// power
if (text.isNotEmpty()) text.append(" · ")
text.append("${charger.maxPower.roundToInt()} kW")
// availability
availabilities[charger.id]?.second?.let { av ->
val status = av.status.values.flatten()
val available = availabilityText(status)
val total = charger.chargepoints.sumBy { it.count }
if (text.isNotEmpty()) text.append(" · ")
text.append(
"$available/$total",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
addText(text)
setMetadata(
Metadata.Builder()
.setPlace(place)
.build()
)
setOnClickListener {
screenManager.push(ChargerDetailScreen(carContext, charger))
}
}.build()
}
override fun updateLocation(location: Location) {
this.location = location
if (updateCoroutine != null) {
// don't update while still loading last update
return
}
invalidate()
if (lastUpdateLocation == null ||
location.distanceTo(lastUpdateLocation) > updateThreshold
) {
lastUpdateLocation = location
// update displayed chargers
loadChargers(location)
}
}
private val db = AppDatabase.getInstance(carContext)
private fun loadChargers(location: Location) {
numUpdates++
println(numUpdates)
if (numUpdates > maxNumUpdates) {
CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
.show()
return
}
updateCoroutine = lifecycleScope.launch {
try {
// load chargers
if (favorites) {
chargers = db.chargeLocationsDao().getAllChargeLocationsAsync().sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
} else {
val response = api.getChargepointsRadius(
getReferenceData(),
LatLng.fromLocation(location),
searchRadius,
zoom = 16f,
null
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < 6) {
// try again with larger radius
val response = api.getChargepointsRadius(
getReferenceData(),
LatLng.fromLocation(location),
searchRadius * 5,
zoom = 16f,
emptyList()
)
chargers =
response.data?.filterIsInstance(ChargeLocation::class.java)
}
}
}
// remove outdated availabilities
availabilities = availabilities.filter {
Duration.between(
it.value.first,
ZonedDateTime.now()
) > availabilityUpdateThreshold
}.toMutableMap()
// update availabilities
chargers?.take(maxRows)?.map {
lifecycleScope.async {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
val date = ZonedDateTime.now()
val availability = getAvailability(it).data
if (availability != null) {
availabilities[it.id] = date to availability
}
}
}
}?.awaitAll()
updateCoroutine = null
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
private suspend fun getReferenceData(): ReferenceData {
val api = api
return when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
lifecycleScope,
db.geReferenceDataDao(),
prefs
).getReferenceData().await()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
lifecycleScope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData().await()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
}
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64)
override fun onGetTemplate(): Template {
if (charger == null) loadCharger()
return PaneTemplate.Builder(
Pane.Builder().apply {
charger?.let { charger ->
addRow(Row.Builder().apply {
setTitle(charger.address.toString())
val icon = iconGen.getBitmap(
tint = getMarkerTint(charger),
fault = charger.faultReport != null,
multi = charger.isMulti()
)
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(icon)).build(),
Row.IMAGE_TYPE_LARGE
)
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
if (i > 0) chargepointsText.append(" · ")
chargepointsText.append(
"${cp.count}× ${
nameForPlugType(
carContext.stringProvider(),
cp.type
)
} ${cp.formatPower()}"
)
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
addText(chargepointsText)
}.build())
addRow(Row.Builder().apply {
photo?.let {
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
Row.IMAGE_TYPE_LARGE
)
}
val operatorText = StringBuilder().apply {
charger.operator?.let { append(it) }
charger.network?.let {
if (isNotEmpty()) append(" · ")
append(it)
}
}.ifEmpty {
carContext.getString(R.string.unknown_operator)
}
setTitle(operatorText)
charger.cost?.let { addText(it.getStatusText(carContext, emoji = true)) }
charger.faultReport?.created?.let {
addText(
carContext.getString(
R.string.auto_fault_report_date,
it.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
}
/*val types = charger.chargepoints.map { it.type }.distinct()
if (types.size == 1) {
setImage(
CarIcon.of(IconCompat.createWithResource(carContext, iconForPlugType(types[0]))),
Row.IMAGE_TYPE_ICON)
}*/
}.build())
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).build()
)
.setTitle(carContext.getString(R.string.navigate))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
navigateToCharger(charger)
}
.build())
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, charger.id)
.putExtra(EXTRA_LAT, charger.coordinates.lat)
.putExtra(EXTRA_LON, charger.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
} ?: setLoading(true)
}.build()
).apply {
setTitle(chargerSparse.name)
setHeaderAction(Action.BACK)
}.build()
}
private fun navigateToCharger(charger: ChargeLocation) {
val coord = charger.coordinates
val intent =
Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
)
carContext.startCarApp(intent)
}
private fun loadCharger() {
lifecycleScope.launch {
try {
val response = api.getChargepointDetail(getReferenceData(), chargerSparse.id)
charger = response.data!!
val photo = charger?.photos?.firstOrNull()
photo?.let {
val size = (carContext.resources.displayMetrics.density * 64).roundToInt()
val url = photo.getUrl(size = size)
val request = ImageRequest.Builder(carContext).data(url).build()
this@ChargerDetailScreen.photo =
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
}
availability = charger?.let { getAvailability(it).data }
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
private suspend fun getReferenceData(): ReferenceData {
val api = api
return when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
lifecycleScope,
db.geReferenceDataDao(),
prefs
).getReferenceData().await()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
lifecycleScope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData().await()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
}
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
val allFaulted = status.all { it == ChargepointStatus.FAULTED }
return if (unknown) {
CarColor.DEFAULT
} else if (available > 0) {
CarColor.GREEN
} else if (allFaulted) {
CarColor.RED
} else {
CarColor.BLUE
}
}

View File

@@ -0,0 +1,234 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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.createApi
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.GEReferenceDataRepository
import net.vonforst.evmap.storage.OCMReferenceDataRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.viewmodel.Status
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.roundToInt
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64)
override fun onGetTemplate(): Template {
if (charger == null) loadCharger()
return PaneTemplate.Builder(
Pane.Builder().apply {
charger?.let { charger ->
addRow(Row.Builder().apply {
setTitle(charger.address.toString())
val icon = iconGen.getBitmap(
tint = getMarkerTint(charger),
fault = charger.faultReport != null,
multi = charger.isMulti()
)
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(icon)).build(),
Row.IMAGE_TYPE_LARGE
)
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
if (i > 0) chargepointsText.append(" · ")
chargepointsText.append(
"${cp.count}× ${
nameForPlugType(
carContext.stringProvider(),
cp.type
)
} ${cp.formatPower()}"
)
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
addText(chargepointsText)
}.build())
addRow(Row.Builder().apply {
photo?.let {
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
Row.IMAGE_TYPE_LARGE
)
}
val operatorText = StringBuilder().apply {
charger.operator?.let { append(it) }
charger.network?.let {
if (isNotEmpty()) append(" · ")
append(it)
}
}.ifEmpty {
carContext.getString(R.string.unknown_operator)
}
setTitle(operatorText)
charger.cost?.let { addText(it.getStatusText(carContext, emoji = true)) }
charger.faultReport?.created?.let {
addText(
carContext.getString(
R.string.auto_fault_report_date,
it.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
}
/*val types = charger.chargepoints.map { it.type }.distinct()
if (types.size == 1) {
setImage(
CarIcon.of(IconCompat.createWithResource(carContext, iconForPlugType(types[0]))),
Row.IMAGE_TYPE_ICON)
}*/
}.build())
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).build()
)
.setTitle(carContext.getString(R.string.navigate))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
navigateToCharger(charger)
}
.build())
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, charger.id)
.putExtra(EXTRA_LAT, charger.coordinates.lat)
.putExtra(EXTRA_LON, charger.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
} ?: setLoading(true)
}.build()
).apply {
setTitle(chargerSparse.name)
setHeaderAction(Action.BACK)
}.build()
}
private fun navigateToCharger(charger: ChargeLocation) {
val coord = charger.coordinates
val intent =
Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
)
carContext.startCarApp(intent)
}
private fun loadCharger() {
lifecycleScope.launch {
val response = api.getChargepointDetail(getReferenceData(), chargerSparse.id)
if (response.status == Status.SUCCESS) {
charger = response.data!!
val photo = charger?.photos?.firstOrNull()
photo?.let {
val size = (carContext.resources.displayMetrics.density * 64).roundToInt()
val url = photo.getUrl(size = size)
val request = ImageRequest.Builder(carContext).data(url).build()
this@ChargerDetailScreen.photo =
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
}
availability = charger?.let { getAvailability(it).data }
invalidate()
} else {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
private suspend fun getReferenceData(): ReferenceData {
val api = api
return when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
lifecycleScope,
db.geReferenceDataDao(),
prefs
).getReferenceData().await()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
lifecycleScope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData().await()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
}

View File

@@ -0,0 +1,287 @@
package net.vonforst.evmap.auto
import android.location.Location
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.await
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.GEReferenceDataRepository
import net.vonforst.evmap.storage.OCMReferenceDataRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.distanceBetween
import java.io.IOException
import java.time.Duration
import java.time.ZonedDateTime
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
private var numUpdates = 0
private val maxNumUpdates = 3
private var location: Location? = null
private var lastUpdateLocation: Location? = null
private var chargers: List<ChargeLocation>? = null
private var prefs = PreferenceDataSource(ctx)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val searchRadius = 5 // kilometers
private val updateThreshold = 2000 // meters
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
HashMap()
private val maxRows = 6
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
carContext.getString(
if (favorites) {
R.string.auto_favorites
} else {
R.string.auto_chargers_closeby
}
)
)
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
} ?: setLoading(true)
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address.city }.distinct().size > 1
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger, showCity))
}
builder.setNoItemsMessage(
carContext.getString(
if (favorites) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
}
)
)
setItemList(builder.build())
} ?: setLoading(true)
setCurrentLocationEnabled(true)
setHeaderAction(Action.BACK)
build()
}.build()
}
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
val color = ContextCompat.getColor(carContext, getMarkerTint(charger))
val place =
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
.setMarker(
PlaceMarker.Builder()
.setColor(CarColor.createCustom(color, color))
.build()
)
.build()
return Row.Builder().apply {
// only show the city if not all chargers are in the same city (-> showCity == true)
// and the city is not already contained in the charger name
if (showCity && charger.address.city != null && charger.address.city !in charger.name) {
setTitle(
CarText.Builder("${charger.name} · ${charger.address.city}")
.addVariant(charger.name)
.build())
} else {
setTitle(charger.name)
}
val text = SpannableStringBuilder()
// distance
location?.let {
val distance = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
) / 1000
text.append(
"distance",
DistanceSpan.create(Distance.create(distance, Distance.UNIT_KILOMETERS)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
// power
if (text.isNotEmpty()) text.append(" · ")
text.append("${charger.maxPower.roundToInt()} kW")
// availability
availabilities[charger.id]?.second?.let { av ->
val status = av.status.values.flatten()
val available = availabilityText(status)
val total = charger.chargepoints.sumBy { it.count }
if (text.isNotEmpty()) text.append(" · ")
text.append(
"$available/$total",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
addText(text)
setMetadata(
Metadata.Builder()
.setPlace(place)
.build()
)
setOnClickListener {
screenManager.push(ChargerDetailScreen(carContext, charger))
}
}.build()
}
override fun updateLocation(location: Location) {
this.location = location
if (updateCoroutine != null) {
// don't update while still loading last update
return
}
invalidate()
if (lastUpdateLocation == null ||
location.distanceTo(lastUpdateLocation) > updateThreshold
) {
lastUpdateLocation = location
// update displayed chargers
loadChargers(location)
}
}
private val db = AppDatabase.getInstance(carContext)
private fun loadChargers(location: Location) {
numUpdates++
println(numUpdates)
if (numUpdates > maxNumUpdates) {
CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
.show()
return
}
updateCoroutine = lifecycleScope.launch {
try {
// load chargers
if (favorites) {
chargers = db.chargeLocationsDao().getAllChargeLocationsAsync().sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
} else {
val response = api.getChargepointsRadius(
getReferenceData(),
LatLng.fromLocation(location),
searchRadius,
zoom = 16f,
null
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < 6) {
// try again with larger radius
val response = api.getChargepointsRadius(
getReferenceData(),
LatLng.fromLocation(location),
searchRadius * 5,
zoom = 16f,
emptyList()
)
chargers =
response.data?.filterIsInstance(ChargeLocation::class.java)
}
}
}
// remove outdated availabilities
availabilities = availabilities.filter {
Duration.between(
it.value.first,
ZonedDateTime.now()
) > availabilityUpdateThreshold
}.toMutableMap()
// update availabilities
chargers?.take(maxRows)?.map {
lifecycleScope.async {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
val date = ZonedDateTime.now()
val availability = getAvailability(it).data
if (availability != null) {
availabilities[it.id] = date to availability
}
}
}
}?.awaitAll()
updateCoroutine = null
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
private suspend fun getReferenceData(): ReferenceData {
val api = api
return when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
lifecycleScope,
db.geReferenceDataDao(),
prefs
).getReferenceData().await()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
lifecycleScope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData().await()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
}

View File

@@ -0,0 +1,65 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.os.Bundle
import android.os.ResultReceiver
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.*
import net.vonforst.evmap.R
/**
* Screen to grant 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))
.setTitle(carContext.getString(R.string.app_name))
.setHeaderAction(Action.APP_ICON)
.addAction(
Action.Builder()
.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()
})
.build()
)
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
carContext.finishCarApp()
}
.build(),
)
.build()
}
}

View File

@@ -0,0 +1,20 @@
package net.vonforst.evmap.auto
import androidx.car.app.model.CarColor
import net.vonforst.evmap.api.availability.ChargepointStatus
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
val allFaulted = status.all { it == ChargepointStatus.FAULTED }
return if (unknown) {
CarColor.DEFAULT
} else if (available > 0) {
CarColor.GREEN
} else if (allFaulted) {
CarColor.RED
} else {
CarColor.BLUE
}
}

View File

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

View File

@@ -7,6 +7,7 @@ import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
@@ -362,30 +363,32 @@ class GoingElectricApiWrapper(
override suspend fun getReferenceData(): Resource<GEReferenceData> =
withContext(Dispatchers.IO) {
try {
val plugs = async { api.getPlugs() }
val chargeCards = async { api.getChargeCards() }
val networks = async { api.getNetworks() }
supervisorScope {
try {
val plugs = async { api.getPlugs() }
val chargeCards = async { api.getChargeCards() }
val networks = async { api.getNetworks() }
val plugsResponse = plugs.await()
val chargeCardsResponse = chargeCards.await()
val networksResponse = networks.await()
val plugsResponse = plugs.await()
val chargeCardsResponse = chargeCards.await()
val networksResponse = networks.await()
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
if (responses.map { it.isSuccessful }.all { it }) {
Resource.success(
GEReferenceData(
plugsResponse.body()!!.result,
networksResponse.body()!!.result,
chargeCardsResponse.body()!!.result
if (responses.map { it.isSuccessful }.all { it }) {
Resource.success(
GEReferenceData(
plugsResponse.body()!!.result,
networksResponse.body()!!.result,
chargeCardsResponse.body()!!.result
)
)
)
} else {
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
} else {
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
}
} catch (e: IOException) {
Resource.error(e.message, null)
}
} catch (e: IOException) {
Resource.error(e.message, null)
}
}

View File

@@ -77,7 +77,7 @@ data class OCMChargepoint(
val comment = userComments.filter { it.commentTypeId == faultReportCommentType }
.maxByOrNull { it.dateCreated }
if (comment != null) {
return FaultReport(comment.dateCreated.toInstant(), comment.comment)
return FaultReport(comment.dateCreated.toInstant(), comment.comment ?: "")
}
}
if (statusType != null && statusType.id in faultStatuses) {
@@ -229,7 +229,7 @@ data class OCMMediaItem(
data class OCMUserComment(
@Json(name = "ID") val id: Long,
@Json(name = "CommentTypeID") val commentTypeId: Long,
@Json(name = "Comment") val comment: String,
@Json(name = "Comment") val comment: String?,
@Json(name = "UserName") val userName: String,
@Json(name = "DateCreated") val dateCreated: ZonedDateTime
)
@@ -257,4 +257,4 @@ class OCMChargerPhotoAdapter(
else -> largeUrl
}
}
}
}

View File

@@ -36,15 +36,8 @@ class SettingsFragment : PreferenceFragmentCompat(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
prefs = PreferenceDataSource(requireContext())
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
myVehiclePreference.isEnabled = false
vm.vehicles.observe(viewLifecycleOwner) { res ->
@@ -130,6 +123,13 @@ class SettingsFragment : PreferenceFragmentCompat(),
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
val navController = findNavController()
val toolbar = requireView().findViewById(R.id.toolbar) as Toolbar
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
}
override fun onPause() {

View File

@@ -92,7 +92,14 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
MutableLiveData<List<Float>>().apply {
value = prefs.chargepriceBatteryRange
observeForever {
prefs.chargepriceBatteryRange = it
if (it[0] == it[1]) {
value = if (it[0] < 1.0) {
listOf(it[0], it[1] + 1)
} else {
listOf(it[0] - 1, it[1])
}
}
prefs.chargepriceBatteryRange = value!!
}
}
}

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.*
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.availability.ChargeLocationStatus
@@ -378,9 +379,12 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
availability.value = getAvailability(charger)
}
private var chargerLoadingTask: Job? = null
private fun loadChargerDetails(charger: ChargeLocation, referenceData: ReferenceData) {
chargerDetails.value = Resource.loading(null)
viewModelScope.launch {
chargerLoadingTask?.cancel()
chargerLoadingTask = viewModelScope.launch {
try {
chargerDetails.value = api.getChargepointDetail(referenceData, charger.id)
} catch (e: IOException) {

View File

@@ -0,0 +1,2 @@
- Android Auto: Anzeige des Ortsnamens (wenn genug Platz und nicht eindeutig)
- Abstürze behoben

View File

@@ -0,0 +1,2 @@
- Chargeprice: Auswahl von gleichem Start- und Endladestand verhindert
- Abstürze behoben

View File

@@ -0,0 +1,2 @@
- Android Auto: Show place name next to charger name (if enough room available)
- Fixed various crashes

View File

@@ -0,0 +1,2 @@
- Chargeprice: Prevent selection of same state of charge for start and end
- Fixed crashes