Add support for OAuth flow after settings import

This commit is contained in:
cketti 2022-06-07 15:39:11 +02:00
parent 94c61a7999
commit 9ae7d27e79
9 changed files with 142 additions and 30 deletions

View file

@ -77,17 +77,19 @@ public class SettingsImporter {
public final AccountDescription original;
public final AccountDescription imported;
public final boolean overwritten;
public final boolean authorizationNeeded;
public final boolean incomingPasswordNeeded;
public final boolean outgoingPasswordNeeded;
public final String incomingServerName;
public final String outgoingServerName;
private AccountDescriptionPair(AccountDescription original, AccountDescription imported,
boolean overwritten, boolean incomingPasswordNeeded, boolean outgoingPasswordNeeded,
String incomingServerName, String outgoingServerName) {
boolean overwritten, boolean authorizationNeeded, boolean incomingPasswordNeeded,
boolean outgoingPasswordNeeded, String incomingServerName, String outgoingServerName) {
this.original = original;
this.imported = imported;
this.overwritten = overwritten;
this.authorizationNeeded = authorizationNeeded;
this.incomingPasswordNeeded = incomingPasswordNeeded;
this.outgoingPasswordNeeded = outgoingPasswordNeeded;
this.incomingServerName = incomingServerName;
@ -372,8 +374,11 @@ public class SettingsImporter {
String incomingServerName = incoming.host;
boolean incomingPasswordNeeded = AuthType.EXTERNAL != incoming.authenticationType &&
AuthType.XOAUTH2 != incoming.authenticationType &&
(incoming.password == null || incoming.password.isEmpty());
boolean authorizationNeeded = incoming.authenticationType == AuthType.XOAUTH2;
String incomingServerType = ServerTypeConverter.toServerSettingsType(account.incoming.type);
if (account.outgoing == null && !incomingServerType.equals(Protocols.WEBDAV)) {
// All account types except WebDAV need to provide outgoing server settings
@ -395,15 +400,18 @@ public class SettingsImporter {
*/
String outgoingServerType = ServerTypeConverter.toServerSettingsType(outgoing.type);
outgoingPasswordNeeded = AuthType.EXTERNAL != outgoing.authenticationType &&
AuthType.XOAUTH2 != outgoing.authenticationType &&
!outgoingServerType.equals(Protocols.WEBDAV) &&
outgoing.username != null &&
!outgoing.username.isEmpty() &&
(outgoing.password == null || outgoing.password.isEmpty());
authorizationNeeded |= outgoing.authenticationType == AuthType.XOAUTH2;
outgoingServerName = outgoing.host;
}
boolean createAccountDisabled = incomingPasswordNeeded || outgoingPasswordNeeded;
boolean createAccountDisabled = incomingPasswordNeeded || outgoingPasswordNeeded || authorizationNeeded;
if (createAccountDisabled) {
editor.putBoolean(accountKeyPrefix + "enabled", false);
}
@ -465,7 +473,7 @@ public class SettingsImporter {
putString(editor, accountKeyPrefix + "messagesNotificationChannelVersion", messageNotificationChannelVersion);
AccountDescription imported = new AccountDescription(accountName, uuid);
return new AccountDescriptionPair(original, imported, mergeImportedAccount,
return new AccountDescriptionPair(original, imported, mergeImportedAccount, authorizationNeeded,
incomingPasswordNeeded, outgoingPasswordNeeded, incomingServerName, outgoingServerName);
}
@ -1061,11 +1069,12 @@ public class SettingsImporter {
String type = ServerTypeConverter.toServerSettingsType(importedServer.type);
int port = convertPort(importedServer.port);
ConnectionSecurity connectionSecurity = convertConnectionSecurity(importedServer.connectionSecurity);
String password = importedServer.authenticationType == AuthType.XOAUTH2 ? "" : importedServer.password;
Map<String, String> extra = importedServer.extras != null ?
unmodifiableMap(importedServer.extras.settings) : emptyMap();
return new ServerSettings(type, importedServer.host, port, connectionSecurity,
importedServer.authenticationType, importedServer.username, importedServer.password,
importedServer.authenticationType, importedServer.username, password,
importedServer.clientCertificateAlias, extra);
}

View file

@ -18,7 +18,16 @@ class AccountActivator(
val account = preferences.getAccount(accountUuid) ?: error("Account $accountUuid not found")
setAccountPasswords(account, incomingServerPassword, outgoingServerPassword)
enableAccount(account)
}
fun enableAccount(accountUuid: String) {
val account = preferences.getAccount(accountUuid) ?: error("Account $accountUuid not found")
enableAccount(account)
}
private fun enableAccount(account: Account) {
// Start services if necessary
Core.setServicesEnabled(context)

View file

@ -14,6 +14,7 @@ import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import com.fsck.k9.activity.setup.OAuthFlowActivity
import com.fsck.k9.ui.R
import com.fsck.k9.ui.observeNotNull
import com.mikepenz.fastadapter.FastAdapter
@ -103,6 +104,12 @@ class SettingsImportFragment : Fragment() {
StatusText.IMPORT_SUCCESS_PASSWORD_REQUIRED -> {
statusText.text = getString(R.string.settings_import_password_required)
}
StatusText.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED -> {
statusText.text = getString(R.string.settings_import_authorization_required)
}
StatusText.IMPORT_SUCCESS_PASSWORD_AND_AUTHORIZATION_REQUIRED -> {
statusText.text = getString(R.string.settings_import_authorization_and_password_required)
}
StatusText.IMPORT_READ_FAILURE -> {
statusText.text = getString(R.string.settings_import_read_failure)
}
@ -142,6 +149,7 @@ class SettingsImportFragment : Fragment() {
is Action.Close -> closeImportScreen(action)
is Action.PickDocument -> pickDocument()
is Action.PasswordPrompt -> showPasswordPrompt(action)
is Action.StartAuthorization -> startAuthorization(action)
}
}
@ -160,6 +168,15 @@ class SettingsImportFragment : Fragment() {
startActivityForResult(createDocumentIntent, REQUEST_PICK_DOCUMENT)
}
private fun startAuthorization(action: Action.StartAuthorization) {
val intent = OAuthFlowActivity.buildLaunchIntent(
context = requireContext(),
accountUuid = action.accountUuid
)
startActivityForResult(intent, REQUEST_AUTHORIZATION)
}
private fun showPasswordPrompt(action: Action.PasswordPrompt) {
val dialogFragment = PasswordPromptDialogFragment.create(
action.accountUuid,
@ -183,6 +200,7 @@ class SettingsImportFragment : Fragment() {
when (requestCode) {
REQUEST_PICK_DOCUMENT -> handlePickDocumentResult(resultCode, data)
REQUEST_PASSWORD_PROMPT -> handlePasswordPromptResult(resultCode, data)
REQUEST_AUTHORIZATION -> handleAuthorizationResult(resultCode)
}
}
@ -203,9 +221,16 @@ class SettingsImportFragment : Fragment() {
}
}
private fun handleAuthorizationResult(resultCode: Int) {
if (resultCode == Activity.RESULT_OK) {
viewModel.onReturnAfterAuthorization()
}
}
companion object {
private const val REQUEST_PICK_DOCUMENT = Activity.RESULT_FIRST_USER
private const val REQUEST_PASSWORD_PROMPT = Activity.RESULT_FIRST_USER + 1
private const val REQUEST_AUTHORIZATION = Activity.RESULT_FIRST_USER + 2
}
}

View file

@ -32,8 +32,9 @@ abstract class ImportListItem<VH : ImportCheckBoxViewHolder>(
val imageLevel = when (importStatus) {
ImportStatus.IMPORT_SUCCESS -> 0
ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED -> 1
ImportStatus.NOT_SELECTED -> 2
ImportStatus.IMPORT_FAILURE -> 3
ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED -> 2
ImportStatus.NOT_SELECTED -> 3
ImportStatus.IMPORT_FAILURE -> 4
else -> error("Unexpected import status: $importStatus")
}
holder.statusIcon.setImageLevel(imageLevel)
@ -41,6 +42,7 @@ abstract class ImportListItem<VH : ImportCheckBoxViewHolder>(
val contentDescriptionStringResId = when (importStatus) {
ImportStatus.IMPORT_SUCCESS -> R.string.settings_import_status_success
ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED -> R.string.settings_import_status_password_required
ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED -> R.string.settings_import_status_log_in_required
ImportStatus.NOT_SELECTED -> R.string.settings_import_status_not_imported
ImportStatus.IMPORT_FAILURE -> R.string.settings_import_status_error
else -> error("Unexpected import status: $importStatus")

View file

@ -67,13 +67,13 @@ class SettingsImportUiModel {
statusText = StatusText.IMPORT_SUCCESS
}
private fun showPasswordRequiredText() {
private fun showActionRequiredText(actionText: StatusText) {
importButton = ButtonState.GONE
closeButton = ButtonState.ENABLED
closeButtonLabel = CloseButtonLabel.LATER
isImportProgressVisible = false
isSettingsListEnabled = true
statusText = StatusText.IMPORT_SUCCESS_PASSWORD_REQUIRED
statusText = actionText
}
fun showReadFailureText() {
@ -120,7 +120,7 @@ class SettingsImportUiModel {
fun setSettingsListState(position: Int, status: ImportStatus) {
settingsList[position].importStatus = status
settingsList[position].enabled = status == ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED
settingsList[position].enabled = status.isActionRequired
}
private fun updateImportButtonFromSelection() {
@ -141,17 +141,26 @@ class SettingsImportUiModel {
return
}
val passwordsMissing = settingsList.any { it.importStatus == ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED }
if (passwordsMissing) {
showPasswordRequiredText()
return
val passwordsMissing = settingsList.any {
it.importStatus == ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED
}
val authorizationRequired = settingsList.any {
it.importStatus == ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED
}
val partialImportError = settingsList.any { it.importStatus == ImportStatus.IMPORT_FAILURE }
if (partialImportError) {
showPartialImportErrorText()
if (passwordsMissing && authorizationRequired) {
showActionRequiredText(StatusText.IMPORT_SUCCESS_PASSWORD_AND_AUTHORIZATION_REQUIRED)
} else if (passwordsMissing) {
showActionRequiredText(StatusText.IMPORT_SUCCESS_PASSWORD_REQUIRED)
} else if (authorizationRequired) {
showActionRequiredText(StatusText.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED)
} else {
showSuccessText()
val partialImportError = settingsList.any { it.importStatus == ImportStatus.IMPORT_FAILURE }
if (partialImportError) {
showPartialImportErrorText()
} else {
showSuccessText()
}
}
}
}
@ -165,15 +174,13 @@ sealed class SettingsListItem {
class Account(val accountIndex: Int, var displayName: String) : SettingsListItem()
}
enum class ImportStatus {
NOT_AVAILABLE,
NOT_SELECTED,
IMPORT_SUCCESS,
IMPORT_SUCCESS_PASSWORD_REQUIRED,
IMPORT_FAILURE;
val isSuccess: Boolean
get() = this == IMPORT_SUCCESS || this == IMPORT_SUCCESS_PASSWORD_REQUIRED
enum class ImportStatus(val isSuccess: Boolean, val isActionRequired: Boolean) {
NOT_AVAILABLE(isSuccess = false, isActionRequired = false),
NOT_SELECTED(isSuccess = false, isActionRequired = false),
IMPORT_SUCCESS(isSuccess = true, isActionRequired = false),
IMPORT_SUCCESS_PASSWORD_REQUIRED(isSuccess = true, isActionRequired = true),
IMPORT_SUCCESS_AUTHORIZATION_REQUIRED(isSuccess = true, isActionRequired = true),
IMPORT_FAILURE(isSuccess = false, isActionRequired = false)
}
enum class ButtonState {
@ -188,6 +195,8 @@ enum class StatusText {
IMPORTING_PROGRESS,
IMPORT_SUCCESS,
IMPORT_SUCCESS_PASSWORD_REQUIRED,
IMPORT_SUCCESS_AUTHORIZATION_REQUIRED,
IMPORT_SUCCESS_PASSWORD_AND_AUTHORIZATION_REQUIRED,
IMPORT_READ_FAILURE,
IMPORT_PARTIAL_FAILURE,
IMPORT_FAILURE

View file

@ -36,6 +36,7 @@ class SettingsImportViewModel(
private var accountsMap: MutableMap<AccountNumber, AccountUuid> = mutableMapOf()
private val accountStateMap: MutableMap<AccountNumber, AccountState> = mutableMapOf()
private var contentUri: Uri? = null
private var currentlyAuthorizingAccountUuid: String? = null
private val containsGeneralSettings: Boolean
get() = uiModel.settingsList.any { it is SettingsListItem.GeneralSettings }
@ -69,6 +70,7 @@ class SettingsImportViewModel(
fun initializeFromSavedState(savedInstanceState: Bundle) {
contentUri = savedInstanceState.getParcelable(STATE_CONTENT_URI)
currentlyAuthorizingAccountUuid = savedInstanceState.getString(STATE_CURRENTLY_AUTHORIZING_ACCOUNT_UUID)
updateUiModel {
isSettingsListVisible = savedInstanceState.getBoolean(STATE_SETTINGS_LIST_VISIBLE)
@ -145,6 +147,7 @@ class SettingsImportViewModel(
outState.putBoolean(STATE_LOADING_PROGRESS_VISIBLE, isLoadingProgressVisible)
outState.putBoolean(STATE_IMPORT_PROGRESS_VISIBLE, isImportProgressVisible)
outState.putEnum(STATE_STATUS_TEXT, statusText)
outState.putString(STATE_CURRENTLY_AUTHORIZING_ACCOUNT_UUID, currentlyAuthorizingAccountUuid)
if (hasDocumentBeenRead) {
val containsGeneralSettings = this@SettingsImportViewModel.containsGeneralSettings
@ -200,6 +203,9 @@ class SettingsImportViewModel(
ImportStatus.NOT_AVAILABLE -> updateUiModel {
toggleSettingsListItemSelection(position)
}
ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED -> {
startAuthorization(settingsListItem as SettingsListItem.Account)
}
ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED -> {
showPasswordPromptDialog(settingsListItem as SettingsListItem.Account)
}
@ -222,6 +228,21 @@ class SettingsImportViewModel(
}
}
fun onReturnAfterAuthorization() {
currentlyAuthorizingAccountUuid?.let { accountUuid ->
currentlyAuthorizingAccountUuid = null
updateUiModel {
val index = getListIndexOfAccount(accountUuid)
setSettingsListState(index, ImportStatus.IMPORT_SUCCESS)
updateCloseButtonAndImportStatusText()
}
GlobalScope.launch(Dispatchers.IO) {
accountActivator.enableAccount(accountUuid)
}
}
}
private fun getListIndexOfAccount(accountUuid: String): Int {
return uiModel.settingsList.indexOfFirst {
it is SettingsListItem.Account && accountsMap[it.accountIndex] == accountUuid
@ -333,7 +354,9 @@ class SettingsImportViewModel(
accountsMap[accountIndex] = accountPair.imported.uuid
listItem.displayName = accountPair.imported.name
if (accountPair.incomingPasswordNeeded || accountPair.outgoingPasswordNeeded) {
if (accountPair.authorizationNeeded) {
setSettingsListState(index, ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED)
} else if (accountPair.incomingPasswordNeeded || accountPair.outgoingPasswordNeeded) {
accountStateMap[accountIndex] = AccountState(
accountPair.incomingServerName,
accountPair.outgoingServerName,
@ -364,6 +387,14 @@ class SettingsImportViewModel(
}
}
private fun startAuthorization(settingsListItem: SettingsListItem.Account) {
val accountIndex = settingsListItem.accountIndex
val accountUuid = accountsMap[accountIndex]!!
currentlyAuthorizingAccountUuid = accountUuid
sendActionEvent(Action.StartAuthorization(accountUuid))
}
private fun showPasswordPromptDialog(settingsListItem: SettingsListItem.Account) {
val accountIndex = settingsListItem.accountIndex
@ -431,12 +462,14 @@ class SettingsImportViewModel(
private const val STATE_CONTENT_URI = "contentUri"
private const val STATE_GENERAL_SETTINGS_IMPORT_STATUS = "generalSettingsImportStatus"
private const val STATE_ACCOUNT_LIST = "accountList"
private const val STATE_CURRENTLY_AUTHORIZING_ACCOUNT_UUID = "currentlyAuthorizingAccountUuid"
}
}
sealed class Action {
class Close(val importSuccess: Boolean) : Action()
object PickDocument : Action()
class StartAuthorization(val accountUuid: String) : Action()
class PasswordPrompt(
val accountUuid: String,
val accountName: String,

View file

@ -9,11 +9,15 @@
android:maxLevel="1"
android:minLevel="1" />
<item
android:drawable="@drawable/ic_not_imported"
android:drawable="@drawable/ic_login"
android:maxLevel="2"
android:minLevel="2" />
<item
android:drawable="@drawable/ic_error"
android:drawable="@drawable/ic_not_imported"
android:maxLevel="3"
android:minLevel="3" />
<item
android:drawable="@drawable/ic_error"
android:maxLevel="4"
android:minLevel="4" />
</level-list>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10,17V14H3V10H10V7L15,12L10,17M10,2H19A2,2 0 0,1 21,4V20A2,2 0 0,1 19,22H10A2,2 0 0,1 8,20V18H10V20H19V4H10V6H8V4A2,2 0 0,1 10,2Z" />
</vector>

View file

@ -932,12 +932,23 @@ Please submit bug reports, contribute new features and ask questions at
<string name="settings_import_pick_document_button">Select file</string>
<string name="settings_import_button">Import</string>
<string name="settings_import_success_generic">Settings successfully imported</string>
<!-- Displayed after importing accounts when one or multiple accounts require entering a password -->
<string name="settings_import_password_required">Please enter passwords</string>
<!-- Displayed after importing accounts when one or multiple accounts require to use the OAuth authorization flow -->
<string name="settings_import_authorization_required">Please sign in</string>
<!-- Displayed after importing accounts when some accounts require entering a password and some to use the OAuth authorization flow -->
<string name="settings_import_authorization_and_password_required">Please sign in and enter passwords</string>
<string name="settings_import_failure">Failed to import settings</string>
<string name="settings_import_read_failure">Failed to read settings file</string>
<string name="settings_import_partial_failure">Failed to import some settings</string>
<string name="settings_import_status_success">Imported successfully</string>
<string name="settings_import_status_password_required">Password required</string>
<!-- Content description for the icon that is displayed next to an imported account that requires sign-in -->
<string name="settings_import_status_log_in_required">Sign-in required</string>
<string name="settings_import_status_not_imported">Not imported</string>
<string name="settings_import_status_error">Import failure</string>
<string name="settings_import_later_button">Later</string>