From 66dbd6426ff973e6742dd0bbb93c41a40a48fef7 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sat, 16 Sep 2023 12:36:05 +0200 Subject: [PATCH] implement Tesla Login for Android Auto/AAOS --- app/src/main/AndroidManifest.xml | 4 + .../availability/TeslaAvailabilityDetector.kt | 13 ++ .../evmap/auto/ChargerDetailScreen.kt | 2 +- .../vonforst/evmap/auto/OAuthLoginActivity.kt | 31 ++++ .../vonforst/evmap/auto/SettingsScreens.kt | 132 ++++++++++++++++++ .../fragment/oauth/OAuthLoginFragment.kt | 38 ++++- .../preference/DataSettingsFragment.kt | 14 +- .../evmap/storage/PreferenceDataSource.kt | 5 +- .../main/res/layout/activity_oauth_login.xml | 6 + 9 files changed, 228 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/net/vonforst/evmap/auto/OAuthLoginActivity.kt create mode 100644 app/src/main/res/layout/activity_oauth_login.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 906100c7..fa5110f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -289,6 +289,10 @@ android:resource="@xml/shortcuts" /> + + + + (R.id.fragment_container_view, args = intent.extras) + } + } + + LocalBroadcastManager.getInstance(this).registerReceiver(object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + finish() + } + }, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT)) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt b/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt index bc02cc61..55d7c83b 100644 --- a/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt +++ b/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt @@ -1,10 +1,15 @@ package net.vonforst.evmap.auto +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager.NameNotFoundException import android.hardware.Sensor import android.hardware.SensorManager +import android.net.Uri +import android.os.Bundle +import android.os.IInterface import android.text.Html import androidx.annotation.StringRes import androidx.car.app.CarContext @@ -13,16 +18,27 @@ import androidx.car.app.Screen import androidx.car.app.annotations.ExperimentalCarApi import androidx.car.app.constraints.ConstraintManager import androidx.car.app.model.* +import androidx.core.content.IntentCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.text.HtmlCompat import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.launch import net.vonforst.evmap.* +import net.vonforst.evmap.api.availability.TeslaAuthenticationApi +import net.vonforst.evmap.api.availability.TeslaOwnerApi import net.vonforst.evmap.api.chargeprice.ChargepriceApi import net.vonforst.evmap.api.chargeprice.ChargepriceCar import net.vonforst.evmap.api.chargeprice.ChargepriceTariff +import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment +import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs import net.vonforst.evmap.storage.AppDatabase +import net.vonforst.evmap.storage.EncryptedPreferenceDataStore import net.vonforst.evmap.storage.PreferenceDataSource +import okhttp3.OkHttpClient +import java.io.IOException +import java.time.Instant import kotlin.math.max import kotlin.math.min @@ -125,6 +141,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) { class DataSettingsScreen(ctx: CarContext) : Screen(ctx) { val prefs = PreferenceDataSource(ctx) + val encryptedPrefs = EncryptedPreferenceDataStore(ctx) val db = AppDatabase.getInstance(ctx) val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names) @@ -134,6 +151,8 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) { val searchProviderValues = carContext.resources.getStringArray(R.array.pref_search_provider_values) + var teslaLoggingIn = false + override fun onGetTemplate(): Template { return ListTemplate.Builder().apply { setTitle(carContext.getString(R.string.settings_data_sources)) @@ -183,9 +202,122 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) { } } }.build()) + addItem( + Row.Builder() + .setTitle(carContext.getString(R.string.pref_prediction_enabled)) + .addText(carContext.getString(R.string.pref_prediction_enabled_summary)) + .setToggle(Toggle.Builder { + prefs.predictionEnabled = it + }.setChecked(prefs.predictionEnabled).build()) + .build() + ) + addItem(Row.Builder().apply { + setTitle(carContext.getString(R.string.pref_tesla_account)) + addText( + if (encryptedPrefs.teslaRefreshToken != null) { + carContext.getString( + R.string.pref_tesla_account_enabled, + encryptedPrefs.teslaEmail + ) + } else if (teslaLoggingIn) { + carContext.getString(R.string.logging_in) + } else { + carContext.getString(R.string.pref_tesla_account_disabled) + } + ) + if (encryptedPrefs.teslaRefreshToken != null) { + setOnClickListener { + teslaLogout() + } + } else { + setOnClickListener(ParkedOnlyOnClickListener.create { + teslaLogin() + }) + } + }.build()) }.build()) }.build() } + + private fun teslaLogin() { + val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier() + val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier) + val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge) + + val args = OAuthLoginFragmentArgs( + uri.toString(), + TeslaAuthenticationApi.resultUrlPrefix, + "#000000" + ).toBundle() + val intent = Intent(carContext, OAuthLoginActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtras(args) + + LocalBroadcastManager.getInstance(carContext) + .registerReceiver(object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + val url = IntentCompat.getParcelableExtra( + intent, + OAuthLoginFragment.EXTRA_URL, + Uri::class.java + ) + teslaGetAccessToken(url!!, codeVerifier) + } + }, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT)) + + carContext.startActivity(intent) + + if (BuildConfig.FLAVOR_automotive != "automotive") { + CarToast.makeText( + carContext, + R.string.opened_on_phone, + CarToast.LENGTH_LONG + ).show() + } + } + + private fun teslaGetAccessToken(url: Uri, codeVerifier: String) { + teslaLoggingIn = true + invalidate() + + val code = url.getQueryParameter("code") ?: return + 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) { + CarToast.makeText( + carContext, + R.string.generic_connection_error, + CarToast.LENGTH_SHORT + ).show() + } finally { + teslaLoggingIn = false + } + invalidate() + } + } + + private fun teslaLogout() { + // sign out + encryptedPrefs.teslaRefreshToken = null + encryptedPrefs.teslaAccessToken = null + encryptedPrefs.teslaAccessTokenExpiry = -1 + encryptedPrefs.teslaEmail = null + CarToast.makeText(carContext, R.string.logged_out, CarToast.LENGTH_SHORT).show() + + invalidate() + } } class ChooseDataSourceScreen( 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 index 3d844c3f..6e2e94ff 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/oauth/OAuthLoginFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/oauth/OAuthLoginFragment.kt @@ -1,6 +1,7 @@ package net.vonforst.evmap.fragment.oauth import android.annotation.SuppressLint +import android.content.Intent import android.graphics.Bitmap import android.graphics.Color import android.net.Uri @@ -13,17 +14,25 @@ import android.webkit.CookieManager import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResult +import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.navigation.fragment.findNavController import androidx.navigation.ui.setupWithNavController import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.transition.MaterialSharedAxis import net.vonforst.evmap.MapsActivity import net.vonforst.evmap.R +import java.lang.IllegalStateException class OAuthLoginFragment : Fragment() { + companion object { + val ACTION_OAUTH_RESULT = "oauth_result" + val EXTRA_URL = "url" + } + private lateinit var webView: WebView override fun onCreate(savedInstanceState: Bundle?) { @@ -43,10 +52,24 @@ class OAuthLoginFragment : Fragment() { super.onViewCreated(view, savedInstanceState) val toolbar = view.findViewById(R.id.toolbar) - toolbar.setupWithNavController( - findNavController(), - (requireActivity() as MapsActivity).appBarConfiguration - ) + val navController = try { + findNavController() + } catch (e: IllegalStateException) { + null + // standalone in OAuthLoginActivity + } + + if (navController != null) { + toolbar.setupWithNavController( + navController, + (requireActivity() as MapsActivity).appBarConfiguration + ) + } else { + toolbar.title = getString(R.string.login) + toolbar.navigationIcon = + AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_back) + toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + } val args = OAuthLoginFragmentArgs.fromBundle(requireArguments()) val uri = Uri.parse(args.url) @@ -68,7 +91,12 @@ class OAuthLoginFragment : Fragment() { val result = Bundle() result.putString("url", url.toString()) setFragmentResult(args.url, result) - findNavController().popBackStack() + context?.let { + LocalBroadcastManager.getInstance(it).sendBroadcast( + Intent(ACTION_OAUTH_RESULT).putExtra(EXTRA_URL, url) + ) + } + navController?.popBackStack() } return url.host != uri.host 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 5d18f930..311d75b7 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 @@ -141,18 +141,11 @@ class DataSettingsFragment : BaseSettingsFragment() { 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 uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge) val args = OAuthLoginFragmentArgs( uri.toString(), - "https://auth.tesla.com/void/callback", + TeslaAuthenticationApi.resultUrlPrefix, "#000000" ).toBundle() @@ -184,7 +177,8 @@ class DataSettingsFragment : BaseSettingsFragment() { encryptedPrefs.teslaRefreshToken = response.refreshToken } catch (e: IOException) { view?.let { - Snackbar.make(it, R.string.connection_error, Snackbar.LENGTH_SHORT).show() + Snackbar.make(it, R.string.generic_connection_error, Snackbar.LENGTH_SHORT) + .show() } } refreshTeslaAccountStatus() diff --git a/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt b/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt index 98e0bf41..adf18e92 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt @@ -261,8 +261,11 @@ class PreferenceDataSource(val context: Context) { sp.edit().putBoolean("show_chargers_ahead_android_auto", value).apply() } - val predictionEnabled: Boolean + var predictionEnabled: Boolean get() = sp.getBoolean("prediction_enabled", true) + set(value) { + sp.edit().putBoolean("prediction_enabled", value).apply() + } var developerModeEnabled: Boolean get() = sp.getBoolean("dev_mode_enabled", false) diff --git a/app/src/main/res/layout/activity_oauth_login.xml b/app/src/main/res/layout/activity_oauth_login.xml new file mode 100644 index 00000000..8b679c80 --- /dev/null +++ b/app/src/main/res/layout/activity_oauth_login.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file