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