diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1fe4dfa1..41c3002b0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -315,7 +315,7 @@ dependencies { automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion") // AnyMaps - val anyMapsVersion = "3e6c71410f" + val anyMapsVersion = "010de4e275" implementation("com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion") googleImplementation("com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion") googleImplementation("com.google.android.gms:play-services-maps:19.0.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1159eafda..585d0a843 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + diff --git a/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt b/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt index 4cc8892c9..35eb77a6a 100644 --- a/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt +++ b/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.car2go.maps.model.LatLng import net.vonforst.evmap.R +import net.vonforst.evmap.autocomplete.PlaceWithBounds import net.vonforst.evmap.location.FusionEngine import net.vonforst.evmap.location.LocationEngine import net.vonforst.evmap.location.Priority @@ -125,8 +126,11 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver } override fun onCreateScreen(intent: Intent): Screen { - - val mapScreen = MapScreen(carContext, this) + val mapScreen = if (supportsNewMapScreen(carContext)) { + MapScreen(carContext, this) + } else { + LegacyMapScreen(carContext, this) + } val screens = mutableListOf(mapScreen) handleActionsIntent(intent)?.let { @@ -186,7 +190,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver val lon = it.getQueryParameter("longitude")?.toDouble() val name = it.getQueryParameter("name") if (lat != null && lon != null) { - prefs.placeSearchResultAndroidAuto = LatLng(lat, lon) + prefs.placeSearchResultAndroidAuto = PlaceWithBounds(LatLng(lat, lon), null) prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon) return null } else if (name != null) { diff --git a/app/src/main/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt index 698aeb45c..bba2efbe5 100644 --- a/app/src/main/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt +++ b/app/src/main/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt @@ -3,7 +3,6 @@ package net.vonforst.evmap.auto import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Color import android.graphics.Matrix import android.graphics.RectF import android.graphics.drawable.BitmapDrawable @@ -11,10 +10,8 @@ import android.net.Uri import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.Spanned -import android.util.Log import androidx.car.app.CarContext import androidx.car.app.CarToast -import androidx.car.app.HostException import androidx.car.app.Screen import androidx.car.app.constraints.ConstraintManager import androidx.car.app.model.Action @@ -54,11 +51,7 @@ import net.vonforst.evmap.api.createApi import net.vonforst.evmap.api.fronyx.FronyxApi import net.vonforst.evmap.api.fronyx.PredictionData import net.vonforst.evmap.api.fronyx.PredictionRepository -import net.vonforst.evmap.api.iconForPlugType -import net.vonforst.evmap.api.nameForPlugType -import net.vonforst.evmap.api.stringProvider import net.vonforst.evmap.model.ChargeLocation -import net.vonforst.evmap.model.Coordinate import net.vonforst.evmap.model.Cost import net.vonforst.evmap.model.FaultReport import net.vonforst.evmap.model.Favorite @@ -67,7 +60,6 @@ import net.vonforst.evmap.storage.AppDatabase import net.vonforst.evmap.storage.ChargeLocationsRepository import net.vonforst.evmap.storage.PreferenceDataSource import net.vonforst.evmap.ui.ChargerIconGenerator -import net.vonforst.evmap.ui.availabilityText import net.vonforst.evmap.ui.getMarkerTint import net.vonforst.evmap.viewmodel.Status import net.vonforst.evmap.viewmodel.awaitFinished @@ -77,8 +69,6 @@ import java.time.format.FormatStyle import kotlin.math.floor import kotlin.math.roundToInt -private const val TAG = "ChargerDetailScreen" - class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) { var charger: ChargeLocation? = null var photo: Bitmap? = null @@ -138,7 +128,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : .setFlags(Action.FLAG_PRIMARY) .setBackgroundColor(CarColor.PRIMARY) .setOnClickListener { - navigateToCharger(charger) + navigateToCharger(carContext, charger) } .build()) if (ChargepriceApi.isChargerSupported(charger)) { @@ -275,7 +265,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Row.IMAGE_TYPE_LARGE ) } - addText(generateChargepointsText(charger)) + addText(generateChargepointsText(charger, availability, carContext)) }.build()) if (maxRows <= 3) { // row 2: operator + cost + fault report @@ -488,47 +478,6 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : return string } - private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder { - val chargepointsText = SpannableStringBuilder() - charger.chargepointsMerged.forEachIndexed { i, cp -> - chargepointsText.apply { - if (i > 0) append(" · ") - append("${cp.count}× ") - val plugIcon = iconForPlugType(cp.type) - if (plugIcon != 0) { - append( - nameForPlugType(carContext.stringProvider(), cp.type), - CarIconSpan.create( - CarIcon.Builder( - IconCompat.createWithResource( - carContext, - plugIcon - ) - ).setTint( - CarColor.createCustom(Color.WHITE, Color.BLACK) - ).build() - ), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE - ) - } else { - append(nameForPlugType(carContext.stringProvider(), cp.type)) - } - cp.formatPower()?.let { - append(" ") - append(it) - } - } - availability?.status?.get(cp)?.let { status -> - chargepointsText.append( - " (${availabilityText(status)}/${cp.count})", - ForegroundCarColorSpan.create(carAvailabilityColor(status)), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - } - return chargepointsText - } - private fun generateOperatorText(charger: ChargeLocation) = if (charger.operator != null && charger.network != null) { if (charger.operator.contains(charger.network)) { @@ -546,54 +495,6 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : carContext.getString(R.string.unknown_operator) } - private fun navigateToCharger(charger: ChargeLocation) { - val success = navigateCarApp(charger) - if (!success && BuildConfig.FLAVOR_automotive == "automotive") { - // on AAOS, some OEMs' navigation apps might not support - navigateRegularApp(charger) - } - } - - private fun navigateCarApp(charger: ChargeLocation): Boolean { - val coord = charger.coordinates - val intent = - Intent( - CarContext.ACTION_NAVIGATE, - Uri.parse("geo:${coord.lat},${coord.lng}") - ) - try { - carContext.startCarApp(intent) - return true - } catch (e: HostException) { - Log.w(TAG, "Could not start navigation using car app intent") - Log.w(TAG, intent.toString()) - e.printStackTrace() - } catch (e: SecurityException) { - Log.w(TAG, "Could not start navigation using car app intent") - Log.w(TAG, intent.toString()) - e.printStackTrace() - } - return false - } - - private fun navigateRegularApp(charger: ChargeLocation): Boolean { - val coord = charger.coordinates - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse( - "geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${ - Uri.encode(charger.name) - })" - ) - if (intent.resolveActivity(carContext.packageManager) != null) { - carContext.startActivity(intent) - return true - } else { - Log.w(TAG, "Could not start navigation using regular intent") - Log.w(TAG, intent.toString()) - } - return false - } - private fun loadCharger() { lifecycleScope.launch { favorite = db.favoritesDao().findFavorite(chargerSparse.id, chargerSparse.dataSource) diff --git a/app/src/main/java/net/vonforst/evmap/auto/ChargerListFormatter.kt b/app/src/main/java/net/vonforst/evmap/auto/ChargerListFormatter.kt new file mode 100644 index 000000000..62568bd46 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/auto/ChargerListFormatter.kt @@ -0,0 +1,242 @@ +package net.vonforst.evmap.auto + +import android.location.Location +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import androidx.car.app.CarContext +import androidx.car.app.hardware.info.EnergyLevel +import androidx.car.app.model.Action +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.CarIconSpan +import androidx.car.app.model.CarLocation +import androidx.car.app.model.CarText +import androidx.car.app.model.DistanceSpan +import androidx.car.app.model.ForegroundCarColorSpan +import androidx.car.app.model.ItemList +import androidx.car.app.model.Metadata +import androidx.car.app.model.Pane +import androidx.car.app.model.Place +import androidx.car.app.model.PlaceMarker +import androidx.car.app.model.Row +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat +import net.vonforst.evmap.R +import net.vonforst.evmap.api.availability.ChargeLocationStatus +import net.vonforst.evmap.model.ChargeLocation +import net.vonforst.evmap.model.FILTERS_FAVORITES +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.time.ZonedDateTime +import kotlin.math.roundToInt + +interface ChargerListDelegate : ItemList.OnItemVisibilityChangedListener { + val locationError: Boolean + val loadingError: Boolean + val maxRows: Int + val filterStatus: Long + val location: Location? + val energyLevel: EnergyLevel? + fun onChargerClick(charger: ChargeLocation) +} + +class ChargerListFormatter(val carContext: CarContext, val screen: ChargerListDelegate) { + private val iconGen = ChargerIconGenerator(carContext, null, height = 96) + var favorites: Set = emptySet() + + fun buildChargerList( + chargers: List?, + availabilities: Map> + ): ItemList? { + return if (chargers != null) { + val chargerList = chargers.take(screen.maxRows) + 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, + availabilities, + showCity, + charger.id in favorites + ) + ) + } + builder.setNoItemsMessage( + carContext.getString( + if (screen.filterStatus == FILTERS_FAVORITES) { + R.string.auto_no_favorites_found + } else { + R.string.auto_no_chargers_found + } + ) + ) + builder.setOnItemsVisibilityChangedListener(screen) + builder.build() + } else { + if (screen.loadingError) { + val builder = ItemList.Builder() + builder.setNoItemsMessage( + carContext.getString(R.string.connection_error) + ) + builder.build() + } else if (screen.locationError) { + val builder = ItemList.Builder() + builder.setNoItemsMessage( + carContext.getString(R.string.location_error) + ) + builder.build() + } else { + null + } + } + } + + private fun formatCharger( + charger: ChargeLocation, + availabilities: Map>, + showCity: Boolean, + isFavorite: Boolean + ): Row { + val markerTint = getMarkerTint(charger) + val backgroundTint = if ((charger.maxPower ?: 0.0) > 100) { + R.color.charger_100kw_dark // slightly darker color for better contrast + } else { + markerTint + } + val color = ContextCompat.getColor(carContext, backgroundTint) + val place = + Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng)) + .setMarker( + PlaceMarker.Builder() + .setColor(CarColor.createCustom(color, color)) + .build() + ) + .build() + + val icon = iconGen.getBitmap( + markerTint, + fault = charger.faultReport != null, + multi = charger.isMulti(), + fav = isFavorite + ) + val iconSpan = + CarIconSpan.create(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build()) + + return Row.Builder().apply { + // only show the city if not all chargers are in the same city (-> showCity == true) + // and the city is not already contained in the charger name + val title = SpannableStringBuilder().apply { + append(" ", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE) + append(" ") + append(charger.name) + } + if (showCity && charger.address?.city != null && charger.address.city !in charger.name) { + val titleWithCity = SpannableStringBuilder().apply { + append("", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE) + append(" ") + append("${charger.name} · ${charger.address.city}") + } + setTitle(CarText.Builder(titleWithCity).addVariant(title).build()) + } else { + setTitle(title) + } + + val text = SpannableStringBuilder() + + // distance + screen.location?.let { + val distanceMeters = distanceBetween( + it.latitude, it.longitude, + charger.coordinates.lat, charger.coordinates.lng + ) + text.append( + "distance", + DistanceSpan.create( + roundValueToDistance( + distanceMeters, + screen.energyLevel?.distanceDisplayUnit?.value, + carContext + ) + ), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + // power + val power = charger.maxPower + if (power != null) { + if (text.isNotEmpty()) text.append(" · ") + text.append("${power.roundToInt()} kW") + } + + // availability + availabilities[charger.id]?.second?.let { av -> + val status = av.status.values.flatten() + val available = availabilityText(status) + val total = charger.chargepoints.sumOf { it.count } + + 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 { + screen.onChargerClick(charger) + } + }.build() + } + + fun buildSingleCharger( + charger: ChargeLocation, + availability: ChargeLocationStatus?, + onClick: () -> Unit + ) = Pane.Builder().apply { + val icon = iconGen.getBitmap( + getMarkerTint(charger), + fault = charger.faultReport != null, + multi = charger.isMulti(), + fav = charger.id in favorites + ) + + + addRow(Row.Builder().apply { + setImage(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build()) + setTitle(charger.address.toString()) + addText(generateChargepointsText(charger, availability, carContext)) + }.build()) + addAction(Action.Builder().apply { + setTitle(carContext.getString(R.string.show_more)) + setOnClickListener(onClick) + }.build()) + addAction(Action.Builder().apply { + setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_navigation + ) + ).build() + ) + setTitle(carContext.getString(R.string.navigate)) + setBackgroundColor(CarColor.PRIMARY) + setOnClickListener { + navigateToCharger(carContext, charger) + } + }.build()) + }.build() +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt new file mode 100644 index 000000000..8fe72e88f --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt @@ -0,0 +1,533 @@ +package net.vonforst.evmap.auto + +import android.content.pm.PackageManager +import android.location.Location +import android.os.Handler +import android.os.Looper +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.constraints.ConstraintManager +import androidx.car.app.hardware.CarHardwareManager +import androidx.car.app.hardware.info.CarInfo +import androidx.car.app.hardware.info.CarSensors +import androidx.car.app.hardware.info.Compass +import androidx.car.app.hardware.info.EnergyLevel +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.CarLocation +import androidx.car.app.model.OnContentRefreshListener +import androidx.car.app.model.Place +import androidx.car.app.model.PlaceListMapTemplate +import androidx.car.app.model.PlaceMarker +import androidx.car.app.model.Template +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.car2go.maps.model.LatLng +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.vonforst.evmap.BuildConfig +import net.vonforst.evmap.R +import net.vonforst.evmap.api.availability.AvailabilityRepository +import net.vonforst.evmap.api.availability.ChargeLocationStatus +import net.vonforst.evmap.api.createApi +import net.vonforst.evmap.api.stringProvider +import net.vonforst.evmap.model.ChargeLocation +import net.vonforst.evmap.model.FILTERS_FAVORITES +import net.vonforst.evmap.model.FilterValue +import net.vonforst.evmap.model.FilterWithValue +import net.vonforst.evmap.storage.AppDatabase +import net.vonforst.evmap.storage.ChargeLocationsRepository +import net.vonforst.evmap.storage.PreferenceDataSource +import net.vonforst.evmap.utils.bearingBetween +import net.vonforst.evmap.utils.distanceBetween +import net.vonforst.evmap.utils.headingDiff +import net.vonforst.evmap.viewmodel.Status +import net.vonforst.evmap.viewmodel.await +import net.vonforst.evmap.viewmodel.awaitFinished +import net.vonforst.evmap.viewmodel.filtersWithValue +import retrofit2.HttpException +import java.io.IOException +import java.time.Duration +import java.time.Instant +import java.time.ZonedDateTime +import kotlin.collections.set +import kotlin.math.abs +import kotlin.math.min + +/** + * Main map screen showing either nearby chargers or favorites + * + * Legacy implementation for Car App API level < 7 + */ +@androidx.car.app.annotations.ExperimentalCarApi +class LegacyMapScreen(ctx: CarContext, val session: EVMapSession) : + Screen(ctx), LocationAwareScreen, OnContentRefreshListener, + ChargerListDelegate, DefaultLifecycleObserver { + + private val db = AppDatabase.getInstance(carContext) + private var prefs = PreferenceDataSource(ctx) + private val repo = + ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs) + private val availabilityRepo = AvailabilityRepository(ctx) + + private var updateCoroutine: Job? = null + private var availabilityUpdateCoroutine: Job? = null + + private var visibleStart: Int? = null + private var visibleEnd: Int? = null + + override var location: Location? = null + private var lastDistanceUpdateTime: Instant? = null + private var lastChargersUpdateTime: Instant? = null + private var chargers: List? = null + private val favorites = db.favoritesDao().getAllFavorites() + override var loadingError = false + override var locationError = false + private val searchRadius = 5 // kilometers + private val distanceUpdateThreshold = Duration.ofSeconds(15) + private val availabilityUpdateThreshold = Duration.ofMinutes(1) + private val chargersUpdateThresholdDistance = 500 // meters + private val chargersUpdateThresholdTime = Duration.ofSeconds(30) + private var availabilities: MutableMap> = + HashMap() + override val maxRows = + min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25) + private val supportsRefresh = ctx.isAppDrivenRefreshSupported + + override var filterStatus = prefs.filterStatus + + private var filtersWithValue: List>? = null + + private val carInfo: CarInfo by lazy { + (ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo + } + private val carSensors: CarSensors by lazy { carContext.patchedCarSensors } + override var energyLevel: EnergyLevel? = null + private var heading: Compass? = null + private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") { + listOf( + "android.car.permission.CAR_ENERGY", + "android.car.permission.CAR_ENERGY_PORTS", + "android.car.permission.READ_CAR_DISPLAY_UNITS", + ) + } else { + listOf( + "com.google.android.gms.permission.CAR_FUEL" + ) + } + + private var searchLocation: LatLng? = null + + private val formatter = ChargerListFormatter(ctx, this) + + init { + lifecycle.addObserver(this) + marker = MapScreen.MARKER + } + + override fun onGetTemplate(): Template { + session.mapScreen = this + return PlaceListMapTemplate.Builder().apply { + setTitle( + prefs.placeSearchResultAndroidAutoName?.let { + carContext.getString(R.string.auto_chargers_near_location, it) + } ?: carContext.getString( + if (filterStatus == FILTERS_FAVORITES) { + R.string.auto_favorites + } else { + R.string.auto_chargers_closeby + } + ) + ) + if (prefs.placeSearchResultAndroidAutoName != null) { + searchLocation?.let { + setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply { + if (prefs.placeSearchResultAndroidAutoName != null) { + setMarker( + PlaceMarker.Builder() + .setColor(CarColor.PRIMARY) + .build() + ) + } + }.build()) + } + } else { + location?.let { + setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build()) + } + } + formatter.buildChargerList(chargers, availabilities)?.let { + setItemList(it) + } ?: setLoading(true) + setCurrentLocationEnabled(true) + setHeaderAction(Action.APP_ICON) + val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else { + filtersWithValue?.count { + !it.value.hasSameValueAs(it.filter.defaultValue()) + } + } + + setActionStrip( + ActionStrip.Builder() + .addAction( + Action.Builder() + .setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_settings + ) + ).setTint(CarColor.DEFAULT).build() + ) + .setOnClickListener { + screenManager.push(SettingsScreen(carContext, session)) + session.mapScreen = null + } + .build()) + .addAction(Action.Builder().apply { + setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + if (prefs.placeSearchResultAndroidAuto != null) { + R.drawable.ic_search_off + } else { + R.drawable.ic_search + } + ) + ).build() + + ) + setOnClickListener { + if (prefs.placeSearchResultAndroidAuto != null) { + prefs.placeSearchResultAndroidAutoName = null + prefs.placeSearchResultAndroidAuto = null + if (!supportsRefresh) { + screenManager.pushForResult(DummyReturnScreen(carContext)) { + chargers = null + loadChargers() + } + } else { + chargers = null + loadChargers() + } + } else { + screenManager.pushForResult( + PlaceSearchScreen( + carContext, + session + ) + ) { + chargers = null + loadChargers() + } + session.mapScreen = null + } + } + }.build()) + .addAction( + Action.Builder() + .setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_filter + ) + ) + .setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT) + .build() + ) + .setOnClickListener { + screenManager.push(FilterScreen(carContext, session)) + session.mapScreen = null + } + .build()) + .build()) + if (carContext.carAppApiLevel >= 5 || + (BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4) + ) { + setOnContentRefreshListener(this@LegacyMapScreen) + } + }.build() + } + + override fun onChargerClick(charger: ChargeLocation) { + screenManager.push(ChargerDetailScreen(carContext, charger)) + session.mapScreen = null + } + + override fun updateLocation(location: Location) { + if (location.latitude == this.location?.latitude + && location.longitude == this.location?.longitude + ) { + return + } + val previousLocation = this.location + this.location = location + if (previousLocation == null) { + loadChargers() + return + } + + val now = Instant.now() + if (lastDistanceUpdateTime == null || + Duration.between(lastDistanceUpdateTime, now) > distanceUpdateThreshold + ) { + lastDistanceUpdateTime = now + // update displayed distances + invalidate() + } + + // if chargers are searched around current location, consider app-driven refresh + val searchLocation = + if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null + val distance = searchLocation?.let { + distanceBetween( + it.latitude, it.longitude, location.latitude, location.longitude + ) + } ?: 0.0 + if (supportsRefresh && (lastChargersUpdateTime == null || + Duration.between( + lastChargersUpdateTime, + now + ) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance) + ) { + onContentRefreshRequested() + } + } + + private fun loadChargers() { + val location = location ?: return + + val searchLocation = + prefs.placeSearchResultAndroidAuto?.latLng ?: LatLng.fromLocation(location) + this.searchLocation = searchLocation + + updateCoroutine = lifecycleScope.launch { + loadingError = false + try { + filterStatus = prefs.filterStatus + val filterValues = + db.filterValueDao().getFilterValuesAsync(filterStatus, prefs.dataSource) + val filters = repo.getFiltersAsync(carContext.stringProvider()) + filtersWithValue = filtersWithValue(filters, filterValues) + + val apiId = repo.api.value!!.id + + // load chargers + if (filterStatus == FILTERS_FAVORITES) { + val chargers = favorites.await().map { it.charger }.sortedBy { + distanceBetween( + location.latitude, location.longitude, + it.coordinates.lat, it.coordinates.lng + ) + } + this@LegacyMapScreen.chargers = chargers + } else { + // try multiple search radii until we have enough chargers + var chargers: List? = null + val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50) + for (radius in radiusValues) { + val response = repo.getChargepointsRadius( + searchLocation, + radius, + zoom = 16f, + filtersWithValue + ).awaitFinished() + if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) { + loadingError = true + this@LegacyMapScreen.chargers = null + invalidate() + return@launch + } + chargers = response.data?.filterIsInstance(ChargeLocation::class.java) + if (prefs.placeSearchResultAndroidAutoName == null) { + chargers = headingFilter( + chargers, + searchLocation + ) + } + if (chargers == null || chargers.size >= maxRows) { + break + } + } + this@LegacyMapScreen.chargers = chargers + } + + updateCoroutine = null + lastChargersUpdateTime = Instant.now() + lastDistanceUpdateTime = Instant.now() + invalidate() + } catch (e: IOException) { + loadingError = true + invalidate() + } catch (e: HttpException) { + loadingError = true + invalidate() + } + } + } + + /** + * Filters by heading if heading available and enabled + */ + private fun headingFilter( + chargers: List?, + searchLocation: LatLng + ): List? { + // use compass heading if available, otherwise fall back to GPS + val location = location + val heading = heading?.orientations?.value?.get(0) + ?: if (location?.hasBearing() == true) location.bearing else null + return heading?.let { + if (!prefs.showChargersAheadAndroidAuto) return@let chargers + + chargers?.filter { + val bearing = bearingBetween( + searchLocation.latitude, + searchLocation.longitude, + it.coordinates.lat, + it.coordinates.lng + ) + val diff = headingDiff(bearing, heading.toDouble()) + abs(diff) < 30 + } + } ?: chargers + } + + private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) { + val isUpdate = this.energyLevel == null + this.energyLevel = energyLevel + if (isUpdate) invalidate() + } + + private fun onCompassUpdated(compass: Compass) { + this.heading = compass + } + + override fun onStart(owner: LifecycleOwner) { + setupListeners() + session.requestLocationUpdates() + locationError = false + Handler(Looper.getMainLooper()).postDelayed({ + if (location == null) { + locationError = true + invalidate() + } + }, 5000) + + // Reloading chargers in onStart does not seem to count towards content limit. + // So let's do this so the user gets fresh chargers when re-entering the app. + if (prefs.dataSource != repo.api.value?.id) { + repo.api.value = createApi(prefs.dataSource, carContext) + } + invalidate() + loadChargers() + } + + private fun setupListeners() { + val exec = ContextCompat.getMainExecutor(carContext) + if (supportsCarApiLevel3(carContext)) { + carSensors.addCompassListener( + CarSensors.UPDATE_RATE_NORMAL, + exec, + ::onCompassUpdated + ) + } + if (!permissions.all { + ContextCompat.checkSelfPermission( + carContext, + it + ) == PackageManager.PERMISSION_GRANTED + }) + return + + if (supportsCarApiLevel3(carContext)) { + println("Setting up energy level listener") + carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated) + } + } + + override fun onStop(owner: LifecycleOwner) { + // Reloading chargers in onStart does not seem to count towards content limit. + // So let's do this so the user gets fresh chargers when re-entering the app. + // Deleting the data already in onStop makes sure that we show a loading screen directly + // (i.e. onGetTemplate is not called while the old data is still there) + chargers = null + availabilities.clear() + location = null + removeListeners() + } + + private fun removeListeners() { + if (supportsCarApiLevel3(carContext)) { + println("Removing energy level listener") + carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated) + carSensors.removeCompassListener(::onCompassUpdated) + } + } + + override fun onContentRefreshRequested() { + loadChargers() + availabilities.clear() + + val start = visibleStart + val end = visibleEnd + if (start != null && end != null) { + onItemVisibilityChanged(start, end) + } + } + + override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) { + // when the list is scrolled, load corresponding availabilities + if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return + if (startIndex == -1 || endIndex == -1) return + if (availabilityUpdateCoroutine != null) return + + visibleEnd = endIndex + visibleStart = startIndex + + // remove outdated availabilities + availabilities = availabilities.filter { + Duration.between( + it.value.first, + ZonedDateTime.now() + ) <= availabilityUpdateThreshold + }.toMutableMap() + + // update availabilities + availabilityUpdateCoroutine = lifecycleScope.launch { + delay(300L) + + val chargers = chargers ?: return@launch + if (chargers.isEmpty()) return@launch + + val tasks = chargers.subList( + min(startIndex, chargers.size - 1), + min(endIndex, chargers.size - 1) + ).mapNotNull { + // update only if not yet stored + if (!availabilities.containsKey(it.id)) { + lifecycleScope.async { + val availability = availabilityRepo.getAvailability(it).data + val date = ZonedDateTime.now() + availabilities[it.id] = date to availability + } + } else null + } + if (tasks.isNotEmpty()) { + tasks.awaitAll() + invalidate() + } + availabilityUpdateCoroutine = null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/auto/MapAttributionScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/MapAttributionScreen.kt new file mode 100644 index 000000000..03b21bb57 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/auto/MapAttributionScreen.kt @@ -0,0 +1,43 @@ +package net.vonforst.evmap.auto + +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.Header +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.ParkedOnlyOnClickListener +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import com.car2go.maps.AttributionClickListener +import net.vonforst.evmap.R + +class MapAttributionScreen( + ctx: CarContext, + val attributions: List +) : Screen(ctx) { + override fun onGetTemplate(): Template { + return ListTemplate.Builder() + .setHeader( + Header.Builder() + .setStartHeaderAction(Action.BACK) + .setTitle(carContext.getString(R.string.maplibre_attributionsDialogTitle)) + .build() + ) + .setSingleList(ItemList.Builder().apply { + attributions.forEach { attr -> + addItem( + Row.Builder() + .setTitle(attr.title) + .setBrowsable(true) + .setOnClickListener( + ParkedOnlyOnClickListener.create { + openUrl(carContext, attr.url) + }).build() + ) + } + }.build()) + .build() + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/MapScreen.kt index c27dd27eb..a795d1619 100644 --- a/app/src/main/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/main/java/net/vonforst/evmap/auto/MapScreen.kt @@ -1,28 +1,44 @@ package net.vonforst.evmap.auto import android.content.pm.PackageManager +import android.content.res.Configuration import android.location.Location -import android.os.Handler -import android.os.Looper -import android.text.SpannableString -import android.text.SpannableStringBuilder -import android.text.Spanned +import androidx.activity.OnBackPressedCallback +import androidx.car.app.AppManager import androidx.car.app.CarContext import androidx.car.app.Screen +import androidx.car.app.annotations.ExperimentalCarApi +import androidx.car.app.annotations.RequiresCarApi import androidx.car.app.constraints.ConstraintManager import androidx.car.app.hardware.CarHardwareManager import androidx.car.app.hardware.info.CarInfo import androidx.car.app.hardware.info.CarSensors import androidx.car.app.hardware.info.Compass import androidx.car.app.hardware.info.EnergyLevel -import androidx.car.app.model.* +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.Header +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.MessageTemplate +import androidx.car.app.model.PaneTemplate +import androidx.car.app.model.Template +import androidx.car.app.navigation.model.MapController +import androidx.car.app.navigation.model.MapWithContentTemplate import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope +import com.car2go.maps.AnyMap +import com.car2go.maps.OnMapReadyCallback import com.car2go.maps.model.LatLng -import kotlinx.coroutines.* +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import net.vonforst.evmap.BuildConfig import net.vonforst.evmap.R import net.vonforst.evmap.api.availability.AvailabilityRepository @@ -30,19 +46,18 @@ import net.vonforst.evmap.api.availability.ChargeLocationStatus import net.vonforst.evmap.api.createApi import net.vonforst.evmap.api.stringProvider import net.vonforst.evmap.model.ChargeLocation +import net.vonforst.evmap.model.ChargeLocationCluster +import net.vonforst.evmap.model.ChargepointListItem import net.vonforst.evmap.model.FILTERS_FAVORITES import net.vonforst.evmap.model.FilterValue import net.vonforst.evmap.model.FilterWithValue import net.vonforst.evmap.storage.AppDatabase import net.vonforst.evmap.storage.ChargeLocationsRepository import net.vonforst.evmap.storage.PreferenceDataSource -import net.vonforst.evmap.ui.ChargerIconGenerator -import net.vonforst.evmap.ui.availabilityText -import net.vonforst.evmap.ui.getMarkerTint -import net.vonforst.evmap.utils.bearingBetween +import net.vonforst.evmap.ui.MarkerManager import net.vonforst.evmap.utils.distanceBetween -import net.vonforst.evmap.utils.headingDiff import net.vonforst.evmap.viewmodel.Status +import net.vonforst.evmap.viewmodel.await import net.vonforst.evmap.viewmodel.awaitFinished import net.vonforst.evmap.viewmodel.filtersWithValue import retrofit2.HttpException @@ -51,58 +66,62 @@ import java.time.Duration import java.time.Instant import java.time.ZonedDateTime import kotlin.collections.set -import kotlin.math.abs import kotlin.math.min -import kotlin.math.roundToInt /** - * Main map screen showing either nearby chargers or favorites + * Main map screen showing either nearby chargers or favorites. + * + * New implementation for Car App API Level >= 7 with interactive map using MapSurfaceCallback */ -@androidx.car.app.annotations.ExperimentalCarApi +@RequiresCarApi(7) +@ExperimentalCarApi class MapScreen(ctx: CarContext, val session: EVMapSession) : - Screen(ctx), LocationAwareScreen, OnContentRefreshListener, - ItemList.OnItemVisibilityChangedListener, DefaultLifecycleObserver { + Screen(ctx), LocationAwareScreen, ChargerListDelegate, + DefaultLifecycleObserver, OnMapReadyCallback { companion object { val MARKER = "map" } + private val db = AppDatabase.getInstance(carContext) + private var prefs = PreferenceDataSource(ctx) + private val repo = + ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs) + private val availabilityRepo = AvailabilityRepository(ctx) + private var updateCoroutine: Job? = null private var availabilityUpdateCoroutine: Job? = null private var visibleStart: Int? = null private var visibleEnd: Int? = null - private var location: Location? = null + override var location: Location? = null private var lastDistanceUpdateTime: Instant? = null - private var lastChargersUpdateTime: Instant? = null - private var chargers: List? = null - private var isFavorite: List? = null - private var loadingError = false - private var locationError = false - private var prefs = PreferenceDataSource(ctx) - private val db = AppDatabase.getInstance(carContext) - private val repo = - ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs) - private val availabilityRepo = AvailabilityRepository(ctx) - private val searchRadius = 5 // kilometers + private var chargers: List? = null + private var selectedCharger: ChargeLocation? = null + private val favorites = db.favoritesDao().getAllFavorites() + + override var loadingError = false + override val locationError = false + + private val mapSurfaceCallback = MapSurfaceCallback(carContext, lifecycleScope) + private val distanceUpdateThreshold = Duration.ofSeconds(15) private val availabilityUpdateThreshold = Duration.ofMinutes(1) - private val chargersUpdateThresholdDistance = 500 // meters - private val chargersUpdateThresholdTime = Duration.ofSeconds(30) + private var availabilities: MutableMap> = HashMap() - private val maxRows = + override val maxRows = min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25) - private val supportsRefresh = ctx.isAppDrivenRefreshSupported - private var filterStatus = prefs.filterStatus + override var filterStatus = prefs.filterStatus private var filtersWithValue: List>? = null private val carInfo: CarInfo by lazy { (ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo } private val carSensors: CarSensors by lazy { carContext.patchedCarSensors } - private var energyLevel: EnergyLevel? = null + override var energyLevel: EnergyLevel? = null + private var heading: Compass? = null private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") { listOf( @@ -116,280 +135,234 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : ) } - private var searchLocation: LatLng? = null + private var map: AnyMap? = null + private var markerManager: MarkerManager? = null + private var myLocationEnabled = false + private var myLocationNeedsUpdate = false - private val iconGen = - ChargerIconGenerator(carContext, null, height = 96) + private val formatter = ChargerListFormatter(ctx, this) + private val backPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + clearSelectedCharger() + } + } init { lifecycle.addObserver(this) marker = MARKER + + favorites.observe(this) { + val favoriteIds = it.map { it.favorite.chargerId }.toSet() + markerManager?.favorites = favoriteIds + formatter.favorites = favoriteIds + } + } + + override fun onCreate(owner: LifecycleOwner) { + carContext.getCarService(AppManager::class.java) + .setSurfaceCallback(mapSurfaceCallback) + + carContext.onBackPressedDispatcher.addCallback(this, backPressedCallback) } override fun onGetTemplate(): Template { session.mapScreen = this - return PlaceListMapTemplate.Builder().apply { - setTitle( - prefs.placeSearchResultAndroidAutoName?.let { - carContext.getString(R.string.auto_chargers_near_location, it) - } ?: carContext.getString( - if (filterStatus == FILTERS_FAVORITES) { - R.string.auto_favorites - } else { - R.string.auto_chargers_closeby - } - ) - ) - if (prefs.placeSearchResultAndroidAutoName != null) { - searchLocation?.let { - setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply { - if (prefs.placeSearchResultAndroidAutoName != null) { - setMarker( - PlaceMarker.Builder() - .setColor(CarColor.PRIMARY) - .build() - ) - } - }.build()) - } + val map = map + + val title = prefs.placeSearchResultAndroidAutoName ?: carContext.getString( + if (filterStatus == FILTERS_FAVORITES) { + R.string.auto_favorites + } else if (myLocationEnabled) { + R.string.auto_chargers_closeby } else { - location?.let { - setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build()) - } + R.string.app_name } - chargers?.take(maxRows)?.let { chargerList -> - val builder = ItemList.Builder() - // only show the city if not all chargers are in the same city - val showCity = chargerList.map { it.address?.city }.distinct().size > 1 - chargerList.forEachIndexed { i, charger -> - builder.addItem(formatCharger(charger, showCity, isFavorite?.get(i) ?: false)) - } - builder.setNoItemsMessage( - carContext.getString( - if (filterStatus == FILTERS_FAVORITES) { - R.string.auto_no_favorites_found - } else { - R.string.auto_no_chargers_found - } - ) - ) - builder.setOnItemsVisibilityChangedListener(this@MapScreen) - setItemList(builder.build()) - } ?: run { - if (loadingError) { - val builder = ItemList.Builder() - builder.setNoItemsMessage( - carContext.getString(R.string.connection_error) + ) + + val actionStrip = buildActionStrip() + val selectedCharger = selectedCharger + + val contentTemplate = if (selectedCharger != null) { + PaneTemplate.Builder( + formatter.buildSingleCharger( + selectedCharger, + availabilities.get(selectedCharger.id)?.second + ) { + screenManager.push(ChargerDetailScreen(carContext, selectedCharger)) + session.mapScreen = null + }).apply { + setHeader(Header.Builder().apply { + setTitle(selectedCharger.name) + setStartHeaderAction(Action.BACK) + }.build()) + }.build() + } else if (chargers?.filterIsInstance()?.isNotEmpty() == true) { + MessageTemplate.Builder(carContext.getString(R.string.auto_zoom_for_details)) + .apply { + setHeader(Header.Builder().apply { + setTitle(title) + setStartHeaderAction(Action.APP_ICON) + }.build()) + }.build() + } else { + ListTemplate.Builder().apply { + setHeader(Header.Builder().apply { + setTitle(title) + setStartHeaderAction(Action.APP_ICON) + }.build()) + + formatter.buildChargerList( + chargers?.filterIsInstance(), + availabilities + )?.let { + setSingleList(it) + } ?: setLoading(true) + }.build() + } + return MapWithContentTemplate.Builder().apply { + setContentTemplate(contentTemplate) + setActionStrip(actionStrip) + setMapController(MapController.Builder().apply { + setMapActionStrip(buildMapActionStrip()) + setPanModeListener { } + }.build()) + }.build() + } + + private fun buildMapActionStrip() = ActionStrip.Builder() + .addAction(Action.PAN) + .addAction( + Action.Builder().setIcon( + CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_location)) + .setTint(if (myLocationEnabled) CarColor.SECONDARY else CarColor.DEFAULT) + .build() + ).setOnClickListener { + enableLocation(true) + }.build() + ) + .addAction( + Action.Builder().setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_add ) - setItemList(builder.build()) - } else if (locationError) { - val builder = ItemList.Builder() - builder.setNoItemsMessage( - carContext.getString(R.string.location_error) + ).setTint(CarColor.DEFAULT).build() + ).setOnClickListener { + val map = map ?: return@setOnClickListener + mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.zoomBy(0.5f)) + }.build() + ) + .addAction( + Action.Builder().setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_remove ) - setItemList(builder.build()) - } else { - setLoading(true) - } + ).setTint(CarColor.DEFAULT).build() + ).setOnClickListener { + val map = map ?: return@setOnClickListener + mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.zoomBy(-0.5f)) + }.build() + ).build() + + private fun buildActionStrip(): ActionStrip { + val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else { + filtersWithValue?.count { + !it.value.hasSameValueAs(it.filter.defaultValue()) } - setCurrentLocationEnabled(true) - setHeaderAction(Action.APP_ICON) - val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else { - filtersWithValue?.count { - !it.value.hasSameValueAs(it.filter.defaultValue()) - } - } - - setActionStrip( - ActionStrip.Builder() - .addAction( - Action.Builder() - .setIcon( - CarIcon.Builder( - IconCompat.createWithResource( - carContext, - R.drawable.ic_settings - ) - ).setTint(CarColor.DEFAULT).build() - ) - .setOnClickListener { - screenManager.push(SettingsScreen(carContext, session)) - session.mapScreen = null - } - .build()) - .addAction(Action.Builder().apply { - setIcon( - CarIcon.Builder( - IconCompat.createWithResource( - carContext, - if (prefs.placeSearchResultAndroidAuto != null) { - R.drawable.ic_search_off - } else { - R.drawable.ic_search - } - ) - ).build() - - ) - setOnClickListener { + } + return ActionStrip.Builder() + .addAction( + Action.Builder() + .setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_settings + ) + ).setTint(CarColor.DEFAULT).build() + ) + .setOnClickListener { + screenManager.push(SettingsScreen(carContext, session)) + session.mapScreen = null + } + .build()) + .addAction(Action.Builder().apply { + setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, if (prefs.placeSearchResultAndroidAuto != null) { - prefs.placeSearchResultAndroidAutoName = null - prefs.placeSearchResultAndroidAuto = null - if (!supportsRefresh) { - screenManager.pushForResult(DummyReturnScreen(carContext)) { - chargers = null - isFavorite = null - loadChargers() - } - } else { - chargers = null - isFavorite = null - loadChargers() - } + R.drawable.ic_search_off } else { - screenManager.pushForResult( - PlaceSearchScreen( - carContext, - session - ) - ) { - chargers = null - isFavorite = null - loadChargers() - } - session.mapScreen = null + R.drawable.ic_search } + ) + ).build() + + ) + setOnClickListener { + if (prefs.placeSearchResultAndroidAuto != null) { + prefs.placeSearchResultAndroidAutoName = null + prefs.placeSearchResultAndroidAuto = null + markerManager?.searchResult = null + invalidate() + } else { + screenManager.pushForResult( + PlaceSearchScreen( + carContext, + session + ) + ) { + chargers = null + loadChargers() } - }.build()) - .addAction( - Action.Builder() - .setIcon( - CarIcon.Builder( - IconCompat.createWithResource( - carContext, - R.drawable.ic_filter - ) - ) - .setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT) - .build() + session.mapScreen = null + } + } + }.build()) + .addAction( + Action.Builder() + .setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_filter ) - .setOnClickListener { - screenManager.push(FilterScreen(carContext, session)) - session.mapScreen = null - } - .build()) + ) + .setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT) + .build() + ) + .setOnClickListener { + screenManager.push(FilterScreen(carContext, session)) + session.mapScreen = null + } .build()) - if (carContext.carAppApiLevel >= 5 || - (BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4) - ) { - setOnContentRefreshListener(this@MapScreen) - } - }.build() + .build() } - private fun formatCharger( - charger: ChargeLocation, - showCity: Boolean, - isFavorite: Boolean - ): Row { - val markerTint = getMarkerTint(charger) - val backgroundTint = if ((charger.maxPower ?: 0.0) > 100) { - R.color.charger_100kw_dark // slightly darker color for better contrast - } else { - markerTint + override fun onChargerClick(charger: ChargeLocation) { + selectedCharger = charger + markerManager?.highlighedCharger = charger + markerManager?.animateBounce(charger) + backPressedCallback.isEnabled = true + invalidate() + // load availability + lifecycleScope.launch { + val availability = availabilityRepo.getAvailability(charger).data + val date = ZonedDateTime.now() + availabilities[charger.id] = date to availability + invalidate() } - val color = ContextCompat.getColor(carContext, backgroundTint) - val place = - Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng)) - .setMarker( - PlaceMarker.Builder() - .setColor(CarColor.createCustom(color, color)) - .build() - ) - .build() - - val icon = iconGen.getBitmap( - markerTint, - fault = charger.faultReport != null, - multi = charger.isMulti(), - fav = isFavorite - ) - val iconSpan = - CarIconSpan.create(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build()) - - return Row.Builder().apply { - // only show the city if not all chargers are in the same city (-> showCity == true) - // and the city is not already contained in the charger name - val title = SpannableStringBuilder().apply { - append(" ", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE) - append(" ") - append(charger.name) - } - if (showCity && charger.address?.city != null && charger.address.city !in charger.name) { - val titleWithCity = SpannableStringBuilder().apply { - append("", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE) - append(" ") - append("${charger.name} · ${charger.address.city}") - } - setTitle(CarText.Builder(titleWithCity).addVariant(title).build()) - } else { - setTitle(title) - } - - val text = SpannableStringBuilder() - - // distance - location?.let { - val distanceMeters = distanceBetween( - it.latitude, it.longitude, - charger.coordinates.lat, charger.coordinates.lng - ) - text.append( - "distance", - DistanceSpan.create( - roundValueToDistance( - distanceMeters, - energyLevel?.distanceDisplayUnit?.value, - carContext - ) - ), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - - // power - val power = charger.maxPower - if (power != null) { - if (text.isNotEmpty()) text.append(" · ") - text.append("${power.roundToInt()} kW") - } - - // availability - availabilities[charger.id]?.second?.let { av -> - val status = av.status.values.flatten() - val available = availabilityText(status) - val total = charger.chargepoints.sumOf { it.count } - - 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)) - session.mapScreen = null - } - }.build() + fun clearSelectedCharger() { + selectedCharger = null + markerManager?.highlighedCharger = null + backPressedCallback.isEnabled = false + invalidate() } override fun updateLocation(location: Location) { @@ -398,11 +371,25 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : ) { return } - val previousLocation = this.location + val oldLoc = this.location?.let { LatLng.fromLocation(it) } + val latLng = LatLng.fromLocation(location) this.location = location - if (previousLocation == null) { - loadChargers() - return + + val map = map ?: return + if (myLocationEnabled) { + if (oldLoc == null) { + mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.newLatLngZoom(latLng, 13f)) + } else if (latLng != oldLoc && distanceBetween( + latLng.latitude, + latLng.longitude, + oldLoc.latitude, + oldLoc.longitude + ) > 1 + ) { + // only update map if location changed by more than 1 meter + val camUpdate = map.cameraUpdateFactory.newLatLng(latLng) + mapSurfaceCallback.animateCamera(camUpdate) + } } val now = Instant.now() @@ -413,31 +400,11 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : // update displayed distances invalidate() } - - // if chargers are searched around current location, consider app-driven refresh - val searchLocation = - if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null - val distance = searchLocation?.let { - distanceBetween( - it.latitude, it.longitude, location.latitude, location.longitude - ) - } ?: 0.0 - if (supportsRefresh && (lastChargersUpdateTime == null || - Duration.between( - lastChargersUpdateTime, - now - ) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance) - ) { - onContentRefreshRequested() - } } private fun loadChargers() { val location = location ?: return - - val searchLocation = - prefs.placeSearchResultAndroidAuto ?: LatLng.fromLocation(location) - this.searchLocation = searchLocation + val map = map ?: return updateCoroutine = lifecycleScope.launch { loadingError = false @@ -448,56 +415,33 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : val filters = repo.getFiltersAsync(carContext.stringProvider()) filtersWithValue = filtersWithValue(filters, filterValues) - val apiId = repo.api.value!!.id - // load chargers if (filterStatus == FILTERS_FAVORITES) { - val chargers = - db.favoritesDao().getAllFavoritesAsync().map { it.charger }.sortedBy { - distanceBetween( - location.latitude, location.longitude, - it.coordinates.lat, it.coordinates.lng - ) - } + val chargers = favorites.await().map { it.charger }.sortedBy { + distanceBetween( + location.latitude, location.longitude, + it.coordinates.lat, it.coordinates.lng + ) + } this@MapScreen.chargers = chargers - isFavorite = List(chargers.size) { true } } else { - // try multiple search radii until we have enough chargers - var chargers: List? = null - val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50) - for (radius in radiusValues) { - val response = repo.getChargepointsRadius( - searchLocation, - radius, - zoom = 16f, - filtersWithValue - ).awaitFinished() - if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) { - loadingError = true - this@MapScreen.chargers = null - invalidate() - return@launch - } - chargers = response.data?.filterIsInstance(ChargeLocation::class.java) - if (prefs.placeSearchResultAndroidAutoName == null) { - chargers = headingFilter( - chargers, - searchLocation - ) - } - if (chargers == null || chargers.size >= maxRows) { - break - } + val response = repo.getChargepoints( + map.projection.visibleRegion.latLngBounds, + map.cameraPosition.zoom, + filtersWithValue, + false + ).awaitFinished() + if (response.status == Status.ERROR || response.data == null) { + loadingError = true + this@MapScreen.chargers = null + invalidate() + return@launch } - val isFavorite = chargers?.map { - db.favoritesDao().findFavorite(it.id, apiId) != null - } - this@MapScreen.chargers = chargers - this@MapScreen.isFavorite = isFavorite + this@MapScreen.chargers = response.data + markerManager?.chargepoints = response.data } updateCoroutine = null - lastChargersUpdateTime = Instant.now() lastDistanceUpdateTime = Instant.now() invalidate() } catch (e: IOException) { @@ -510,33 +454,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : } } - /** - * Filters by heading if heading available and enabled - */ - private fun headingFilter( - chargers: List?, - searchLocation: LatLng - ): List? { - // use compass heading if available, otherwise fall back to GPS - val location = location - val heading = heading?.orientations?.value?.get(0) - ?: if (location?.hasBearing() == true) location.bearing else null - return heading?.let { - if (!prefs.showChargersAheadAndroidAuto) return@let chargers - - chargers?.filter { - val bearing = bearingBetween( - searchLocation.latitude, - searchLocation.longitude, - it.coordinates.lat, - it.coordinates.lng - ) - val diff = headingDiff(bearing, heading.toDouble()) - abs(diff) < 30 - } - } ?: chargers - } - private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) { val isUpdate = this.energyLevel == null this.energyLevel = energyLevel @@ -548,15 +465,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : } override fun onStart(owner: LifecycleOwner) { + mapSurfaceCallback.getMapAsync(this) setupListeners() session.requestLocationUpdates() - locationError = false - Handler(Looper.getMainLooper()).postDelayed({ - if (location == null) { - locationError = true - invalidate() - } - }, 5000) // Reloading chargers in onStart does not seem to count towards content limit. // So let's do this so the user gets fresh chargers when re-entering the app. @@ -564,7 +475,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : repo.api.value = createApi(prefs.dataSource, carContext) } invalidate() - loadChargers() } private fun setupListeners() { @@ -598,9 +508,20 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : chargers = null availabilities.clear() location = null + myLocationEnabled = false removeListeners() } + override fun onPause(owner: LifecycleOwner) { + super.onPause(owner) + + map?.let { + prefs.currentMapLocation = it.cameraPosition.target + prefs.currentMapZoom = it.cameraPosition.zoom + } + prefs.currentMapMyLocationEnabled = myLocationEnabled + } + private fun removeListeners() { if (supportsCarApiLevel3(carContext)) { println("Removing energy level listener") @@ -609,17 +530,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : } } - override fun onContentRefreshRequested() { - loadChargers() - availabilities.clear() - - val start = visibleStart - val end = visibleEnd - if (start != null && end != null) { - onItemVisibilityChanged(start, end) - } - } - override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) { // when the list is scrolled, load corresponding availabilities if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return @@ -641,7 +551,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : availabilityUpdateCoroutine = lifecycleScope.launch { delay(300L) - val chargers = chargers ?: return@launch + val chargers = chargers?.filterIsInstance(ChargeLocation::class.java) ?: return@launch if (chargers.isEmpty()) return@launch val tasks = chargers.subList( @@ -664,4 +574,97 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : availabilityUpdateCoroutine = null } } + + override fun onMapReady(map: AnyMap) { + this.map = map + this.markerManager = + MarkerManager( + mapSurfaceCallback.presentation.context, + map, + this, + markerHeight = if (BuildConfig.FLAVOR_automotive == "automotive") 36 else 64 + ).apply { + this@MapScreen.chargers?.let { chargepoints = it } + onChargerClick = this@MapScreen::onChargerClick + onClusterClick = { + val newZoom = map.cameraPosition.zoom + 2 + mapSurfaceCallback.animateCamera( + map.cameraUpdateFactory.newLatLngZoom( + LatLng(it.coordinates.lat, it.coordinates.lng), + newZoom + ) + ) + } + searchResult = prefs.placeSearchResultAndroidAuto + highlighedCharger = selectedCharger + } + + map.setMyLocationEnabled(true) + map.uiSettings.setMyLocationButtonEnabled(false) + map.setAttributionClickListener { attributions -> + screenManager.push(MapAttributionScreen(carContext, attributions)) + } + map.setOnMapClickListener { + clearSelectedCharger() + } + + val mode = carContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + map.setMapStyle( + if (mode == Configuration.UI_MODE_NIGHT_YES) AnyMap.Style.DARK else AnyMap.Style.NORMAL + ) + + prefs.placeSearchResultAndroidAuto?.let { place -> + // move to the location of the search result + myLocationEnabled = false + markerManager?.searchResult = place + if (place.viewport != null) { + map.moveCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0)) + } else { + map.moveCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f)) + } + } ?: if (prefs.currentMapMyLocationEnabled) { + enableLocation(false) + } else { + // use position saved in preferences, fall back to default (Europe) + val cameraUpdate = + map.cameraUpdateFactory.newLatLngZoom( + prefs.currentMapLocation, + prefs.currentMapZoom + ) + map.moveCamera(cameraUpdate) + } + + mapSurfaceCallback.cameraMoveStartedListener = { + if (myLocationEnabled) { + myLocationEnabled = false + myLocationNeedsUpdate = true + } + } + + mapSurfaceCallback.cameraIdleListener = { + loadChargers() + if (myLocationNeedsUpdate) { + invalidate() + myLocationNeedsUpdate = false + } + } + loadChargers() + } + + private fun enableLocation(animated: Boolean) { + myLocationEnabled = true + myLocationNeedsUpdate = true + if (location != null) { + val map = map ?: return + val update = map.cameraUpdateFactory.newLatLngZoom( + LatLng.fromLocation(location), + 13f + ) + if (animated) { + mapSurfaceCallback.animateCamera(update) + } else { + map.moveCamera(update) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt b/app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt new file mode 100644 index 000000000..d1cb0bdff --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt @@ -0,0 +1,284 @@ +package net.vonforst.evmap.auto + +import android.animation.ValueAnimator +import android.app.Presentation +import android.content.Context +import android.graphics.Rect +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.os.Build +import android.os.SystemClock +import android.util.Log +import android.view.MotionEvent +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.SurfaceContainer +import androidx.car.app.annotations.RequiresCarApi +import androidx.core.animation.doOnEnd +import androidx.core.content.ContextCompat +import androidx.interpolator.view.animation.LinearOutSlowInInterpolator +import androidx.lifecycle.LifecycleCoroutineScope +import com.car2go.maps.AnyMap +import com.car2go.maps.AnyMap.CancelableCallback +import com.car2go.maps.CameraUpdate +import com.car2go.maps.MapContainerView +import com.car2go.maps.MapFactory +import com.car2go.maps.OnMapReadyCallback +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.vonforst.evmap.BuildConfig +import net.vonforst.evmap.R +import net.vonforst.evmap.storage.PreferenceDataSource +import kotlin.math.hypot +import kotlin.math.roundToInt +import kotlin.math.roundToLong + + +class MapSurfaceCallback(val ctx: CarContext, val lifecycleScope: LifecycleCoroutineScope) : + SurfaceCallback, OnMapReadyCallback { + private val VIRTUAL_DISPLAY_NAME = "evmap_map" + private val VELOCITY_THRESHOLD_IGNORE_FLING = 1000 + private val STATUSBAR_OFFSET_SYSTEMS = listOf( + "VolvoCars/ihu_emulator_volvo_car/ihu_emulator:11", + "Google/sdk_gcar_x86_64/generic_64bitonly_x86_64:11" + ) + + private val prefs = PreferenceDataSource(ctx) + + private lateinit var virtualDisplay: VirtualDisplay + lateinit var presentation: Presentation + private lateinit var mapView: MapContainerView + private var width: Int = 0 + private var height: Int = 0 + private var visibleArea: Rect? = null + private var map: AnyMap? = null + private val mapCallbacks = mutableListOf() + + private var flingAnimator: ValueAnimator? = null + private var idle = true + private var idleDelay: Job? = null + var cameraMoveStartedListener: (() -> Unit)? = null + var cameraIdleListener: (() -> Unit)? = null + + + override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) { + if (surfaceContainer.surface == null || surfaceContainer.dpi == 0 || surfaceContainer.height == 0 || surfaceContainer.width == 0) { + return + } + + if (Build.FINGERPRINT.contains("emulator") || Build.FINGERPRINT.contains("sdk_gcar")) { + // fix for MapLibre in Android Automotive Emulators + System.setProperty("ro.kernel.qemu", "1") + } + + width = surfaceContainer.width + height = surfaceContainer.height + virtualDisplay = ContextCompat + .getSystemService(ctx, DisplayManager::class.java)!! + .createVirtualDisplay( + VIRTUAL_DISPLAY_NAME, + width, + height, + (surfaceContainer.dpi * when (getMapProvider()) { + "mapbox" -> 1.6 + "google" -> 1.0 + else -> 1.0 + }).roundToInt(), + surfaceContainer.surface, + DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY + ) + presentation = Presentation(ctx, virtualDisplay.display, R.style.AppTheme) + + mapView = createMap(presentation.context) + mapView.onCreate(null) + mapView.onResume() + + presentation.setContentView(mapView) + presentation.show() + + mapView.getMapAsync(this) + } + + private fun getMapProvider(): String = if (BuildConfig.FLAVOR_automotive == "automotive") { + // Google Maps SDK is not available on AAOS (not even AAOS with GAS, so far) + "mapbox" + } else prefs.mapProvider + + override fun onVisibleAreaChanged(visibleArea: Rect) { + Log.d("MapSurfaceCallback", "visible area: $visibleArea") + this.visibleArea = visibleArea + updateVisibleArea() + } + + override fun onStableAreaChanged(stableArea: Rect) { + Log.d("MapSurfaceCallback", "stable area: $stableArea") + } + + override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) { + mapView.onPause() + mapView.onStop() + mapView.onDestroy() + map = null + + presentation.dismiss() + virtualDisplay.release() + } + + @RequiresCarApi(2) + override fun onScroll(distanceX: Float, distanceY: Float) { + flingAnimator?.cancel() + val map = map ?: return + map.moveCamera(map.cameraUpdateFactory.scrollBy(distanceX, distanceY)) + dispatchCameraMoveStarted() + } + + @RequiresCarApi(2) + override fun onFling(velocityX: Float, velocityY: Float) { + val map = map ?: return + val screenDensity: Float = presentation.resources.displayMetrics.density + + // calculate velocity vector for xy dimensions, independent from screen size + val velocityXY = + hypot((velocityX / screenDensity).toDouble(), (velocityY / screenDensity).toDouble()) + if (velocityXY < VELOCITY_THRESHOLD_IGNORE_FLING) { + // ignore short flings, these can occur when other gestures just have finished executing + return + } + + idleDelay?.cancel() + + val offsetX = velocityX / 10 + val offsetY = velocityY / 10 + val animationTime = (velocityXY / 10).roundToLong() + + flingAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = animationTime + interpolator = LinearOutSlowInInterpolator() + + var last = 0f + addUpdateListener { + val current = it.animatedFraction + val diff = last - current + map.moveCamera(map.cameraUpdateFactory.scrollBy(diff * offsetX, diff * offsetY)) + last = current + } + start() + + doOnEnd { dispatchCameraIdle() } + } + } + + @RequiresCarApi(2) + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + flingAnimator?.cancel() + val map = map ?: return + if (scaleFactor == 2f) return + + val offsetX = (focusX - mapView.width / 2) * (scaleFactor - 1f) + val offsetY = (offsetY(focusY) - mapView.height / 2) * (scaleFactor - 1f) + + Log.i("MapSurfaceCallback", "focus: $focusX, $focusY, scaleFactor: $scaleFactor") + map.moveCamera(map.cameraUpdateFactory.zoomBy(scaleFactor - 1)) + map.moveCamera(map.cameraUpdateFactory.scrollBy(offsetX, offsetY)) + dispatchCameraMoveStarted() + } + + fun animateCamera(update: CameraUpdate) { + val map = map ?: return + map.animateCamera(update, object : CancelableCallback { + override fun onFinish() { + dispatchCameraIdle() + } + + override fun onCancel() { + } + }) + } + + private fun dispatchCameraMoveStarted() { + if (idle) { + idle = false + cameraMoveStartedListener?.invoke() + } + idleDelay?.cancel() + idleDelay = lifecycleScope.launch { + delay(500) + dispatchCameraIdle() + } + } + + private fun dispatchCameraIdle() { + idle = true + cameraIdleListener?.invoke() + } + + @RequiresCarApi(5) + override fun onClick(x: Float, y: Float) { + flingAnimator?.cancel() + val downTime: Long = SystemClock.uptimeMillis() + val eventTime: Long = downTime + 100 + val yOffset = offsetY(y) + + val downEvent = MotionEvent.obtain( + downTime, + downTime, + MotionEvent.ACTION_DOWN, + x, + yOffset, + 0 + ) + mapView.dispatchTouchEvent(downEvent) + downEvent.recycle() + val upEvent = MotionEvent.obtain( + downTime, + eventTime, + MotionEvent.ACTION_UP, + x, + yOffset, + 0 + ) + mapView.dispatchTouchEvent(upEvent) + upEvent.recycle() + } + + private fun offsetY(y: Float): Float { + if (!STATUSBAR_OFFSET_SYSTEMS.any { Build.FINGERPRINT.startsWith(it) }) return y + + // In some emulators, touch locations are offset by the status bar height + // related: https://issuetracker.google.com/issues/256905247 + val resId = ctx.resources.getIdentifier("status_bar_height", "dimen", "android") + val offset = resId.takeIf { it > 0 }?.let { ctx.resources.getDimensionPixelSize(it) } ?: 0 + return y + offset + } + + private fun createMap(ctx: Context): MapContainerView { + val priority = arrayOf( + when (getMapProvider()) { + "mapbox" -> MapFactory.MAPLIBRE + "google" -> MapFactory.GOOGLE + else -> null + }, + MapFactory.GOOGLE, + MapFactory.MAPLIBRE + ) + return MapFactory.createMap(ctx, priority).view + } + + override fun onMapReady(anyMap: AnyMap) { + this.map = anyMap + updateVisibleArea() + mapCallbacks.forEach { it.onMapReady(anyMap) } + mapCallbacks.clear() + } + + private fun updateVisibleArea() { + visibleArea?.let { + map?.setPadding(it.left, it.top, width - it.right, height - it.bottom) + } + } + + fun getMapAsync(callback: OnMapReadyCallback) { + mapCallbacks.add(callback) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt index 4eba3a6eb..557c052cd 100644 --- a/app/src/main/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt +++ b/app/src/main/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt @@ -11,19 +11,34 @@ import androidx.car.app.annotations.ExperimentalCarApi import androidx.car.app.constraints.ConstraintManager import androidx.car.app.hardware.CarHardwareManager import androidx.car.app.hardware.info.EnergyLevel -import androidx.car.app.model.* +import androidx.car.app.model.Action +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.DistanceSpan +import androidx.car.app.model.ItemList +import androidx.car.app.model.Row +import androidx.car.app.model.SearchTemplate +import androidx.car.app.model.Template import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.car2go.maps.model.LatLng -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.vonforst.evmap.BuildConfig import net.vonforst.evmap.R import net.vonforst.evmap.adapter.iconForPlaceType import net.vonforst.evmap.adapter.isSpecialPlace -import net.vonforst.evmap.autocomplete.* +import net.vonforst.evmap.autocomplete.ApiUnavailableException +import net.vonforst.evmap.autocomplete.AutocompletePlace +import net.vonforst.evmap.autocomplete.AutocompleteProvider +import net.vonforst.evmap.autocomplete.PlaceWithBounds +import net.vonforst.evmap.autocomplete.getAutocompleteProviders import net.vonforst.evmap.storage.AppDatabase import net.vonforst.evmap.storage.PreferenceDataSource import net.vonforst.evmap.storage.RecentAutocompletePlace @@ -117,7 +132,7 @@ class PlaceSearchScreen( setOnClickListener { lifecycleScope.launch { val placeDetails = getDetails(place.id) ?: return@launch - prefs.placeSearchResultAndroidAuto = placeDetails.latLng + prefs.placeSearchResultAndroidAuto = placeDetails prefs.placeSearchResultAndroidAutoName = place.primaryText.toString() screenManager.popTo(MapScreen.MARKER) diff --git a/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt b/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt index b254ce0e0..e15074f9a 100644 --- a/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt +++ b/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt @@ -114,22 +114,25 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) { } .build() ) - addItem( - Row.Builder() - .setTitle(carContext.getString(R.string.auto_chargers_ahead)) - .setToggle(Toggle.Builder { - prefs.showChargersAheadAndroidAuto = it - }.setChecked(prefs.showChargersAheadAndroidAuto).build()) - .setImage( - CarIcon.Builder( - IconCompat.createWithResource( - carContext, - R.drawable.ic_navigation - ) - ).setTint(CarColor.DEFAULT).build() - ) - .build() - ) + if (carContext.carAppApiLevel < 7 || !carContext.isAppDrivenRefreshSupported) { + // this option is only supported in LegacyMapScreen + addItem( + Row.Builder() + .setTitle(carContext.getString(R.string.auto_chargers_ahead)) + .setToggle(Toggle.Builder { + prefs.showChargersAheadAndroidAuto = it + }.setChecked(prefs.showChargersAheadAndroidAuto).build()) + .setImage( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_navigation + ) + ).setTint(CarColor.DEFAULT).build() + ) + .build() + ) + } } addItem( Row.Builder() @@ -164,6 +167,10 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) { carContext.resources.getStringArray(R.array.pref_search_provider_names) val searchProviderValues = carContext.resources.getStringArray(R.array.pref_search_provider_values) + val mapProviderNames = + carContext.resources.getStringArray(R.array.pref_map_provider_names) + val mapProviderValues = + carContext.resources.getStringArray(R.array.pref_map_provider_values) var teslaLoggingIn = false @@ -203,6 +210,25 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) { ) } }.build()) + if (supportsNewMapScreen(carContext) && BuildConfig.FLAVOR_automotive != "automotive") { + // Google Maps SDK is not available on AAOS (not even AAOS with GAS, so far) + addItem(Row.Builder().apply { + setTitle(carContext.getString(R.string.pref_map_provider)) + setBrowsable(true) + val mapProviderId = prefs.mapProvider + val mapProviderDesc = + mapProviderNames[mapProviderValues.indexOf(mapProviderId)] + addText(mapProviderDesc) + setOnClickListener { + screenManager.push( + ChooseDataSourceScreen( + carContext, + ChooseDataSourceScreen.Type.MAP_PROVIDER + ) + ) + } + }.build()) + } addItem(Row.Builder().apply { setTitle(carContext.getString(R.string.pref_search_delete_recent)) setOnClickListener { @@ -341,25 +367,33 @@ class ChooseDataSourceScreen( @StringRes val extraDesc: Int? = null ) : Screen(ctx) { enum class Type { - CHARGER_DATA_SOURCE, SEARCH_PROVIDER + CHARGER_DATA_SOURCE, SEARCH_PROVIDER, MAP_PROVIDER } val prefs = PreferenceDataSource(carContext) val title = when (type) { Type.CHARGER_DATA_SOURCE -> R.string.pref_data_source Type.SEARCH_PROVIDER -> R.string.pref_search_provider + Type.MAP_PROVIDER -> R.string.pref_map_provider } - val names = when (type) { - Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_names) - Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_names) - } - val values = when (type) { - Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_values) - Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_values) - } + val names = carContext.resources.getStringArray( + when (type) { + Type.CHARGER_DATA_SOURCE -> R.array.pref_data_source_names + Type.SEARCH_PROVIDER -> R.array.pref_search_provider_names + Type.MAP_PROVIDER -> R.array.pref_map_provider_names + } + ) + val values = carContext.resources.getStringArray( + when (type) { + Type.CHARGER_DATA_SOURCE -> R.array.pref_data_source_values + Type.SEARCH_PROVIDER -> R.array.pref_search_provider_values + Type.MAP_PROVIDER -> R.array.pref_map_provider_values + } + ) val currentValue: String = when (type) { Type.CHARGER_DATA_SOURCE -> prefs.dataSource Type.SEARCH_PROVIDER -> prefs.searchProvider + Type.MAP_PROVIDER -> prefs.mapProvider } val descriptions = when (type) { Type.CHARGER_DATA_SOURCE -> listOf( @@ -367,6 +401,7 @@ class ChooseDataSourceScreen( carContext.getString(R.string.data_source_openchargemap_desc) ) Type.SEARCH_PROVIDER -> null + Type.MAP_PROVIDER -> null } val callback: (String) -> Unit = when (type) { Type.CHARGER_DATA_SOURCE -> { it -> @@ -376,6 +411,9 @@ class ChooseDataSourceScreen( Type.SEARCH_PROVIDER -> { it -> prefs.searchProvider = it } + Type.MAP_PROVIDER -> { it -> + prefs.mapProvider = it + } } override fun onGetTemplate(): Template { diff --git a/app/src/main/java/net/vonforst/evmap/auto/Utils.kt b/app/src/main/java/net/vonforst/evmap/auto/Utils.kt index f309cac73..d407ac2a0 100644 --- a/app/src/main/java/net/vonforst/evmap/auto/Utils.kt +++ b/app/src/main/java/net/vonforst/evmap/auto/Utils.kt @@ -4,19 +4,26 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.graphics.Color import android.graphics.Typeface import android.net.Uri +import android.text.SpannableStringBuilder +import android.text.Spanned import android.text.TextPaint +import android.util.Log import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent import androidx.car.app.CarContext import androidx.car.app.CarToast +import androidx.car.app.HostException import androidx.car.app.Screen import androidx.car.app.constraints.ConstraintManager import androidx.car.app.hardware.common.CarUnit import androidx.car.app.model.CarColor import androidx.car.app.model.CarIcon +import androidx.car.app.model.CarIconSpan import androidx.car.app.model.Distance +import androidx.car.app.model.ForegroundCarColorSpan import androidx.car.app.model.MessageTemplate import androidx.car.app.model.Template import androidx.car.app.versioning.CarAppApiLevels @@ -24,11 +31,17 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat import net.vonforst.evmap.BuildConfig import net.vonforst.evmap.R +import net.vonforst.evmap.api.availability.ChargeLocationStatus import net.vonforst.evmap.api.availability.ChargepointStatus +import net.vonforst.evmap.api.iconForPlugType +import net.vonforst.evmap.api.nameForPlugType +import net.vonforst.evmap.api.stringProvider import net.vonforst.evmap.ftPerMile import net.vonforst.evmap.getPackageInfoCompat import net.vonforst.evmap.kmPerMile +import net.vonforst.evmap.model.ChargeLocation import net.vonforst.evmap.shouldUseImperialUnits +import net.vonforst.evmap.ui.availabilityText import net.vonforst.evmap.ydPerMile import java.util.Locale import kotlin.math.roundToInt @@ -221,6 +234,9 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean { return true } +fun supportsNewMapScreen(ctx: CarContext) = + ctx.carAppApiLevel >= 7 && ctx.isAppDrivenRefreshSupported + fun openUrl(carContext: CarContext, url: String) { val intent = CustomTabsIntent.Builder() .setDefaultColorSchemeParams( @@ -255,6 +271,54 @@ fun openUrl(carContext: CarContext, url: String) { } } +fun navigateToCharger(ctx: CarContext, charger: ChargeLocation) { + val success = navigateCarApp(ctx, charger) + if (!success && BuildConfig.FLAVOR_automotive == "automotive") { + // on AAOS, some OEMs' navigation apps might not support + navigateRegularApp(ctx, charger) + } +} + +private fun navigateCarApp(ctx: CarContext, charger: ChargeLocation): Boolean { + val coord = charger.coordinates + val intent = + Intent( + CarContext.ACTION_NAVIGATE, + Uri.parse("geo:${coord.lat},${coord.lng}") + ) + try { + ctx.startCarApp(intent) + return true + } catch (e: HostException) { + Log.w("navigateToCharger", "Could not start navigation using car app intent") + Log.w("navigateToCharger", intent.toString()) + e.printStackTrace() + } catch (e: SecurityException) { + Log.w("navigateToCharger", "Could not start navigation using car app intent") + Log.w("navigateToCharger", intent.toString()) + e.printStackTrace() + } + return false +} + +private fun navigateRegularApp(ctx: CarContext, charger: ChargeLocation): Boolean { + val coord = charger.coordinates + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse( + "geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${ + Uri.encode(charger.name) + })" + ) + if (intent.resolveActivity(ctx.packageManager) != null) { + ctx.startActivity(intent) + return true + } else { + Log.w("navigateToCharger", "Could not start navigation using regular intent") + Log.w("navigateToCharger", intent.toString()) + } + return false +} + class DummyReturnScreen(ctx: CarContext) : Screen(ctx) { /* Dummy screen to get around template refresh limitations. @@ -279,4 +343,49 @@ class TextMeasurer(ctx: CarContext) { fun measureText(text: CharSequence): Float { return textPaint.measureText(text, 0, text.length) } +} + +fun generateChargepointsText( + charger: ChargeLocation, + availability: ChargeLocationStatus?, + ctx: Context +): SpannableStringBuilder { + val chargepointsText = SpannableStringBuilder() + charger.chargepointsMerged.forEachIndexed { i, cp -> + chargepointsText.apply { + if (i > 0) append(" · ") + append("${cp.count}× ") + val plugIcon = iconForPlugType(cp.type) + if (plugIcon != 0) { + append( + nameForPlugType(ctx.stringProvider(), cp.type), + CarIconSpan.create( + CarIcon.Builder( + IconCompat.createWithResource( + ctx, + plugIcon + ) + ).setTint( + CarColor.createCustom(Color.WHITE, Color.BLACK) + ).build() + ), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE + ) + } else { + append(nameForPlugType(ctx.stringProvider(), cp.type)) + } + cp.formatPower()?.let { + append(" ") + append(it) + } + } + availability?.status?.get(cp)?.let { status -> + chargepointsText.append( + " (${availabilityText(status)}/${cp.count})", + ForegroundCarColorSpan.create(carAvailabilityColor(status)), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + return chargepointsText } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt index 1ce536d2d..ebef9c77f 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt @@ -55,6 +55,7 @@ import androidx.transition.TransitionManager import coil.load import coil.memory.MemoryCache import com.car2go.maps.AnyMap +import com.car2go.maps.MapFactory import com.car2go.maps.MapFragment import com.car2go.maps.OnMapReadyCallback import com.car2go.maps.model.LatLng @@ -200,12 +201,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac mapFragment = MapFragment() mapFragment!!.priority = arrayOf( when (provider) { - "mapbox" -> MapFragment.MAPLIBRE - "google" -> MapFragment.GOOGLE + "mapbox" -> MapFactory.MAPLIBRE + "google" -> MapFactory.GOOGLE else -> null }, - MapFragment.GOOGLE, - MapFragment.MAPLIBRE + MapFactory.GOOGLE, + MapFactory.MAPLIBRE ) childFragmentManager .beginTransaction() diff --git a/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt b/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt index a6aa97882..11913469b 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt @@ -6,7 +6,9 @@ import android.content.SharedPreferences.Editor import androidx.preference.PreferenceManager import com.car2go.maps.AnyMap import com.car2go.maps.model.LatLng +import com.car2go.maps.model.LatLngBounds import net.vonforst.evmap.R +import net.vonforst.evmap.autocomplete.PlaceWithBounds import net.vonforst.evmap.model.FILTERS_CUSTOM import net.vonforst.evmap.model.FILTERS_DISABLED import java.time.Instant @@ -108,11 +110,14 @@ class PreferenceDataSource(val context: Context) { val darkmode: String get() = sp.getString("darkmode", "default")!! - val mapProvider: String + var mapProvider: String get() = sp.getString( "map_provider", context.getString(R.string.pref_map_provider_default) )!! + set(value) { + sp.edit().putString("map_provider", value).apply() + } var searchProvider: String get() = sp.getString( @@ -250,10 +255,16 @@ class PreferenceDataSource(val context: Context) { .apply() } - var placeSearchResultAndroidAuto: LatLng? - get() = sp.getLatLng("place_search_result_android_auto") + var placeSearchResultAndroidAuto: PlaceWithBounds? + get() { + val latLng = sp.getLatLng("place_search_result_android_auto") + val bounds = sp.getLatLngBounds("place_search_result_android_auto_viewport") + return latLng?.let { PlaceWithBounds(latLng, bounds) } + } set(value) { - sp.edit().putLatLng("place_search_result_android_auto", value).apply() + sp.edit().putLatLng("place_search_result_android_auto", value?.latLng).apply() + sp.edit().putLatLngBounds("place_search_result_android_auto_viewport", value?.viewport) + .apply() } var placeSearchResultAndroidAutoName: String? @@ -315,7 +326,7 @@ class PreferenceDataSource(val context: Context) { } fun SharedPreferences.getLatLng(key: String): LatLng? = - if (contains("${key}_lat") && contains("${key}_lng")) { + if (containsLatLng(key)) { LatLng( Double.fromBits(getLong("${key}_lat", 0L)), Double.fromBits(getLong("${key}_lng", 0L)) @@ -332,3 +343,23 @@ fun Editor.putLatLng(key: String, value: LatLng?): Editor { } return this } + +fun SharedPreferences.containsLatLng(key: String) = contains("${key}_lat") && contains("${key}_lng") + +fun SharedPreferences.getLatLngBounds(key: String): LatLngBounds? = + if (containsLatLng("${key}_sw") && containsLatLng("${key}_ne")) { + LatLngBounds( + getLatLng("${key}_sw"), getLatLng("${key}_ne") + ) + } else null + +fun Editor.putLatLngBounds(key: String, value: LatLngBounds?): Editor { + if (value == null) { + putLatLng("${key}_sw", null) + putLatLng("${key}_ne", null) + } else { + putLatLng("${key}_sw", value.southwest) + putLatLng("${key}_ne", value.northeast) + } + return this +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 92b59cdfe..4a3f92b2c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -367,4 +367,5 @@ Preisvergleich in EVMap Preise werden direkt in EVMap angezeigt Preisvergleich verlinkt auf die App oder Website von Chargeprice + Für Details hineinzoomen \ No newline at end of file diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index cd8567998..909702796 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -40,4 +40,5 @@ eprimo ©2020–2024 Johan von Forstner and contributors https://acra.muc.vonforst.net/report + MapLibre Maps SDK for Android diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f8b7dacae..c34c2549c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -367,4 +367,5 @@ Price comparison within EVMap Pricing data will be shown directly in EVMap Price comparison button will refer to the Chargeprice app or website + Zoom in to see details \ No newline at end of file