From 4d8289b2452cbf1d153dccaabb24380f6864a365 Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Tue, 8 Mar 2022 21:22:19 +0100 Subject: [PATCH] Implement Login via WebView (#2371) * Improve login process with newer APIs * Implement login with WebView instead of browser tab or external browser Oauth process requires us to open login prompt for correct instance and we need to receive the result back. Usually it is done with redirect parameter. Previously we've been using BrowserTabs API and have been falling back to just opening browser. This mostly worked but is very clumsy: - It relies on few system mechanisms for opening URLs in both directions - Browsers do weird things and tend to break quite a bit - There's a good chance that the app can die in the process and we need to recover our state. So instead we are now using WebView. It has disadvantages (users have to trust us to show correct page, logins are not shared w/ browser) but it should be more reliable. * Changes to login after review * Move login classes to their own package * Fix linting issues --- app/src/main/AndroidManifest.xml | 13 +- .../com/keylesspalace/tusky/BaseActivity.java | 1 + .../com/keylesspalace/tusky/LoginActivity.kt | 365 ------------------ .../com/keylesspalace/tusky/MainActivity.kt | 1 + .../com/keylesspalace/tusky/SplashActivity.kt | 1 + .../tusky/components/login/LoginActivity.kt | 295 ++++++++++++++ .../components/login/LoginWebViewActivity.kt | 148 +++++++ .../tusky/di/ActivitiesModule.kt | 6 +- .../tusky/network/MastodonApi.kt | 8 +- app/src/main/res/layout/activity_login.xml | 2 +- app/src/main/res/layout/login_webview.xml | 25 ++ 11 files changed, 483 insertions(+), 382 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt create mode 100644 app/src/main/res/layout/login_webview.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 59e461752..a32259b77 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,19 +35,10 @@ - - - - - - - - + diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 8e193c349..d34dd6df8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -38,6 +38,7 @@ import androidx.preference.PreferenceManager; import com.google.android.material.snackbar.Snackbar; import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; +import com.keylesspalace.tusky.components.login.LoginActivity; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt deleted file mode 100644 index 08140b63d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt +++ /dev/null @@ -1,365 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky - -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.net.Uri -import android.os.Bundle -import android.text.method.LinkMovementMethod -import android.util.Log -import android.view.View -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import androidx.browser.customtabs.CustomTabColorSchemeParams -import androidx.browser.customtabs.CustomTabsIntent -import com.bumptech.glide.Glide -import com.keylesspalace.tusky.databinding.ActivityLoginBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.entity.AccessToken -import com.keylesspalace.tusky.entity.AppCredentials -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.ThemeUtils -import com.keylesspalace.tusky.util.getNonNullString -import com.keylesspalace.tusky.util.rickRoll -import com.keylesspalace.tusky.util.shouldRickRoll -import com.keylesspalace.tusky.util.viewBinding -import okhttp3.HttpUrl -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import javax.inject.Inject - -class LoginActivity : BaseActivity(), Injectable { - - @Inject - lateinit var mastodonApi: MastodonApi - - private val binding by viewBinding(ActivityLoginBinding::inflate) - - private lateinit var preferences: SharedPreferences - - private val oauthRedirectUri: String - get() { - val scheme = getString(R.string.oauth_scheme) - val host = BuildConfig.APPLICATION_ID - return "$scheme://$host/" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContentView(binding.root) - - if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) { - binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) - binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) - } - - if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { - Glide.with(binding.loginLogo) - .load(BuildConfig.CUSTOM_LOGO_URL) - .placeholder(null) - .into(binding.loginLogo) - } - - preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE - ) - - binding.loginButton.setOnClickListener { onButtonClick() } - - binding.whatsAnInstanceTextView.setOnClickListener { - val dialog = AlertDialog.Builder(this) - .setMessage(R.string.dialog_whats_an_instance) - .setPositiveButton(R.string.action_close, null) - .show() - val textView = dialog.findViewById(android.R.id.message) - textView?.movementMethod = LinkMovementMethod.getInstance() - } - - if (isAdditionalLogin()) { - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setDisplayShowTitleEnabled(false) - } else { - binding.toolbar.visibility = View.GONE - } - } - - override fun requiresLogin(): Boolean { - return false - } - - override fun finish() { - super.finish() - if (isAdditionalLogin()) { - overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right) - } - } - - /** - * Obtain the oauth client credentials for this app. This is only necessary the first time the - * app is run on a given server instance. So, after the first authentication, they are - * saved in SharedPreferences and every subsequent run they are simply fetched from there. - */ - private fun onButtonClick() { - - binding.loginButton.isEnabled = false - - val domain = canonicalizeDomain(binding.domainEditText.text.toString()) - - try { - HttpUrl.Builder().host(domain).scheme("https").build() - } catch (e: IllegalArgumentException) { - setLoading(false) - binding.domainTextInputLayout.error = getString(R.string.error_invalid_domain) - return - } - - if (shouldRickRoll(this, domain)) { - rickRoll(this) - return - } - - val callback = object : Callback { - override fun onResponse( - call: Call, - response: Response - ) { - if (!response.isSuccessful) { - binding.loginButton.isEnabled = true - binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration) - setLoading(false) - Log.e(TAG, "App authentication failed. " + response.message()) - return - } - val credentials = response.body() - val clientId = credentials!!.clientId - val clientSecret = credentials.clientSecret - - preferences.edit() - .putString("domain", domain) - .putString("clientId", clientId) - .putString("clientSecret", clientSecret) - .apply() - - redirectUserToAuthorizeAndLogin(domain, clientId) - } - - override fun onFailure(call: Call, t: Throwable) { - binding.loginButton.isEnabled = true - binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration) - setLoading(false) - Log.e(TAG, Log.getStackTraceString(t)) - } - } - - mastodonApi - .authenticateApp( - domain, getString(R.string.app_name), oauthRedirectUri, - OAUTH_SCOPES, getString(R.string.tusky_website) - ) - .enqueue(callback) - setLoading(true) - } - - private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) { - /* To authorize this app and log in it's necessary to redirect to the domain given, - * login there, and the server will redirect back to the app with its response. */ - val endpoint = MastodonApi.ENDPOINT_AUTHORIZE - val parameters = mapOf( - "client_id" to clientId, - "redirect_uri" to oauthRedirectUri, - "response_type" to "code", - "scope" to OAUTH_SCOPES - ) - val url = "https://" + domain + endpoint + "?" + toQueryString(parameters) - val uri = Uri.parse(url) - if (!openInCustomTab(uri, this)) { - val viewIntent = Intent(Intent.ACTION_VIEW, uri) - if (viewIntent.resolveActivity(packageManager) != null) { - startActivity(viewIntent) - } else { - binding.domainEditText.error = getString(R.string.error_no_web_browser_found) - setLoading(false) - } - } - } - - override fun onStart() { - super.onStart() - /* Check if we are resuming during authorization by seeing if the intent contains the - * redirect that was given to the server. If so, its response is here! */ - val uri = intent.data - val redirectUri = oauthRedirectUri - - if (uri != null && uri.toString().startsWith(redirectUri)) { - // This should either have returned an authorization code or an error. - val code = uri.getQueryParameter("code") - val error = uri.getQueryParameter("error") - - /* restore variables from SharedPreferences */ - val domain = preferences.getNonNullString(DOMAIN, "") - val clientId = preferences.getNonNullString(CLIENT_ID, "") - val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "") - - if (code != null && domain.isNotEmpty() && clientId.isNotEmpty() && clientSecret.isNotEmpty()) { - - setLoading(true) - /* Since authorization has succeeded, the final step to log in is to exchange - * the authorization code for an access token. */ - val callback = object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - onLoginSuccess(response.body()!!.accessToken, domain) - } else { - setLoading(false) - binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) - Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), response.message())) - } - } - - override fun onFailure(call: Call, t: Throwable) { - setLoading(false) - binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) - Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), t.message)) - } - } - - mastodonApi.fetchOAuthToken( - domain, clientId, clientSecret, redirectUri, code, - "authorization_code" - ).enqueue(callback) - } else if (error != null) { - /* Authorization failed. Put the error response where the user can read it and they - * can try again. */ - setLoading(false) - binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied) - Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error)) - } else { - // This case means a junk response was received somehow. - setLoading(false) - binding.domainTextInputLayout.error = getString(R.string.error_authorization_unknown) - } - } else { - // first show or user cancelled login - setLoading(false) - } - } - - private fun setLoading(loadingState: Boolean) { - if (loadingState) { - binding.loginLoadingLayout.visibility = View.VISIBLE - binding.loginInputLayout.visibility = View.GONE - } else { - binding.loginLoadingLayout.visibility = View.GONE - binding.loginInputLayout.visibility = View.VISIBLE - binding.loginButton.isEnabled = true - } - } - - private fun isAdditionalLogin(): Boolean { - return intent.getBooleanExtra(LOGIN_MODE, false) - } - - private fun onLoginSuccess(accessToken: String, domain: String) { - - setLoading(true) - - accountManager.addAccount(accessToken, domain) - - val intent = Intent(this, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - finish() - overridePendingTransition(R.anim.explode, R.anim.explode) - } - - companion object { - private const val TAG = "LoginActivity" // logging tag - private const val OAUTH_SCOPES = "read write follow" - private const val LOGIN_MODE = "LOGIN_MODE" - private const val DOMAIN = "domain" - private const val CLIENT_ID = "clientId" - private const val CLIENT_SECRET = "clientSecret" - - @JvmStatic - fun getIntent(context: Context, mode: Boolean): Intent { - val loginIntent = Intent(context, LoginActivity::class.java) - loginIntent.putExtra(LOGIN_MODE, mode) - return loginIntent - } - - /** Make sure the user-entered text is just a fully-qualified domain name. */ - private fun canonicalizeDomain(domain: String): String { - // Strip any schemes out. - var s = domain.replaceFirst("http://", "") - s = s.replaceFirst("https://", "") - // If a username was included (e.g. username@example.com), just take what's after the '@'. - val at = s.lastIndexOf('@') - if (at != -1) { - s = s.substring(at + 1) - } - return s.trim { it <= ' ' } - } - - /** - * Chain together the key-value pairs into a query string, for either appending to a URL or - * as the content of an HTTP request. - */ - private fun toQueryString(parameters: Map): String { - val s = StringBuilder() - var between = "" - for ((key, value) in parameters) { - s.append(between) - s.append(Uri.encode(key)) - s.append("=") - s.append(Uri.encode(value)) - between = "&" - } - return s.toString() - } - - private fun openInCustomTab(uri: Uri, context: Context): Boolean { - - val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface) - val navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor) - val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor) - - val colorSchemeParams = CustomTabColorSchemeParams.Builder() - .setToolbarColor(toolbarColor) - .setNavigationBarColor(navigationbarColor) - .setNavigationBarDividerColor(navigationbarDividerColor) - .build() - - val customTabsIntent = CustomTabsIntent.Builder() - .setDefaultColorSchemeParams(colorSchemeParams) - .build() - - try { - customTabsIntent.launchUrl(context, uri) - } catch (e: ActivityNotFoundException) { - Log.w(TAG, "Activity was not found for intent $customTabsIntent") - return false - } - - return true - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index e7d748dbe..73aedbd95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -64,6 +64,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canH import com.keylesspalace.tusky.components.conversation.ConversationsRepository import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftsActivity +import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt index 1b7e29947..0225147f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt @@ -18,6 +18,7 @@ package com.keylesspalace.tusky import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt new file mode 100644 index 000000000..cc2bd776b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -0,0 +1,295 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.login + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.util.Log +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityLoginBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.AppCredentials +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.getNonNullString +import com.keylesspalace.tusky.util.rickRoll +import com.keylesspalace.tusky.util.shouldRickRoll +import com.keylesspalace.tusky.util.viewBinding +import kotlinx.coroutines.launch +import okhttp3.HttpUrl +import javax.inject.Inject + +/** Main login page, the first thing that users see. Has prompt for instance and login button. */ +class LoginActivity : BaseActivity(), Injectable { + + @Inject + lateinit var mastodonApi: MastodonApi + + private val binding by viewBinding(ActivityLoginBinding::inflate) + + private lateinit var preferences: SharedPreferences + + private val oauthRedirectUri: String + get() { + val scheme = getString(R.string.oauth_scheme) + val host = BuildConfig.APPLICATION_ID + return "$scheme://$host/" + } + + private val doWebViewAuth = registerForActivityResult(OauthLogin()) { result -> + when (result) { + is LoginResult.Ok -> lifecycleScope.launch { + fetchOauthToken(result.code) + } + is LoginResult.Err -> { + // Authorization failed. Put the error response where the user can read it and they + // can try again. + setLoading(false) + binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied) + Log.e( + TAG, + "%s %s".format( + getString(R.string.error_authorization_denied), + result.errorMessage + ) + ) + } + is LoginResult.Cancel -> { + setLoading(false) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + + if (savedInstanceState == null && + BuildConfig.CUSTOM_INSTANCE.isNotBlank() && + !isAdditionalLogin() + ) { + binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) + binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) + } + + if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { + Glide.with(binding.loginLogo) + .load(BuildConfig.CUSTOM_LOGO_URL) + .placeholder(null) + .into(binding.loginLogo) + } + + preferences = getSharedPreferences( + getString(R.string.preferences_file_key), Context.MODE_PRIVATE + ) + + binding.loginButton.setOnClickListener { onButtonClick() } + + binding.whatsAnInstanceTextView.setOnClickListener { + val dialog = AlertDialog.Builder(this) + .setMessage(R.string.dialog_whats_an_instance) + .setPositiveButton(R.string.action_close, null) + .show() + val textView = dialog.findViewById(android.R.id.message) + textView?.movementMethod = LinkMovementMethod.getInstance() + } + + if (isAdditionalLogin()) { + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowTitleEnabled(false) + } else { + binding.toolbar.visibility = View.GONE + } + } + + override fun requiresLogin(): Boolean { + return false + } + + override fun finish() { + super.finish() + if (isAdditionalLogin()) { + overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right) + } + } + + /** + * Obtain the oauth client credentials for this app. This is only necessary the first time the + * app is run on a given server instance. So, after the first authentication, they are + * saved in SharedPreferences and every subsequent run they are simply fetched from there. + */ + private fun onButtonClick() { + binding.loginButton.isEnabled = false + binding.domainTextInputLayout.error = null + + val domain = canonicalizeDomain(binding.domainEditText.text.toString()) + + try { + HttpUrl.Builder().host(domain).scheme("https").build() + } catch (e: IllegalArgumentException) { + setLoading(false) + binding.domainTextInputLayout.error = getString(R.string.error_invalid_domain) + return + } + + if (shouldRickRoll(this, domain)) { + rickRoll(this) + return + } + + setLoading(true) + + lifecycleScope.launch { + val credentials: AppCredentials = try { + mastodonApi.authenticateApp( + domain, getString(R.string.app_name), oauthRedirectUri, + OAUTH_SCOPES, getString(R.string.tusky_website) + ) + } catch (e: Exception) { + binding.loginButton.isEnabled = true + binding.domainTextInputLayout.error = + getString(R.string.error_failed_app_registration) + setLoading(false) + Log.e(TAG, Log.getStackTraceString(e)) + return@launch + } + + // Before we open browser page we save the data. + // Even if we don't open other apps user may go to password manager or somewhere else + // and we will need to pick up the process where we left off. + // Alternatively we could pass it all as part of the intent and receive it back + // but it is a bit of a workaround. + preferences.edit() + .putString(DOMAIN, domain) + .putString(CLIENT_ID, credentials.clientId) + .putString(CLIENT_SECRET, credentials.clientSecret) + .apply() + + redirectUserToAuthorizeAndLogin(domain, credentials.clientId) + } + } + + private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) { + // To authorize this app and log in it's necessary to redirect to the domain given, + // login there, and the server will redirect back to the app with its response. + val url = HttpUrl.Builder() + .scheme("https") + .host(domain) + .addPathSegments(MastodonApi.ENDPOINT_AUTHORIZE) + .addQueryParameter("client_id", clientId) + .addQueryParameter("redirect_uri", oauthRedirectUri) + .addQueryParameter("response_type", "code") + .addQueryParameter("scope", OAUTH_SCOPES) + .build() + doWebViewAuth.launch(LoginData(url.toString().toUri(), oauthRedirectUri.toUri())) + } + + override fun onStart() { + super.onStart() + // first show or user cancelled login + setLoading(false) + } + + private suspend fun fetchOauthToken(code: String) { + /* restore variables from SharedPreferences */ + val domain = preferences.getNonNullString(DOMAIN, "") + val clientId = preferences.getNonNullString(CLIENT_ID, "") + val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "") + + setLoading(true) + + val accessToken = try { + mastodonApi.fetchOAuthToken( + domain, clientId, clientSecret, oauthRedirectUri, code, + "authorization_code" + ) + } catch (e: Exception) { + setLoading(false) + binding.domainTextInputLayout.error = + getString(R.string.error_retrieving_oauth_token) + Log.e( + TAG, + "%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message), + ) + return + } + + accountManager.addAccount(accessToken.accessToken, domain) + + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + finish() + overridePendingTransition(R.anim.explode, R.anim.explode) + } + + private fun setLoading(loadingState: Boolean) { + if (loadingState) { + binding.loginLoadingLayout.visibility = View.VISIBLE + binding.loginInputLayout.visibility = View.GONE + } else { + binding.loginLoadingLayout.visibility = View.GONE + binding.loginInputLayout.visibility = View.VISIBLE + binding.loginButton.isEnabled = true + } + } + + private fun isAdditionalLogin(): Boolean { + return intent.getBooleanExtra(LOGIN_MODE, false) + } + + companion object { + private const val TAG = "LoginActivity" // logging tag + private const val OAUTH_SCOPES = "read write follow" + private const val LOGIN_MODE = "LOGIN_MODE" + private const val DOMAIN = "domain" + private const val CLIENT_ID = "clientId" + private const val CLIENT_SECRET = "clientSecret" + + @JvmStatic + fun getIntent(context: Context, mode: Boolean): Intent { + val loginIntent = Intent(context, LoginActivity::class.java) + loginIntent.putExtra(LOGIN_MODE, mode) + return loginIntent + } + + /** Make sure the user-entered text is just a fully-qualified domain name. */ + private fun canonicalizeDomain(domain: String): String { + // Strip any schemes out. + var s = domain.replaceFirst("http://", "") + s = s.replaceFirst("https://", "") + // If a username was included (e.g. username@example.com), just take what's after the '@'. + val at = s.lastIndexOf('@') + if (at != -1) { + s = s.substring(at + 1) + } + return s.trim { it <= ' ' } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt new file mode 100644 index 000000000..16e07f99d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -0,0 +1,148 @@ +package com.keylesspalace.tusky.components.login + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.os.Parcelable +import android.util.Log +import android.webkit.CookieManager +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebStorage +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.result.contract.ActivityResultContract +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.databinding.LoginWebviewBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.util.viewBinding +import kotlinx.parcelize.Parcelize + +/** Contract for starting [LoginWebViewActivity]. */ +class OauthLogin : ActivityResultContract() { + override fun createIntent(context: Context, input: LoginData): Intent { + val intent = Intent(context, LoginWebViewActivity::class.java) + intent.putExtra(DATA_EXTRA, input) + return intent + } + + override fun parseResult(resultCode: Int, intent: Intent?): LoginResult { + // Can happen automatically on up or back press + return if (resultCode == Activity.RESULT_CANCELED) { + LoginResult.Cancel + } else { + intent!!.getParcelableExtra(RESULT_EXTRA)!! + } + } + + companion object { + private const val RESULT_EXTRA = "result" + private const val DATA_EXTRA = "data" + + fun parseData(intent: Intent): LoginData { + return intent.getParcelableExtra(DATA_EXTRA)!! + } + + fun makeResultIntent(result: LoginResult): Intent { + val intent = Intent() + intent.putExtra(RESULT_EXTRA, result) + return intent + } + } +} + +@Parcelize +data class LoginData( + val url: Uri, + val oauthRedirectUrl: Uri, +) : Parcelable + +sealed class LoginResult : Parcelable { + @Parcelize + data class Ok(val code: String) : LoginResult() + + @Parcelize + data class Err(val errorMessage: String) : LoginResult() + + @Parcelize + object Cancel : LoginResult() +} + +/** Activity to do Oauth process using WebView. */ +class LoginWebViewActivity : BaseActivity(), Injectable { + private val binding by viewBinding(LoginWebviewBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val data = OauthLogin.parseData(intent) + + setContentView(binding.root) + + setSupportActionBar(binding.loginToolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowTitleEnabled(false) + + val webView = binding.loginWebView + webView.settings.allowContentAccess = false + webView.settings.allowFileAccess = false + webView.settings.databaseEnabled = false + webView.settings.displayZoomControls = false + webView.settings.javaScriptCanOpenWindowsAutomatically = false + webView.settings.userAgentString += " Tusky/${BuildConfig.VERSION_NAME}" + + val oauthUrl = data.oauthRedirectUrl + + webView.webViewClient = object : WebViewClient() { + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError + ) { + Log.d("LoginWeb", "Failed to load ${data.url}: $error") + finish() + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + val url = request.url + return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) { + val error = url.getQueryParameter("error") + if (error != null) { + sendResult(LoginResult.Err(error)) + } else { + val code = url.getQueryParameter("code").orEmpty() + sendResult(LoginResult.Ok(code)) + } + true + } else { + false + } + } + } + webView.setBackgroundColor(Color.TRANSPARENT) + webView.loadUrl(data.url.toString()) + } + + override fun onDestroy() { + // We don't want to keep user session in WebView, we just want our own accessToken + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().removeAllCookies(null) + super.onDestroy() + } + + override fun requiresLogin(): Boolean { + return false + } + + private fun sendResult(result: LoginResult) { + setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result)) + finish() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 7d68f75f7..285fe916c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -22,7 +22,6 @@ import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.LicenseActivity import com.keylesspalace.tusky.ListsActivity -import com.keylesspalace.tusky.LoginActivity import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.SplashActivity import com.keylesspalace.tusky.StatusListActivity @@ -34,6 +33,8 @@ import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.components.login.LoginWebViewActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity @@ -84,6 +85,9 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesLoginActivity(): LoginActivity + @ContributesAndroidInjector + abstract fun contributesLoginWebViewActivity(): LoginWebViewActivity + @ContributesAndroidInjector abstract fun contributesSplashActivity(): SplashActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 8804124cd..fe5def875 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -440,24 +440,24 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/apps") - fun authenticateApp( + suspend fun authenticateApp( @Header(DOMAIN_HEADER) domain: String, @Field("client_name") clientName: String, @Field("redirect_uris") redirectUris: String, @Field("scopes") scopes: String, @Field("website") website: String - ): Call + ): AppCredentials @FormUrlEncoded @POST("oauth/token") - fun fetchOAuthToken( + suspend fun fetchOAuthToken( @Header(DOMAIN_HEADER) domain: String, @Field("client_id") clientId: String, @Field("client_secret") clientSecret: String, @Field("redirect_uri") redirectUri: String, @Field("code") code: String, @Field("grant_type") grantType: String - ): Call + ): AccessToken @FormUrlEncoded @POST("api/v1/lists") diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 70c912428..c8ce50793 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -6,7 +6,7 @@ android:layout_height="match_parent" android:gravity="center" android:orientation="vertical" - tools:context="com.keylesspalace.tusky.LoginActivity"> + tools:context="com.keylesspalace.tusky.components.login.LoginActivity"> + + + + + + + + + + + \ No newline at end of file