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
This commit is contained in:
Ivan Kupalov 2022-03-08 21:22:19 +01:00 committed by GitHub
parent 1b380f07f5
commit 4d8289b245
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 483 additions and 382 deletions

View file

@ -35,19 +35,10 @@
</activity>
<activity
android:name=".LoginActivity"
android:name=".components.login.LoginActivity"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${applicationId}"
android:scheme="@string/oauth_scheme" />
</intent-filter>
</activity>
<activity android:name=".components.login.LoginWebViewActivity" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize">

View file

@ -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;

View file

@ -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 <http://www.gnu.org/licenses>. */
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<TextView>(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<AppCredentials> {
override fun onResponse(
call: Call<AppCredentials>,
response: Response<AppCredentials>
) {
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<AppCredentials>, 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<AccessToken> {
override fun onResponse(call: Call<AccessToken>, response: Response<AccessToken>) {
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<AccessToken>, 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, String>): 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
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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 <http://www.gnu.org/licenses>. */
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<TextView>(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 <= ' ' }
}
}
}

View file

@ -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<LoginData, LoginResult>() {
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()
}
}

View file

@ -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

View file

@ -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>
): 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>
): AccessToken
@FormUrlEncoded
@POST("api/v1/lists")

View file

@ -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">
<ScrollView
android:layout_width="match_parent"

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/loginToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:titleTextColor="#fff" />
</com.google.android.material.appbar.AppBarLayout>
<WebView
android:id="@+id/loginWebView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>