/* 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.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 } 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 <= ' ' } } } }