mirror of
https://github.com/ev-map/EVMap.git
synced 2026-01-28 16:51:30 -05:00
@@ -167,6 +167,7 @@ dependencies {
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.browser:browser:1.5.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
implementation 'com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
|
||||
|
||||
@@ -23,8 +23,8 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.getAvailability
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.iconForPlugType
|
||||
@@ -57,6 +57,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||
|
||||
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
|
||||
@@ -465,7 +466,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
|
||||
invalidate()
|
||||
|
||||
availability = getAvailability(charger).data
|
||||
availability = availabilityRepo.getAvailability(charger).data
|
||||
|
||||
invalidate()
|
||||
} else {
|
||||
|
||||
@@ -24,8 +24,8 @@ import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.*
|
||||
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.availability.getAvailability
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
@@ -79,6 +79,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
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 val distanceUpdateThreshold = Duration.ofSeconds(15)
|
||||
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
|
||||
@@ -606,7 +607,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
// update only if not yet stored
|
||||
if (!availabilities.containsKey(it.id)) {
|
||||
lifecycleScope.async {
|
||||
val availability = getAvailability(it).data
|
||||
val availability = availabilityRepo.getAvailability(it).data
|
||||
val date = ZonedDateTime.now()
|
||||
availabilities[it.id] = date to availability
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.load
|
||||
import coil.memory.MemoryCache
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.model.ChargerPhoto
|
||||
|
||||
@@ -70,6 +71,7 @@ class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener?
|
||||
memoryKeys[item.id] = metadata.memoryCacheKey
|
||||
}
|
||||
)
|
||||
allowHardware(!BuildConfig.DEBUG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.api.RateLimitInterceptor
|
||||
@@ -9,6 +10,8 @@ import net.vonforst.evmap.cartesianProduct
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -158,38 +161,41 @@ private val cookieManager = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
|
||||
private val okhttp = OkHttpClient.Builder()
|
||||
.addInterceptor(RateLimitInterceptor())
|
||||
.addDebugInterceptors()
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||
.build()
|
||||
val availabilityDetectors = listOf(
|
||||
RheinenergieAvailabilityDetector(okhttp),
|
||||
EnBwAvailabilityDetector(okhttp),
|
||||
NewMotionAvailabilityDetector(okhttp)
|
||||
)
|
||||
class AvailabilityRepository(context: Context) {
|
||||
private val okhttp = OkHttpClient.Builder()
|
||||
.addInterceptor(RateLimitInterceptor())
|
||||
.addDebugInterceptors()
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||
.build()
|
||||
private val availabilityDetectors = listOf(
|
||||
RheinenergieAvailabilityDetector(okhttp),
|
||||
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
|
||||
EnBwAvailabilityDetector(okhttp),
|
||||
NewMotionAvailabilityDetector(okhttp)
|
||||
)
|
||||
|
||||
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
|
||||
var value: Resource<ChargeLocationStatus>? = null
|
||||
withContext(Dispatchers.IO) {
|
||||
for (ad in availabilityDetectors) {
|
||||
if (!ad.isChargerSupported(charger)) continue
|
||||
try {
|
||||
value = Resource.success(ad.getAvailability(charger))
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
} catch (e: HttpException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
} catch (e: AvailabilityDetectorException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
|
||||
var value: Resource<ChargeLocationStatus>? = null
|
||||
withContext(Dispatchers.IO) {
|
||||
for (ad in availabilityDetectors) {
|
||||
if (!ad.isChargerSupported(charger)) continue
|
||||
try {
|
||||
value = Resource.success(ad.getAvailability(charger))
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
} catch (e: HttpException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
} catch (e: AvailabilityDetectorException) {
|
||||
value = Resource.error(e.message, null)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return value ?: Resource.error(null, null)
|
||||
}
|
||||
return value ?: Resource.error(null, null)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import android.util.Base64
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.model.Coordinate
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.Instant
|
||||
|
||||
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
|
||||
|
||||
interface TeslaAuthenticationApi {
|
||||
@POST("oauth2/v3/token")
|
||||
suspend fun getToken(@Body request: OAuth2Request): OAuth2Response
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
class AuthCodeRequest(
|
||||
val code: String,
|
||||
@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 email offline_access",
|
||||
@Json(name = "client_id") clientId: String = "ownerapi"
|
||||
) : OAuth2Request(scope, clientId)
|
||||
|
||||
sealed class OAuth2Request(
|
||||
val scope: String,
|
||||
val clientId: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OAuth2Response(
|
||||
@Json(name = "access_token") val accessToken: String,
|
||||
@Json(name = "token_type") val tokenType: String,
|
||||
@Json(name = "expires_in") val expiresIn: Long,
|
||||
@Json(name = "refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val charSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
fun create(client: OkHttpClient, baseUrl: String? = null): TeslaAuthenticationApi {
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://auth.tesla.com")
|
||||
.addConverterFactory(
|
||||
MoshiConverterFactory.create(
|
||||
Moshi.Builder()
|
||||
.add(
|
||||
PolymorphicJsonAdapterFactory.of(
|
||||
OAuth2Request::class.java,
|
||||
"grant_type"
|
||||
)
|
||||
.withSubtype(AuthCodeRequest::class.java, "authorization_code")
|
||||
.withSubtype(RefreshTokenRequest::class.java, "refresh_token")
|
||||
.withDefaultValue(null)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.client(client)
|
||||
.build()
|
||||
return retrofit.create(TeslaAuthenticationApi::class.java)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TeslaOwnerApi {
|
||||
@GET("/api/1/users/me")
|
||||
suspend fun getUserInfo(): UserInfoResponse
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserInfoResponse(
|
||||
val response: UserInfo
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserInfo(
|
||||
val email: String,
|
||||
@Json(name = "full_name") val fullName: String,
|
||||
@Json(name = "profile_image_url") val profileImageUrl: String
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, token: String, baseUrl: String? = null): TeslaOwnerApi {
|
||||
val clientWithInterceptor = client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
// add API key to every request
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("User-Agent", "okhttp/4.9.2")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://owner-api.teslamotors.com")
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
.client(clientWithInterceptor)
|
||||
.build()
|
||||
return retrofit.create(TeslaOwnerApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TeslaGraphQlApi {
|
||||
@POST("/graphql")
|
||||
suspend fun getNearbyChargingSites(
|
||||
@Body request: GetNearbyChargingSitesRequest,
|
||||
@Query("operationName") operationName: String = "GetNearbyChargingSites",
|
||||
@Query("deviceLanguage") deviceLanguage: String = "en",
|
||||
@Query("deviceCountry") deviceCountry: String = "US",
|
||||
@Query("ttpLocale") ttpLocale: String = "en_US",
|
||||
@Query("vin") vin: String = "",
|
||||
): GetNearbyChargingSitesResponse
|
||||
|
||||
@POST("/graphql")
|
||||
suspend fun getChargingSiteInformation(
|
||||
@Body request: GetChargingSiteInformationRequest,
|
||||
@Query("operationName") operationName: String = "getChargingSiteInformation",
|
||||
@Query("deviceLanguage") deviceLanguage: String = "en",
|
||||
@Query("deviceCountry") deviceCountry: String = "US",
|
||||
@Query("ttpLocale") ttpLocale: String = "en_US",
|
||||
@Query("vin") vin: String = "",
|
||||
): GetChargingSiteInformationResponse
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesRequest(
|
||||
override val variables: GetNearbyChargingSitesVariables,
|
||||
override val operationName: String = "GetNearbyChargingSites",
|
||||
override val query: String =
|
||||
"\n query GetNearbyChargingSites(\$args: GetNearbyChargingSitesRequestType!) {\n charging {\n nearbySites(args: \$args) {\n sitesAndDistances {\n ...ChargingNearbySitesFragment\n }\n }\n }\n}\n \n fragment ChargingNearbySitesFragment on ChargerSiteAndDistanceType {\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n availableStalls {\n value\n }\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n drivingDistanceMiles {\n value\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n haversineDistanceMiles {\n value\n }\n id {\n text\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n trtId {\n value\n }\n totalStalls {\n value\n }\n siteType\n accessType\n waitEstimateBucket\n hasHighCongestion\n}\n \n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n "
|
||||
) : GraphQlRequest()
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesVariables(val args: GetNearbyChargingSitesArgs)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesArgs(
|
||||
val userLocation: Coordinate,
|
||||
val northwestCorner: Coordinate,
|
||||
val southeastCorner: Coordinate,
|
||||
val openToNonTeslasFilter: OpenToNonTeslasFilterValue,
|
||||
val languageCode: String = "en",
|
||||
val countryCode: String = "US",
|
||||
//val vin: String = "",
|
||||
//val maxCount: Int = 100
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OpenToNonTeslasFilterValue(val value: Boolean)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Coordinate(val latitude: Double, val longitude: Double)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationRequest(
|
||||
override val variables: GetChargingSiteInformationVariables,
|
||||
override val operationName: String = "getChargingSiteInformation",
|
||||
override val query: String =
|
||||
"\n query getChargingSiteInformation(\$id: ChargingSiteIdentifierInputType!, \$vehicleMakeType: ChargingVehicleMakeTypeEnum, \$deviceCountry: String!, \$deviceLanguage: String!) {\n charging {\n site(\n id: \$id\n deviceCountry: \$deviceCountry\n deviceLanguage: \$deviceLanguage\n vehicleMakeType: \$vehicleMakeType\n ) {\n siteStatic {\n ...SiteStaticFragmentNoHoldAmt\n }\n siteDynamic {\n ...SiteDynamicFragment\n }\n pricing(vehicleMakeType: \$vehicleMakeType) {\n userRates {\n ...ChargingActiveRateFragment\n }\n memberRates {\n ...ChargingActiveRateFragment\n }\n hasMembershipPricing\n hasMSPPricing\n canDisplayCombinedComparison\n }\n holdAmount(vehicleMakeType: \$vehicleMakeType) {\n currencyCode\n holdAmount\n }\n congestionPriceHistogram(vehicleMakeType: \$vehicleMakeType) {\n ...CongestionPriceHistogramFragment\n }\n }\n }\n}\n \n fragment SiteStaticFragmentNoHoldAmt on ChargingSiteStaticType {\n address {\n ...AddressFragment\n }\n amenities\n centroid {\n ...EnergySvcCoordinateTypeFields\n }\n entryPoint {\n ...EnergySvcCoordinateTypeFields\n }\n id {\n text\n }\n accessCode {\n value\n }\n localizedSiteName {\n value\n }\n maxPowerKw {\n value\n }\n name\n openToPublic\n chargers {\n id {\n text\n }\n label {\n value\n }\n }\n publicStallCount\n timeZone {\n id\n version\n }\n fastchargeSiteId {\n value\n }\n siteType\n accessType\n isMagicDockSupportedSite\n}\n \n fragment AddressFragment on EnergySvcAddressType {\n streetNumber {\n value\n }\n street {\n value\n }\n district {\n value\n }\n city {\n value\n }\n state {\n value\n }\n postalCode {\n value\n }\n country\n}\n \n\n fragment EnergySvcCoordinateTypeFields on EnergySvcCoordinateType {\n latitude\n longitude\n}\n \n\n fragment SiteDynamicFragment on ChargingSiteDynamicType {\n id {\n text\n }\n activeOutages {\n message\n nonTeslasAffectedOnly {\n value\n }\n }\n chargersAvailable {\n value\n }\n chargerDetails {\n charger {\n id {\n text\n }\n label {\n value\n }\n name\n }\n availability\n }\n waitEstimateBucket\n currentCongestion\n}\n \n\n fragment ChargingActiveRateFragment on ChargingActiveRateType {\n activePricebook {\n charging {\n ...ChargingUserRateFragment\n }\n parking {\n ...ChargingUserRateFragment\n }\n priceBookID\n }\n}\n \n fragment ChargingUserRateFragment on ChargingUserRateType {\n currencyCode\n programType\n rates\n buckets {\n start\n end\n }\n bucketUom\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n uom\n vehicleMakeType\n}\n \n\n fragment CongestionPriceHistogramFragment on HistogramData {\n axisLabels {\n index\n value\n }\n regionLabels {\n index\n value {\n ...ChargingPriceFragment\n ... on HistogramRegionLabelValueString {\n value\n }\n }\n }\n chargingUom\n parkingUom\n parkingRate {\n ...ChargingPriceFragment\n }\n data\n activeBar\n maxRateIndex\n whenRateChanges\n}\n \n fragment ChargingPriceFragment on ChargingPrice {\n currencyCode\n price\n}\n"
|
||||
) : GraphQlRequest()
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationVariables(
|
||||
val id: ChargingSiteIdentifier,
|
||||
val vehicleMakeType: VehicleMakeType,
|
||||
val deviceLanguage: String = "en",
|
||||
val deviceCountry: String = "US",
|
||||
val ttpLocale: String = "en_US"
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteIdentifier(
|
||||
val id: String,
|
||||
val type: ChargingSiteIdentifierType = ChargingSiteIdentifierType.SITE_ID
|
||||
)
|
||||
|
||||
enum class ChargingSiteIdentifierType {
|
||||
SITE_ID
|
||||
}
|
||||
|
||||
enum class VehicleMakeType {
|
||||
TESLA, NON_TESLA
|
||||
}
|
||||
|
||||
sealed class GraphQlRequest {
|
||||
abstract val operationName: String
|
||||
abstract val query: String
|
||||
abstract val variables: Any?
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesResponse(val data: GetNearbyChargingSitesResponseData)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesResponseData(val charging: GetNearbyChargingSitesResponseDataCharging)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesResponseDataCharging(val nearbySites: GetNearbyChargingSitesResponseDataChargingNearbySites)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetNearbyChargingSitesResponseDataChargingNearbySites(val sitesAndDistances: List<ChargingSite>)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSite(
|
||||
val activeOutages: List<Outage>,
|
||||
val availableStalls: Value<Int>?,
|
||||
val centroid: Coordinate,
|
||||
val drivingDistanceMiles: Value<Double>?,
|
||||
val entryPoint: Coordinate,
|
||||
val haversineDistanceMiles: Value<Double>,
|
||||
val id: Text,
|
||||
val localizedSiteName: Value<String>,
|
||||
val maxPowerKw: Value<Int>,
|
||||
val totalStalls: Value<Int>
|
||||
// TODO: siteType, accessType
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Outage(val message: String /* TODO: */)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Value<T : Any>(val value: T)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Text(val text: String)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationResponse(val data: GetChargingSiteInformationResponseData)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteInformation(
|
||||
// TODO: congestionPriceHistogram, pricing
|
||||
val siteDynamic: SiteDynamic,
|
||||
val siteStatic: SiteStatic
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SiteDynamic(
|
||||
val activeOutages: List<Outage>,
|
||||
val chargerDetails: List<ChargerDetail>,
|
||||
val chargersAvailable: Value<Int>?,
|
||||
val currentCongestion: Double,
|
||||
val id: Text,
|
||||
val waitEstimateBucket: WaitEstimateBucket
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargerDetail(
|
||||
val availability: ChargerAvailability,
|
||||
val charger: ChargerId
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargerId(
|
||||
val id: Text,
|
||||
val label: Value<String>,
|
||||
val name: String?
|
||||
) {
|
||||
val labelNumber
|
||||
get() = label.value.replace(Regex("""\D"""), "").toInt()
|
||||
val labelLetter
|
||||
get() = label.value.replace(Regex("""\d"""), "")
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SiteStatic(
|
||||
val accessCode: Value<String>?,
|
||||
val centroid: Coordinate,
|
||||
val chargers: List<ChargerId>,
|
||||
val entryPoint: Coordinate,
|
||||
val fastchargeSiteId: Value<Long>,
|
||||
val id: Text,
|
||||
val isMagicDockSupportedSite: Boolean,
|
||||
val localizedSiteName: Value<String>,
|
||||
val maxPowerKw: Value<Int>,
|
||||
val name: String,
|
||||
val openToPublic: Boolean,
|
||||
val publicStallCount: Int
|
||||
// TODO: siteType, accessType, address, amenities, timeZone
|
||||
)
|
||||
|
||||
enum class ChargerAvailability {
|
||||
@Json(name = "CHARGER_AVAILABILITY_AVAILABLE")
|
||||
AVAILABLE,
|
||||
|
||||
@Json(name = "CHARGER_AVAILABILITY_OCCUPIED")
|
||||
OCCUPIED,
|
||||
|
||||
@Json(name = "CHARGER_AVAILABILITY_DOWN")
|
||||
DOWN,
|
||||
@Json(name = "CHARGER_AVAILABILITY_UNKNOWN")
|
||||
UNKNOWN;
|
||||
|
||||
fun toStatus() = when (this) {
|
||||
AVAILABLE -> ChargepointStatus.AVAILABLE
|
||||
OCCUPIED -> ChargepointStatus.OCCUPIED
|
||||
DOWN -> ChargepointStatus.FAULTED
|
||||
UNKNOWN -> ChargepointStatus.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
enum class WaitEstimateBucket {
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_NO_WAIT")
|
||||
NO_WAIT,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_LESS_THAN_5_MINUTES")
|
||||
LESS_THAN_5_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_5_MINUTES")
|
||||
APPROXIMATELY_5_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_10_MINUTES")
|
||||
APPROXIMATELY_10_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_15_MINUTES")
|
||||
APPROXIMATELY_15_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
|
||||
APPROXIMATELY_20_MINUTES,
|
||||
|
||||
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, token: String, baseUrl: String? = null): TeslaGraphQlApi {
|
||||
val clientWithInterceptor = client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
// add API key to every request
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("User-Agent", "okhttp/4.9.2")
|
||||
.header("x-tesla-user-agent", "TeslaApp/4.19.5-1667/3a5d531cc3/android/27")
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl ?: "https://akamai-apigateway-charging-ownership.tesla.com")
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
.client(clientWithInterceptor)
|
||||
.build()
|
||||
return retrofit.create(TeslaGraphQlApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Coordinate.asTeslaCoord() =
|
||||
TeslaGraphQlApi.Coordinate(this.lat, this.lng)
|
||||
|
||||
class TeslaAvailabilityDetector(
|
||||
private val client: OkHttpClient,
|
||||
private val tokenStore: TokenStore,
|
||||
private val baseUrl: String? = null
|
||||
) :
|
||||
BaseAvailabilityDetector(client) {
|
||||
|
||||
private val authApi = TeslaAuthenticationApi.create(client, null)
|
||||
private var api: TeslaGraphQlApi? = null
|
||||
|
||||
interface TokenStore {
|
||||
var teslaRefreshToken: String?
|
||||
var teslaAccessToken: String?
|
||||
var teslaAccessTokenExpiry: Long
|
||||
}
|
||||
|
||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||
val api = initApi()
|
||||
val req = TeslaGraphQlApi.GetNearbyChargingSitesRequest(
|
||||
TeslaGraphQlApi.GetNearbyChargingSitesVariables(
|
||||
TeslaGraphQlApi.GetNearbyChargingSitesArgs(
|
||||
location.coordinates.asTeslaCoord(),
|
||||
TeslaGraphQlApi.Coordinate(
|
||||
location.coordinates.lat + coordRange,
|
||||
location.coordinates.lng - coordRange
|
||||
),
|
||||
TeslaGraphQlApi.Coordinate(
|
||||
location.coordinates.lat - coordRange,
|
||||
location.coordinates.lng + coordRange
|
||||
),
|
||||
TeslaGraphQlApi.OpenToNonTeslasFilterValue(false)
|
||||
)
|
||||
)
|
||||
)
|
||||
val results = api.getNearbyChargingSites(
|
||||
req,
|
||||
req.operationName
|
||||
).data.charging.nearbySites.sitesAndDistances
|
||||
val result =
|
||||
results.minByOrNull { it.haversineDistanceMiles.value }
|
||||
?: throw AvailabilityDetectorException("no candidates found.")
|
||||
|
||||
val details = api.getChargingSiteInformation(
|
||||
TeslaGraphQlApi.GetChargingSiteInformationRequest(
|
||||
TeslaGraphQlApi.GetChargingSiteInformationVariables(
|
||||
TeslaGraphQlApi.ChargingSiteIdentifier(result.id.text),
|
||||
TeslaGraphQlApi.VehicleMakeType.TESLA
|
||||
)
|
||||
)
|
||||
).data.charging.site
|
||||
|
||||
|
||||
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
|
||||
val scV2CCSConnectors = location.chargepoints.filter {
|
||||
it.type in listOf(
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CCS_UNKNOWN
|
||||
) && it.power != null && it.power <= 150
|
||||
}
|
||||
val scV3Connectors = location.chargepoints.filter {
|
||||
it.type in listOf(
|
||||
Chargepoint.CCS_TYPE_2,
|
||||
Chargepoint.CCS_UNKNOWN
|
||||
) && it.power != null && it.power > 150
|
||||
}
|
||||
if (location.totalChargepoints != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } + scV2CCSConnectors.sumOf { it.count }) throw AvailabilityDetectorException(
|
||||
"charger has unknown connectors"
|
||||
)
|
||||
|
||||
val statusSorted = details.siteDynamic.chargerDetails.sortedBy { it.charger.labelLetter }
|
||||
.sortedBy { it.charger.labelNumber }
|
||||
|
||||
val statusMap = emptyMap<Chargepoint, List<ChargepointStatus>>().toMutableMap()
|
||||
var i = 0;
|
||||
for (connector in scV2Connectors) {
|
||||
statusMap[connector] =
|
||||
statusSorted.subList(i, i + connector.count).map { it.availability.toStatus() }
|
||||
i += connector.count
|
||||
}
|
||||
if (scV2CCSConnectors.isNotEmpty()) {
|
||||
i = 0;
|
||||
for (connector in scV2CCSConnectors) {
|
||||
statusMap[connector] =
|
||||
statusSorted.subList(i, i + connector.count).map { it.availability.toStatus() }
|
||||
i += connector.count
|
||||
}
|
||||
}
|
||||
for (connector in scV3Connectors) {
|
||||
statusMap[connector] =
|
||||
statusSorted.subList(i, i + connector.count).map { it.availability.toStatus() }
|
||||
i += connector.count
|
||||
}
|
||||
return ChargeLocationStatus(statusMap, "Tesla")
|
||||
}
|
||||
|
||||
override fun isChargerSupported(charger: ChargeLocation): Boolean {
|
||||
return when (charger.dataSource) {
|
||||
"goingelectric" -> charger.network == "Tesla Supercharger"
|
||||
"openchargemap" -> charger.chargepriceData?.network in listOf("23", "3534")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun initApi(): TeslaGraphQlApi {
|
||||
return api ?: run {
|
||||
val now = Instant.now().epochSecond
|
||||
val token =
|
||||
tokenStore.teslaAccessToken.takeIf { tokenStore.teslaAccessTokenExpiry > now }
|
||||
?: run {
|
||||
val refreshToken = tokenStore.teslaRefreshToken
|
||||
?: throw AvailabilityDetectorException("not signed in")
|
||||
val response =
|
||||
authApi.getToken(TeslaAuthenticationApi.RefreshTokenRequest(refreshToken))
|
||||
tokenStore.teslaAccessToken = response.accessToken
|
||||
tokenStore.teslaAccessTokenExpiry = now + response.expiresIn
|
||||
response.accessToken
|
||||
}
|
||||
val newApi = TeslaGraphQlApi.create(client, token, baseUrl)
|
||||
api = newApi
|
||||
newApi
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -211,6 +211,7 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) {
|
||||
"Typ1" -> Chargepoint.TYPE_1
|
||||
"Typ2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
"Typ3" -> Chargepoint.TYPE_3
|
||||
"Tesla Supercharger CCS" -> Chargepoint.CCS_UNKNOWN
|
||||
"CCS" -> Chargepoint.CCS_UNKNOWN
|
||||
"Schuko" -> Chargepoint.SCHUKO
|
||||
"CHAdeMO" -> Chargepoint.CHADEMO
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package net.vonforst.evmap.fragment.oauth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
class OAuthLoginFragment : Fragment() {
|
||||
private lateinit var webView: WebView
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View = inflater.inflate(R.layout.fragment_oauth_login, container, false)
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||
toolbar.setupWithNavController(
|
||||
findNavController(),
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
|
||||
val uri = Uri.parse(args.url)
|
||||
|
||||
webView = view.findViewById(R.id.webView)
|
||||
CookieManager.getInstance().removeAllCookies(null)
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): Boolean {
|
||||
val url = request.url
|
||||
|
||||
if (url.toString().startsWith(args.resultUrlPrefix)) {
|
||||
val result = Bundle()
|
||||
result.putString("url", url.toString())
|
||||
setFragmentResult(args.url, result)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
return url.host != uri.host
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
}
|
||||
}
|
||||
webView.settings.javaScriptEnabled = true
|
||||
webView.loadUrl(args.url)
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,18 @@ import com.google.android.material.transition.MaterialFadeThrough
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
|
||||
abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
protected lateinit var prefs: PreferenceDataSource
|
||||
protected lateinit var encryptedPrefs: EncryptedPreferenceDataStore
|
||||
protected abstract val isTopLevel: Boolean
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
prefs = PreferenceDataSource(requireContext())
|
||||
encryptedPrefs = EncryptedPreferenceDataStore(requireContext())
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (isTopLevel) {
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
package net.vonforst.evmap.fragment.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
|
||||
import net.vonforst.evmap.api.availability.TeslaOwnerApi
|
||||
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
|
||||
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.IOException
|
||||
import java.time.Instant
|
||||
|
||||
class DataSettingsFragment : BaseSettingsFragment() {
|
||||
override val isTopLevel = false
|
||||
@@ -23,8 +37,25 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
}
|
||||
})
|
||||
|
||||
private lateinit var teslaAccountPreference: Preference
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings_data, rootKey)
|
||||
teslaAccountPreference = findPreference<Preference>("tesla_account")!!
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshTeslaAccountStatus()
|
||||
}
|
||||
|
||||
private fun refreshTeslaAccountStatus() {
|
||||
teslaAccountPreference.summary =
|
||||
if (encryptedPrefs.teslaRefreshToken != null) {
|
||||
getString(R.string.pref_tesla_account_enabled, encryptedPrefs.teslaEmail)
|
||||
} else {
|
||||
getString(R.string.pref_tesla_account_disabled)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
@@ -60,7 +91,85 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
vm.deleteRecentSearchResults()
|
||||
true
|
||||
}
|
||||
|
||||
"tesla_account" -> {
|
||||
if (encryptedPrefs.teslaRefreshToken != null) {
|
||||
teslaLogout()
|
||||
} else {
|
||||
teslaLogin()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun teslaLogin() {
|
||||
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(),
|
||||
"https://auth.tesla.com/void/callback"
|
||||
).toBundle()
|
||||
|
||||
setFragmentResultListener(uri.toString()) { _, result ->
|
||||
teslaGetAccessToken(result, codeVerifier)
|
||||
}
|
||||
|
||||
findNavController().navigate(R.id.oauth_login, args)
|
||||
}
|
||||
|
||||
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")!!
|
||||
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
|
||||
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()
|
||||
|
||||
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.connection_error, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
refreshTeslaAccountStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun teslaLogout() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(getString(R.string.pref_tesla_account_enabled, encryptedPrefs.teslaEmail))
|
||||
.setPositiveButton(R.string.ok) { _, _ -> }
|
||||
.setNegativeButton(R.string.log_out) { _, _ ->
|
||||
// sign out
|
||||
encryptedPrefs.teslaRefreshToken = null
|
||||
encryptedPrefs.teslaAccessToken = null
|
||||
encryptedPrefs.teslaAccessTokenExpiry = -1
|
||||
encryptedPrefs.teslaEmail = null
|
||||
view?.let { Snackbar.make(it, R.string.logged_out, Snackbar.LENGTH_SHORT).show() }
|
||||
refreshTeslaAccountStatus()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import net.vonforst.evmap.api.availability.TeslaAvailabilityDetector
|
||||
|
||||
/**
|
||||
* Encrypted data storage for sensitive data such as API access tokens.
|
||||
* This will not be included in backups.
|
||||
*/
|
||||
class EncryptedPreferenceDataStore(context: Context) : TeslaAvailabilityDetector.TokenStore {
|
||||
val sp = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"encrypted_prefs",
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
override var teslaRefreshToken: String?
|
||||
get() = sp.getString(
|
||||
"tesla_refresh_token", null
|
||||
)
|
||||
set(value) {
|
||||
sp.edit().putString("tesla_refresh_token", value).apply()
|
||||
}
|
||||
override var teslaAccessToken: String?
|
||||
get() = sp.getString("tesla_access_token", null)
|
||||
set(value) {
|
||||
sp.edit().putString("tesla_access_token", value).apply()
|
||||
}
|
||||
override var teslaAccessTokenExpiry: Long
|
||||
get() = sp.getLong("tesla_access_token_expiry", -1)
|
||||
set(value) {
|
||||
sp.edit().putLong("tesla_access_token_expiry", value).apply()
|
||||
}
|
||||
|
||||
var teslaEmail: String?
|
||||
get() = sp.getString("tesla_email", null)
|
||||
set(value) {
|
||||
sp.edit().putString("tesla_email", value).apply()
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||
import net.vonforst.evmap.api.availability.getAvailability
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Favorite
|
||||
import net.vonforst.evmap.model.FavoriteWithDetail
|
||||
@@ -19,6 +19,7 @@ import net.vonforst.evmap.utils.distanceBetween
|
||||
class FavoritesViewModel(application: Application) :
|
||||
AndroidViewModel(application) {
|
||||
private var db = AppDatabase.getInstance(application)
|
||||
private val availabilityRepo = AvailabilityRepository(application)
|
||||
|
||||
val favorites: LiveData<List<FavoriteWithDetail>> by lazy {
|
||||
db.favoritesDao().getAllFavorites()
|
||||
@@ -53,7 +54,7 @@ class FavoritesViewModel(application: Application) :
|
||||
|
||||
chargers.map { charger ->
|
||||
async {
|
||||
data[charger.id] = getAvailability(charger)
|
||||
data[charger.id] = availabilityRepo.getAvailability(charger)
|
||||
availability.value = data
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
@@ -14,8 +14,8 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
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.availability.getAvailability
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.api.fronyx.FronyxApi
|
||||
@@ -59,6 +59,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
db,
|
||||
prefs
|
||||
)
|
||||
private val availabilityRepo = AvailabilityRepository(application)
|
||||
|
||||
val apiId = repo.api.map { it.id }
|
||||
|
||||
@@ -202,7 +203,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
triggerAvailabilityRefresh.switchMap {
|
||||
liveData {
|
||||
emit(Resource.loading(null))
|
||||
emit(getAvailability(charger))
|
||||
emit(availabilityRepo.getAvailability(charger))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
app/src/main/res/layout/fragment_oauth_login.xml
Normal file
26
app/src/main/res/layout/fragment_oauth_login.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/toolbar_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:actionBarSize" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webView"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_width="match_parent" />
|
||||
</LinearLayout>
|
||||
@@ -169,4 +169,15 @@
|
||||
app:popUpTo="@id/onboarding"
|
||||
app:popUpToInclusive="true" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/oauth_login"
|
||||
android:name="net.vonforst.evmap.fragment.oauth.OAuthLoginFragment"
|
||||
android:label="@string/login">
|
||||
<argument
|
||||
android:name="url"
|
||||
app:argType="string" />
|
||||
<argument
|
||||
android:name="resultUrlPrefix"
|
||||
app:argType="string" />
|
||||
</fragment>
|
||||
</navigation>
|
||||
@@ -296,4 +296,12 @@
|
||||
<string name="compass">Kompass</string>
|
||||
<string name="charger_website">Website</string>
|
||||
<string name="location_status">Standortdienste-Status</string>
|
||||
<string name="pref_tesla_account">Tesla-Account</string>
|
||||
<string name="pref_tesla_account_enabled">Angemeldet als %s</string>
|
||||
<string name="pref_tesla_account_disabled">Anmelden, um Echtzeitdaten für Tesla Supercharger zu sehen. Kein Tesla-Fahrzeug notwendig</string>
|
||||
<string name="logging_in">Anmelden…</string>
|
||||
<string name="log_out">Abmelden</string>
|
||||
<string name="logged_out">Abgemeldet</string>
|
||||
<string name="login">Login</string>
|
||||
<string name="login_error">Login fehlgeschlagen</string>
|
||||
</resources>
|
||||
@@ -296,4 +296,12 @@
|
||||
<string name="compass">Compass</string>
|
||||
<string name="charger_website">Website</string>
|
||||
<string name="location_status">Location provider status</string>
|
||||
<string name="pref_tesla_account">Tesla account</string>
|
||||
<string name="pref_tesla_account_enabled">Logged in as %s</string>
|
||||
<string name="pref_tesla_account_disabled">Log in to see real-time data for Tesla Superchargers. No Tesla vehicle necessary</string>
|
||||
<string name="logging_in">Logging in…</string>
|
||||
<string name="log_out">Log out</string>
|
||||
<string name="logged_out">Logged out</string>
|
||||
<string name="login">Login</string>
|
||||
<string name="login_error">Login failed</string>
|
||||
</resources>
|
||||
@@ -15,6 +15,10 @@
|
||||
android:title="@string/pref_prediction_enabled"
|
||||
android:defaultValue="true"
|
||||
android:summary="@string/pref_prediction_enabled_summary" />
|
||||
|
||||
<Preference
|
||||
android:key="tesla_account"
|
||||
android:title="@string/pref_tesla_account" />
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory android:title="@string/settings_map">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user