2018-02-03 22:45:14 +01:00
|
|
|
/* 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
|
2019-12-20 20:08:02 +01:00
|
|
|
import android.os.Build
|
2018-02-03 22:45:14 +01:00
|
|
|
import android.os.Bundle
|
|
|
|
import android.text.method.LinkMovementMethod
|
|
|
|
import android.util.Log
|
|
|
|
import android.view.MenuItem
|
|
|
|
import android.view.View
|
|
|
|
import android.widget.TextView
|
2018-12-17 20:47:42 +01:00
|
|
|
import androidx.appcompat.app.AlertDialog
|
2019-12-20 20:08:02 +01:00
|
|
|
import androidx.browser.customtabs.CustomTabsIntent
|
2019-10-29 20:30:46 +01:00
|
|
|
import com.bumptech.glide.Glide
|
2018-04-22 17:20:01 +02:00
|
|
|
import com.keylesspalace.tusky.di.Injectable
|
2018-02-03 22:45:14 +01:00
|
|
|
import com.keylesspalace.tusky.entity.AccessToken
|
|
|
|
import com.keylesspalace.tusky.entity.AppCredentials
|
|
|
|
import com.keylesspalace.tusky.network.MastodonApi
|
|
|
|
import com.keylesspalace.tusky.util.ThemeUtils
|
2019-09-22 08:18:44 +02:00
|
|
|
import com.keylesspalace.tusky.util.getNonNullString
|
2018-02-03 22:45:14 +01:00
|
|
|
import kotlinx.android.synthetic.main.activity_login.*
|
2018-04-22 17:20:01 +02:00
|
|
|
import okhttp3.HttpUrl
|
2018-02-03 22:45:14 +01:00
|
|
|
import retrofit2.Call
|
|
|
|
import retrofit2.Callback
|
|
|
|
import retrofit2.Response
|
2018-04-22 17:20:01 +02:00
|
|
|
import javax.inject.Inject
|
2018-02-03 22:45:14 +01:00
|
|
|
|
2018-12-17 15:25:35 +01:00
|
|
|
class LoginActivity : BaseActivity(), Injectable {
|
2018-04-22 17:20:01 +02:00
|
|
|
|
|
|
|
@Inject
|
|
|
|
lateinit var mastodonApi: MastodonApi
|
2018-02-03 22:45:14 +01:00
|
|
|
|
|
|
|
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(R.layout.activity_login)
|
|
|
|
|
2019-10-29 20:30:46 +01:00
|
|
|
if(savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) {
|
|
|
|
domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
|
|
|
|
domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
|
|
|
|
}
|
|
|
|
|
|
|
|
if(BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
|
|
|
|
Glide.with(loginLogo)
|
|
|
|
.load(BuildConfig.CUSTOM_LOGO_URL)
|
|
|
|
.placeholder(null)
|
|
|
|
.into(loginLogo)
|
2018-02-03 22:45:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
preferences = getSharedPreferences(
|
|
|
|
getString(R.string.preferences_file_key), Context.MODE_PRIVATE)
|
|
|
|
|
|
|
|
loginButton.setOnClickListener { onButtonClick() }
|
|
|
|
|
|
|
|
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)
|
2018-12-17 20:47:42 +01:00
|
|
|
textView?.movementMethod = LinkMovementMethod.getInstance()
|
2018-02-03 22:45:14 +01:00
|
|
|
}
|
|
|
|
|
2018-03-09 22:02:32 +01:00
|
|
|
if (isAdditionalLogin()) {
|
2018-02-03 22:45:14 +01:00
|
|
|
setSupportActionBar(toolbar)
|
|
|
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
|
|
supportActionBar?.setDisplayShowTitleEnabled(false)
|
|
|
|
} else {
|
|
|
|
toolbar.visibility = View.GONE
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2018-12-17 15:25:35 +01:00
|
|
|
override fun requiresLogin(): Boolean {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2018-08-18 13:03:30 +02:00
|
|
|
override fun finish() {
|
|
|
|
super.finish()
|
|
|
|
if(isAdditionalLogin()) {
|
|
|
|
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-02-03 22:45:14 +01:00
|
|
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
2018-03-09 22:02:32 +01:00
|
|
|
if (item.itemId == android.R.id.home) {
|
2018-02-03 22:45:14 +01:00
|
|
|
onBackPressed()
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
return super.onOptionsItemSelected(item)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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() {
|
|
|
|
|
|
|
|
loginButton.isEnabled = false
|
|
|
|
|
2019-10-13 21:09:23 +02:00
|
|
|
val domain = canonicalizeDomain(domainEditText.text.toString())
|
2018-04-22 17:20:01 +02:00
|
|
|
|
|
|
|
try {
|
|
|
|
HttpUrl.Builder().host(domain).scheme("https").build()
|
|
|
|
} catch (e: IllegalArgumentException) {
|
|
|
|
setLoading(false)
|
2018-12-17 15:25:35 +01:00
|
|
|
domainTextInputLayout.error = getString(R.string.error_invalid_domain)
|
2018-04-22 17:20:01 +02:00
|
|
|
return
|
|
|
|
}
|
2018-02-03 22:45:14 +01:00
|
|
|
|
|
|
|
val callback = object : Callback<AppCredentials> {
|
|
|
|
override fun onResponse(call: Call<AppCredentials>,
|
|
|
|
response: Response<AppCredentials>) {
|
|
|
|
if (!response.isSuccessful) {
|
|
|
|
loginButton.isEnabled = true
|
2018-12-17 15:25:35 +01:00
|
|
|
domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
|
2018-08-28 18:47:12 +02:00
|
|
|
setLoading(false)
|
2018-02-03 22:45:14 +01:00
|
|
|
Log.e(TAG, "App authentication failed. " + response.message())
|
|
|
|
return
|
|
|
|
}
|
|
|
|
val credentials = response.body()
|
2019-10-13 21:09:23 +02:00
|
|
|
val clientId = credentials!!.clientId
|
|
|
|
val clientSecret = credentials.clientSecret
|
2018-02-03 22:45:14 +01:00
|
|
|
|
2019-10-13 21:09:23 +02:00
|
|
|
preferences.edit()
|
|
|
|
.putString("domain", domain)
|
|
|
|
.putString("clientId", clientId)
|
|
|
|
.putString("clientSecret", clientSecret)
|
|
|
|
.apply()
|
|
|
|
|
|
|
|
redirectUserToAuthorizeAndLogin(domain, clientId)
|
2018-02-03 22:45:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
override fun onFailure(call: Call<AppCredentials>, t: Throwable) {
|
|
|
|
loginButton.isEnabled = true
|
2018-12-17 15:25:35 +01:00
|
|
|
domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
|
2018-02-03 22:45:14 +01:00
|
|
|
setLoading(false)
|
|
|
|
Log.e(TAG, Log.getStackTraceString(t))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-04-22 17:20:01 +02:00
|
|
|
mastodonApi
|
|
|
|
.authenticateApp(domain, getString(R.string.app_name), oauthRedirectUri,
|
2019-10-29 20:30:46 +01:00
|
|
|
OAUTH_SCOPES, getString(R.string.tusky_website))
|
2018-04-22 17:20:01 +02:00
|
|
|
.enqueue(callback)
|
|
|
|
setLoading(true)
|
2018-02-03 22:45:14 +01:00
|
|
|
|
|
|
|
}
|
|
|
|
|
2019-10-13 21:09:23 +02:00
|
|
|
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
|
2018-02-03 22:45:14 +01:00
|
|
|
/* To authorize this app and log in it's necessary to redirect to the domain given,
|
2019-10-13 21:09:23 +02:00
|
|
|
* login there, and the server will redirect back to the app with its response. */
|
2018-02-03 22:45:14 +01:00
|
|
|
val endpoint = MastodonApi.ENDPOINT_AUTHORIZE
|
2019-10-13 21:09:23 +02:00
|
|
|
val parameters = mapOf(
|
|
|
|
"client_id" to clientId,
|
2019-10-29 20:30:46 +01:00
|
|
|
"redirect_uri" to oauthRedirectUri,
|
|
|
|
"response_type" to "code",
|
|
|
|
"scope" to OAUTH_SCOPES
|
2019-10-13 21:09:23 +02:00
|
|
|
)
|
2018-02-03 22:45:14 +01:00
|
|
|
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 {
|
2019-10-13 21:09:23 +02:00
|
|
|
domainEditText.error = getString(R.string.error_no_web_browser_found)
|
2018-02-03 22:45:14 +01:00
|
|
|
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")
|
|
|
|
|
2019-10-13 21:09:23 +02:00
|
|
|
/* restore variables from SharedPreferences */
|
|
|
|
val domain = preferences.getNonNullString(DOMAIN, "")
|
|
|
|
val clientId = preferences.getNonNullString(CLIENT_ID, "")
|
|
|
|
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
|
2018-03-09 19:54:24 +01:00
|
|
|
|
2019-10-13 21:09:23 +02:00
|
|
|
if (code != null && domain.isNotEmpty() && clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
|
2018-02-03 22:45:14 +01:00
|
|
|
|
|
|
|
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) {
|
2019-10-13 21:09:23 +02:00
|
|
|
onLoginSuccess(response.body()!!.accessToken, domain)
|
2018-02-03 22:45:14 +01:00
|
|
|
} else {
|
|
|
|
setLoading(false)
|
2018-12-17 15:25:35 +01:00
|
|
|
domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
|
2018-02-03 22:45:14 +01:00
|
|
|
Log.e(TAG, String.format("%s %s",
|
|
|
|
getString(R.string.error_retrieving_oauth_token),
|
|
|
|
response.message()))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun onFailure(call: Call<AccessToken>, t: Throwable) {
|
|
|
|
setLoading(false)
|
2018-12-17 15:25:35 +01:00
|
|
|
domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
|
2018-02-03 22:45:14 +01:00
|
|
|
Log.e(TAG, String.format("%s %s",
|
|
|
|
getString(R.string.error_retrieving_oauth_token),
|
|
|
|
t.message))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-10-13 21:09:23 +02:00
|
|
|
mastodonApi.fetchOAuthToken(domain, clientId, clientSecret, redirectUri, code,
|
2018-02-03 22:45:14 +01:00
|
|
|
"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)
|
2018-12-17 15:25:35 +01:00
|
|
|
domainTextInputLayout.error = getString(R.string.error_authorization_denied)
|
2018-02-03 22:45:14 +01:00
|
|
|
Log.e(TAG, String.format("%s %s",
|
|
|
|
getString(R.string.error_authorization_denied),
|
|
|
|
error))
|
|
|
|
} else {
|
|
|
|
// This case means a junk response was received somehow.
|
|
|
|
setLoading(false)
|
2018-12-17 15:25:35 +01:00
|
|
|
domainTextInputLayout.error = getString(R.string.error_authorization_unknown)
|
2018-02-03 22:45:14 +01:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// first show or user cancelled login
|
|
|
|
setLoading(false)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun setLoading(loadingState: Boolean) {
|
|
|
|
if (loadingState) {
|
|
|
|
loginLoadingLayout.visibility = View.VISIBLE
|
|
|
|
loginInputLayout.visibility = View.GONE
|
|
|
|
} else {
|
|
|
|
loginLoadingLayout.visibility = View.GONE
|
|
|
|
loginInputLayout.visibility = View.VISIBLE
|
|
|
|
loginButton.isEnabled = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-03-09 22:02:32 +01:00
|
|
|
private fun isAdditionalLogin(): Boolean {
|
2018-02-03 22:45:14 +01:00
|
|
|
return intent.getBooleanExtra(LOGIN_MODE, false)
|
|
|
|
}
|
|
|
|
|
2019-10-13 21:09:23 +02:00
|
|
|
private fun onLoginSuccess(accessToken: String, domain: String) {
|
2018-02-03 22:45:14 +01:00
|
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
2018-04-22 17:20:01 +02:00
|
|
|
accountManager.addAccount(accessToken, domain)
|
2018-02-03 22:45:14 +01:00
|
|
|
|
|
|
|
val intent = Intent(this, MainActivity::class.java)
|
|
|
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
|
|
|
startActivity(intent)
|
|
|
|
finish()
|
2018-08-07 23:08:53 +02:00
|
|
|
overridePendingTransition(R.anim.explode, R.anim.explode)
|
2018-02-03 22:45:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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. */
|
2018-04-22 17:20:01 +02:00
|
|
|
private fun canonicalizeDomain(domain: String): String {
|
2018-02-03 22:45:14 +01:00
|
|
|
// 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 {
|
|
|
|
|
2020-01-30 21:37:28 +01:00
|
|
|
val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface)
|
2019-12-20 20:08:02 +01:00
|
|
|
val customTabsIntentBuilder = CustomTabsIntent.Builder()
|
2019-03-30 15:18:40 +01:00
|
|
|
.setToolbarColor(toolbarColor)
|
2019-12-20 20:08:02 +01:00
|
|
|
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
|
|
|
customTabsIntentBuilder.setNavigationBarColor(
|
|
|
|
ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
val customTabsIntent = customTabsIntentBuilder.build()
|
2018-02-03 22:45:14 +01:00
|
|
|
try {
|
2019-10-29 20:30:46 +01:00
|
|
|
customTabsIntent.launchUrl(context, uri)
|
2018-02-03 22:45:14 +01:00
|
|
|
} catch (e: ActivityNotFoundException) {
|
2019-03-30 15:18:40 +01:00
|
|
|
Log.w(TAG, "Activity was not found for intent $customTabsIntent")
|
2018-02-03 22:45:14 +01:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|