diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 59e46175..a32259b7 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 8e193c34..d34dd6df 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 08140b63..00000000
--- 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 e7d748db..73aedbd9 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 1b7e2994..0225147f 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 00000000..cc2bd776
--- /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 00000000..16e07f99
--- /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 7d68f75f..285fe916 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 8804124c..fe5def87 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 70c91242..c8ce5079 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