Compare commits

..

1 Commits

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

View File

@@ -6,5 +6,4 @@
<string name="openchargemap_key" translatable="false">ci</string>
<string name="fronyx_key" translatable="false">ci</string>
<string name="acra_credentials" translatable="false">ci:ci</string>
<string name="tesla_credentials" translatable="false">ci:ci</string>
</resources>

View File

@@ -32,7 +32,6 @@ jobs:
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
ACRA_CRASHREPORT_CREDENTIALS: ${{ secrets.ACRA_CRASHREPORT_CREDENTIALS }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}

View File

@@ -19,8 +19,8 @@ android {
minSdkVersion 21
targetSdkVersion 34
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 204
versionName "1.7.0"
versionCode 194
versionName "1.6.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(',')
@@ -152,13 +152,6 @@ android {
if (acraKey != null) {
variant.resValue "string", "acra_credentials", acraKey
}
def teslaKey = env.TESLA_CREDENTIALS ?: project.findProperty("TESLA_CREDENTIALS")
if (teslaKey == null && project.hasProperty("TESLA_CREDENTIALS_ENCRYPTED")) {
teslaKey = decode(project.findProperty("TESLA_CREDENTIALS_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (teslaKey != null) {
variant.resValue "string", "tesla_credentials", teslaKey
}
}
packagingOptions {
@@ -177,7 +170,7 @@ configurations {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation "androidx.activity:activity-ktx:1.7.2"
implementation "androidx.fragment:fragment-ktx:1.6.1"
@@ -209,7 +202,7 @@ dependencies {
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
def carAppVersion = '1.4.0-beta02'
def carAppVersion = '1.4.0-beta01'
implementation "androidx.car.app:app:$carAppVersion"
normalImplementation "androidx.car.app:app-projected:$carAppVersion"
automotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
@@ -242,7 +235,7 @@ dependencies {
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.6.2"
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
@@ -260,6 +253,7 @@ dependencies {
// ACRA (crash reporting)
def acraVersion = "5.11.1"
implementation("ch.acra:acra-mail:$acraVersion")
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")

View File

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

View File

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

View File

@@ -209,8 +209,8 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
checkedItem = item
root.post {
notifyDataSetChanged()
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}

View File

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

View File

@@ -3,7 +3,6 @@ package net.vonforst.evmap.api.availability
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
@@ -171,18 +170,9 @@ class AvailabilityRepository(context: Context) {
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
private val teslaAvailabilityDetector = run {
val (clientId, clientSecret) = context.getString(R.string.tesla_credentials).split(":")
TeslaAvailabilityDetector(
okhttp,
EncryptedPreferenceDataStore(context),
clientId,
clientSecret
)
}
private val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
teslaAvailabilityDetector,
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
)
@@ -209,10 +199,4 @@ class AvailabilityRepository(context: Context) {
}
return value ?: Resource.error(null, null)
}
fun isSupercharger(charger: ChargeLocation) =
teslaAvailabilityDetector.isChargerSupported(charger)
fun isTeslaSupported(charger: ChargeLocation) =
teslaAvailabilityDetector.isChargerSupported(charger) && teslaAvailabilityDetector.isSignedIn()
}

View File

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

View File

@@ -1,6 +1,5 @@
package net.vonforst.evmap.api.availability
import android.net.Uri
import android.util.Base64
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
@@ -35,24 +34,22 @@ interface TeslaAuthenticationApi {
@JsonClass(generateAdapter = true)
class AuthCodeRequest(
val code: String,
@Json(name = "redirect_uri") val redirectUri: String = "https://ev-map.app/void/callback",
scope: String = "openid offline_access vehicle_device_data",
@Json(name = "client_id") clientId: String,
@Json(name = "client_secret") clientSecret: String
) : OAuth2Request(scope, clientId, clientSecret)
@Json(name = "code_verifier") val codeVerifier: String,
@Json(name = "redirect_uri") val redirectUri: String = "https://auth.tesla.com/void/callback",
scope: String = "openid email offline_access",
@Json(name = "client_id") clientId: String = "ownerapi"
) : OAuth2Request(scope, clientId)
@JsonClass(generateAdapter = true)
class RefreshTokenRequest(
@Json(name = "refresh_token") val refreshToken: String,
scope: String = "openid offline_access vehicle_device_data",
@Json(name = "client_id") clientId: String,
@Json(name = "client_secret") clientSecret: String,
) : OAuth2Request(scope, clientId, clientSecret)
scope: String = "openid email offline_access",
@Json(name = "client_id") clientId: String = "ownerapi"
) : OAuth2Request(scope, clientId)
sealed class OAuth2Request(
val scope: String,
val clientId: String,
val clientSecret: String
val clientId: String
)
@JsonClass(generateAdapter = true)
@@ -87,15 +84,24 @@ interface TeslaAuthenticationApi {
return retrofit.create(TeslaAuthenticationApi::class.java)
}
fun buildSignInUri(clientId: String): Uri =
Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", "https://ev-map.app/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid offline_access vehicle_device_data")
.appendQueryParameter("state", "123").build()
fun generateCodeVerifier(): String {
val code = ByteArray(64)
SecureRandom().nextBytes(code)
return Base64.encodeToString(
code,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
}
val resultUrlPrefix = "https://ev-map.app/void/callback"
fun generateCodeChallenge(codeVerifier: String): String {
val bytes = codeVerifier.toByteArray()
val messageDigest = MessageDigest.getInstance("SHA-256")
messageDigest.update(bytes, 0, bytes.size)
return Base64.encodeToString(
messageDigest.digest(),
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
}
}
}
@@ -268,7 +274,7 @@ interface TeslaGraphQlApi {
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation?)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation)
@JsonClass(generateAdapter = true)
data class ChargingSiteInformation(
@@ -426,9 +432,6 @@ interface TeslaGraphQlApi {
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
APPROXIMATELY_20_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_GREATER_THAN_25_MINUTES")
GREATER_THAN_25_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
UNKNOWN
}
@@ -481,8 +484,6 @@ fun Coordinate.asTeslaCoord() =
class TeslaAvailabilityDetector(
private val client: OkHttpClient,
private val tokenStore: TokenStore,
private val clientId: String,
private val clientSecret: String,
private val baseUrl: String? = null
) :
BaseAvailabilityDetector(client) {
@@ -530,7 +531,7 @@ class TeslaAvailabilityDetector(
TeslaGraphQlApi.VehicleMakeType.NON_TESLA
)
)
).data.charging.site ?: throw AvailabilityDetectorException("no candidates found.")
).data.charging.site
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
@@ -627,9 +628,7 @@ class TeslaAvailabilityDetector(
val response =
authApi.getToken(
TeslaAuthenticationApi.RefreshTokenRequest(
refreshToken,
clientId = clientId,
clientSecret = clientSecret
refreshToken
)
)
tokenStore.teslaAccessToken = response.accessToken
@@ -643,6 +642,4 @@ class TeslaAvailabilityDetector(
}
}
fun isSignedIn() = tokenStore.teslaRefreshToken != null
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,9 +41,7 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
page = 0
} else {
val index =
paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
page = index.takeUnless { it == -1 } ?: 0
page = paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
}
invalidate()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,7 +77,6 @@ import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.model.*
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.shouldUseImperialUnits
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.*
@@ -278,7 +277,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
try {
findNavController().safeNavigate(MapFragmentDirections.actionMapToOpensourceDonations())
findNavController().navigate(R.id.action_map_to_opensource_donations)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
} catch (ignored: IllegalStateException) {
@@ -287,7 +286,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
/*if (!prefs.update060AndroidAutoDialogShown) {
try {
navController.safeNavigate(MapFragmentDirections.actionMapToUpdate060AndroidAuto())
navController.navigate(R.id.action_map_to_update_060_androidauto)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
}
@@ -376,9 +375,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val charger = vm.charger.value?.data ?: return@setOnClickListener
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().safeNavigate(
MapFragmentDirections.actionMapToChargepriceFragment(charger),
extras
findNavController().navigate(
R.id.action_map_to_chargepriceFragment,
ChargepriceFragmentArgs(charger).toBundle(),
null, extras
)
}
binding.detailView.btnChargerWebsite.setOnClickListener {
@@ -386,8 +386,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
}
binding.detailView.btnLogin.setOnClickListener {
findNavController().safeNavigate(
MapFragmentDirections.actionMapToDataSettings(true)
findNavController().navigate(
R.id.settings_data,
DataSettingsFragmentArgs(true).toBundle()
)
}
binding.detailView.imgPredictionSource.setOnClickListener {
@@ -1042,7 +1043,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.search.requestFocus()
binding.search.setSelection(locationName.length)
}
if (context.checkAnyLocationPermission() && prefs.currentMapMyLocationEnabled) {
if (context.checkAnyLocationPermission()) {
enableLocation(!positionSet, false)
positionSet = true
}
@@ -1224,29 +1225,26 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
MenuCompat.setGroupDividerEnabled(popup.menu, true)
popup.setForceShowIcon(true)
popup.setOnMenuItemClickListener {
val navController = requireView().findNavController()
when (it.itemId) {
R.id.menu_edit_filters -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
lifecycleScope.launch {
vm.copyFiltersToCustom()
navController.safeNavigate(
MapFragmentDirections.actionMapToFilterFragment()
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
)
}
true
}
R.id.menu_manage_filter_profiles -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
navController.safeNavigate(
MapFragmentDirections.actionMapToFilterProfilesFragment()
requireView().findNavController().navigate(
R.id.action_map_to_filterProfilesFragment
)
true
}
else -> {
val profileId = profilesMap.inverse[it]
if (profileId != null) {
@@ -1402,9 +1400,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
prefs.currentMapLocation = it.bounds.center
prefs.currentMapZoom = it.zoom
}
vm.myLocationEnabled.value?.let {
prefs.currentMapMyLocationEnabled = it
}
}
override fun onDestroy() {

View File

@@ -21,7 +21,6 @@ import androidx.viewpager2.widget.ViewPager2
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.*
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
class OnboardingFragment : Fragment() {
@@ -83,7 +82,7 @@ class OnboardingFragment : Fragment() {
fun goToNext() {
if (binding.viewPager.currentItem == adapter.itemCount - 1) {
findNavController().safeNavigate(OnboardingFragmentDirections.actionOnboardingToMap())
findNavController().navigate(R.id.action_onboarding_to_map)
} else {
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
}

View File

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

View File

@@ -16,7 +16,6 @@ import com.mikepenz.aboutlibraries.LibsBuilder
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -109,11 +108,11 @@ class AboutFragment : PreferenceFragmentCompat() {
"donate" -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToDonateFragment())
findNavController().navigate(R.id.action_about_to_donateFragment)
true
}
"github_sponsors" -> {
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToGithubSponsors())
findNavController().navigate(R.id.action_about_to_github_sponsors)
true
}
"twitter" -> {

View File

@@ -139,49 +139,52 @@ class DataSettingsFragment : BaseSettingsFragment() {
}
private fun teslaLogin() {
val (clientId, _) = getString(R.string.tesla_credentials).split(":")
val uri = TeslaAuthenticationApi.buildSignInUri(clientId = clientId)
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
val uri = Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
.appendQueryParameter("client_id", "ownerapi")
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid email offline_access")
.appendQueryParameter("state", "123").build()
val args = OAuthLoginFragmentArgs(
uri.toString(),
TeslaAuthenticationApi.resultUrlPrefix,
"https://auth.tesla.com/void/callback",
"#000000"
).toBundle()
setFragmentResultListener(uri.toString()) { _, result ->
teslaGetAccessToken(result)
teslaGetAccessToken(result, codeVerifier)
}
findNavController().navigate(R.id.oauth_login, args)
}
private fun teslaGetAccessToken(result: Bundle) {
private fun teslaGetAccessToken(result: Bundle, codeVerifier: String) {
teslaAccountPreference.summary = getString(R.string.logging_in)
val url = Uri.parse(result.getString("url"))
val code = url.getQueryParameter("code") ?: return
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val (clientId, clientSecret) = getString(R.string.tesla_credentials).split(":")
val request = TeslaAuthenticationApi.AuthCodeRequest(
code,
clientId = clientId,
clientSecret = clientSecret
)
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
lifecycleScope.launch {
try {
val time = Instant.now().epochSecond
val response =
TeslaAuthenticationApi.create(okhttp).getToken(request)
// val userResponse =
// TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
val userResponse =
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
encryptedPrefs.teslaEmail = "user@example.com"
encryptedPrefs.teslaEmail = userResponse.response.email
encryptedPrefs.teslaAccessToken = response.accessToken
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
encryptedPrefs.teslaRefreshToken = response.refreshToken
} catch (e: IOException) {
view?.let {
Snackbar.make(it, R.string.generic_connection_error, Snackbar.LENGTH_SHORT)
.show()
Snackbar.make(it, R.string.connection_error, Snackbar.LENGTH_SHORT).show()
}
}
refreshTeslaAccountStatus()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -275,7 +275,6 @@
<string name="about_contributors">Mitwirkende</string>
<string name="about_contributors_text">Dank an alle Mitwirkenden für ihre Beiträge von Code und Übersetzungen für EVMap:</string>
<string name="utilization_prediction">Auslastungsprognose</string>
<string name="powered_by_fronyx">powered by fronyx</string>
<string name="prediction_help">Die Prognose basiert auf Faktoren wie Wochentag, Uhrzeit und Nutzung in der Vergangenheit. So kannst du stark ausgelastete Ladesäulen vermeiden. Keine Garantie.</string>
<string name="prediction_time_colon">%s Uhr:</string>
<plurals name="prediction_number_available">
@@ -349,7 +348,7 @@
<string name="auto_heading">Fahrtrichtung</string>
<string name="auto_settings">Einstellungen</string>
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
<string name="welcome_android_auto_detail">Auf unterstützten Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="sounds_cool">Klingt cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%1$s %2$s).</string>
@@ -366,5 +365,4 @@
<string name="referrals">Empfehlungslinks</string>
<string name="referrals_info">Du kannst auch einen der Empfehlungslinks unten benutzen, um den Entwickler mit deinem Kauf zu unterstützen.</string>
<string name="referral_tesla">Tesla</string>
<string name="generic_connection_error">Daten konnten nicht geladen werden</string>
</resources>

View File

@@ -331,18 +331,4 @@
<string name="loading">Laster inn …</string>
<string name="auto_multipage_goto">Side %d</string>
<string name="auto_multipage">(%d/%d)</string>
<string name="charge_price_minute_format">%2$s%1$.2f/min</string>
<string name="website">Nettside</string>
<string name="pref_tesla_account_enabled">Innlogget som %s</string>
<string name="pref_units">Enheter</string>
<string name="log_out">Logg ut</string>
<string name="logging_in">Logger inn …</string>
<string name="realtime_data_login_needed">Tesla-konto kreves for sanntidsdata</string>
<string name="generic_connection_error">Kunne ikke laste inn data</string>
<string name="logged_out">Utlogget</string>
<string name="pref_tesla_account">Tesla-konto</string>
<string name="tesla_pricing_others">Andre kunder:</string>
<string name="referral_tesla">Tesla</string>
<string name="pref_units_default">Enhetsforvalg</string>
<string name="login">Logg inn</string>
</resources>

View File

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

View File

@@ -275,7 +275,6 @@
<string name="about_contributors">Contributors</string>
<string name="about_contributors_text">Thanks to all contributors for their coding and translation contributions to EVMap:</string>
<string name="utilization_prediction">Utilization prediction</string>
<string name="powered_by_fronyx">powered by fronyx</string>
<string name="prediction_help">The prediction is based on factors like day of the week, time of day and past usage, so that you can avoid overcrowded chargers. No guarantee.</string>
<string name="prediction_time_colon">%s:</string>
<plurals name="prediction_number_available">
@@ -305,7 +304,7 @@
<string name="logging_in">Logging in…</string>
<string name="log_out">Log out</string>
<string name="logged_out">Logged out</string>
<string name="login">Log in</string>
<string name="login">Login</string>
<string name="login_error">Login failed</string>
<string name="tesla_pricing_owners">Tesla vehicles only:</string>
<string name="tesla_pricing_members">Tesla vehicles &amp; members:</string>
@@ -366,5 +365,4 @@
<string name="referrals">Referral links</string>
<string name="referrals_info">You can also use one of the referral links below to support the developer with your purchase.</string>
<string name="referral_tesla">Tesla</string>
<string name="generic_connection_error">Could not load data</string>
</resources>

View File

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

View File

@@ -3,7 +3,7 @@
buildscript {
ext.kotlin_version = '1.9.0'
ext.about_libs_version = '8.9.4'
ext.nav_version = '2.7.2'
ext.nav_version = '2.7.1'
repositories {
google()
mavenCentral()

View File

@@ -149,21 +149,6 @@ in German.
</details>
### **Tesla**
[API documentation](https://developer.tesla.com/docs/fleet-api)
<details>
<summary>How to obtain an API key</summary>
1. [Sign up](https://www.tesla.com/teslaaccount) for a Tesla account
2. In the [Tesla Developer Portal](https://developer.tesla.com/), click on "Request app access"
3. Enter the details of your app
4. You will receive a *Client ID* and *Client Secret*. Enter them both into `tesla_credentials`,
separated by a colon (`:`).
</details>
Pricing providers
-----------------

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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