From 04e6f63cd7d3c5ff6a6eb5214a36e15d873e2c2e Mon Sep 17 00:00:00 2001 From: Johan von Forstner Date: Fri, 1 Jan 2021 17:03:28 +0100 Subject: [PATCH] Android Auto: Add permission screen, add selection between nearby and favorites --- app/src/google/AndroidManifest.xml | 4 +- .../net/vonforst/evmap/auto/CarAppService.kt | 233 +++++++++++++++--- .../vonforst/evmap/auto/PermissionActivity.kt | 72 ++++++ app/src/google/res/values-de/values.xml | 5 + app/src/google/res/values/values.xml | 5 + .../evmap/storage/ChargeLocationsDao.kt | 3 + 6 files changed, 288 insertions(+), 34 deletions(-) create mode 100644 app/src/google/java/net/vonforst/evmap/auto/PermissionActivity.kt diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml index 0b26250d..b8dc6d13 100644 --- a/app/src/google/AndroidManifest.xml +++ b/app/src/google/AndroidManifest.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" package="net.vonforst.evmap"> - + @@ -36,5 +36,7 @@ android:name=".auto.CarLocationService" android:foregroundServiceType="location" android:enabled="true" /> + + \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt index e6632f69..f07f4713 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/CarAppService.kt @@ -1,11 +1,15 @@ package net.vonforst.evmap.auto +import android.Manifest import android.content.* +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.core.content.ContextCompat @@ -32,6 +36,7 @@ import net.vonforst.evmap.api.availability.getAvailability import net.vonforst.evmap.api.goingelectric.ChargeLocation import net.vonforst.evmap.api.goingelectric.GoingElectricApi import net.vonforst.evmap.api.nameForPlugType +import net.vonforst.evmap.storage.AppDatabase import net.vonforst.evmap.ui.availabilityText import net.vonforst.evmap.ui.getMarkerTint import net.vonforst.evmap.utils.distanceBetween @@ -39,9 +44,17 @@ import java.time.Duration import java.time.ZonedDateTime import kotlin.math.roundToInt +interface LocationAwareScreen { + fun updateLocation(location: Location) +} class CarAppService : com.google.android.libraries.car.app.CarAppService(), LifecycleObserver { - private lateinit var mapScreen: MapScreen + var mapScreen: LocationAwareScreen? = null + set(value) { + field = value + location?.let { value?.updateLocation(it) } + } + private var location: Location? = null private var locationService: CarLocationService? = null private val serviceConnection = object : ServiceConnection { @@ -60,23 +73,35 @@ class CarAppService : com.google.android.libraries.car.app.CarAppService(), Life private val locationReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location? - if (location != null) { + val mapScreen = this@CarAppService.mapScreen + if (location != null && mapScreen != null) { mapScreen.updateLocation(location) } + this@CarAppService.location = location } } override fun onCreate() { - mapScreen = MapScreen(carContext) lifecycle.addObserver(this) } override fun onCreateScreen(intent: Intent): Screen { - return mapScreen + return if (locationPermissionGranted()) { + WelcomeScreen(carContext, this) + } else { + PermissionScreen(carContext, this) + } } + private fun locationPermissionGranted() = + ContextCompat.checkSelfPermission( + carContext, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + @OnLifecycleEvent(Lifecycle.Event.ON_START) - private fun bindLocationService() { + fun bindLocationService() { + if (!locationPermissionGranted()) return bindService( Intent(this, CarLocationService::class.java), serviceConnection, @@ -104,7 +129,118 @@ class CarAppService : com.google.android.libraries.car.app.CarAppService(), Life } } -class MapScreen(ctx: CarContext) : Screen(ctx) { +/** + * Welcome screen with selection between favorites and nearby chargers + */ +class WelcomeScreen(ctx: CarContext, val cas: CarAppService) : Screen(ctx), LocationAwareScreen { + private var location: Location? = null + + override fun getTemplate(): Template { + cas.mapScreen = this + return PlaceListMapTemplate.builder().apply { + setTitle(carContext.getString(R.string.app_name)) + location?.let { + setAnchor(Place.builder(LatLng.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() + ) + .setIsBrowsable(true) + .setOnClickListener { + screenManager.push(MapScreen(carContext, cas, 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() + ) + .setIsBrowsable(true) + .setOnClickListener { + screenManager.push(MapScreen(carContext, cas, 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 cas: CarAppService) : Screen(ctx) { + override fun getTemplate(): Template { + return MessageTemplate.builder(carContext.getString(R.string.auto_location_permission_needed)) + .setTitle(carContext.getString(R.string.app_name)) + .setHeaderAction(Action.APP_ICON) + .setActions(listOf( + 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)) { + cas.bindLocationService() + screenManager.push(WelcomeScreen(carContext, cas)) + } + } + }) + carContext.startActivity(intent) + CarToast.makeText( + carContext, + R.string.opened_on_phone, + CarToast.LENGTH_LONG + ).show() + }) + .build(), + Action.builder() + .setTitle(carContext.getString(R.string.cancel)) + .setOnClickListener { + cas.stopSelf() + } + .build(), + + )) + .build() + } +} + +/** + * Main map screen showing either nearby chargers or favorites + */ +class MapScreen(ctx: CarContext, val cas: CarAppService, val favorites: Boolean = false) : + Screen(ctx), LocationAwareScreen { private var location: Location? = null private var lastUpdateLocation: Location? = null private var chargers: List? = null @@ -119,8 +255,17 @@ class MapScreen(ctx: CarContext) : Screen(ctx) { private val maxRows = 6 override fun getTemplate(): Template { + cas.mapScreen = this return PlaceListMapTemplate.builder().apply { - setTitle("EVMap") + setTitle( + carContext.getString( + if (favorites) { + R.string.auto_favorites + } else { + R.string.auto_chargers_closeby + } + ) + ) location?.let { setAnchor(Place.builder(LatLng.create(it)).build()) } ?: setIsLoading(true) @@ -129,11 +274,19 @@ class MapScreen(ctx: CarContext) : Screen(ctx) { chargerList.forEach { charger -> builder.addItem(formatCharger(charger)) } - builder.setNoItemsMessage(carContext.getString(R.string.auto_no_chargers_found)) + builder.setNoItemsMessage( + carContext.getString( + if (favorites) { + R.string.auto_no_favorites_found + } else { + R.string.auto_no_chargers_found + } + ) + ) setItemList(builder.build()) } ?: setIsLoading(true) setCurrentLocationEnabled(true) - setHeaderAction(Action.APP_ICON) + setHeaderAction(Action.BACK) build() }.build() } @@ -195,7 +348,7 @@ class MapScreen(ctx: CarContext) : Screen(ctx) { }.build() } - fun updateLocation(location: Location) { + override fun updateLocation(location: Location) { this.location = location if (lastUpdateLocation == null) invalidate() @@ -206,8 +359,23 @@ class MapScreen(ctx: CarContext) : Screen(ctx) { lastUpdateLocation = location // update displayed chargers - lifecycleScope.launch { - // load chargers + loadChargers(location) + } + } + + private val db = AppDatabase.getInstance(carContext) + + private fun loadChargers(location: Location) { + lifecycleScope.launch { + // 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( location.latitude, location.longitude, @@ -216,31 +384,31 @@ class MapScreen(ctx: CarContext) : Screen(ctx) { ) chargers = response.body()?.chargelocations?.filterIsInstance(ChargeLocation::class.java) + } - // remove outdated availabilities - availabilities = availabilities.filter { - Duration.between( - it.value.first, - ZonedDateTime.now() - ) > availabilityUpdateThreshold - }.toMutableMap() + // 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 - } + // 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() + } + }?.awaitAll() - invalidate() - } + invalidate() } } } @@ -340,7 +508,6 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : R.string.opened_on_phone, CarToast.LENGTH_LONG ).show() - // TODO: pass options to open this specific charger }) .build() ) diff --git a/app/src/google/java/net/vonforst/evmap/auto/PermissionActivity.kt b/app/src/google/java/net/vonforst/evmap/auto/PermissionActivity.kt new file mode 100644 index 00000000..13a5a468 --- /dev/null +++ b/app/src/google/java/net/vonforst/evmap/auto/PermissionActivity.kt @@ -0,0 +1,72 @@ +package net.vonforst.evmap.auto + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.ResultReceiver +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + + +class PermissionActivity : Activity() { + companion object { + const val EXTRA_RESULT_RECEIVER = "result_receiver"; + const val RESULT_GRANTED = "granted" + } + + private lateinit var resultReceiver: ResultReceiver + private val permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + private val requestCode = 1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent != null) { + resultReceiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER)!! + if (!hasPermissions(permissions)) { + ActivityCompat.requestPermissions(this, permissions, requestCode) + } else { + onComplete( + requestCode, + permissions, + intArrayOf(PackageManager.PERMISSION_GRANTED) + ) + } + } else { + finish() + } + } + + private fun onComplete(requestCode: Int, permissions: Array?, grantResults: IntArray) { + val bundle = Bundle() + bundle.putBoolean( + RESULT_GRANTED, + grantResults.all { it == PackageManager.PERMISSION_GRANTED }) + resultReceiver.send(requestCode, bundle) + finish() + } + + private fun hasPermissions(permissions: Array): Boolean { + var result = true + for (permission in permissions) { + if (ContextCompat.checkSelfPermission( + this, + permission + ) != PackageManager.PERMISSION_GRANTED + ) { + result = false + break + } + } + return result + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + onComplete(requestCode, permissions, grantResults) + } +} \ No newline at end of file diff --git a/app/src/google/res/values-de/values.xml b/app/src/google/res/values-de/values.xml index 8f4e4cd1..f9ac7f37 100644 --- a/app/src/google/res/values-de/values.xml +++ b/app/src/google/res/values-de/values.xml @@ -7,6 +7,11 @@ Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 30% Gebühren ab. EVMap läuft unter Android Auto und nutzt dafür deinen Standort. Keine Ladestationen in der Nähe gefunden + Keine Favoriten gefunden In App öffnen Auf dem Telefon geöffnet + Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort. + Auf Telefon zulassen + In der Nähe + Favoriten \ No newline at end of file diff --git a/app/src/google/res/values/values.xml b/app/src/google/res/values/values.xml index 9b236356..02695d37 100644 --- a/app/src/google/res/values/values.xml +++ b/app/src/google/res/values/values.xml @@ -12,6 +12,11 @@ Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 30% off every donation. EVMap is running on Android Auto and using your location. No nearby chargers found + No favorites found Open in app Opened on phone + To run EVMap on Android Auto, you need to grant access to your location. + Grant on phone + Nearby chargers + Favorites \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt b/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt index 0e2a080b..24c82a6d 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt @@ -14,4 +14,7 @@ interface ChargeLocationsDao { @Query("SELECT * FROM chargelocation") fun getAllChargeLocations(): LiveData> + + @Query("SELECT * FROM chargelocation") + suspend fun getAllChargeLocationsAsync(): List } \ No newline at end of file