diff --git a/app/build.gradle b/app/build.gradle index afa5396e..a59870f6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt index dd4eb521..eb89be9d 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt @@ -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 { diff --git a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt index 4403339c..195529b0 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt @@ -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 } diff --git a/app/src/main/java/net/vonforst/evmap/adapter/GalleryAdapter.kt b/app/src/main/java/net/vonforst/evmap/adapter/GalleryAdapter.kt index 175e3f2c..93b92cde 100644 --- a/app/src/main/java/net/vonforst/evmap/adapter/GalleryAdapter.kt +++ b/app/src/main/java/net/vonforst/evmap/adapter/GalleryAdapter.kt @@ -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) } } } diff --git a/app/src/main/java/net/vonforst/evmap/api/availability/AvailabilityDetector.kt b/app/src/main/java/net/vonforst/evmap/api/availability/AvailabilityDetector.kt index e3463439..33adcd91 100644 --- a/app/src/main/java/net/vonforst/evmap/api/availability/AvailabilityDetector.kt +++ b/app/src/main/java/net/vonforst/evmap/api/availability/AvailabilityDetector.kt @@ -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 { - var value: Resource? = 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 { + var value: Resource? = 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) } diff --git a/app/src/main/java/net/vonforst/evmap/api/availability/TeslaAvailabilityDetector.kt b/app/src/main/java/net/vonforst/evmap/api/availability/TeslaAvailabilityDetector.kt new file mode 100644 index 00000000..7e3de840 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/api/availability/TeslaAvailabilityDetector.kt @@ -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) + + @JsonClass(generateAdapter = true) + data class ChargingSite( + val activeOutages: List, + val availableStalls: Value?, + val centroid: Coordinate, + val drivingDistanceMiles: Value?, + val entryPoint: Coordinate, + val haversineDistanceMiles: Value, + val id: Text, + val localizedSiteName: Value, + val maxPowerKw: Value, + val totalStalls: Value + // TODO: siteType, accessType + ) + + @JsonClass(generateAdapter = true) + data class Outage(val message: String /* TODO: */) + + @JsonClass(generateAdapter = true) + data class Value(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, + val chargerDetails: List, + val chargersAvailable: Value?, + 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, + 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?, + val centroid: Coordinate, + val chargers: List, + val entryPoint: Coordinate, + val fastchargeSiteId: Value, + val id: Text, + val isMagicDockSupportedSite: Boolean, + val localizedSiteName: Value, + val maxPowerKw: Value, + 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>().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 + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricModel.kt b/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricModel.kt index cd7f7328..71284198 100644 --- a/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricModel.kt +++ b/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricModel.kt @@ -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 diff --git a/app/src/main/java/net/vonforst/evmap/fragment/oauth/OAuthLoginFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/oauth/OAuthLoginFragment.kt new file mode 100644 index 00000000..e4d9aefe --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/fragment/oauth/OAuthLoginFragment.kt @@ -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(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) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/fragment/preference/BaseSettingsFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/preference/BaseSettingsFragment.kt index c847368c..34ab4916 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/preference/BaseSettingsFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/preference/BaseSettingsFragment.kt @@ -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) { diff --git a/app/src/main/java/net/vonforst/evmap/fragment/preference/DataSettingsFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/preference/DataSettingsFragment.kt index 71615b52..53433f67 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/preference/DataSettingsFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/preference/DataSettingsFragment.kt @@ -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("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() + } } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/EncryptedPreferenceDataStore.kt b/app/src/main/java/net/vonforst/evmap/storage/EncryptedPreferenceDataStore.kt new file mode 100644 index 00000000..3e5f8b45 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/storage/EncryptedPreferenceDataStore.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/FavoritesViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/FavoritesViewModel.kt index 7002a8c5..09f00f08 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/FavoritesViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/FavoritesViewModel.kt @@ -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> 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() diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt index 56dcaee8..032a0149 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt @@ -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)) } } } diff --git a/app/src/main/res/layout/fragment_oauth_login.xml b/app/src/main/res/layout/fragment_oauth_login.xml new file mode 100644 index 00000000..6465a73f --- /dev/null +++ b/app/src/main/res/layout/fragment_oauth_login.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 795dd38a..87598097 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -169,4 +169,15 @@ app:popUpTo="@id/onboarding" app:popUpToInclusive="true" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d4c6b423..72e5c919 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -296,4 +296,12 @@ Kompass Website Standortdienste-Status + Tesla-Account + Angemeldet als %s + Anmelden, um Echtzeitdaten für Tesla Supercharger zu sehen. Kein Tesla-Fahrzeug notwendig + Anmelden… + Abmelden + Abgemeldet + Login + Login fehlgeschlagen \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7901d214..af830cef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -296,4 +296,12 @@ Compass Website Location provider status + Tesla account + Logged in as %s + Log in to see real-time data for Tesla Superchargers. No Tesla vehicle necessary + Logging in… + Log out + Logged out + Login + Login failed \ No newline at end of file diff --git a/app/src/main/res/xml/settings_data.xml b/app/src/main/res/xml/settings_data.xml index 108bc4d1..7f8d9bc9 100644 --- a/app/src/main/res/xml/settings_data.xml +++ b/app/src/main/res/xml/settings_data.xml @@ -15,6 +15,10 @@ android:title="@string/pref_prediction_enabled" android:defaultValue="true" android:summary="@string/pref_prediction_enabled_summary" /> + +