Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 18:55:42 +01:00
parent a629de6271
commit 3cef7c5092
2161 changed files with 246605 additions and 2 deletions

View file

@ -0,0 +1,83 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(ThunderbirdPlugins.Library.android)
alias(libs.plugins.kotlin.parcelize)
}
dependencies {
api(projects.app.ui.base)
implementation(projects.app.core)
implementation(projects.app.autodiscovery.api)
implementation(projects.app.autodiscovery.providersxml)
implementation(projects.mail.common)
implementation(projects.uiUtils.toolbarBottomSheet)
// Remove AccountSetupIncoming's dependency on these
compileOnly(projects.mail.protocols.imap)
compileOnly(projects.mail.protocols.webdav)
implementation(projects.plugins.openpgpApiLib.openpgpApi)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.preference)
implementation(libs.preferencex)
implementation(libs.preferencex.datetimepicker)
implementation(libs.preferencex.colorpicker)
implementation(libs.androidx.recyclerview)
implementation(projects.uiUtils.linearLayoutManager)
implementation(projects.uiUtils.itemTouchHelper)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.localbroadcastmanager)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.ckchangelog.core)
implementation(libs.tokenautocomplete)
implementation(libs.safeContentResolver)
implementation(libs.materialdrawer)
implementation(libs.searchPreference)
implementation(libs.fastadapter)
implementation(libs.fastadapter.extensions.drag)
implementation(libs.fastadapter.extensions.utils)
implementation(libs.circleimageview)
api(libs.appauth)
implementation(libs.commons.io)
implementation(libs.androidx.core.ktx)
implementation(libs.jcip.annotations)
implementation(libs.timber)
implementation(libs.mime4j.core)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.glide)
annotationProcessor(libs.glide.compiler)
testImplementation(projects.core.testing)
testImplementation(projects.mail.testing)
testImplementation(projects.app.storage)
testImplementation(projects.app.testing)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
}
android {
namespace = "com.fsck.k9.ui"
buildFeatures {
buildConfig = true
}
buildTypes {
debug {
manifestPlaceholders["appAuthRedirectScheme"] = "FIXME: override this in your app project"
}
release {
manifestPlaceholders["appAuthRedirectScheme"] = "FIXME: override this in your app project"
}
}
}

View file

@ -0,0 +1,19 @@
{
"data": [
{
"name": "Personal",
"email": "user@domain.example",
"color": "#FF1976D2"
},
{
"name": "Work",
"email": "firstname.lastname@work.example",
"color": "#FFE91E63"
},
{
"name": "Club",
"email": "name@sportsclub.example",
"color": "#FFFFB300"
}
]
}

View file

@ -0,0 +1,48 @@
{
"data": [
{
"name": "Inbox",
"icon": "?attr/iconFolderInbox"
},
{
"name": "Outbox",
"icon": "?attr/iconFolderOutbox"
},
{
"name": "Archive",
"icon": "?attr/iconFolderArchive"
},
{
"name": "Drafts",
"icon": "?attr/iconFolderDrafts"
},
{
"name": "Sent",
"icon": "?attr/iconFolderSent"
},
{
"name": "Spam",
"icon": "?attr/iconFolderSpam"
},
{
"name": "Trash",
"icon": "?attr/iconFolderTrash"
},
{
"name": "Regular folder",
"icon": "?attr/iconFolder"
},
{
"name": "Another folder",
"icon": "?attr/iconFolder"
},
{
"name": "And yet another folder",
"icon": "?attr/iconFolder"
},
{
"name": "Folder",
"icon": "?attr/iconFolder"
}
]
}

View file

@ -0,0 +1,27 @@
package com.fsck.k9.ui.settings
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.ui.ConnectionSettings
object ExtraAccountDiscovery {
@JvmStatic
fun discover(email: String): ConnectionSettings? {
return if (email.endsWith("@k9mail.example")) {
val serverSettings = ServerSettings(
type = "demo",
host = "irrelevant",
port = 23,
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
authenticationType = AuthType.AUTOMATIC,
username = "irrelevant",
password = "irrelevant",
clientCertificateAlias = null
)
ConnectionSettings(incoming = serverSettings, outgoing = serverSettings)
} else {
null
}
}
}

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:theme="@style/Theme.K9.Light"
android:supportsRtl="true" />
<queries>
<intent>
<!-- Used to check whether to display the "Add from Contacts" menu item in the compose screen -->
<action android:name="android.intent.action.PICK" />
<data
android:mimeType="*/*"
android:scheme="content"
android:host="com.android.contacts" />
</intent>
<intent>
<!-- Used by AttachmentController to find the best Intent to view an attachment -->
<action android:name="android.intent.action.VIEW" />
<data
android:mimeType="*/*"
android:scheme="content" />
</intent>
<intent>
<!-- Used by the OpenPGP API -->
<action android:name="org.openintents.openpgp.IOpenPgpService2" />
</intent>
<intent>
<!-- Used by OpenPgpAppSelectDialog -->
<action android:name="android.intent.action.VIEW" />
<data
android:mimeType="*/*"
android:scheme="market"
android:host="details" />
</intent>
<intent>
<!-- Used by OpenPgpAppSelectDialog -->
<action android:name="org.openintents.openpgp.IOpenPgpService" />
</intent>
</queries>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,41 @@
package com.fsck.k9
import com.fsck.k9.account.accountModule
import com.fsck.k9.activity.activityModule
import com.fsck.k9.autodiscovery.providersxml.autodiscoveryProvidersXmlModule
import com.fsck.k9.contacts.contactsModule
import com.fsck.k9.ui.account.accountUiModule
import com.fsck.k9.ui.base.uiBaseModule
import com.fsck.k9.ui.changelog.changelogUiModule
import com.fsck.k9.ui.choosefolder.chooseFolderUiModule
import com.fsck.k9.ui.endtoend.endToEndUiModule
import com.fsck.k9.ui.folders.foldersUiModule
import com.fsck.k9.ui.managefolders.manageFoldersUiModule
import com.fsck.k9.ui.messagedetails.messageDetailsUiModule
import com.fsck.k9.ui.messagelist.messageListUiModule
import com.fsck.k9.ui.messagesource.messageSourceModule
import com.fsck.k9.ui.messageview.messageViewUiModule
import com.fsck.k9.ui.settings.settingsUiModule
import com.fsck.k9.ui.uiModule
import com.fsck.k9.view.viewModule
val uiModules = listOf(
uiBaseModule,
activityModule,
uiModule,
settingsUiModule,
endToEndUiModule,
foldersUiModule,
messageListUiModule,
manageFoldersUiModule,
chooseFolderUiModule,
contactsModule,
accountModule,
autodiscoveryProvidersXmlModule,
viewModule,
changelogUiModule,
messageSourceModule,
accountUiModule,
messageDetailsUiModule,
messageViewUiModule
)

View file

@ -0,0 +1,69 @@
package com.fsck.k9.account
import android.content.res.Resources
import com.fsck.k9.Account.DeletePolicy
import com.fsck.k9.Preferences
import com.fsck.k9.core.R
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.preferences.Protocols
/**
* Deals with logic surrounding account creation.
*
* TODO Move much of the code from com.fsck.k9.activity.setup.* into here
*/
class AccountCreator(private val preferences: Preferences, private val resources: Resources) {
fun getDefaultDeletePolicy(type: String): DeletePolicy {
return when (type) {
Protocols.IMAP -> DeletePolicy.ON_DELETE
Protocols.POP3 -> DeletePolicy.NEVER
Protocols.WEBDAV -> DeletePolicy.ON_DELETE
"demo" -> DeletePolicy.ON_DELETE
else -> throw AssertionError("Unhandled case: $type")
}
}
fun getDefaultPort(securityType: ConnectionSecurity, serverType: String): Int {
return when (serverType) {
Protocols.IMAP -> getImapDefaultPort(securityType)
Protocols.WEBDAV -> getWebDavDefaultPort(securityType)
Protocols.POP3 -> getPop3DefaultPort(securityType)
Protocols.SMTP -> getSmtpDefaultPort(securityType)
else -> throw AssertionError("Unhandled case: $serverType")
}
}
private fun getImapDefaultPort(connectionSecurity: ConnectionSecurity): Int {
return if (connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) 993 else 143
}
private fun getPop3DefaultPort(connectionSecurity: ConnectionSecurity): Int {
return if (connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) 995 else 110
}
private fun getWebDavDefaultPort(connectionSecurity: ConnectionSecurity): Int {
return if (connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) 443 else 80
}
private fun getSmtpDefaultPort(connectionSecurity: ConnectionSecurity): Int {
return if (connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) 465 else 587
}
fun pickColor(): Int {
val accounts = preferences.accounts
val usedAccountColors = accounts.map { it.chipColor }.toSet()
val accountColors = resources.getIntArray(R.array.account_colors).toList()
val availableColors = accountColors - usedAccountColors
if (availableColors.isEmpty()) {
return accountColors.random()
}
val defaultAccountColors = resources.getIntArray(R.array.default_account_colors)
return availableColors.shuffled().minByOrNull { color ->
val index = defaultAccountColors.indexOf(color)
if (index != -1) index else defaultAccountColors.size
} ?: error("availableColors must not be empty")
}
}

View file

@ -0,0 +1,73 @@
package com.fsck.k9.account
import com.fsck.k9.Account
import com.fsck.k9.Core
import com.fsck.k9.LocalKeyStoreManager
import com.fsck.k9.Preferences
import com.fsck.k9.backend.BackendManager
import com.fsck.k9.controller.MessagingController
import com.fsck.k9.mailstore.LocalStoreProvider
import timber.log.Timber
/**
* Removes an account and all associated data.
*/
class AccountRemover(
private val localStoreProvider: LocalStoreProvider,
private val messagingController: MessagingController,
private val backendManager: BackendManager,
private val localKeyStoreManager: LocalKeyStoreManager,
private val preferences: Preferences
) {
fun removeAccount(accountUuid: String) {
val account = preferences.getAccount(accountUuid)
if (account == null) {
Timber.w("Can't remove account with UUID %s because it doesn't exist.", accountUuid)
return
}
val accountName = account.toString()
Timber.v("Removing account '%s'…", accountName)
removeLocalStore(account)
messagingController.deleteAccount(account)
removeBackend(account)
preferences.deleteAccount(account)
removeCertificates(account)
Core.setServicesEnabled()
Timber.v("Finished removing account '%s'.", accountName)
}
private fun removeLocalStore(account: Account) {
try {
val localStore = localStoreProvider.getInstance(account)
localStore.delete()
} catch (e: Exception) {
Timber.w(e, "Error removing message database for account '%s'", account)
// Ignore, this may lead to localStores on sd-cards that are currently not inserted to be left
}
localStoreProvider.removeInstance(account)
}
private fun removeBackend(account: Account) {
try {
backendManager.removeBackend(account)
} catch (e: Exception) {
Timber.e(e, "Failed to reset remote store for account %s", account)
}
}
private fun removeCertificates(account: Account) {
try {
localKeyStoreManager.deleteCertificates(account)
} catch (e: Exception) {
Timber.e(e, "Failed to remove certificates for account %s", account)
}
}
}

View file

@ -0,0 +1,34 @@
package com.fsck.k9.account
import android.content.Context
import android.content.Intent
import androidx.core.app.JobIntentService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* A [JobIntentService] to remove an account in the background.
*/
class AccountRemoverService : JobIntentService(), KoinComponent {
private val accountRemover: AccountRemover by inject()
override fun onHandleWork(intent: Intent) {
val accountUuid = intent.getStringExtra(ARG_ACCOUNT_UUID)
?: throw IllegalArgumentException("No account UUID provided")
accountRemover.removeAccount(accountUuid)
}
companion object {
private const val JOB_ID = 1
private const val ARG_ACCOUNT_UUID = "accountUuid"
fun enqueueRemoveAccountJob(context: Context, accountUuid: String) {
val workIntent = Intent().apply {
putExtra(ARG_ACCOUNT_UUID, accountUuid)
}
JobIntentService.enqueueWork(context, AccountRemoverService::class.java, JOB_ID, workIntent)
}
}
}

View file

@ -0,0 +1,14 @@
package com.fsck.k9.account
import android.content.Context
/**
* Triggers asynchronous removal of an account.
*/
class BackgroundAccountRemover(private val context: Context) {
fun removeAccountAsync(accountUuid: String) {
// TODO: Add a mechanism to hide the account from the UI right away
AccountRemoverService.enqueueRemoveAccountJob(context, accountUuid)
}
}

View file

@ -0,0 +1,17 @@
package com.fsck.k9.account
import org.koin.dsl.module
val accountModule = module {
factory {
AccountRemover(
localStoreProvider = get(),
messagingController = get(),
backendManager = get(),
localKeyStoreManager = get(),
preferences = get()
)
}
factory { BackgroundAccountRemover(get()) }
factory { AccountCreator(get(), get()) }
}

View file

@ -0,0 +1,161 @@
package com.fsck.k9.activity;
import java.util.ArrayList;
import java.util.List;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import com.fsck.k9.Account;
import com.fsck.k9.BaseAccount;
import com.fsck.k9.K9;
import com.fsck.k9.Preferences;
import com.fsck.k9.ui.R;
import com.fsck.k9.search.SearchAccount;
/**
* Activity displaying the list of accounts.
*
* <p>
* Classes extending this abstract class have to provide an {@link #onAccountSelected(BaseAccount)}
* method to perform an action when an account is selected.
* </p>
*/
public abstract class AccountList extends K9ListActivity implements OnItemClickListener {
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
setResult(RESULT_CANCELED);
setLayout(R.layout.account_list);
ListView listView = getListView();
listView.setOnItemClickListener(this);
listView.setItemsCanFocus(false);
}
/**
* Reload list of accounts when this activity is resumed.
*/
@Override
public void onResume() {
super.onResume();
new LoadAccounts().execute();
}
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
BaseAccount account = (BaseAccount) parent.getItemAtPosition(position);
onAccountSelected(account);
}
/**
* Create a new {@link AccountsAdapter} instance and assign it to the {@link ListView}.
*
* @param realAccounts
* An array of accounts to display.
*/
public void populateListView(List<Account> realAccounts) {
List<BaseAccount> accounts = new ArrayList<>();
if (K9.isShowUnifiedInbox()) {
BaseAccount unifiedInboxAccount = SearchAccount.createUnifiedInboxAccount();
accounts.add(unifiedInboxAccount);
}
accounts.addAll(realAccounts);
AccountsAdapter adapter = new AccountsAdapter(accounts);
ListView listView = getListView();
listView.setAdapter(adapter);
listView.invalidate();
}
/**
* This method will be called when an account was selected.
*
* @param account
* The account the user selected.
*/
protected abstract void onAccountSelected(BaseAccount account);
class AccountsAdapter extends ArrayAdapter<BaseAccount> {
public AccountsAdapter(List<BaseAccount> accounts) {
super(AccountList.this, 0, accounts);
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
final BaseAccount account = getItem(position);
final View view;
if (convertView != null) {
view = convertView;
} else {
view = getLayoutInflater().inflate(R.layout.accounts_item, parent, false);
}
AccountViewHolder holder = (AccountViewHolder) view.getTag();
if (holder == null) {
holder = new AccountViewHolder();
holder.description = view.findViewById(R.id.description);
holder.email = view.findViewById(R.id.email);
holder.chip = view.findViewById(R.id.chip);
view.setTag(holder);
}
String accountName = account.getName();
if (accountName != null) {
holder.description.setText(accountName);
holder.email.setText(account.getEmail());
holder.email.setVisibility(View.VISIBLE);
} else {
holder.description.setText(account.getEmail());
holder.email.setVisibility(View.GONE);
}
if (account instanceof Account) {
Account realAccount = (Account) account;
holder.chip.setBackgroundColor(realAccount.getChipColor());
} else {
holder.chip.setBackgroundColor(0xff999999);
}
holder.chip.getBackground().setAlpha(255);
return view;
}
class AccountViewHolder {
public TextView description;
public TextView email;
public View chip;
}
}
/**
* Load accounts in a background thread
*/
class LoadAccounts extends AsyncTask<Void, Void, List<Account>> {
@Override
protected List<Account> doInBackground(Void... params) {
return Preferences.getPreferences().getAccounts();
}
@Override
protected void onPostExecute(List<Account> accounts) {
populateListView(accounts);
}
}
}

View file

@ -0,0 +1,274 @@
package com.fsck.k9.activity;
import java.util.List;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.PorterDuff.Mode;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import androidx.annotation.AttrRes;
import androidx.annotation.DrawableRes;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import com.fsck.k9.activity.compose.RecipientAdapter;
import com.fsck.k9.ui.ContactBadge;
import com.fsck.k9.ui.R;
import com.fsck.k9.view.RecipientSelectView.Recipient;
import com.fsck.k9.view.ThemeUtils;
public class AlternateRecipientAdapter extends BaseAdapter {
private static final int NUMBER_OF_FIXED_LIST_ITEMS = 2;
private static final int POSITION_HEADER_VIEW = 0;
private static final int POSITION_CURRENT_ADDRESS = 1;
private final Context context;
private final AlternateRecipientListener listener;
private List<Recipient> recipients;
private Recipient currentRecipient;
private boolean showAdvancedInfo;
public AlternateRecipientAdapter(Context context, AlternateRecipientListener listener) {
super();
this.context = context;
this.listener = listener;
}
public void setCurrentRecipient(Recipient currentRecipient) {
this.currentRecipient = currentRecipient;
}
public void setAlternateRecipientInfo(List<Recipient> recipients) {
this.recipients = recipients;
int indexOfCurrentRecipient = recipients.indexOf(currentRecipient);
if (indexOfCurrentRecipient >= 0) {
currentRecipient = recipients.get(indexOfCurrentRecipient);
}
recipients.remove(currentRecipient);
notifyDataSetChanged();
}
@Override
public int getCount() {
if (recipients == null) {
return NUMBER_OF_FIXED_LIST_ITEMS;
}
return recipients.size() + NUMBER_OF_FIXED_LIST_ITEMS;
}
@Override
public Recipient getItem(int position) {
if (position == POSITION_HEADER_VIEW || position == POSITION_CURRENT_ADDRESS) {
return currentRecipient;
}
return recipients == null ? null : getRecipientFromPosition(position);
}
@Override
public long getItemId(int position) {
return position;
}
private Recipient getRecipientFromPosition(int position) {
return recipients.get(position - NUMBER_OF_FIXED_LIST_ITEMS);
}
@Override
public View getView(int position, View view, ViewGroup parent) {
if (view == null) {
view = newView(parent);
}
Recipient recipient = getItem(position);
if (position == POSITION_HEADER_VIEW) {
bindHeaderView(view, recipient);
} else {
bindItemView(view, recipient);
}
return view;
}
public View newView(ViewGroup parent) {
View view = LayoutInflater.from(context).inflate(R.layout.recipient_alternate_item, parent, false);
RecipientTokenHolder holder = new RecipientTokenHolder(view);
view.setTag(holder);
return view;
}
@Override
public boolean isEnabled(int position) {
return position != POSITION_HEADER_VIEW;
}
public void bindHeaderView(View view, Recipient recipient) {
RecipientTokenHolder holder = (RecipientTokenHolder) view.getTag();
holder.setShowAsHeader(true);
holder.headerName.setText(recipient.getNameOrUnknown(context));
if (!TextUtils.isEmpty(recipient.addressLabel)) {
holder.headerAddressLabel.setText(recipient.addressLabel);
holder.headerAddressLabel.setVisibility(View.VISIBLE);
} else {
holder.headerAddressLabel.setVisibility(View.GONE);
}
RecipientAdapter.setContactPhotoOrPlaceholder(context, holder.headerPhoto, recipient);
holder.headerPhoto.assignContactUri(recipient.getContactLookupUri());
holder.headerRemove.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
listener.onRecipientRemove(currentRecipient);
}
});
}
public void bindItemView(View view, final Recipient recipient) {
RecipientTokenHolder holder = (RecipientTokenHolder) view.getTag();
holder.setShowAsHeader(false);
String address = recipient.address.getAddress();
holder.itemAddress.setText(address);
if (!TextUtils.isEmpty(recipient.addressLabel)) {
holder.itemAddressLabel.setText(recipient.addressLabel);
holder.itemAddressLabel.setVisibility(View.VISIBLE);
} else {
holder.itemAddressLabel.setVisibility(View.GONE);
}
boolean isCurrent = currentRecipient == recipient;
holder.itemAddress.setTypeface(null, isCurrent ? Typeface.BOLD : Typeface.NORMAL);
holder.itemAddressLabel.setTypeface(null, isCurrent ? Typeface.BOLD : Typeface.NORMAL);
holder.layoutItem.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
listener.onRecipientChange(currentRecipient, recipient);
}
});
configureCryptoStatusView(holder, recipient);
}
private void configureCryptoStatusView(RecipientTokenHolder holder, Recipient recipient) {
if (showAdvancedInfo) {
configureCryptoStatusViewAdvanced(holder, recipient);
} else {
bindCryptoSimple(holder, recipient);
}
}
private void configureCryptoStatusViewAdvanced(RecipientTokenHolder holder, Recipient recipient) {
switch (recipient.getCryptoStatus()) {
case AVAILABLE_TRUSTED: {
setCryptoStatusView(holder, R.drawable.status_lock_dots_3, R.attr.openpgp_green);
break;
}
case AVAILABLE_UNTRUSTED: {
setCryptoStatusView(holder, R.drawable.status_lock_dots_2, R.attr.openpgp_orange);
break;
}
case UNAVAILABLE: {
setCryptoStatusView(holder, R.drawable.status_lock_disabled_dots_1, R.attr.openpgp_red);
break;
}
case UNDEFINED: {
holder.itemCryptoStatus.setVisibility(View.GONE);
break;
}
}
}
private void setCryptoStatusView(RecipientTokenHolder holder, @DrawableRes int cryptoStatusRes,
@AttrRes int cryptoStatusColorAttr) {
Resources resources = context.getResources();
Drawable drawable = resources.getDrawable(cryptoStatusRes);
// noinspection ConstantConditions, we know the resource exists!
drawable.mutate();
int cryptoStatusColor = ThemeUtils.getStyledColor(context, cryptoStatusColorAttr);
drawable.setColorFilter(cryptoStatusColor, Mode.SRC_ATOP);
holder.itemCryptoStatusIcon.setImageDrawable(drawable);
holder.itemCryptoStatus.setVisibility(View.VISIBLE);
}
private void bindCryptoSimple(RecipientTokenHolder holder, Recipient recipient) {
holder.itemCryptoStatus.setVisibility(View.GONE);
switch (recipient.getCryptoStatus()) {
case AVAILABLE_TRUSTED:
case AVAILABLE_UNTRUSTED: {
holder.itemCryptoStatusSimple.setVisibility(View.VISIBLE);
break;
}
case UNAVAILABLE:
case UNDEFINED: {
holder.itemCryptoStatusSimple.setVisibility(View.GONE);
break;
}
}
}
public void setShowAdvancedInfo(boolean showAdvancedInfo) {
this.showAdvancedInfo = showAdvancedInfo;
}
private static class RecipientTokenHolder {
public final View layoutHeader, layoutItem;
public final TextView headerName;
public final TextView headerAddressLabel;
public final ContactBadge headerPhoto;
public final View headerRemove;
public final TextView itemAddress;
public final TextView itemAddressLabel;
public final View itemCryptoStatus;
public final ImageView itemCryptoStatusIcon;
public final ImageView itemCryptoStatusSimple;
public RecipientTokenHolder(View view) {
layoutHeader = view.findViewById(R.id.alternate_container_header);
layoutItem = view.findViewById(R.id.alternate_container_item);
headerName = view.findViewById(R.id.alternate_header_name);
headerAddressLabel = view.findViewById(R.id.alternate_header_label);
headerPhoto = view.findViewById(R.id.alternate_contact_photo);
headerRemove = view.findViewById(R.id.alternate_remove);
itemAddress = view.findViewById(R.id.alternate_address);
itemAddressLabel = view.findViewById(R.id.alternate_address_label);
itemCryptoStatus = view.findViewById(R.id.alternate_crypto_status);
itemCryptoStatusIcon = view.findViewById(R.id.alternate_crypto_status_icon);
itemCryptoStatusSimple = view.findViewById(R.id.alternate_crypto_status_simple);
}
public void setShowAsHeader(boolean isHeader) {
layoutHeader.setVisibility(isHeader ? View.VISIBLE : View.GONE);
layoutItem.setVisibility(isHeader ? View.GONE : View.VISIBLE);
}
}
public interface AlternateRecipientListener {
void onRecipientRemove(Recipient currentRecipient);
void onRecipientChange(Recipient currentRecipient, Recipient alternateRecipient);
}
}

View file

@ -0,0 +1,27 @@
package com.fsck.k9.activity;
import android.content.Intent;
import android.os.Bundle;
import com.fsck.k9.BaseAccount;
import com.fsck.k9.ui.R;
public class ChooseAccount extends AccountList {
public static final String EXTRA_ACCOUNT_UUID = "com.fsck.k9.ChooseAccount_account_uuid";
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
setTitle(R.string.choose_account_title);
}
@Override
protected void onAccountSelected(BaseAccount account) {
Intent intent = new Intent();
intent.putExtra(EXTRA_ACCOUNT_UUID, account.getUuid());
setResult(RESULT_OK, intent);
finish();
}
}

View file

@ -0,0 +1,91 @@
package com.fsck.k9.activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.view.Window;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.Toast;
import com.fsck.k9.Account;
import com.fsck.k9.Identity;
import com.fsck.k9.Preferences;
import com.fsck.k9.ui.R;
import java.util.List;
public class ChooseIdentity extends K9ListActivity {
Account mAccount;
ArrayAdapter<String> adapter;
public static final String EXTRA_ACCOUNT = "com.fsck.k9.ChooseIdentity_account";
public static final String EXTRA_IDENTITY = "com.fsck.k9.ChooseIdentity_identity";
protected List<Identity> identities = null;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
setLayout(R.layout.list_content_simple);
setTitle(R.string.choose_identity_title);
getListView().setTextFilterEnabled(true);
getListView().setItemsCanFocus(false);
getListView().setChoiceMode(ListView.CHOICE_MODE_NONE);
Intent intent = getIntent();
String accountUuid = intent.getStringExtra(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1);
setListAdapter(adapter);
setupClickListeners();
}
@Override
public void onResume() {
super.onResume();
refreshView();
}
protected void refreshView() {
adapter.setNotifyOnChange(false);
adapter.clear();
identities = mAccount.getIdentities();
for (Identity identity : identities) {
String description = identity.getDescription();
if (description == null || description.trim().isEmpty()) {
description = getString(R.string.message_view_from_format, identity.getName(), identity.getEmail());
}
adapter.add(description);
}
adapter.notifyDataSetChanged();
}
protected void setupClickListeners() {
this.getListView().setOnItemClickListener(new AdapterView.OnItemClickListener() {
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Identity identity = mAccount.getIdentity(position);
String email = identity.getEmail();
if (email != null && !email.trim().equals("")) {
Intent intent = new Intent();
intent.putExtra(EXTRA_IDENTITY, mAccount.getIdentity(position));
setResult(RESULT_OK, intent);
finish();
} else {
Toast.makeText(ChooseIdentity.this, getString(R.string.identity_has_no_email),
Toast.LENGTH_LONG).show();
}
}
});
}
}

View file

@ -0,0 +1,126 @@
package com.fsck.k9.activity
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.CheckBox
import android.widget.TextView
import androidx.core.view.isVisible
import com.fsck.k9.Account
import com.fsck.k9.Identity
import com.fsck.k9.Preferences
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.K9Activity
class EditIdentity : K9Activity() {
private lateinit var account: Account
private lateinit var identity: Identity
private lateinit var description: TextView
private lateinit var name: TextView
private lateinit var email: TextView
private lateinit var replyTo: TextView
private lateinit var signatureUse: CheckBox
private lateinit var signature: TextView
private lateinit var signatureLayout: View
private var identityIndex: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setLayout(R.layout.edit_identity)
setTitle(R.string.edit_identity_title)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
identityIndex = intent.getIntExtra(EXTRA_IDENTITY_INDEX, -1)
val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT) ?: error("Missing account UUID")
account = Preferences.getPreferences().getAccount(accountUuid) ?: error("Couldn't find account")
identity = when {
savedInstanceState != null -> savedInstanceState.getParcelable(EXTRA_IDENTITY) ?: error("Missing state")
identityIndex != -1 -> intent.getParcelableExtra(EXTRA_IDENTITY) ?: error("Missing argument")
else -> Identity()
}
description = findViewById(R.id.description)
name = findViewById(R.id.name)
email = findViewById(R.id.email)
replyTo = findViewById(R.id.reply_to)
signatureUse = findViewById(R.id.signature_use)
signature = findViewById(R.id.signature)
signatureLayout = findViewById(R.id.signature_layout)
description.text = identity.description
name.text = identity.name
email.text = identity.email
replyTo.text = identity.replyTo
signatureUse.isChecked = identity.signatureUse
signatureUse.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
signatureLayout.isVisible = true
signature.text = identity.signature
} else {
signatureLayout.isVisible = false
}
}
if (signatureUse.isChecked) {
signature.text = identity.signature
} else {
signatureLayout.isVisible = false
}
}
private fun saveIdentity() {
identity = identity.copy(
description = description.text.toString(),
email = email.text.toString(),
name = name.text.toString(),
signatureUse = signatureUse.isChecked,
signature = signature.text.toString(),
replyTo = if (replyTo.text.isNotEmpty()) replyTo.text.toString() else null
)
val identities = account.identities
if (identityIndex == -1) {
identities.add(identity)
} else {
identities.removeAt(identityIndex)
identities.add(identityIndex, identity)
}
Preferences.getPreferences().saveAccount(account)
finish()
}
public override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelable(EXTRA_IDENTITY, identity)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.edit_identity_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
finish()
return true
} else if (item.itemId == R.id.edit_identity_save) {
saveIdentity()
finish()
return true
}
return super.onOptionsItemSelected(item)
}
companion object {
const val EXTRA_IDENTITY = "com.fsck.k9.EditIdentity_identity"
const val EXTRA_IDENTITY_INDEX = "com.fsck.k9.EditIdentity_identity_index"
const val EXTRA_ACCOUNT = "com.fsck.k9.EditIdentity_account"
}
}

View file

@ -0,0 +1,48 @@
package com.fsck.k9.activity
import com.fsck.k9.Account
import com.fsck.k9.mailstore.Folder
import com.fsck.k9.mailstore.FolderType
import com.fsck.k9.mailstore.LocalFolder
import com.fsck.k9.ui.folders.FolderNameFormatter
class FolderInfoHolder(
private val folderNameFormatter: FolderNameFormatter,
localFolder: LocalFolder,
account: Account
) {
@JvmField val databaseId = localFolder.databaseId
@JvmField val displayName = getDisplayName(account, localFolder)
@JvmField var loading = false
@JvmField var moreMessages = localFolder.hasMoreMessages()
private fun getDisplayName(account: Account, localFolder: LocalFolder): String {
val folderId = localFolder.databaseId
val folder = Folder(
id = folderId,
name = localFolder.name,
type = getFolderType(account, folderId),
isLocalOnly = localFolder.isLocalOnly
)
return folderNameFormatter.displayName(folder)
}
companion object {
@JvmStatic
fun getFolderType(account: Account, folderId: Long): FolderType {
return when (folderId) {
account.inboxFolderId -> FolderType.INBOX
account.outboxFolderId -> FolderType.OUTBOX
account.archiveFolderId -> FolderType.ARCHIVE
account.draftsFolderId -> FolderType.DRAFTS
account.sentFolderId -> FolderType.SENT
account.spamFolderId -> FolderType.SPAM
account.trashFolderId -> FolderType.TRASH
else -> FolderType.REGULAR
}
}
}
}

View file

@ -0,0 +1,33 @@
package com.fsck.k9.activity;
import android.view.View;
import android.widget.ListAdapter;
import android.widget.ListView;
import com.fsck.k9.ui.base.K9Activity;
public abstract class K9ListActivity extends K9Activity {
protected ListAdapter adapter;
protected ListView list;
protected ListView getListView() {
if (list == null) {
list = findViewById(android.R.id.list);
View emptyView = findViewById(android.R.id.empty);
if (emptyView != null) {
list.setEmptyView(emptyView);
}
}
return list;
}
protected void setListAdapter(ListAdapter listAdapter) {
if (list == null) {
list = findViewById(android.R.id.list);
}
list.setAdapter(listAdapter);
adapter = listAdapter;
}
}

View file

@ -0,0 +1,10 @@
package com.fsck.k9.activity
import com.fsck.k9.activity.setup.AuthViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val activityModule = module {
single { MessageLoaderHelperFactory(messageViewInfoExtractorFactory = get(), htmlSettingsProvider = get()) }
viewModel { AuthViewModel(application = get(), accountManager = get(), oAuthConfigurationProvider = get()) }
}

View file

@ -0,0 +1,50 @@
package com.fsck.k9.activity;
import android.content.Intent;
import android.os.Bundle;
import android.os.Parcelable;
import com.fsck.k9.Account;
import com.fsck.k9.BaseAccount;
import com.fsck.k9.ui.R;
import com.fsck.k9.search.SearchAccount;
public class LauncherShortcuts extends AccountList {
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
setTitle(R.string.shortcuts_title);
// finish() immediately if we aren't supposed to be here
if (!Intent.ACTION_CREATE_SHORTCUT.equals(getIntent().getAction())) {
finish();
}
}
@Override
protected void onAccountSelected(BaseAccount account) {
Intent shortcutIntent;
if (account instanceof SearchAccount) {
SearchAccount searchAccount = (SearchAccount) account;
shortcutIntent = MessageList.shortcutIntent(this, searchAccount.getId());
} else {
shortcutIntent = MessageList.shortcutIntentForAccount(this, (Account) account);
}
Intent intent = new Intent();
intent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent);
String accountName = account.getName();
String displayName;
if (accountName != null) {
displayName = accountName;
} else {
displayName = account.getEmail();
}
intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, displayName);
Parcelable iconResource = Intent.ShortcutIconResource.fromContext(this, R.mipmap.icon);
intent.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, iconResource);
setResult(RESULT_OK, intent);
finish();
}
}

View file

@ -0,0 +1,146 @@
package com.fsck.k9.activity;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.AdapterView.AdapterContextMenuInfo;
import android.widget.ListView;
import android.widget.Toast;
import com.fsck.k9.Identity;
import com.fsck.k9.Preferences;
import com.fsck.k9.ui.R;
public class ManageIdentities extends ChooseIdentity {
private boolean mIdentitiesChanged = false;
private static final int ACTIVITY_EDIT_IDENTITY = 1;
public static void start(Activity activity, String accountUuid) {
Intent intent = new Intent(activity, ManageIdentities.class);
intent.putExtra(ChooseIdentity.EXTRA_ACCOUNT, accountUuid);
activity.startActivity(intent);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setTitle(R.string.manage_identities_title);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
@Override
protected void setupClickListeners() {
this.getListView().setOnItemClickListener(new AdapterView.OnItemClickListener() {
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
editItem(position);
}
});
ListView listView = getListView();
registerForContextMenu(listView);
}
private void editItem(int i) {
Intent intent = new Intent(ManageIdentities.this, EditIdentity.class);
intent.putExtra(EditIdentity.EXTRA_ACCOUNT, mAccount.getUuid());
intent.putExtra(EditIdentity.EXTRA_IDENTITY, mAccount.getIdentity(i));
intent.putExtra(EditIdentity.EXTRA_IDENTITY_INDEX, i);
startActivityForResult(intent, ACTIVITY_EDIT_IDENTITY);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
getMenuInflater().inflate(R.menu.manage_identities_option, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.new_identity) {
Intent intent = new Intent(ManageIdentities.this, EditIdentity.class);
intent.putExtra(EditIdentity.EXTRA_ACCOUNT, mAccount.getUuid());
startActivityForResult(intent, ACTIVITY_EDIT_IDENTITY);
} else if (item.getItemId() == android.R.id.home) {
finish();
} else {
return super.onOptionsItemSelected(item);
}
return true;
}
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
super.onCreateContextMenu(menu, v, menuInfo);
menu.setHeaderTitle(R.string.manage_identities_context_menu_title);
getMenuInflater().inflate(R.menu.manage_identities_context, menu);
}
@Override
public boolean onContextItemSelected(android.view.MenuItem item) {
AdapterContextMenuInfo menuInfo = (AdapterContextMenuInfo)item.getMenuInfo();
int id = item.getItemId();
if (id == R.id.edit) {
editItem(menuInfo.position);
} else if (id == R.id.up) {
if (menuInfo.position > 0) {
Identity identity = identities.remove(menuInfo.position);
identities.add(menuInfo.position - 1, identity);
mIdentitiesChanged = true;
refreshView();
}
} else if (id == R.id.down) {
if (menuInfo.position < identities.size() - 1) {
Identity identity = identities.remove(menuInfo.position);
identities.add(menuInfo.position + 1, identity);
mIdentitiesChanged = true;
refreshView();
}
} else if (id == R.id.top) {
Identity identity = identities.remove(menuInfo.position);
identities.add(0, identity);
mIdentitiesChanged = true;
refreshView();
} else if (id == R.id.remove) {
if (identities.size() > 1) {
identities.remove(menuInfo.position);
mIdentitiesChanged = true;
refreshView();
} else {
Toast.makeText(this, getString(R.string.no_removable_identity),
Toast.LENGTH_LONG).show();
}
}
return true;
}
@Override
public void onResume() {
super.onResume();
//mAccount.refresh(Preferences.getPreferences(getApplication().getApplicationContext()));
refreshView();
}
@Override
public void onBackPressed() {
saveIdentities();
super.onBackPressed();
}
private void saveIdentities() {
if (mIdentitiesChanged) {
mAccount.setIdentities(identities);
Preferences.getPreferences().saveAccount(mAccount);
}
finish();
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,74 @@
package com.fsck.k9.activity
import com.fsck.k9.K9
import com.fsck.k9.SwipeAction
import com.fsck.k9.UiDensity
import com.fsck.k9.preferences.AppTheme
import com.fsck.k9.preferences.GeneralSettingsManager
import com.fsck.k9.preferences.SubTheme
data class MessageListActivityConfig(
val appTheme: AppTheme,
val isShowUnifiedInbox: Boolean,
val isShowMessageListStars: Boolean,
val isShowCorrespondentNames: Boolean,
val isMessageListSenderAboveSubject: Boolean,
val isShowContactName: Boolean,
val isChangeContactNameColor: Boolean,
val isShowContactPicture: Boolean,
val isColorizeMissingContactPictures: Boolean,
val isUseBackgroundAsUnreadIndicator: Boolean,
val isShowComposeButton: Boolean,
val contactNameColor: Int,
val messageViewTheme: SubTheme,
val messageListPreviewLines: Int,
val messageListDensity: UiDensity,
val splitViewMode: K9.SplitViewMode,
val fontSizeMessageListSubject: Int,
val fontSizeMessageListSender: Int,
val fontSizeMessageListDate: Int,
val fontSizeMessageListPreview: Int,
val fontSizeMessageViewSender: Int,
val fontSizeMessageViewRecipients: Int,
val fontSizeMessageViewSubject: Int,
val fontSizeMessageViewDate: Int,
val fontSizeMessageViewContentAsPercent: Int,
val swipeRightAction: SwipeAction,
val swipeLeftAction: SwipeAction,
) {
companion object {
fun create(generalSettingsManager: GeneralSettingsManager): MessageListActivityConfig {
val settings = generalSettingsManager.getSettings()
return MessageListActivityConfig(
appTheme = settings.appTheme,
isShowUnifiedInbox = K9.isShowUnifiedInbox,
isShowMessageListStars = K9.isShowMessageListStars,
isShowCorrespondentNames = K9.isShowCorrespondentNames,
isMessageListSenderAboveSubject = K9.isMessageListSenderAboveSubject,
isShowContactName = K9.isShowContactName,
isChangeContactNameColor = K9.isChangeContactNameColor,
isShowContactPicture = K9.isShowContactPicture,
isColorizeMissingContactPictures = K9.isColorizeMissingContactPictures,
isUseBackgroundAsUnreadIndicator = K9.isUseBackgroundAsUnreadIndicator,
isShowComposeButton = K9.isShowComposeButtonOnMessageList,
contactNameColor = K9.contactNameColor,
messageViewTheme = settings.messageViewTheme,
messageListPreviewLines = K9.messageListPreviewLines,
messageListDensity = K9.messageListDensity,
splitViewMode = K9.splitViewMode,
fontSizeMessageListSubject = K9.fontSizes.messageListSubject,
fontSizeMessageListSender = K9.fontSizes.messageListSender,
fontSizeMessageListDate = K9.fontSizes.messageListDate,
fontSizeMessageListPreview = K9.fontSizes.messageListPreview,
fontSizeMessageViewSender = K9.fontSizes.messageViewSender,
fontSizeMessageViewRecipients = K9.fontSizes.messageViewRecipients,
fontSizeMessageViewSubject = K9.fontSizes.messageViewSubject,
fontSizeMessageViewDate = K9.fontSizes.messageViewDate,
fontSizeMessageViewContentAsPercent = K9.fontSizes.messageViewContentAsPercent,
swipeRightAction = K9.swipeRightAction,
swipeLeftAction = K9.swipeLeftAction,
)
}
}
}

View file

@ -0,0 +1,534 @@
package com.fsck.k9.activity;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.fragment.app.FragmentManager;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.Loader;
import com.fsck.k9.Account;
import com.fsck.k9.Preferences;
import com.fsck.k9.autocrypt.AutocryptOperations;
import com.fsck.k9.controller.MessageReference;
import com.fsck.k9.controller.MessagingController;
import com.fsck.k9.controller.MessagingListener;
import com.fsck.k9.controller.SimpleMessagingListener;
import com.fsck.k9.helper.RetainFragment;
import com.fsck.k9.mail.Flag;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mailstore.LocalMessage;
import com.fsck.k9.mailstore.MessageCryptoAnnotations;
import com.fsck.k9.mailstore.MessageViewInfo;
import com.fsck.k9.mailstore.MessageViewInfoExtractor;
import com.fsck.k9.ui.crypto.MessageCryptoCallback;
import com.fsck.k9.ui.crypto.MessageCryptoHelper;
import com.fsck.k9.ui.crypto.OpenPgpApiFactory;
import com.fsck.k9.ui.message.LocalMessageExtractorLoader;
import com.fsck.k9.ui.message.LocalMessageLoader;
import org.openintents.openpgp.OpenPgpDecryptionResult;
import timber.log.Timber;
/** This class is responsible for loading a message start to finish, and
* retaining or reloading the loading state on configuration changes.
*
* In particular, it takes care of the following:
* - load raw message data from the database, using LocalMessageLoader
* - download partial message content if it is missing using MessagingController
* - apply crypto operations if applicable, using MessageCryptoHelper
* - extract MessageViewInfo from the message and crypto data using DecodeMessageLoader
* - download complete message content for partially downloaded messages if requested
*
* No state is retained in this object itself. Instead, state is stored in the
* message loaders and the MessageCryptoHelper which is stored in a
* RetainFragment. The public interface is intended for use by an Activity or
* Fragment, which should construct a new instance of this class in onCreate,
* then call asyncStartOrResumeLoadingMessage to start or resume loading the
* message, receiving callbacks when it is loaded.
*
* When the Activity or Fragment is ultimately destroyed, it should call
* onDestroy, which stops loading and deletes all state kept in loaders and
* fragments by this object. If it is only destroyed for a configuration
* change, it should call onDestroyChangingConfigurations, which cancels any
* further callbacks from this object but retains the loading state to resume
* from at the next call to asyncStartOrResumeLoadingMessage.
*
* If the message is already loaded, a call to asyncStartOrResumeLoadingMessage
* will typically load by starting the decode message loader, retrieving the
* already cached LocalMessage. This message will be passed to the retained
* CryptoMessageHelper instance, returning the already cached
* MessageCryptoAnnotations. These two objects will be checked against the
* retained DecodeMessageLoader, returning the final result. At each
* intermediate step, the input of the respective loaders will be checked for
* consistency, reloading if there is a mismatch.
*
*/
public class MessageLoaderHelper {
private static final int LOCAL_MESSAGE_LOADER_ID = 1;
private static final int DECODE_MESSAGE_LOADER_ID = 2;
// injected state - all of this may be cleared to avoid data leakage!
private Context context;
private FragmentManager fragmentManager;
private LoaderManager loaderManager;
@Nullable // make this explicitly nullable, make sure to cancel/ignore any operation if this is null
private MessageLoaderCallbacks callback;
private final MessageViewInfoExtractor messageViewInfoExtractor;
private Handler handler = new Handler(Looper.getMainLooper());
// transient state
private boolean onlyLoadMetadata;
private MessageReference messageReference;
private Account account;
private LocalMessage localMessage;
private MessageCryptoAnnotations messageCryptoAnnotations;
private OpenPgpDecryptionResult cachedDecryptionResult;
private MessageCryptoHelper messageCryptoHelper;
public MessageLoaderHelper(Context context, LoaderManager loaderManager, FragmentManager fragmentManager,
@NonNull MessageLoaderCallbacks callback, MessageViewInfoExtractor messageViewInfoExtractor) {
this.context = context;
this.loaderManager = loaderManager;
this.fragmentManager = fragmentManager;
this.callback = callback;
this.messageViewInfoExtractor = messageViewInfoExtractor;
}
// public interface
@UiThread
public void asyncStartOrResumeLoadingMessage(MessageReference messageReference, Parcelable cachedDecryptionResult) {
onlyLoadMetadata = false;
this.messageReference = messageReference;
this.account = Preferences.getPreferences().getAccount(messageReference.getAccountUuid());
if (cachedDecryptionResult != null) {
if (cachedDecryptionResult instanceof OpenPgpDecryptionResult) {
this.cachedDecryptionResult = (OpenPgpDecryptionResult) cachedDecryptionResult;
} else {
Timber.e("Got decryption result of unknown type - ignoring");
}
}
startOrResumeLocalMessageLoader();
}
@UiThread
public void asyncStartOrResumeLoadingMessageMetadata(MessageReference messageReference) {
onlyLoadMetadata = true;
this.messageReference = messageReference;
this.account = Preferences.getPreferences().getAccount(messageReference.getAccountUuid());
startOrResumeLocalMessageLoader();
}
@UiThread
public void asyncReloadMessage() {
startOrResumeLocalMessageLoader();
}
public void resumeCryptoOperationIfNecessary() {
if (messageCryptoHelper != null) {
messageCryptoHelper.resumeCryptoOperationIfNecessary();
}
}
@UiThread
public void asyncRestartMessageCryptoProcessing() {
cancelAndClearCryptoOperation();
cancelAndClearDecodeLoader();
String openPgpProvider = account.getOpenPgpProvider();
if (openPgpProvider != null) {
startOrResumeCryptoOperation(openPgpProvider);
} else {
startOrResumeDecodeMessage();
}
}
/** Cancels all loading processes, prevents future callbacks, and destroys all loading state. */
@UiThread
public void onDestroy() {
if (messageCryptoHelper != null) {
messageCryptoHelper.cancelIfRunning();
}
callback = null;
context = null;
fragmentManager = null;
loaderManager = null;
}
/** Prevents future callbacks, but retains loading state to pick up from in a call to
* asyncStartOrResumeLoadingMessage in a new instance of this class. */
@UiThread
public void onDestroyChangingConfigurations() {
cancelAndClearDecodeLoader();
if (messageCryptoHelper != null) {
messageCryptoHelper.detachCallback();
}
callback = null;
context = null;
fragmentManager = null;
loaderManager = null;
}
@UiThread
public void downloadCompleteMessage() {
startDownloadingMessageBody(true);
}
@UiThread
public void onActivityResult(int requestCode, int resultCode, Intent data) {
messageCryptoHelper.onActivityResult(requestCode, resultCode, data);
}
// load from database
private void startOrResumeLocalMessageLoader() {
LocalMessageLoader loader =
(LocalMessageLoader) loaderManager.<LocalMessage>getLoader(LOCAL_MESSAGE_LOADER_ID);
boolean isLoaderStale = (loader == null) || !loader.isCreatedFor(messageReference);
if (isLoaderStale) {
Timber.d("Creating new local message loader");
cancelAndClearCryptoOperation();
cancelAndClearDecodeLoader();
loaderManager.restartLoader(LOCAL_MESSAGE_LOADER_ID, null, localMessageLoaderCallback);
} else {
Timber.d("Reusing local message loader");
loaderManager.initLoader(LOCAL_MESSAGE_LOADER_ID, null, localMessageLoaderCallback);
}
}
@UiThread
private void onLoadMessageFromDatabaseFinished() {
if (callback == null) {
throw new IllegalStateException("unexpected call when callback is already detached");
}
callback.onMessageDataLoadFinished(localMessage);
boolean downloadedCompletely = localMessage.isSet(Flag.X_DOWNLOADED_FULL);
boolean downloadedPartially = localMessage.isSet(Flag.X_DOWNLOADED_PARTIAL);
boolean messageIncomplete = !downloadedCompletely && !downloadedPartially;
if (messageIncomplete) {
startDownloadingMessageBody(false);
return;
}
if (onlyLoadMetadata) {
MessageViewInfo messageViewInfo = MessageViewInfo.createForMetadataOnly(localMessage, !downloadedCompletely);
onDecodeMessageFinished(messageViewInfo);
return;
}
String openPgpProvider = account.getOpenPgpProvider();
if (openPgpProvider != null) {
startOrResumeCryptoOperation(openPgpProvider);
return;
}
startOrResumeDecodeMessage();
}
private void onLoadMessageFromDatabaseFailed() {
if (callback == null) {
throw new IllegalStateException("unexpected call when callback is already detached");
}
callback.onMessageDataLoadFailed();
}
private void cancelAndClearLocalMessageLoader() {
loaderManager.destroyLoader(LOCAL_MESSAGE_LOADER_ID);
}
private LoaderCallbacks<LocalMessage> localMessageLoaderCallback = new LoaderCallbacks<LocalMessage>() {
@Override
public Loader<LocalMessage> onCreateLoader(int id, Bundle args) {
if (id != LOCAL_MESSAGE_LOADER_ID) {
throw new IllegalStateException("loader id must be message loader id");
}
MessagingController messagingController = MessagingController.getInstance(context);
return new LocalMessageLoader(context, messagingController, account, messageReference, onlyLoadMetadata);
}
@Override
public void onLoadFinished(Loader<LocalMessage> loader, LocalMessage message) {
if (loader.getId() != LOCAL_MESSAGE_LOADER_ID) {
throw new IllegalStateException("loader id must be message loader id");
}
if (message == localMessage) {
return;
}
localMessage = message;
if (message == null) {
onLoadMessageFromDatabaseFailed();
} else {
onLoadMessageFromDatabaseFinished();
}
}
@Override
public void onLoaderReset(Loader<LocalMessage> loader) {
if (loader.getId() != LOCAL_MESSAGE_LOADER_ID) {
throw new IllegalStateException("loader id must be message loader id");
}
// Do nothing
}
};
// process with crypto helper
private void startOrResumeCryptoOperation(String openPgpProvider) {
RetainFragment<MessageCryptoHelper> retainCryptoHelperFragment = getMessageCryptoHelperRetainFragment(true);
if (retainCryptoHelperFragment.hasData()) {
messageCryptoHelper = retainCryptoHelperFragment.getData();
}
if (messageCryptoHelper == null || !messageCryptoHelper.isConfiguredForOpenPgpProvider(openPgpProvider)) {
messageCryptoHelper = new MessageCryptoHelper(
context, new OpenPgpApiFactory(), AutocryptOperations.getInstance(), openPgpProvider);
retainCryptoHelperFragment.setData(messageCryptoHelper);
}
messageCryptoHelper.asyncStartOrResumeProcessingMessage(
localMessage, messageCryptoCallback, cachedDecryptionResult, !account.isOpenPgpHideSignOnly());
}
private void cancelAndClearCryptoOperation() {
RetainFragment<MessageCryptoHelper> retainCryptoHelperFragment = getMessageCryptoHelperRetainFragment(false);
if (retainCryptoHelperFragment != null) {
if (retainCryptoHelperFragment.hasData()) {
messageCryptoHelper = retainCryptoHelperFragment.getData();
messageCryptoHelper.cancelIfRunning();
messageCryptoHelper = null;
}
retainCryptoHelperFragment.clearAndRemove(fragmentManager);
}
}
private RetainFragment<MessageCryptoHelper> getMessageCryptoHelperRetainFragment(boolean createIfNotExists) {
if (createIfNotExists) {
return RetainFragment.findOrCreate(fragmentManager, "crypto_helper_" + messageReference.hashCode());
} else {
return RetainFragment.findOrNull(fragmentManager, "crypto_helper_" + messageReference.hashCode());
}
}
private MessageCryptoCallback messageCryptoCallback = new MessageCryptoCallback() {
@Override
public void onCryptoHelperProgress(int current, int max) {
if (callback == null) {
throw new IllegalStateException("unexpected call when callback is already detached");
}
callback.setLoadingProgress(current, max);
}
@Override
public void onCryptoOperationsFinished(MessageCryptoAnnotations annotations) {
if (callback == null) {
throw new IllegalStateException("unexpected call when callback is already detached");
}
messageCryptoAnnotations = annotations;
startOrResumeDecodeMessage();
}
@Override
public boolean startPendingIntentForCryptoHelper(IntentSender si, int requestCode, Intent fillIntent,
int flagsMask, int flagValues, int extraFlags) {
if (callback == null) {
throw new IllegalStateException("unexpected call when callback is already detached");
}
return callback.startIntentSenderForMessageLoaderHelper(si, requestCode, fillIntent,
flagsMask, flagValues, extraFlags);
}
};
// decode message
private void startOrResumeDecodeMessage() {
LocalMessageExtractorLoader loader =
(LocalMessageExtractorLoader) loaderManager.<MessageViewInfo>getLoader(DECODE_MESSAGE_LOADER_ID);
boolean isLoaderStale = (loader == null) || !loader.isCreatedFor(localMessage, messageCryptoAnnotations);
if (isLoaderStale) {
Timber.d("Creating new decode message loader");
loaderManager.restartLoader(DECODE_MESSAGE_LOADER_ID, null, decodeMessageLoaderCallback);
} else {
Timber.d("Reusing decode message loader");
loaderManager.initLoader(DECODE_MESSAGE_LOADER_ID, null, decodeMessageLoaderCallback);
}
}
private void onDecodeMessageFinished(MessageViewInfo messageViewInfo) {
if (callback == null) {
throw new IllegalStateException("unexpected call when callback is already detached");
}
if (messageViewInfo == null) {
messageViewInfo = createErrorStateMessageViewInfo();
callback.onMessageViewInfoLoadFailed(messageViewInfo);
return;
}
if (messageViewInfo.isSubjectEncrypted && !localMessage.hasCachedDecryptedSubject()) {
try {
localMessage.setCachedDecryptedSubject(messageViewInfo.subject);
} catch (MessagingException e) {
throw new AssertionError(e);
}
}
callback.onMessageViewInfoLoadFinished(messageViewInfo);
}
@NonNull
private MessageViewInfo createErrorStateMessageViewInfo() {
boolean isMessageIncomplete = !localMessage.isSet(Flag.X_DOWNLOADED_FULL);
return MessageViewInfo.createWithErrorState(localMessage, isMessageIncomplete);
}
private void cancelAndClearDecodeLoader() {
loaderManager.destroyLoader(DECODE_MESSAGE_LOADER_ID);
}
private LoaderCallbacks<MessageViewInfo> decodeMessageLoaderCallback = new LoaderCallbacks<MessageViewInfo>() {
private MessageViewInfo messageViewInfo;
@Override
public Loader<MessageViewInfo> onCreateLoader(int id, Bundle args) {
if (id != DECODE_MESSAGE_LOADER_ID) {
throw new IllegalStateException("loader id must be message decoder id");
}
return new LocalMessageExtractorLoader(context, localMessage, messageCryptoAnnotations,
messageViewInfoExtractor);
}
@Override
public void onLoadFinished(Loader<MessageViewInfo> loader, MessageViewInfo messageViewInfo) {
if (loader.getId() != DECODE_MESSAGE_LOADER_ID) {
throw new IllegalStateException("loader id must be message decoder id");
}
if (messageViewInfo == this.messageViewInfo) {
return;
}
this.messageViewInfo = messageViewInfo;
onDecodeMessageFinished(messageViewInfo);
}
@Override
public void onLoaderReset(Loader<MessageViewInfo> loader) {
if (loader.getId() != DECODE_MESSAGE_LOADER_ID) {
throw new IllegalStateException("loader id must be message decoder id");
}
messageViewInfo = null;
}
};
// download missing body
private void startDownloadingMessageBody(boolean downloadComplete) {
if (downloadComplete) {
MessagingController.getInstance(context).loadMessageRemote(
account, messageReference.getFolderId(), messageReference.getUid(), downloadMessageListener);
} else {
MessagingController.getInstance(context).loadMessageRemotePartial(
account, messageReference.getFolderId(), messageReference.getUid(), downloadMessageListener);
}
}
private void onMessageDownloadFinished() {
if (callback == null) {
return;
}
cancelAndClearLocalMessageLoader();
cancelAndClearDecodeLoader();
cancelAndClearCryptoOperation();
startOrResumeLocalMessageLoader();
}
private void onDownloadMessageFailed(final Throwable t) {
if (callback == null) {
return;
}
if (t instanceof IllegalArgumentException) {
callback.onDownloadErrorMessageNotFound();
} else {
callback.onDownloadErrorNetworkError();
}
}
MessagingListener downloadMessageListener = new SimpleMessagingListener() {
@Override
public void loadMessageRemoteFinished(final Account account, final long folderId, final String uid) {
handler.post(() -> {
if (!messageReference.equals(account.getUuid(), folderId, uid)) {
return;
}
onMessageDownloadFinished();
});
}
@Override
public void loadMessageRemoteFailed(Account account, long folderId, String uid, final Throwable t) {
handler.post(new Runnable() {
@Override
public void run() {
onDownloadMessageFailed(t);
}
});
}
};
// callback interface
public interface MessageLoaderCallbacks {
void onMessageDataLoadFinished(LocalMessage message);
void onMessageDataLoadFailed();
void onMessageViewInfoLoadFinished(MessageViewInfo messageViewInfo);
void onMessageViewInfoLoadFailed(MessageViewInfo messageViewInfo);
void setLoadingProgress(int current, int max);
boolean startIntentSenderForMessageLoaderHelper(IntentSender si, int requestCode, Intent fillIntent,
int flagsMask, int flagValues, int extraFlags);
void onDownloadErrorMessageNotFound();
void onDownloadErrorNetworkError();
}
}

View file

@ -0,0 +1,35 @@
package com.fsck.k9.activity
import android.content.Context
import androidx.fragment.app.FragmentManager
import androidx.loader.app.LoaderManager
import com.fsck.k9.activity.MessageLoaderHelper.MessageLoaderCallbacks
import com.fsck.k9.mailstore.MessageViewInfoExtractorFactory
import com.fsck.k9.ui.helper.HtmlSettingsProvider
class MessageLoaderHelperFactory(
private val messageViewInfoExtractorFactory: MessageViewInfoExtractorFactory,
private val htmlSettingsProvider: HtmlSettingsProvider
) {
fun createForMessageView(
context: Context,
loaderManager: LoaderManager,
fragmentManager: FragmentManager,
callback: MessageLoaderCallbacks
): MessageLoaderHelper {
val htmlSettings = htmlSettingsProvider.createForMessageView()
val messageViewInfoExtractor = messageViewInfoExtractorFactory.create(htmlSettings)
return MessageLoaderHelper(context, loaderManager, fragmentManager, callback, messageViewInfoExtractor)
}
fun createForMessageCompose(
context: Context,
loaderManager: LoaderManager,
fragmentManager: FragmentManager,
callback: MessageLoaderCallbacks
): MessageLoaderHelper {
val htmlSettings = htmlSettingsProvider.createForMessageCompose()
val messageViewInfoExtractor = messageViewInfoExtractorFactory.create(htmlSettings)
return MessageLoaderHelper(context, loaderManager, fragmentManager, callback, messageViewInfoExtractor)
}
}

View file

@ -0,0 +1,21 @@
package com.fsck.k9.activity;
public class Search extends MessageList {
@Override
public void onStart() {
getSearchStatusManager().setActive(true);
super.onStart();
}
@Override
public void onStop() {
getSearchStatusManager().setActive(false);
super.onStop();
}
@Override
protected boolean isDrawerEnabled() {
return false;
}
}

View file

@ -0,0 +1,223 @@
package com.fsck.k9.activity;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.os.Bundle;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import android.widget.TextView;
import com.fsck.k9.Account;
import com.fsck.k9.K9;
import com.fsck.k9.Preferences;
import com.fsck.k9.ui.R;
import com.fsck.k9.controller.MessagingController;
import com.fsck.k9.mailstore.LocalStore;
import com.fsck.k9.service.DatabaseUpgradeService;
import com.fsck.k9.ui.base.K9Activity;
/**
* This activity triggers a database upgrade if necessary and displays the current upgrade progress.
*
* <p>
* The current upgrade process works as follows:
* <ol>
* <li>Activities that access an account's database call
* {@link #actionUpgradeDatabases(Context, Intent)} in their {@link Activity#onCreate(Bundle)}
* method.</li>
* <li>{@link #actionUpgradeDatabases(Context, Intent)} will call {@link K9#areDatabasesUpToDate()}
* to check if we already know whether the databases have been upgraded.</li>
* <li>{@link K9#areDatabasesUpToDate()} will compare the last known database version stored in a
* {@link SharedPreferences} file to {@link LocalStore#getDbVersion()}. This
* is done as an optimization because it's faster than opening all of the accounts' databases
* one by one.</li>
* <li>If there was an error reading the cached database version or if it shows the databases need
* upgrading this activity ({@code UpgradeDatabases}) is started.</li>
* <li>This activity will display a spinning progress indicator and start
* {@link DatabaseUpgradeService}.</li>
* <li>{@link DatabaseUpgradeService} will acquire a partial wake lock (with a 10 minute timeout),
* start a background thread to perform the database upgrades, and report the progress using
* {@link LocalBroadcastManager} to this activity which will update the UI accordingly.</li>
* <li>Once the upgrade is complete {@link DatabaseUpgradeService} will notify this activity,
* release the wake lock, and stop itself.</li>
* <li>This activity will start the original activity using the intent supplied when calling
* {@link #actionUpgradeDatabases(Context, Intent)}.</li>
* </ol>
* </p><p>
* Currently we make no attempts to stop the background code (e.g. {@link MessagingController}) from
* opening the accounts' databases. If this happens the upgrade is performed in one of the
* background threads and not by {@link DatabaseUpgradeService}. But this is not a problem. Due to
* the locking in {@link com.fsck.k9.mailstore.LocalStoreProvider#getInstance(Account)} the upgrade service will block
* and from the outside (especially for this activity) it will appear as if
* {@link DatabaseUpgradeService} is performing the upgrade.
* </p>
*/
public class UpgradeDatabases extends K9Activity {
private static final String ACTION_UPGRADE_DATABASES = "upgrade_databases";
private static final String EXTRA_START_INTENT = "start_intent";
/**
* Start the {@link UpgradeDatabases} activity if necessary.
*
* @param context
* The {@link Context} used to start the activity.
* @param startIntent
* After the database upgrade is complete an activity is started using this intent.
* Usually this is the intent that was used to start the calling activity.
* Never {@code null}.
*
* @return {@code true}, if the {@code UpgradeDatabases} activity was started. In this case the
* calling activity is expected to finish itself.<br>
* {@code false}, if the account databases don't need upgrading.
*/
public static boolean actionUpgradeDatabases(Context context, Intent startIntent) {
if (K9.areDatabasesUpToDate()) {
return false;
}
Intent intent = new Intent(context, UpgradeDatabases.class);
intent.setAction(ACTION_UPGRADE_DATABASES);
intent.putExtra(EXTRA_START_INTENT, startIntent);
// Make sure this activity is only running once
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
context.startActivity(intent);
return true;
}
private Intent mStartIntent;
private TextView mUpgradeText;
private LocalBroadcastManager mLocalBroadcastManager;
private UpgradeDatabaseBroadcastReceiver mBroadcastReceiver;
private IntentFilter mIntentFilter;
private Preferences mPreferences;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
decodeExtras();
// If the databases have already been upgraded there's no point in displaying this activity.
if (K9.areDatabasesUpToDate()) {
launchOriginalActivity();
return;
}
mPreferences = Preferences.getPreferences();
initializeLayout();
setupBroadcastReceiver();
}
/**
* Initialize the activity's layout
*/
private void initializeLayout() {
setLayout(R.layout.upgrade_databases);
setTitle(R.string.upgrade_databases_title);
mUpgradeText = findViewById(R.id.databaseUpgradeText);
}
/**
* Decode extras in the intent used to start this activity.
*/
private void decodeExtras() {
Intent intent = getIntent();
mStartIntent = intent.getParcelableExtra(EXTRA_START_INTENT);
}
/**
* Setup the broadcast receiver used to receive progress updates from
* {@link DatabaseUpgradeService}.
*/
private void setupBroadcastReceiver() {
mLocalBroadcastManager = LocalBroadcastManager.getInstance(this);
mBroadcastReceiver = new UpgradeDatabaseBroadcastReceiver();
mIntentFilter = new IntentFilter(DatabaseUpgradeService.ACTION_UPGRADE_PROGRESS);
mIntentFilter.addAction(DatabaseUpgradeService.ACTION_UPGRADE_COMPLETE);
}
@Override
public void onResume() {
super.onResume();
// Check if the upgrade was completed while the activity was paused.
if (K9.areDatabasesUpToDate()) {
launchOriginalActivity();
return;
}
// Register the broadcast receiver to listen for progress reports from
// DatabaseUpgradeService.
mLocalBroadcastManager.registerReceiver(mBroadcastReceiver, mIntentFilter);
// Now that the broadcast receiver was registered start DatabaseUpgradeService.
DatabaseUpgradeService.startService(this);
}
@Override
public void onPause() {
// The activity is being paused, so there's no point in listening to the progress of the
// database upgrade service.
mLocalBroadcastManager.unregisterReceiver(mBroadcastReceiver);
super.onPause();
}
/**
* Finish this activity and launch the original activity using the supplied intent.
*/
private void launchOriginalActivity() {
finish();
startActivity(mStartIntent);
}
/**
* Receiver for broadcasts send by {@link DatabaseUpgradeService}.
*/
class UpgradeDatabaseBroadcastReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, final Intent intent) {
String action = intent.getAction();
if (DatabaseUpgradeService.ACTION_UPGRADE_PROGRESS.equals(action)) {
/*
* Information on the current upgrade progress
*/
String accountUuid = intent.getStringExtra(
DatabaseUpgradeService.EXTRA_ACCOUNT_UUID);
Account account = mPreferences.getAccount(accountUuid);
if (account != null) {
String upgradeStatus = getString(R.string.upgrade_database_format, account.getDisplayName());
mUpgradeText.setText(upgradeStatus);
}
} else if (DatabaseUpgradeService.ACTION_UPGRADE_COMPLETE.equals(action)) {
/*
* Upgrade complete
*/
launchOriginalActivity();
}
}
}
}

View file

@ -0,0 +1,477 @@
package com.fsck.k9.activity.compose;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import android.app.Activity;
import android.content.ClipData;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import com.fsck.k9.activity.compose.ComposeCryptoStatus.AttachErrorState;
import com.fsck.k9.activity.loader.AttachmentContentLoader;
import com.fsck.k9.activity.loader.AttachmentInfoLoader;
import com.fsck.k9.activity.misc.Attachment;
import com.fsck.k9.activity.misc.InlineAttachment;
import com.fsck.k9.controller.MessageReference;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mailstore.AttachmentViewInfo;
import com.fsck.k9.mailstore.LocalMessage;
import com.fsck.k9.mailstore.MessageViewInfo;
import com.fsck.k9.message.Attachment.LoadingState;
import com.fsck.k9.provider.RawMessageProvider;
public class AttachmentPresenter {
private static final String STATE_KEY_ATTACHMENTS = "com.fsck.k9.activity.MessageCompose.attachments";
private static final String STATE_KEY_WAITING_FOR_ATTACHMENTS = "waitingForAttachments";
private static final String STATE_KEY_NEXT_LOADER_ID = "nextLoaderId";
private static final String LOADER_ARG_ATTACHMENT = "attachment";
private static final int LOADER_ID_MASK = 1 << 6;
private static final int MAX_TOTAL_LOADERS = LOADER_ID_MASK - 1;
private static final int REQUEST_CODE_ATTACHMENT_URI = 1;
// injected state
private final Context context;
private final AttachmentMvpView attachmentMvpView;
private final LoaderManager loaderManager;
private final AttachmentsChangedListener listener;
// persistent state
private final LinkedHashMap<Uri, Attachment> attachments;
private final LinkedHashMap<Uri, InlineAttachment> inlineAttachments;
private int nextLoaderId = 0;
private WaitingAction actionToPerformAfterWaiting = WaitingAction.NONE;
public AttachmentPresenter(Context context, AttachmentMvpView attachmentMvpView, LoaderManager loaderManager,
AttachmentsChangedListener listener) {
this.context = context;
this.attachmentMvpView = attachmentMvpView;
this.loaderManager = loaderManager;
this.listener = listener;
attachments = new LinkedHashMap<>();
inlineAttachments = new LinkedHashMap<>();
}
public void onSaveInstanceState(Bundle outState) {
outState.putString(STATE_KEY_WAITING_FOR_ATTACHMENTS, actionToPerformAfterWaiting.name());
outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, createAttachmentList());
outState.putInt(STATE_KEY_NEXT_LOADER_ID, nextLoaderId);
}
public void onRestoreInstanceState(Bundle savedInstanceState) {
actionToPerformAfterWaiting = WaitingAction.valueOf(
savedInstanceState.getString(STATE_KEY_WAITING_FOR_ATTACHMENTS));
nextLoaderId = savedInstanceState.getInt(STATE_KEY_NEXT_LOADER_ID);
ArrayList<Attachment> attachmentList = savedInstanceState.getParcelableArrayList(STATE_KEY_ATTACHMENTS);
// noinspection ConstantConditions, we know this is set in onSaveInstanceState
for (Attachment attachment : attachmentList) {
attachments.put(attachment.uri, attachment);
attachmentMvpView.addAttachmentView(attachment);
if (attachment.state == LoadingState.URI_ONLY) {
initAttachmentInfoLoader(attachment);
} else if (attachment.state == LoadingState.METADATA) {
initAttachmentContentLoader(attachment);
}
}
}
public boolean checkOkForSendingOrDraftSaving() {
if (actionToPerformAfterWaiting != WaitingAction.NONE) {
return true;
}
if (hasLoadingAttachments()) {
actionToPerformAfterWaiting = WaitingAction.SEND;
attachmentMvpView.showWaitingForAttachmentDialog(actionToPerformAfterWaiting);
return true;
}
return false;
}
private boolean hasLoadingAttachments() {
for (Attachment attachment : attachments.values()) {
Loader loader = loaderManager.getLoader(attachment.loaderId);
if (loader != null && loader.isStarted()) {
return true;
}
}
return false;
}
public ArrayList<Attachment> createAttachmentList() {
ArrayList<Attachment> result = new ArrayList<>();
for (Attachment attachment : attachments.values()) {
result.add(attachment);
}
return result;
}
public List<com.fsck.k9.message.Attachment> getAttachments() {
return new ArrayList<>(attachments.values());
}
public Map<String, com.fsck.k9.message.Attachment> getInlineAttachments() {
Map<String, com.fsck.k9.message.Attachment> result = new LinkedHashMap<>();
for (InlineAttachment attachment : inlineAttachments.values()) {
result.put(attachment.getContentId(), attachment.getAttachment());
}
return result;
}
public void onClickAddAttachment(RecipientPresenter recipientPresenter) {
ComposeCryptoStatus currentCachedCryptoStatus = recipientPresenter.getCurrentCachedCryptoStatus();
if (currentCachedCryptoStatus == null) {
return;
}
AttachErrorState maybeAttachErrorState = currentCachedCryptoStatus.getAttachErrorStateOrNull();
if (maybeAttachErrorState != null) {
recipientPresenter.showPgpAttachError(maybeAttachErrorState);
return;
}
attachmentMvpView.showPickAttachmentDialog(REQUEST_CODE_ATTACHMENT_URI);
}
private void addExternalAttachment(Uri uri) {
addExternalAttachment(uri, null);
}
private void addInternalAttachment(AttachmentViewInfo attachmentViewInfo) {
if (attachments.containsKey(attachmentViewInfo.internalUri)) {
throw new IllegalStateException("Received the same attachmentViewInfo twice!");
}
int loaderId = getNextFreeLoaderId();
Attachment attachment = Attachment.createAttachment(
attachmentViewInfo.internalUri, loaderId, attachmentViewInfo.mimeType, true, true);
attachment = attachment.deriveWithMetadataLoaded(
attachmentViewInfo.mimeType, attachmentViewInfo.displayName, attachmentViewInfo.size);
addAttachmentAndStartLoader(attachment);
}
public void addExternalAttachment(Uri uri, String contentType) {
addAttachment(uri, contentType, false, false);
}
private void addInlineAttachment(AttachmentViewInfo attachmentViewInfo) {
if (inlineAttachments.containsKey(attachmentViewInfo.internalUri)) {
throw new IllegalStateException("Received the same attachmentViewInfo twice!");
}
int loaderId = getNextFreeLoaderId();
Attachment attachment = Attachment.createAttachment(
attachmentViewInfo.internalUri, loaderId, attachmentViewInfo.mimeType, true, true);
attachment = attachment.deriveWithMetadataLoaded(
attachmentViewInfo.mimeType, attachmentViewInfo.displayName, attachmentViewInfo.size);
inlineAttachments.put(attachment.uri, new InlineAttachment(attachmentViewInfo.part.getContentId(), attachment));
Bundle bundle = new Bundle();
bundle.putParcelable(LOADER_ARG_ATTACHMENT, attachment.uri);
loaderManager.initLoader(attachment.loaderId, bundle, mInlineAttachmentContentLoaderCallback);
}
private void addInternalAttachment(Uri uri, String contentType, boolean allowMessageType) {
addAttachment(uri, contentType, allowMessageType, true);
}
private void addAttachment(Uri uri, String contentType, boolean allowMessageType, boolean internalAttachment) {
if (attachments.containsKey(uri)) {
return;
}
int loaderId = getNextFreeLoaderId();
Attachment attachment = Attachment.createAttachment(uri, loaderId, contentType, allowMessageType, internalAttachment);
addAttachmentAndStartLoader(attachment);
}
public boolean loadAllAvailableAttachments(MessageViewInfo messageViewInfo) {
boolean allPartsAvailable = true;
for (AttachmentViewInfo attachmentViewInfo : messageViewInfo.attachments) {
if (attachmentViewInfo.isContentAvailable()) {
if (attachmentViewInfo.inlineAttachment) {
addInlineAttachment(attachmentViewInfo);
} else {
addInternalAttachment(attachmentViewInfo);
}
} else {
allPartsAvailable = false;
}
}
return allPartsAvailable;
}
public void processMessageToForward(MessageViewInfo messageViewInfo) {
boolean isMissingParts = !loadAllAvailableAttachments(messageViewInfo);
if (isMissingParts) {
attachmentMvpView.showMissingAttachmentsPartialMessageWarning();
}
}
public void processMessageToForwardAsAttachment(MessageViewInfo messageViewInfo) throws MessagingException {
if (messageViewInfo.isMessageIncomplete) {
attachmentMvpView.showMissingAttachmentsPartialMessageForwardWarning();
} else {
LocalMessage localMessage = (LocalMessage) messageViewInfo.message;
MessageReference messageReference = localMessage.makeMessageReference();
Uri rawMessageUri = RawMessageProvider.getRawMessageUri(messageReference);
addInternalAttachment(rawMessageUri, "message/rfc822", true);
}
}
private void addAttachmentAndStartLoader(Attachment attachment) {
attachments.put(attachment.uri, attachment);
listener.onAttachmentAdded();
attachmentMvpView.addAttachmentView(attachment);
if (attachment.state == LoadingState.URI_ONLY) {
initAttachmentInfoLoader(attachment);
} else if (attachment.state == LoadingState.METADATA) {
initAttachmentContentLoader(attachment);
} else {
throw new IllegalStateException("Attachment can only be added in URI_ONLY or METADATA state!");
}
}
private void initAttachmentInfoLoader(Attachment attachment) {
if (attachment.state != LoadingState.URI_ONLY) {
throw new IllegalStateException("initAttachmentInfoLoader can only be called for URI_ONLY state!");
}
Bundle bundle = new Bundle();
bundle.putParcelable(LOADER_ARG_ATTACHMENT, attachment.uri);
loaderManager.initLoader(attachment.loaderId, bundle, mAttachmentInfoLoaderCallback);
}
private void initAttachmentContentLoader(Attachment attachment) {
if (attachment.state != LoadingState.METADATA) {
throw new IllegalStateException("initAttachmentContentLoader can only be called for METADATA state!");
}
Bundle bundle = new Bundle();
bundle.putParcelable(LOADER_ARG_ATTACHMENT, attachment.uri);
loaderManager.initLoader(attachment.loaderId, bundle, mAttachmentContentLoaderCallback);
}
private int getNextFreeLoaderId() {
if (nextLoaderId >= MAX_TOTAL_LOADERS) {
throw new AssertionError("more than " + MAX_TOTAL_LOADERS + " attachments? hum.");
}
return LOADER_ID_MASK | nextLoaderId++;
}
private LoaderManager.LoaderCallbacks<Attachment> mAttachmentInfoLoaderCallback =
new LoaderManager.LoaderCallbacks<Attachment>() {
@Override
public Loader<Attachment> onCreateLoader(int id, Bundle args) {
Uri uri = args.getParcelable(LOADER_ARG_ATTACHMENT);
return new AttachmentInfoLoader(context, attachments.get(uri));
}
@Override
public void onLoadFinished(Loader<Attachment> loader, Attachment attachment) {
int loaderId = loader.getId();
loaderManager.destroyLoader(loaderId);
if (!attachments.containsKey(attachment.uri)) {
return;
}
attachmentMvpView.updateAttachmentView(attachment);
attachments.put(attachment.uri, attachment);
initAttachmentContentLoader(attachment);
}
@Override
public void onLoaderReset(Loader<Attachment> loader) {
// nothing to do
}
};
private LoaderManager.LoaderCallbacks<Attachment> mAttachmentContentLoaderCallback =
new LoaderManager.LoaderCallbacks<Attachment>() {
@Override
public Loader<Attachment> onCreateLoader(int id, Bundle args) {
Uri uri = args.getParcelable(LOADER_ARG_ATTACHMENT);
return new AttachmentContentLoader(context, attachments.get(uri));
}
@Override
public void onLoadFinished(Loader<Attachment> loader, Attachment attachment) {
int loaderId = loader.getId();
loaderManager.destroyLoader(loaderId);
if (!attachments.containsKey(attachment.uri)) {
return;
}
if (attachment.state == Attachment.LoadingState.COMPLETE) {
attachmentMvpView.updateAttachmentView(attachment);
attachments.put(attachment.uri, attachment);
} else {
attachments.remove(attachment.uri);
attachmentMvpView.removeAttachmentView(attachment);
}
postPerformStalledAction();
}
@Override
public void onLoaderReset(Loader<Attachment> loader) {
// nothing to do
}
};
private LoaderManager.LoaderCallbacks<Attachment> mInlineAttachmentContentLoaderCallback =
new LoaderManager.LoaderCallbacks<Attachment>() {
@Override
public Loader<Attachment> onCreateLoader(int id, Bundle args) {
Uri uri = args.getParcelable(LOADER_ARG_ATTACHMENT);
return new AttachmentContentLoader(context, inlineAttachments.get(uri).getAttachment());
}
@Override
public void onLoadFinished(Loader<Attachment> loader, Attachment attachment) {
int loaderId = loader.getId();
loaderManager.destroyLoader(loaderId);
if (attachment.state == Attachment.LoadingState.COMPLETE) {
inlineAttachments.put(attachment.uri, new InlineAttachment(
inlineAttachments.get(attachment.uri).getContentId(), attachment));
} else {
inlineAttachments.remove(attachment.uri);
}
postPerformStalledAction();
}
@Override
public void onLoaderReset(Loader<Attachment> loader) {
// nothing to do
}
};
private void postPerformStalledAction() {
new Handler().post(new Runnable() {
@Override
public void run() {
performStalledAction();
}
});
}
private void performStalledAction() {
attachmentMvpView.dismissWaitingForAttachmentDialog();
WaitingAction waitingFor = actionToPerformAfterWaiting;
actionToPerformAfterWaiting = WaitingAction.NONE;
switch (waitingFor) {
case SEND: {
attachmentMvpView.performSendAfterChecks();
break;
}
case SAVE: {
attachmentMvpView.performSaveAfterChecks();
break;
}
}
}
private void addAttachmentsFromResultIntent(Intent data) {
// TODO draftNeedsSaving = true
ClipData clipData = data.getClipData();
if (clipData != null) {
for (int i = 0, end = clipData.getItemCount(); i < end; i++) {
Uri uri = clipData.getItemAt(i).getUri();
if (uri != null) {
addExternalAttachment(uri);
}
}
return;
}
Uri uri = data.getData();
if (uri != null) {
addExternalAttachment(uri);
}
}
public void attachmentProgressDialogCancelled() {
actionToPerformAfterWaiting = WaitingAction.NONE;
}
public void onClickRemoveAttachment(Uri uri) {
Attachment attachment = attachments.get(uri);
loaderManager.destroyLoader(attachment.loaderId);
attachmentMvpView.removeAttachmentView(attachment);
attachments.remove(uri);
listener.onAttachmentRemoved();
}
public void onActivityResult(int resultCode, int requestCode, Intent data) {
if (requestCode != REQUEST_CODE_ATTACHMENT_URI) {
throw new AssertionError("onActivityResult must only be called for our request code");
}
if (resultCode != Activity.RESULT_OK) {
return;
}
if (data == null) {
return;
}
addAttachmentsFromResultIntent(data);
}
public enum WaitingAction {
NONE,
SEND,
SAVE
}
public interface AttachmentMvpView {
void showWaitingForAttachmentDialog(WaitingAction waitingAction);
void dismissWaitingForAttachmentDialog();
void showPickAttachmentDialog(int requestCode);
void addAttachmentView(Attachment attachment);
void removeAttachmentView(Attachment attachment);
void updateAttachmentView(Attachment attachment);
// TODO these should not really be here :\
void performSendAfterChecks();
void performSaveAfterChecks();
void showMissingAttachmentsPartialMessageWarning();
void showMissingAttachmentsPartialMessageForwardWarning();
}
public interface AttachmentsChangedListener {
void onAttachmentAdded();
void onAttachmentRemoved();
}
}

View file

@ -0,0 +1,165 @@
package com.fsck.k9.activity.compose
import com.fsck.k9.activity.compose.RecipientMvpView.CryptoSpecialModeDisplayType
import com.fsck.k9.activity.compose.RecipientMvpView.CryptoStatusDisplayType
import com.fsck.k9.activity.compose.RecipientPresenter.CryptoMode
import com.fsck.k9.message.AutocryptStatusInteractor
import com.fsck.k9.message.AutocryptStatusInteractor.RecipientAutocryptStatus
import com.fsck.k9.message.CryptoStatus
import com.fsck.k9.view.RecipientSelectView.Recipient
import org.openintents.openpgp.OpenPgpApiManager
import org.openintents.openpgp.OpenPgpApiManager.OpenPgpProviderState
/** This is an immutable object which contains all relevant metadata entered
* during email composition to apply cryptographic operations before sending
* or saving as draft.
*/
data class ComposeCryptoStatus(
private val openPgpProviderState: OpenPgpProviderState,
override val openPgpKeyId: Long?,
val recipientAddresses: List<String>,
override val isPgpInlineModeEnabled: Boolean,
override val isSenderPreferEncryptMutual: Boolean,
override val isReplyToEncrypted: Boolean,
override val isEncryptAllDrafts: Boolean,
override val isEncryptSubject: Boolean,
private val cryptoMode: CryptoMode,
private val recipientAutocryptStatus: RecipientAutocryptStatus? = null
) : CryptoStatus {
constructor(
openPgpProviderState: OpenPgpProviderState,
openPgpKeyId: Long?,
recipientAddresses: List<Recipient>,
isPgpInlineModeEnabled: Boolean,
isSenderPreferEncryptMutual: Boolean,
isReplyToEncrypted: Boolean,
isEncryptAllDrafts: Boolean,
isEncryptSubject: Boolean,
cryptoMode: CryptoMode
) : this(
openPgpProviderState, openPgpKeyId,
recipientAddresses.map { it.address.address },
isPgpInlineModeEnabled, isSenderPreferEncryptMutual, isReplyToEncrypted, isEncryptAllDrafts, isEncryptSubject, cryptoMode
)
private val recipientAutocryptStatusType = recipientAutocryptStatus?.type
private val isRecipientsPreferEncryptMutual = recipientAutocryptStatus?.type?.isMutual ?: false
private val isExplicitlyEnabled = cryptoMode == CryptoMode.CHOICE_ENABLED
private val isMutualAndNotDisabled = cryptoMode != CryptoMode.CHOICE_DISABLED && canEncryptAndIsMutualDefault()
private val isReplyAndNotDisabled = cryptoMode != CryptoMode.CHOICE_DISABLED && isReplyToEncrypted
val isOpenPgpConfigured = openPgpProviderState != OpenPgpProviderState.UNCONFIGURED
override val isSignOnly = cryptoMode == CryptoMode.SIGN_ONLY
override val isEncryptionEnabled = when {
openPgpProviderState == OpenPgpProviderState.UNCONFIGURED -> false
isSignOnly -> false
isExplicitlyEnabled -> true
isMutualAndNotDisabled -> true
isReplyAndNotDisabled -> true
else -> false
}
override fun isProviderStateOk() = openPgpProviderState == OpenPgpProviderState.OK
override fun isUserChoice() = cryptoMode != CryptoMode.NO_CHOICE
override fun isSigningEnabled() = cryptoMode == CryptoMode.SIGN_ONLY || isEncryptionEnabled
val recipientAddressesAsArray = recipientAddresses.toTypedArray()
private val displayTypeFromProviderError = when (openPgpProviderState) {
OpenPgpApiManager.OpenPgpProviderState.OK -> null
OpenPgpApiManager.OpenPgpProviderState.UNCONFIGURED -> CryptoStatusDisplayType.UNCONFIGURED
OpenPgpApiManager.OpenPgpProviderState.UNINITIALIZED -> CryptoStatusDisplayType.UNINITIALIZED
OpenPgpApiManager.OpenPgpProviderState.ERROR, OpenPgpApiManager.OpenPgpProviderState.UI_REQUIRED -> CryptoStatusDisplayType.ERROR
}
private val displayTypeFromAutocryptError = when (recipientAutocryptStatusType) {
null, AutocryptStatusInteractor.RecipientAutocryptStatusType.ERROR -> CryptoStatusDisplayType.ERROR
else -> null
}
private val displayTypeFromEnabledAutocryptStatus = when {
!isEncryptionEnabled -> null
recipientAutocryptStatusType == null -> CryptoStatusDisplayType.ERROR
!recipientAutocryptStatusType.canEncrypt() -> CryptoStatusDisplayType.ENABLED_ERROR
recipientAutocryptStatusType.isConfirmed -> CryptoStatusDisplayType.ENABLED_TRUSTED
else -> CryptoStatusDisplayType.ENABLED
}
private val displayTypeFromSignOnly = when {
isSignOnly -> CryptoStatusDisplayType.SIGN_ONLY
else -> null
}
private val displayTypeFromEncryptionAvailable = when {
recipientAutocryptStatusType?.canEncrypt() == true -> CryptoStatusDisplayType.AVAILABLE
else -> null
}
val displayType =
displayTypeFromProviderError
?: displayTypeFromAutocryptError
?: displayTypeFromEnabledAutocryptStatus
?: displayTypeFromSignOnly
?: displayTypeFromEncryptionAvailable
?: CryptoStatusDisplayType.UNAVAILABLE
val specialModeDisplayType = when {
openPgpProviderState != OpenPgpProviderState.OK -> CryptoSpecialModeDisplayType.NONE
isSignOnly && isPgpInlineModeEnabled -> CryptoSpecialModeDisplayType.SIGN_ONLY_PGP_INLINE
isSignOnly -> CryptoSpecialModeDisplayType.SIGN_ONLY
allRecipientsCanEncrypt() && isPgpInlineModeEnabled -> CryptoSpecialModeDisplayType.PGP_INLINE
else -> CryptoSpecialModeDisplayType.NONE
}
val autocryptPendingIntent = recipientAutocryptStatus?.intent
val sendErrorStateOrNull = when {
openPgpProviderState != OpenPgpProviderState.OK -> SendErrorState.PROVIDER_ERROR
openPgpKeyId == null && (isEncryptionEnabled || isSignOnly) -> SendErrorState.KEY_CONFIG_ERROR
isEncryptionEnabled && !allRecipientsCanEncrypt() -> SendErrorState.ENABLED_ERROR
else -> null
}
val attachErrorStateOrNull = when {
openPgpProviderState == OpenPgpProviderState.UNCONFIGURED -> null
isPgpInlineModeEnabled -> AttachErrorState.IS_INLINE
else -> null
}
fun allRecipientsCanEncrypt() = recipientAutocryptStatus?.type?.canEncrypt() == true
fun canEncryptAndIsMutualDefault() = allRecipientsCanEncrypt() && isSenderPreferEncryptMutual && isRecipientsPreferEncryptMutual
fun hasAutocryptPendingIntent() = recipientAutocryptStatus?.hasPendingIntent() == true
override fun hasRecipients() = recipientAddresses.isNotEmpty()
override fun getRecipientAddresses() = recipientAddresses.toTypedArray()
fun withRecipientAutocryptStatus(recipientAutocryptStatusType: RecipientAutocryptStatus) = ComposeCryptoStatus(
openPgpProviderState = openPgpProviderState,
cryptoMode = cryptoMode,
openPgpKeyId = openPgpKeyId,
isPgpInlineModeEnabled = isPgpInlineModeEnabled,
isSenderPreferEncryptMutual = isSenderPreferEncryptMutual,
isReplyToEncrypted = isReplyToEncrypted,
isEncryptAllDrafts = isEncryptAllDrafts,
isEncryptSubject = isEncryptSubject,
recipientAddresses = recipientAddresses,
recipientAutocryptStatus = recipientAutocryptStatusType
)
enum class SendErrorState {
PROVIDER_ERROR,
KEY_CONFIG_ERROR,
ENABLED_ERROR
}
enum class AttachErrorState {
IS_INLINE
}
}

View file

@ -0,0 +1,151 @@
package com.fsck.k9.activity.compose;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.TextView;
import com.fsck.k9.Account;
import com.fsck.k9.Identity;
import com.fsck.k9.Preferences;
import com.fsck.k9.ui.R;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* Adapter for the <em>Choose identity</em> list view.
*
* <p>
* Account names are displayed as section headers, identities as selectable list items.
* </p>
*/
public class IdentityAdapter extends BaseAdapter {
private LayoutInflater mLayoutInflater;
private List<Object> mItems;
public IdentityAdapter(Context context) {
mLayoutInflater = (LayoutInflater) context.getSystemService(
Context.LAYOUT_INFLATER_SERVICE);
List<Object> items = new ArrayList<>();
Preferences prefs = Preferences.getPreferences();
Collection<Account> accounts = prefs.getAccounts();
for (Account account : accounts) {
items.add(account);
List<Identity> identities = account.getIdentities();
for (Identity identity : identities) {
items.add(new IdentityContainer(identity, account));
}
}
mItems = items;
}
@Override
public int getCount() {
return mItems.size();
}
@Override
public int getViewTypeCount() {
return 2;
}
@Override
public int getItemViewType(int position) {
return (mItems.get(position) instanceof Account) ? 0 : 1;
}
@Override
public boolean isEnabled(int position) {
return (mItems.get(position) instanceof IdentityContainer);
}
@Override
public Object getItem(int position) {
return mItems.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public boolean hasStableIds() {
return false;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
Object item = mItems.get(position);
View view = null;
if (item instanceof Account) {
if (convertView != null && convertView.getTag() instanceof AccountHolder) {
view = convertView;
} else {
view = mLayoutInflater.inflate(R.layout.choose_account_item, parent, false);
AccountHolder holder = new AccountHolder();
holder.name = view.findViewById(R.id.name);
holder.chip = view.findViewById(R.id.chip);
view.setTag(holder);
}
Account account = (Account) item;
AccountHolder holder = (AccountHolder) view.getTag();
holder.name.setText(account.getDisplayName());
holder.chip.setBackgroundColor(account.getChipColor());
} else if (item instanceof IdentityContainer) {
if (convertView != null && convertView.getTag() instanceof IdentityHolder) {
view = convertView;
} else {
view = mLayoutInflater.inflate(R.layout.choose_identity_item, parent, false);
IdentityHolder holder = new IdentityHolder();
holder.name = view.findViewById(R.id.name);
holder.description = view.findViewById(R.id.description);
view.setTag(holder);
}
IdentityContainer identityContainer = (IdentityContainer) item;
Identity identity = identityContainer.identity;
IdentityHolder holder = (IdentityHolder) view.getTag();
holder.name.setText(identity.getDescription());
holder.description.setText(getIdentityDescription(identity));
}
return view;
}
private static String getIdentityDescription(Identity identity) {
return String.format("%s <%s>", identity.getName(), identity.getEmail());
}
/**
* Used to store an {@link Identity} instance together with the {@link Account} it belongs to.
*
* @see IdentityAdapter
*/
public static class IdentityContainer {
public final Identity identity;
public final Account account;
IdentityContainer(Identity identity, Account account) {
this.identity = identity;
this.account = account;
}
}
static class AccountHolder {
public TextView name;
public View chip;
}
static class IdentityHolder {
public TextView name;
public TextView description;
}
}

View file

@ -0,0 +1,104 @@
package com.fsck.k9.activity.compose;
import android.content.Context;
import android.content.Intent;
import android.os.Parcelable;
import com.fsck.k9.Account;
import com.fsck.k9.Preferences;
import com.fsck.k9.activity.MessageCompose;
import com.fsck.k9.activity.setup.AccountSetupBasics;
import com.fsck.k9.controller.MessageReference;
public class MessageActions {
/**
* Compose a new message using the given account. If account is null the default account
* will be used. If there is no default account set, user will be sent to AccountSetupBasics
* activity.
*/
public static void actionCompose(Context context, Account account) {
Account defaultAccount = Preferences.getPreferences().getDefaultAccount();
if (account == null && defaultAccount == null) {
AccountSetupBasics.actionNewAccount(context);
} else {
String accountUuid = (account == null) ?
defaultAccount.getUuid() :
account.getUuid();
Intent i = new Intent(context, MessageCompose.class);
i.putExtra(MessageCompose.EXTRA_ACCOUNT, accountUuid);
i.setAction(MessageCompose.ACTION_COMPOSE);
context.startActivity(i);
}
}
/**
* Get intent for composing a new message as a reply to the given message. If replyAll is true
* the function is reply all instead of simply reply.
*/
public static Intent getActionReplyIntent(
Context context, MessageReference messageReference, boolean replyAll, Parcelable decryptionResult) {
Intent i = new Intent(context, MessageCompose.class);
i.putExtra(MessageCompose.EXTRA_MESSAGE_DECRYPTION_RESULT, decryptionResult);
i.putExtra(MessageCompose.EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString());
if (replyAll) {
i.setAction(MessageCompose.ACTION_REPLY_ALL);
} else {
i.setAction(MessageCompose.ACTION_REPLY);
}
return i;
}
public static Intent getActionReplyIntent(Context context, MessageReference messageReference) {
Intent intent = new Intent(context, MessageCompose.class);
intent.setAction(MessageCompose.ACTION_REPLY);
intent.putExtra(MessageCompose.EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return intent;
}
/**
* Compose a new message as a reply to the given message. If replyAll is true the function
* is reply all instead of simply reply.
*/
public static void actionReply(
Context context, MessageReference messageReference, boolean replyAll, Parcelable decryptionResult) {
context.startActivity(getActionReplyIntent(context, messageReference, replyAll, decryptionResult));
}
/**
* Compose a new message as a forward of the given message.
*/
public static void actionForward(Context context, MessageReference messageReference, Parcelable decryptionResult) {
Intent i = new Intent(context, MessageCompose.class);
i.putExtra(MessageCompose.EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString());
i.putExtra(MessageCompose.EXTRA_MESSAGE_DECRYPTION_RESULT, decryptionResult);
i.setAction(MessageCompose.ACTION_FORWARD);
context.startActivity(i);
}
/**
* Compose a new message as a forward of the given message.
*/
public static void actionForwardAsAttachment(Context context, MessageReference messageReference, Parcelable decryptionResult) {
Intent i = new Intent(context, MessageCompose.class);
i.putExtra(MessageCompose.EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString());
i.putExtra(MessageCompose.EXTRA_MESSAGE_DECRYPTION_RESULT, decryptionResult);
i.setAction(MessageCompose.ACTION_FORWARD_AS_ATTACHMENT);
context.startActivity(i);
}
/**
* Continue composition of the given message. This action modifies the way this Activity
* handles certain actions.
* Save will attempt to replace the message in the given folder with the updated version.
* Discard will delete the message from the given folder.
*/
public static void actionEditDraft(Context context, MessageReference messageReference) {
Intent i = new Intent(context, MessageCompose.class);
i.putExtra(MessageCompose.EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString());
i.setAction(MessageCompose.ACTION_EDIT_DRAFT);
context.startActivity(i);
}
}

View file

@ -0,0 +1,71 @@
package com.fsck.k9.activity.compose;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.os.Bundle;
import androidx.annotation.IdRes;
import android.view.LayoutInflater;
import android.view.View;
import com.fsck.k9.ui.R;
import com.fsck.k9.view.HighlightDialogFragment;
public class PgpEnabledErrorDialog extends HighlightDialogFragment {
private static final String ARG_IS_GOTIT = "is_gotit";
public static PgpEnabledErrorDialog newInstance(boolean isGotItDialog, @IdRes int showcaseView) {
PgpEnabledErrorDialog dialog = new PgpEnabledErrorDialog();
Bundle args = new Bundle();
args.putInt(ARG_HIGHLIGHT_VIEW, showcaseView);
args.putBoolean(ARG_IS_GOTIT, isGotItDialog);
dialog.setArguments(args);
return dialog;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Activity activity = getActivity();
boolean isGotItDialog = getArguments().getBoolean(ARG_IS_GOTIT);
@SuppressLint("InflateParams")
View view = LayoutInflater.from(activity).inflate(R.layout.openpgp_enabled_error_dialog, null);
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setView(view);
builder.setNegativeButton(isGotItDialog ? R.string.openpgp_enabled_error_gotit :
R.string.openpgp_enabled_error_back, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
});
builder.setPositiveButton(R.string.openpgp_enabled_error_disable, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
Activity activity = getActivity();
if (activity == null) {
return;
}
((OnOpenPgpDisableListener) activity).onOpenPgpClickDisable();
dialog.dismiss();
}
});
return builder.create();
}
public interface OnOpenPgpDisableListener {
void onOpenPgpClickDisable();
}
}

View file

@ -0,0 +1,49 @@
package com.fsck.k9.activity.compose;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.os.Bundle;
import androidx.annotation.IdRes;
import android.view.LayoutInflater;
import android.view.View;
import com.fsck.k9.ui.R;
import com.fsck.k9.view.HighlightDialogFragment;
public class PgpEncryptDescriptionDialog extends HighlightDialogFragment {
public static PgpEncryptDescriptionDialog newInstance(@IdRes int showcaseView) {
PgpEncryptDescriptionDialog dialog = new PgpEncryptDescriptionDialog();
Bundle args = new Bundle();
args.putInt(ARG_HIGHLIGHT_VIEW, showcaseView);
dialog.setArguments(args);
return dialog;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Activity activity = getActivity();
@SuppressLint("InflateParams")
View view = LayoutInflater.from(activity).inflate(R.layout.openpgp_encrypt_description_dialog, null);
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setView(view);
builder.setPositiveButton(R.string.openpgp_sign_only_ok, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
});
return builder.create();
}
}

View file

@ -0,0 +1,79 @@
package com.fsck.k9.activity.compose;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.os.Bundle;
import androidx.annotation.IdRes;
import android.view.LayoutInflater;
import android.view.View;
import com.fsck.k9.ui.R;
import com.fsck.k9.view.HighlightDialogFragment;
public class PgpInlineDialog extends HighlightDialogFragment {
public static final String ARG_FIRST_TIME = "first_time";
public static PgpInlineDialog newInstance(boolean firstTime, @IdRes int showcaseView) {
PgpInlineDialog dialog = new PgpInlineDialog();
Bundle args = new Bundle();
args.putInt(ARG_FIRST_TIME, firstTime ? 1 : 0);
args.putInt(ARG_HIGHLIGHT_VIEW, showcaseView);
dialog.setArguments(args);
return dialog;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Activity activity = getActivity();
@SuppressLint("InflateParams")
View view = LayoutInflater.from(activity).inflate(R.layout.openpgp_inline_dialog, null);
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setView(view);
if (getArguments().getInt(ARG_FIRST_TIME) != 0) {
builder.setPositiveButton(R.string.openpgp_inline_ok, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
});
} else {
builder.setPositiveButton(R.string.openpgp_inline_disable, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
Activity activity = getActivity();
if (activity == null) {
return;
}
((OnOpenPgpInlineChangeListener) activity).onOpenPgpInlineChange(false);
dialog.dismiss();
}
});
builder.setNegativeButton(R.string.openpgp_inline_keep_enabled, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
});
}
return builder.create();
}
public interface OnOpenPgpInlineChangeListener {
void onOpenPgpInlineChange(boolean enabled);
}
}

View file

@ -0,0 +1,79 @@
package com.fsck.k9.activity.compose;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.os.Bundle;
import androidx.annotation.IdRes;
import android.view.LayoutInflater;
import android.view.View;
import com.fsck.k9.ui.R;
import com.fsck.k9.view.HighlightDialogFragment;
public class PgpSignOnlyDialog extends HighlightDialogFragment {
public static final String ARG_FIRST_TIME = "first_time";
public static PgpSignOnlyDialog newInstance(boolean firstTime, @IdRes int showcaseView) {
PgpSignOnlyDialog dialog = new PgpSignOnlyDialog();
Bundle args = new Bundle();
args.putInt(ARG_FIRST_TIME, firstTime ? 1 : 0);
args.putInt(ARG_HIGHLIGHT_VIEW, showcaseView);
dialog.setArguments(args);
return dialog;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Activity activity = getActivity();
@SuppressLint("InflateParams")
View view = LayoutInflater.from(activity).inflate(R.layout.openpgp_sign_only_dialog, null);
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setView(view);
if (getArguments().getInt(ARG_FIRST_TIME) != 0) {
builder.setPositiveButton(R.string.openpgp_sign_only_ok, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
});
} else {
builder.setPositiveButton(R.string.openpgp_sign_only_disable, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
Activity activity = getActivity();
if (activity == null) {
return;
}
((OnOpenPgpSignOnlyChangeListener) activity).onOpenPgpSignOnlyChange(false);
dialog.dismiss();
}
});
builder.setNegativeButton(R.string.openpgp_sign_only_keep_enabled, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
});
}
return builder.create();
}
public interface OnOpenPgpSignOnlyChangeListener {
void onOpenPgpSignOnlyChange(boolean enabled);
}
}

View file

@ -0,0 +1,224 @@
package com.fsck.k9.activity.compose;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import android.text.Spannable;
import android.text.style.ForegroundColorSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
import com.fsck.k9.activity.misc.ContactPicture;
import com.fsck.k9.ui.R;
import com.fsck.k9.view.RecipientSelectView.Recipient;
import com.fsck.k9.view.RecipientSelectView.RecipientCryptoStatus;
import com.fsck.k9.view.ThemeUtils;
public class RecipientAdapter extends BaseAdapter implements Filterable {
private final Context context;
private List<Recipient> recipients;
private String highlight;
private boolean showAdvancedInfo;
public RecipientAdapter(Context context) {
super();
this.context = context;
}
public void setRecipients(List<Recipient> recipients) {
this.recipients = recipients;
notifyDataSetChanged();
}
public void setHighlight(String highlight) {
this.highlight = highlight;
}
@Override
public int getCount() {
return recipients == null ? 0 : recipients.size();
}
@Override
public Recipient getItem(int position) {
return recipients == null ? null : recipients.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View view, ViewGroup parent) {
if (view == null) {
view = newView(parent);
}
Recipient recipient = getItem(position);
bindView(view, recipient);
return view;
}
private View newView(ViewGroup parent) {
View view = LayoutInflater.from(context).inflate(R.layout.recipient_dropdown_item, parent, false);
RecipientTokenHolder holder = new RecipientTokenHolder(view);
view.setTag(holder);
return view;
}
private void bindView(View view, Recipient recipient) {
RecipientTokenHolder holder = (RecipientTokenHolder) view.getTag();
holder.name.setText(highlightText(recipient.getDisplayNameOrUnknown(context)));
String address = recipient.address.getAddress();
holder.email.setText(highlightText(address));
setContactPhotoOrPlaceholder(context, holder.photo, recipient);
if (showAdvancedInfo) {
bindCryptoAdvanced(recipient, holder);
} else {
bindCryptoSimple(recipient, holder);
}
}
private void bindCryptoAdvanced(Recipient recipient, RecipientTokenHolder holder) {
holder.cryptoStatusSimple.setVisibility(View.GONE);
Integer cryptoStatusRes = null, cryptoStatusColor = null;
RecipientCryptoStatus cryptoStatus = recipient.getCryptoStatus();
switch (cryptoStatus) {
case AVAILABLE_TRUSTED: {
cryptoStatusRes = R.drawable.status_lock_dots_3;
cryptoStatusColor = ThemeUtils.getStyledColor(context, R.attr.openpgp_green);
break;
}
case AVAILABLE_UNTRUSTED: {
cryptoStatusRes = R.drawable.status_lock_dots_2;
cryptoStatusColor = ThemeUtils.getStyledColor(context, R.attr.openpgp_orange);
break;
}
case UNAVAILABLE: {
cryptoStatusRes = R.drawable.status_lock_disabled_dots_1;
cryptoStatusColor = ThemeUtils.getStyledColor(context, R.attr.openpgp_red);
break;
}
}
if (cryptoStatusRes != null) {
Drawable drawable = ContextCompat.getDrawable(context, cryptoStatusRes);
DrawableCompat.wrap(drawable);
DrawableCompat.setTint(drawable.mutate(), cryptoStatusColor);
holder.cryptoStatusIcon.setImageDrawable(drawable);
holder.cryptoStatus.setVisibility(View.VISIBLE);
} else {
holder.cryptoStatus.setVisibility(View.GONE);
}
}
private void bindCryptoSimple(Recipient recipient, RecipientTokenHolder holder) {
holder.cryptoStatus.setVisibility(View.GONE);
RecipientCryptoStatus cryptoStatus = recipient.getCryptoStatus();
switch (cryptoStatus) {
case AVAILABLE_TRUSTED:
case AVAILABLE_UNTRUSTED: {
holder.cryptoStatusSimple.setVisibility(View.VISIBLE);
break;
}
case UNAVAILABLE: {
holder.cryptoStatusSimple.setVisibility(View.GONE);
break;
}
}
}
public static void setContactPhotoOrPlaceholder(Context context, ImageView imageView, Recipient recipient) {
ContactPicture.getContactPictureLoader().setContactPicture(imageView, recipient);
}
@Override
public Filter getFilter() {
return new Filter() {
@Override
protected FilterResults performFiltering(CharSequence constraint) {
if (recipients == null) {
return null;
}
FilterResults result = new FilterResults();
result.values = recipients;
result.count = recipients.size();
return result;
}
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
notifyDataSetChanged();
}
};
}
public void setShowAdvancedInfo(boolean showAdvancedInfo) {
this.showAdvancedInfo = showAdvancedInfo;
}
private static class RecipientTokenHolder {
public final TextView name;
public final TextView email;
final ImageView photo;
final View cryptoStatus;
final ImageView cryptoStatusIcon;
final ImageView cryptoStatusSimple;
RecipientTokenHolder(View view) {
name = view.findViewById(R.id.text1);
email = view.findViewById(R.id.text2);
photo = view.findViewById(R.id.contact_photo);
cryptoStatus = view.findViewById(R.id.contact_crypto_status);
cryptoStatusIcon = view.findViewById(R.id.contact_crypto_status_icon);
cryptoStatusSimple = view.findViewById(R.id.contact_crypto_status_icon_simple);
}
}
private Spannable highlightText(String text) {
Spannable highlightedSpannable = Spannable.Factory.getInstance().newSpannable(text);
if (highlight == null) {
return highlightedSpannable;
}
Pattern pattern = Pattern.compile(highlight, Pattern.LITERAL | Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
highlightedSpannable.setSpan(
new ForegroundColorSpan(context.getResources().getColor(android.R.color.holo_blue_light)),
matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
return highlightedSpannable;
}
}

View file

@ -0,0 +1,600 @@
package com.fsck.k9.activity.compose;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import android.Manifest;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Email;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.Contacts.Data;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.loader.content.AsyncTaskLoader;
import androidx.core.content.ContextCompat;
import app.k9mail.core.android.common.database.EmptyCursor;
import com.fsck.k9.ui.R;
import com.fsck.k9.mail.Address;
import com.fsck.k9.view.RecipientSelectView.Recipient;
import com.fsck.k9.view.RecipientSelectView.RecipientCryptoStatus;
import org.apache.james.mime4j.util.CharsetUtil;
import timber.log.Timber;
import static java.lang.String.CASE_INSENSITIVE_ORDER;
public class RecipientLoader extends AsyncTaskLoader<List<Recipient>> {
/*
* Indexes of the fields in the projection. This must match the order in {@link #PROJECTION}.
*/
private static final int INDEX_NAME = 1;
private static final int INDEX_LOOKUP_KEY = 2;
private static final int INDEX_EMAIL = 3;
private static final int INDEX_EMAIL_TYPE = 4;
private static final int INDEX_EMAIL_CUSTOM_LABEL = 5;
private static final int INDEX_CONTACT_ID = 6;
private static final int INDEX_PHOTO_URI = 7;
private static final int INDEX_TIMES_CONTACTED = 8;
private static final int INDEX_KEY_PRIMARY = 9;
private static final int INDEX_STARRED = 10;
private static final String[] PROJECTION = {
ContactsContract.CommonDataKinds.Email._ID,
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
ContactsContract.Contacts.LOOKUP_KEY,
ContactsContract.CommonDataKinds.Email.DATA,
ContactsContract.CommonDataKinds.Email.TYPE,
ContactsContract.CommonDataKinds.Email.LABEL,
ContactsContract.CommonDataKinds.Email.CONTACT_ID,
ContactsContract.Contacts.PHOTO_THUMBNAIL_URI,
ContactsContract.CommonDataKinds.Email.TIMES_CONTACTED,
ContactsContract.Contacts.SORT_KEY_PRIMARY,
ContactsContract.Contacts.STARRED,
};
private static final String SORT_ORDER = "" +
ContactsContract.CommonDataKinds.Email.TIMES_CONTACTED + " DESC, " +
ContactsContract.Contacts.SORT_KEY_PRIMARY + "," +
ContactsContract.CommonDataKinds.Email.IS_SUPER_PRIMARY + " DESC, " +
ContactsContract.CommonDataKinds.Email.IS_PRIMARY + " DESC, " +
ContactsContract.Contacts._ID;
private static final String[] PROJECTION_NICKNAME = {
ContactsContract.Data.CONTACT_ID,
ContactsContract.CommonDataKinds.Nickname.NAME
};
private static final int INDEX_CONTACT_ID_FOR_NICKNAME = 0;
private static final int INDEX_NICKNAME = 1;
private static final String[] PROJECTION_CRYPTO_ADDRESSES = {
"address",
"uid_address"
};
private static final int INDEX_USER_ID = 1;
private static final String[] PROJECTION_CRYPTO_STATUS = {
"address",
"uid_key_status",
"autocrypt_key_status"
};
private static final int INDEX_EMAIL_ADDRESS = 0;
private static final int INDEX_EMAIL_STATUS = 1;
private static final int INDEX_AUTOCRYPT_STATUS = 2;
private static final int CRYPTO_PROVIDER_STATUS_UNTRUSTED = 1;
private static final int CRYPTO_PROVIDER_STATUS_TRUSTED = 2;
private static final Comparator<Recipient> RECIPIENT_COMPARATOR = new Comparator<Recipient>() {
@Override
public int compare(Recipient lhs, Recipient rhs) {
if (rhs.starred != lhs.starred) {
return rhs.starred ? 1 : -1;
}
int timesContactedDiff = rhs.timesContacted - lhs.timesContacted;
if (timesContactedDiff != 0) {
return timesContactedDiff;
}
if (lhs.sortKey == null && rhs.sortKey == null) {
return 0;
} else if (lhs.sortKey == null) {
return 1;
} else if (rhs.sortKey == null) {
return -1;
}
return CASE_INSENSITIVE_ORDER.compare(lhs.sortKey, rhs.sortKey);
}
};
private final String query;
private final Address[] addresses;
private final Uri contactUri;
private final Uri lookupKeyUri;
private final String cryptoProvider;
private final ContentResolver contentResolver;
private List<Recipient> cachedRecipients;
private ForceLoadContentObserver observerContact, observerKey;
private RecipientLoader(Context context) {
super(context);
this.query = null;
this.lookupKeyUri = null;
this.addresses = null;
this.contactUri = null;
this.cryptoProvider = null;
this.contentResolver = context.getContentResolver();
}
public RecipientLoader(Context context, String cryptoProvider, String query) {
super(context);
this.query = query;
this.lookupKeyUri = null;
this.addresses = null;
this.contactUri = null;
this.cryptoProvider = cryptoProvider;
contentResolver = context.getContentResolver();
}
public RecipientLoader(Context context, String cryptoProvider, Address... addresses) {
super(context);
this.query = null;
this.addresses = addresses;
this.contactUri = null;
this.cryptoProvider = cryptoProvider;
this.lookupKeyUri = null;
contentResolver = context.getContentResolver();
}
public RecipientLoader(Context context, String cryptoProvider, Uri contactUri, boolean isLookupKey) {
super(context);
this.query = null;
this.addresses = null;
this.contactUri = isLookupKey ? null : contactUri;
this.lookupKeyUri = isLookupKey ? contactUri : null;
this.cryptoProvider = cryptoProvider;
contentResolver = context.getContentResolver();
}
public static RecipientLoader getMostContactedRecipientLoader(Context context, final int maxRecipients) {
return new RecipientLoader(context) {
@Override
public List<Recipient> loadInBackground() {
return super.fillContactDataBySortOrder(maxRecipients);
}
};
}
@Override
public List<Recipient> loadInBackground() {
List<Recipient> recipients = new ArrayList<>();
Map<String, Recipient> recipientMap = new HashMap<>();
if (addresses != null) {
fillContactDataFromAddresses(addresses, recipients, recipientMap);
} else if (contactUri != null) {
fillContactDataFromEmailContentUri(contactUri, recipients, recipientMap);
} else if (query != null) {
fillContactDataFromQuery(query, recipients, recipientMap);
if (cryptoProvider != null) {
fillContactDataFromCryptoProvider(query, recipients, recipientMap);
}
} else if (lookupKeyUri != null) {
fillContactDataFromLookupKey(lookupKeyUri, recipients, recipientMap);
} else {
throw new IllegalStateException("loader must be initialized with query or list of addresses!");
}
return fillCryptoStatusData(recipients, recipientMap);
}
@NonNull
private List<Recipient> fillCryptoStatusData(List<Recipient> recipients, Map<String, Recipient> recipientMap) {
if (recipients.isEmpty()) {
return recipients;
}
if (cryptoProvider != null) {
fillCryptoStatusData(recipientMap);
}
return recipients;
}
private void fillContactDataFromCryptoProvider(String query, List<Recipient> recipients,
Map<String, Recipient> recipientMap) {
Cursor cursor;
try {
Uri queryUri = Uri.parse("content://" + cryptoProvider + ".provider.exported/autocrypt_status");
cursor = contentResolver.query(queryUri, PROJECTION_CRYPTO_ADDRESSES, null,
new String[] { "%" + query + "%" }, null);
if (cursor == null) {
return;
}
} catch (SecurityException e) {
Timber.e(e, "Couldn't obtain recipients from crypto provider!");
return;
}
while (cursor.moveToNext()) {
String uid = cursor.getString(INDEX_USER_ID);
Address[] addresses = Address.parseUnencoded(uid);
for (Address address : addresses) {
String emailAddress = address.getAddress();
if (!isSupportedEmailAddress(emailAddress) || recipientMap.containsKey(emailAddress)) {
continue;
}
Recipient recipient = new Recipient(address);
recipients.add(recipient);
recipientMap.put(emailAddress, recipient);
}
}
cursor.close();
}
private void fillContactDataFromAddresses(Address[] addresses, List<Recipient> recipients,
Map<String, Recipient> recipientMap) {
for (Address address : addresses) {
if (isSupportedEmailAddress(address.getAddress())) {
// TODO actually query contacts - not sure if this is possible in a single query tho :(
Recipient recipient = new Recipient(address);
recipients.add(recipient);
recipientMap.put(address.getAddress(), recipient);
}
}
}
private void fillContactDataFromEmailContentUri(Uri contactUri, List<Recipient> recipients,
Map<String, Recipient> recipientMap) {
Cursor cursor = contentResolver.query(contactUri, PROJECTION, null, null, null);
if (cursor == null) {
return;
}
fillContactDataFromCursor(cursor, recipients, recipientMap);
}
private void fillContactDataFromLookupKey(Uri lookupKeyUri, List<Recipient> recipients,
Map<String, Recipient> recipientMap) {
// We shouldn't try to access the contacts if we don't have the necessary permission
if (!hasContactPermission())
return;
// We could use the contact id from the URI directly, but getting it from the lookup key is safer
Uri contactContentUri = Contacts.lookupContact(contentResolver, lookupKeyUri);
if (contactContentUri == null) {
return;
}
String contactIdStr = getContactIdFromContactUri(contactContentUri);
Cursor cursor = contentResolver.query(
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
PROJECTION, ContactsContract.CommonDataKinds.Email.CONTACT_ID + "=?",
new String[] { contactIdStr }, null);
if (cursor == null) {
return;
}
fillContactDataFromCursor(cursor, recipients, recipientMap);
}
private static String getContactIdFromContactUri(Uri contactUri) {
return contactUri.getLastPathSegment();
}
private boolean hasContactPermission() {
return ContextCompat.checkSelfPermission(getContext(),
Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED;
}
private Cursor getNicknameCursor(String nickname) {
nickname = "%" + nickname + "%";
Uri queryUriForNickname = ContactsContract.Data.CONTENT_URI;
if (hasContactPermission()) {
return contentResolver.query(queryUriForNickname,
PROJECTION_NICKNAME,
ContactsContract.CommonDataKinds.Nickname.NAME + " LIKE ? AND " +
Data.MIMETYPE + " = ?",
new String[] { nickname, ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE },
null);
} else {
return new EmptyCursor();
}
}
@SuppressWarnings("ConstantConditions")
private void fillContactDataFromQuery(String query, List<Recipient> recipients,
Map<String, Recipient> recipientMap) {
boolean foundValidCursor = false;
foundValidCursor |= fillContactDataFromNickname(query, recipients, recipientMap);
foundValidCursor |= fillContactDataFromNameAndEmail(query, recipients, recipientMap, null);
if (foundValidCursor) {
Collections.sort(recipients, RECIPIENT_COMPARATOR);
registerContentObserver();
}
}
private void registerContentObserver() {
if (observerContact != null) {
observerContact = new ForceLoadContentObserver();
contentResolver.registerContentObserver(Email.CONTENT_URI, false, observerContact);
}
}
@SuppressWarnings("ConstantConditions")
private boolean fillContactDataFromNickname(String nickname, List<Recipient> recipients,
Map<String, Recipient> recipientMap) {
boolean hasContact = false;
Uri queryUri = Email.CONTENT_URI;
Cursor nicknameCursor = getNicknameCursor(nickname);
if (nicknameCursor == null) {
return hasContact;
}
try {
while (nicknameCursor.moveToNext()) {
String id = nicknameCursor.getString(INDEX_CONTACT_ID_FOR_NICKNAME);
String selection = ContactsContract.Data.CONTACT_ID + " = ?";
Cursor cursor = contentResolver
.query(queryUri, PROJECTION, selection, new String[] { id }, SORT_ORDER);
String contactNickname = nicknameCursor.getString(INDEX_NICKNAME);
fillContactDataFromCursor(cursor, recipients, recipientMap, contactNickname, null);
hasContact = true;
}
} finally {
nicknameCursor.close();
}
return hasContact;
}
private List<Recipient> fillContactDataBySortOrder(int maxRecipients) {
List<Recipient> recipients = new ArrayList<>();
Uri queryUri = Email.CONTENT_URI;
Cursor cursor = null;
if (hasContactPermission()) {
cursor = contentResolver.query(queryUri, PROJECTION, null, null, SORT_ORDER);
}
if (cursor == null) {
return recipients;
}
fillContactDataFromCursor(cursor, recipients, new HashMap<>(), null, maxRecipients);
return recipients;
}
private boolean fillContactDataFromNameAndEmail(String query, List<Recipient> recipients,
Map<String, Recipient> recipientMap, Integer maxTargets) {
query = "%" + query + "%";
Uri queryUri = Email.CONTENT_URI;
String selection = Contacts.DISPLAY_NAME_PRIMARY + " LIKE ? " +
" OR (" + Email.ADDRESS + " LIKE ? AND " + Data.MIMETYPE + " = '" + Email.CONTENT_ITEM_TYPE + "')";
String[] selectionArgs = { query, query };
Cursor cursor = null;
if (hasContactPermission()) {
cursor = contentResolver.query(queryUri, PROJECTION, selection, selectionArgs, SORT_ORDER);
}
if (cursor == null) {
return false;
}
fillContactDataFromCursor(cursor, recipients, recipientMap);
return true;
}
private void fillContactDataFromCursor(Cursor cursor, List<Recipient> recipients,
Map<String, Recipient> recipientMap) {
fillContactDataFromCursor(cursor, recipients, recipientMap, null, null);
}
private void fillContactDataFromCursor(Cursor cursor, List<Recipient> recipients,
Map<String, Recipient> recipientMap, @Nullable String prefilledName, @Nullable Integer maxRecipients) {
while (cursor.moveToNext() && (maxRecipients == null || recipients.size() < maxRecipients)) {
String name = prefilledName != null ? prefilledName : cursor.getString(INDEX_NAME);
String email = cursor.getString(INDEX_EMAIL);
// already exists? just skip then
if (email == null || !isSupportedEmailAddress(email) || recipientMap.containsKey(email)) {
// TODO We should probably merging contacts with the same email address
continue;
}
long contactId = cursor.getLong(INDEX_CONTACT_ID);
String lookupKey = cursor.getString(INDEX_LOOKUP_KEY);
int timesContacted = cursor.getInt(INDEX_TIMES_CONTACTED);
String sortKey = cursor.getString(INDEX_KEY_PRIMARY);
boolean starred = cursor.getInt(INDEX_STARRED) == 1;
int addressType = cursor.getInt(INDEX_EMAIL_TYPE);
String addressLabel = null;
switch (addressType) {
case ContactsContract.CommonDataKinds.Email.TYPE_HOME: {
addressLabel = getContext().getString(R.string.address_type_home);
break;
}
case ContactsContract.CommonDataKinds.Email.TYPE_WORK: {
addressLabel = getContext().getString(R.string.address_type_work);
break;
}
case ContactsContract.CommonDataKinds.Email.TYPE_OTHER: {
addressLabel = getContext().getString(R.string.address_type_other);
break;
}
case ContactsContract.CommonDataKinds.Email.TYPE_MOBILE: {
// mobile isn't listed as an option contacts app, but it has a constant so we better support it
addressLabel = getContext().getString(R.string.address_type_mobile);
break;
}
case ContactsContract.CommonDataKinds.Email.TYPE_CUSTOM: {
addressLabel = cursor.getString(INDEX_EMAIL_CUSTOM_LABEL);
break;
}
}
Recipient recipient = new Recipient(name, email, addressLabel, contactId, lookupKey,
timesContacted, sortKey, starred);
if (recipient.isValidEmailAddress()) {
recipient.photoThumbnailUri =
cursor.isNull(INDEX_PHOTO_URI) ? null : Uri.parse(cursor.getString(INDEX_PHOTO_URI));
recipientMap.put(email, recipient);
recipients.add(recipient);
}
}
cursor.close();
}
private void fillCryptoStatusData(Map<String, Recipient> recipientMap) {
List<String> recipientList = new ArrayList<>(recipientMap.keySet());
String[] recipientAddresses = recipientList.toArray(new String[recipientList.size()]);
Cursor cursor;
Uri queryUri = Uri.parse("content://" + cryptoProvider + ".provider.exported/autocrypt_status");
try {
cursor = contentResolver.query(queryUri, PROJECTION_CRYPTO_STATUS, null, recipientAddresses, null);
} catch (SecurityException e) {
// TODO escalate error to crypto status?
return;
}
initializeCryptoStatusForAllRecipients(recipientMap);
if (cursor == null) {
return;
}
while (cursor.moveToNext()) {
String email = cursor.getString(INDEX_EMAIL_ADDRESS);
int uidStatus = cursor.getInt(INDEX_EMAIL_STATUS);
int autocryptStatus = cursor.getInt(INDEX_AUTOCRYPT_STATUS);
int effectiveStatus = uidStatus > autocryptStatus ? uidStatus : autocryptStatus;
for (Address address : Address.parseUnencoded(email)) {
String emailAddress = address.getAddress();
if (recipientMap.containsKey(emailAddress)) {
Recipient recipient = recipientMap.get(emailAddress);
switch (effectiveStatus) {
case CRYPTO_PROVIDER_STATUS_UNTRUSTED: {
if (recipient.getCryptoStatus() == RecipientCryptoStatus.UNAVAILABLE) {
recipient.setCryptoStatus(RecipientCryptoStatus.AVAILABLE_UNTRUSTED);
}
break;
}
case CRYPTO_PROVIDER_STATUS_TRUSTED: {
if (recipient.getCryptoStatus() != RecipientCryptoStatus.AVAILABLE_TRUSTED) {
recipient.setCryptoStatus(RecipientCryptoStatus.AVAILABLE_TRUSTED);
}
break;
}
}
}
}
}
cursor.close();
if (observerKey != null) {
observerKey = new ForceLoadContentObserver();
contentResolver.registerContentObserver(queryUri, false, observerKey);
}
}
private void initializeCryptoStatusForAllRecipients(Map<String, Recipient> recipientMap) {
for (Recipient recipient : recipientMap.values()) {
recipient.setCryptoStatus(RecipientCryptoStatus.UNAVAILABLE);
}
}
@Override
public void deliverResult(List<Recipient> data) {
cachedRecipients = data;
if (isStarted()) {
super.deliverResult(data);
}
}
@Override
protected void onStartLoading() {
if (cachedRecipients != null) {
super.deliverResult(cachedRecipients);
return;
}
if (takeContentChanged() || cachedRecipients == null) {
forceLoad();
}
}
@Override
protected void onAbandon() {
super.onAbandon();
if (observerKey != null) {
contentResolver.unregisterContentObserver(observerKey);
}
if (observerContact != null) {
contentResolver.unregisterContentObserver(observerContact);
}
}
private boolean isSupportedEmailAddress(String address) {
return CharsetUtil.isASCII(address);
}
}

View file

@ -0,0 +1,420 @@
package com.fsck.k9.activity.compose
import android.app.PendingIntent
import android.text.TextWatcher
import android.view.View
import android.widget.TextView
import android.widget.Toast
import android.widget.ViewAnimator
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import androidx.loader.app.LoaderManager
import com.fsck.k9.FontSizes
import com.fsck.k9.activity.MessageCompose
import com.fsck.k9.mail.Address
import com.fsck.k9.mail.Message.RecipientType
import com.fsck.k9.ui.R
import com.fsck.k9.view.RecipientSelectView
import com.fsck.k9.view.RecipientSelectView.Recipient
import com.fsck.k9.view.ToolableViewAnimator
import java.lang.AssertionError
class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChangeListener, View.OnClickListener {
private val toView: RecipientSelectView = activity.findViewById(R.id.to)
private val ccView: RecipientSelectView = activity.findViewById(R.id.cc)
private val bccView: RecipientSelectView = activity.findViewById(R.id.bcc)
private val ccWrapper: View = activity.findViewById(R.id.cc_wrapper)
private val ccDivider: View = activity.findViewById(R.id.cc_divider)
private val bccWrapper: View = activity.findViewById(R.id.bcc_wrapper)
private val bccDivider: View = activity.findViewById(R.id.bcc_divider)
private val recipientExpanderContainer: ViewAnimator = activity.findViewById(R.id.recipient_expander_container)
private val cryptoStatusView: ToolableViewAnimator = activity.findViewById(R.id.crypto_status)
private val cryptoSpecialModeIndicator: ToolableViewAnimator = activity.findViewById(R.id.crypto_special_mode)
private val textWatchers: MutableSet<TextWatcher> = HashSet()
private lateinit var presenter: RecipientPresenter
init {
cryptoStatusView.setOnClickListener(this)
cryptoSpecialModeIndicator.setOnClickListener(this)
toView.onFocusChangeListener = this
ccView.onFocusChangeListener = this
bccView.onFocusChangeListener = this
activity.findViewById<View>(R.id.recipient_expander).setOnClickListener(this)
activity.findViewById<View>(R.id.to_label).setOnClickListener(this)
activity.findViewById<View>(R.id.cc_label).setOnClickListener(this)
activity.findViewById<View>(R.id.bcc_label).setOnClickListener(this)
}
val isCcVisible: Boolean
get() = ccWrapper.isVisible
val isBccVisible: Boolean
get() = bccWrapper.isVisible
val toAddresses: List<Address>
get() = toView.addresses.toList()
val ccAddresses: List<Address>
get() = ccView.addresses.toList()
val bccAddresses: List<Address>
get() = bccView.addresses.toList()
val toRecipients: List<Recipient>
get() = toView.objects
val ccRecipients: List<Recipient>
get() = ccView.objects
val bccRecipients: List<Recipient>
get() = bccView.objects
val isCcTextEmpty: Boolean
get() = ccView.text.isEmpty()
val isBccTextEmpty: Boolean
get() = bccView.text.isEmpty()
fun setPresenter(presenter: RecipientPresenter) {
this.presenter = presenter
toView.setTokenListener(object : RecipientSelectView.TokenListener<Recipient> {
override fun onTokenAdded(recipient: Recipient) = presenter.onToTokenAdded()
override fun onTokenRemoved(recipient: Recipient) = presenter.onToTokenRemoved()
override fun onTokenChanged(recipient: Recipient) = presenter.onToTokenChanged()
override fun onTokenIgnored(token: Recipient) = Unit
})
ccView.setTokenListener(object : RecipientSelectView.TokenListener<Recipient> {
override fun onTokenAdded(recipient: Recipient) = presenter.onCcTokenAdded()
override fun onTokenRemoved(recipient: Recipient) = presenter.onCcTokenRemoved()
override fun onTokenChanged(recipient: Recipient) = presenter.onCcTokenChanged()
override fun onTokenIgnored(token: Recipient) = Unit
})
bccView.setTokenListener(object : RecipientSelectView.TokenListener<Recipient> {
override fun onTokenAdded(recipient: Recipient) = presenter.onBccTokenAdded()
override fun onTokenRemoved(recipient: Recipient) = presenter.onBccTokenRemoved()
override fun onTokenChanged(recipient: Recipient) = presenter.onBccTokenChanged()
override fun onTokenIgnored(token: Recipient) = Unit
})
}
fun addTextChangedListener(textWatcher: TextWatcher) {
textWatchers.add(textWatcher)
toView.addTextChangedListener(textWatcher)
ccView.addTextChangedListener(textWatcher)
bccView.addTextChangedListener(textWatcher)
}
private fun removeAllTextChangedListeners(view: TextView) {
for (textWatcher in textWatchers) {
view.removeTextChangedListener(textWatcher)
}
}
private fun addAllTextChangedListeners(view: TextView) {
for (textWatcher in textWatchers) {
view.addTextChangedListener(textWatcher)
}
}
fun setRecipientTokensShowCryptoEnabled(isEnabled: Boolean) {
toView.setShowCryptoEnabled(isEnabled)
ccView.setShowCryptoEnabled(isEnabled)
bccView.setShowCryptoEnabled(isEnabled)
}
fun setCryptoProvider(openPgpProvider: String?) {
// TODO move "show advanced" into settings, or somewhere?
toView.setCryptoProvider(openPgpProvider, false)
ccView.setCryptoProvider(openPgpProvider, false)
bccView.setCryptoProvider(openPgpProvider, false)
}
fun requestFocusOnToField() {
toView.requestFocus()
}
fun requestFocusOnCcField() {
ccView.requestFocus()
}
fun requestFocusOnBccField() {
bccView.requestFocus()
}
fun setFontSizes(fontSizes: FontSizes, fontSize: Int) {
val tokenTextSize = getTokenTextSize(fontSize)
toView.setTokenTextSize(tokenTextSize)
ccView.setTokenTextSize(tokenTextSize)
bccView.setTokenTextSize(tokenTextSize)
fontSizes.setViewTextSize(toView, fontSize)
fontSizes.setViewTextSize(ccView, fontSize)
fontSizes.setViewTextSize(bccView, fontSize)
}
private fun getTokenTextSize(fontSize: Int): Int {
return when (fontSize) {
FontSizes.FONT_10SP -> FontSizes.FONT_10SP
FontSizes.FONT_12SP -> FontSizes.FONT_12SP
FontSizes.SMALL -> FontSizes.SMALL
FontSizes.FONT_16SP -> 15
FontSizes.MEDIUM -> FontSizes.FONT_16SP
FontSizes.FONT_20SP -> FontSizes.MEDIUM
FontSizes.LARGE -> FontSizes.FONT_20SP
else -> FontSizes.FONT_DEFAULT
}
}
fun addRecipients(recipientType: RecipientType, vararg recipients: Recipient) {
when (recipientType) {
RecipientType.TO -> toView.addRecipients(*recipients)
RecipientType.CC -> ccView.addRecipients(*recipients)
RecipientType.BCC -> bccView.addRecipients(*recipients)
else -> throw AssertionError("Unsupported type: $recipientType")
}
}
fun silentlyAddBccAddresses(vararg recipients: Recipient) {
removeAllTextChangedListeners(bccView)
bccView.addRecipients(*recipients)
addAllTextChangedListeners(bccView)
}
fun silentlyRemoveBccAddresses(addresses: Array<Address>) {
if (addresses.isEmpty()) return
val addressesToRemove = addresses.toSet()
for (recipient in bccRecipients.toList()) {
removeAllTextChangedListeners(bccView)
if (recipient.address in addressesToRemove) {
bccView.removeObjectSync(recipient)
}
addAllTextChangedListeners(bccView)
}
}
fun setCcVisibility(visible: Boolean) {
ccWrapper.isVisible = visible
ccDivider.isVisible = visible
}
fun setBccVisibility(visible: Boolean) {
bccWrapper.isVisible = visible
bccDivider.isVisible = visible
}
fun setRecipientExpanderVisibility(visible: Boolean) {
val childToDisplay = if (visible) VIEW_INDEX_BCC_EXPANDER_VISIBLE else VIEW_INDEX_BCC_EXPANDER_HIDDEN
if (recipientExpanderContainer.displayedChild != childToDisplay) {
recipientExpanderContainer.displayedChild = childToDisplay
}
}
fun showNoRecipientsError() {
toView.error = toView.context.getString(R.string.message_compose_error_no_recipients)
}
fun recipientToHasUncompletedText(): Boolean {
return toView.hasUncompletedText()
}
fun recipientCcHasUncompletedText(): Boolean {
return ccView.hasUncompletedText()
}
fun recipientBccHasUncompletedText(): Boolean {
return bccView.hasUncompletedText()
}
fun recipientToTryPerformCompletion(): Boolean {
return toView.tryPerformCompletion()
}
fun recipientCcTryPerformCompletion(): Boolean {
return ccView.tryPerformCompletion()
}
fun recipientBccTryPerformCompletion(): Boolean {
return bccView.tryPerformCompletion()
}
fun showToUncompletedError() {
toView.error = toView.context.getString(R.string.compose_error_incomplete_recipient)
}
fun showCcUncompletedError() {
ccView.error = ccView.context.getString(R.string.compose_error_incomplete_recipient)
}
fun showBccUncompletedError() {
bccView.error = bccView.context.getString(R.string.compose_error_incomplete_recipient)
}
fun showCryptoSpecialMode(cryptoSpecialModeDisplayType: CryptoSpecialModeDisplayType) {
val shouldBeHidden = cryptoSpecialModeDisplayType.childIdToDisplay == VIEW_INDEX_HIDDEN
if (shouldBeHidden) {
cryptoSpecialModeIndicator.isGone = true
return
}
cryptoSpecialModeIndicator.isVisible = true
cryptoSpecialModeIndicator.displayedChildId = cryptoSpecialModeDisplayType.childIdToDisplay
activity.invalidateOptionsMenu()
}
fun showCryptoStatus(cryptoStatusDisplayType: CryptoStatusDisplayType) {
val shouldBeHidden = cryptoStatusDisplayType.childIdToDisplay == VIEW_INDEX_HIDDEN
if (shouldBeHidden) {
cryptoStatusView.animate()
.translationXBy(100.0f)
.alpha(0.0f)
.setDuration(CRYPTO_ICON_OUT_DURATION.toLong())
.setInterpolator(CRYPTO_ICON_OUT_ANIMATOR)
.start()
return
}
cryptoStatusView.isVisible = true
cryptoStatusView.displayedChildId = cryptoStatusDisplayType.childIdToDisplay
cryptoStatusView.animate()
.translationX(0.0f)
.alpha(1.0f)
.setDuration(CRYPTO_ICON_IN_DURATION.toLong())
.setInterpolator(CRYPTO_ICON_IN_ANIMATOR)
.start()
}
fun showContactPicker(requestCode: Int) {
activity.showContactPicker(requestCode)
}
fun showErrorIsSignOnly() {
Toast.makeText(activity, R.string.error_sign_only_no_encryption, Toast.LENGTH_LONG).show()
}
fun showErrorContactNoAddress() {
Toast.makeText(activity, R.string.error_contact_address_not_found, Toast.LENGTH_LONG).show()
}
fun showErrorOpenPgpIncompatible() {
Toast.makeText(activity, R.string.error_crypto_provider_incompatible, Toast.LENGTH_LONG).show()
}
fun showErrorOpenPgpConnection() {
Toast.makeText(activity, R.string.error_crypto_provider_connect, Toast.LENGTH_LONG).show()
}
fun showErrorOpenPgpUserInteractionRequired() {
Toast.makeText(activity, R.string.error_crypto_provider_ui_required, Toast.LENGTH_LONG).show()
}
fun showErrorNoKeyConfigured() {
Toast.makeText(activity, R.string.compose_error_no_key_configured, Toast.LENGTH_LONG).show()
}
fun showErrorInlineAttach() {
Toast.makeText(activity, R.string.error_crypto_inline_attach, Toast.LENGTH_LONG).show()
}
override fun onFocusChange(view: View, hasFocus: Boolean) {
if (!hasFocus) return
when (view.id) {
R.id.to -> presenter.onToFocused()
R.id.cc -> presenter.onCcFocused()
R.id.bcc -> presenter.onBccFocused()
}
}
override fun onClick(view: View) {
when (view.id) {
R.id.to_label -> presenter.onClickToLabel()
R.id.cc_label -> presenter.onClickCcLabel()
R.id.bcc_label -> presenter.onClickBccLabel()
R.id.recipient_expander -> presenter.onClickRecipientExpander()
R.id.crypto_status -> presenter.onClickCryptoStatus()
R.id.crypto_special_mode -> presenter.onClickCryptoSpecialModeIndicator()
}
}
fun showOpenPgpInlineDialog(firstTime: Boolean) {
val dialog = PgpInlineDialog.newInstance(firstTime, R.id.crypto_special_mode)
dialog.show(activity.supportFragmentManager, "openpgp_inline")
}
fun showOpenPgpSignOnlyDialog(firstTime: Boolean) {
val dialog = PgpSignOnlyDialog.newInstance(firstTime, R.id.crypto_special_mode)
dialog.show(activity.supportFragmentManager, "openpgp_signonly")
}
fun showOpenPgpEnabledErrorDialog(isGotItDialog: Boolean) {
val dialog = PgpEnabledErrorDialog.newInstance(isGotItDialog, R.id.crypto_status_anchor)
dialog.show(activity.supportFragmentManager, "openpgp_error")
}
fun showOpenPgpEncryptExplanationDialog() {
val dialog = PgpEncryptDescriptionDialog.newInstance(R.id.crypto_status_anchor)
dialog.show(activity.supportFragmentManager, "openpgp_description")
}
fun launchUserInteractionPendingIntent(pendingIntent: PendingIntent?, requestCode: Int) {
activity.launchUserInteractionPendingIntent(pendingIntent, requestCode)
}
fun setLoaderManager(loaderManager: LoaderManager?) {
toView.setLoaderManager(loaderManager)
ccView.setLoaderManager(loaderManager)
bccView.setLoaderManager(loaderManager)
}
enum class CryptoStatusDisplayType(val childIdToDisplay: Int) {
UNCONFIGURED(VIEW_INDEX_HIDDEN),
UNINITIALIZED(VIEW_INDEX_HIDDEN),
SIGN_ONLY(R.id.crypto_status_disabled),
UNAVAILABLE(VIEW_INDEX_HIDDEN),
ENABLED(R.id.crypto_status_enabled),
ENABLED_ERROR(R.id.crypto_status_error),
ENABLED_TRUSTED(R.id.crypto_status_trusted),
AVAILABLE(R.id.crypto_status_disabled),
ERROR(R.id.crypto_status_error);
}
enum class CryptoSpecialModeDisplayType(val childIdToDisplay: Int) {
NONE(VIEW_INDEX_HIDDEN),
PGP_INLINE(R.id.crypto_special_inline),
SIGN_ONLY(R.id.crypto_special_sign_only),
SIGN_ONLY_PGP_INLINE(R.id.crypto_special_sign_only_inline);
}
companion object {
private const val VIEW_INDEX_HIDDEN = -1
private const val VIEW_INDEX_BCC_EXPANDER_VISIBLE = 0
private const val VIEW_INDEX_BCC_EXPANDER_HIDDEN = 1
private val CRYPTO_ICON_OUT_ANIMATOR = FastOutLinearInInterpolator()
private const val CRYPTO_ICON_OUT_DURATION = 195
private val CRYPTO_ICON_IN_ANIMATOR = LinearOutSlowInInterpolator()
private const val CRYPTO_ICON_IN_DURATION = 225
}
}

View file

@ -0,0 +1,767 @@
package com.fsck.k9.activity.compose
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.AsyncTask
import android.os.Bundle
import android.view.Menu
import androidx.core.content.ContextCompat
import androidx.loader.app.LoaderManager
import com.fsck.k9.Account
import com.fsck.k9.K9
import com.fsck.k9.activity.compose.ComposeCryptoStatus.AttachErrorState
import com.fsck.k9.activity.compose.ComposeCryptoStatus.SendErrorState
import com.fsck.k9.autocrypt.AutocryptDraftStateHeader
import com.fsck.k9.autocrypt.AutocryptDraftStateHeaderParser
import com.fsck.k9.contact.ContactIntentHelper
import com.fsck.k9.helper.MailTo
import com.fsck.k9.helper.ReplyToParser
import com.fsck.k9.mail.Address
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Message.RecipientType
import com.fsck.k9.message.AutocryptStatusInteractor
import com.fsck.k9.message.AutocryptStatusInteractor.RecipientAutocryptStatus
import com.fsck.k9.message.ComposePgpEnableByDefaultDecider
import com.fsck.k9.message.ComposePgpInlineDecider
import com.fsck.k9.message.MessageBuilder
import com.fsck.k9.message.PgpMessageBuilder
import com.fsck.k9.ui.R
import com.fsck.k9.view.RecipientSelectView.Recipient
import org.openintents.openpgp.OpenPgpApiManager
import org.openintents.openpgp.OpenPgpApiManager.OpenPgpApiManagerCallback
import org.openintents.openpgp.OpenPgpApiManager.OpenPgpProviderError
import org.openintents.openpgp.OpenPgpApiManager.OpenPgpProviderState
import timber.log.Timber
private const val STATE_KEY_CC_SHOWN = "state:ccShown"
private const val STATE_KEY_BCC_SHOWN = "state:bccShown"
private const val STATE_KEY_LAST_FOCUSED_TYPE = "state:lastFocusedType"
private const val STATE_KEY_CURRENT_CRYPTO_MODE = "state:currentCryptoMode"
private const val STATE_KEY_CRYPTO_ENABLE_PGP_INLINE = "state:cryptoEnablePgpInline"
private const val CONTACT_PICKER_TO = 1
private const val CONTACT_PICKER_CC = 2
private const val CONTACT_PICKER_BCC = 3
private const val OPENPGP_USER_INTERACTION = 4
private const val REQUEST_CODE_AUTOCRYPT = 5
private const val PGP_DIALOG_DISPLAY_THRESHOLD = 2
class RecipientPresenter(
private val context: Context,
loaderManager: LoaderManager,
private val openPgpApiManager: OpenPgpApiManager,
private val recipientMvpView: RecipientMvpView,
account: Account,
private val composePgpInlineDecider: ComposePgpInlineDecider,
private val composePgpEnableByDefaultDecider: ComposePgpEnableByDefaultDecider,
private val autocryptStatusInteractor: AutocryptStatusInteractor,
private val replyToParser: ReplyToParser,
private val draftStateHeaderParser: AutocryptDraftStateHeaderParser
) {
private lateinit var account: Account
private var alwaysBccAddresses: Array<Address>? = null
private var hasContactPicker: Boolean? = null
private var isReplyToEncryptedMessage = false
private var lastFocusedType = RecipientType.TO
private var currentCryptoMode = CryptoMode.NO_CHOICE
var isForceTextMessageFormat = false
private set
var currentCachedCryptoStatus: ComposeCryptoStatus? = null
private set
val toAddresses: List<Address>
get() = recipientMvpView.toAddresses
val ccAddresses: List<Address>
get() = recipientMvpView.ccAddresses
val bccAddresses: List<Address>
get() = recipientMvpView.bccAddresses
private val allRecipients: List<Recipient>
get() = with(recipientMvpView) { toRecipients + ccRecipients + bccRecipients }
init {
recipientMvpView.setPresenter(this)
recipientMvpView.setLoaderManager(loaderManager)
onSwitchAccount(account)
}
fun checkRecipientsOkForSending(): Boolean {
recipientMvpView.recipientToTryPerformCompletion()
recipientMvpView.recipientCcTryPerformCompletion()
recipientMvpView.recipientBccTryPerformCompletion()
if (recipientMvpView.recipientToHasUncompletedText()) {
recipientMvpView.showToUncompletedError()
return true
}
if (recipientMvpView.recipientCcHasUncompletedText()) {
recipientMvpView.showCcUncompletedError()
return true
}
if (recipientMvpView.recipientBccHasUncompletedText()) {
recipientMvpView.showBccUncompletedError()
return true
}
if (toAddresses.isEmpty() && ccAddresses.isEmpty() && bccAddresses.isEmpty()) {
recipientMvpView.showNoRecipientsError()
return true
}
return false
}
fun initFromReplyToMessage(message: Message?, isReplyAll: Boolean) {
val replyToAddresses = if (isReplyAll) {
replyToParser.getRecipientsToReplyAllTo(message, account)
} else {
replyToParser.getRecipientsToReplyTo(message, account)
}
addToAddresses(*replyToAddresses.to)
addCcAddresses(*replyToAddresses.cc)
val shouldSendAsPgpInline = composePgpInlineDecider.shouldReplyInline(message)
if (shouldSendAsPgpInline) {
isForceTextMessageFormat = true
}
isReplyToEncryptedMessage = composePgpEnableByDefaultDecider.shouldEncryptByDefault(message)
}
fun initFromTrustIdAction(trustId: String?) {
addToAddresses(*Address.parse(trustId))
currentCryptoMode = CryptoMode.CHOICE_ENABLED
}
fun initFromMailto(mailTo: MailTo) {
addToAddresses(*mailTo.to)
addCcAddresses(*mailTo.cc)
addBccAddresses(*mailTo.bcc)
}
fun initFromSendOrViewIntent(intent: Intent) {
val toAddresses = intent.getStringArrayExtra(Intent.EXTRA_EMAIL)?.toAddressArray()
val ccAddresses = intent.getStringArrayExtra(Intent.EXTRA_CC)?.toAddressArray()
val bccAddresses = intent.getStringArrayExtra(Intent.EXTRA_BCC)?.toAddressArray()
if (toAddresses != null) {
addToAddresses(*toAddresses)
}
if (ccAddresses != null) {
addCcAddresses(*ccAddresses)
}
if (bccAddresses != null) {
addBccAddresses(*bccAddresses)
}
}
fun onRestoreInstanceState(savedInstanceState: Bundle) {
recipientMvpView.setCcVisibility(savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN))
recipientMvpView.setBccVisibility(savedInstanceState.getBoolean(STATE_KEY_BCC_SHOWN))
lastFocusedType = RecipientType.valueOf(savedInstanceState.getString(STATE_KEY_LAST_FOCUSED_TYPE)!!)
currentCryptoMode = CryptoMode.valueOf(savedInstanceState.getString(STATE_KEY_CURRENT_CRYPTO_MODE)!!)
isForceTextMessageFormat = savedInstanceState.getBoolean(STATE_KEY_CRYPTO_ENABLE_PGP_INLINE)
updateRecipientExpanderVisibility()
}
fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(STATE_KEY_CC_SHOWN, recipientMvpView.isCcVisible)
outState.putBoolean(STATE_KEY_BCC_SHOWN, recipientMvpView.isBccVisible)
outState.putString(STATE_KEY_LAST_FOCUSED_TYPE, lastFocusedType.toString())
outState.putString(STATE_KEY_CURRENT_CRYPTO_MODE, currentCryptoMode.toString())
outState.putBoolean(STATE_KEY_CRYPTO_ENABLE_PGP_INLINE, isForceTextMessageFormat)
}
fun initFromDraftMessage(message: Message) {
initRecipientsFromDraftMessage(message)
val draftStateHeader = message.getHeader(AutocryptDraftStateHeader.AUTOCRYPT_DRAFT_STATE_HEADER)
if (draftStateHeader.size == 1) {
initEncryptionStateFromDraftStateHeader(draftStateHeader.first())
} else {
initPgpInlineFromDraftMessage(message)
}
}
private fun initEncryptionStateFromDraftStateHeader(headerValue: String) {
val autocryptDraftStateHeader = draftStateHeaderParser.parseAutocryptDraftStateHeader(headerValue)
if (autocryptDraftStateHeader != null) {
initEncryptionStateFromDraftStateHeader(autocryptDraftStateHeader)
}
}
private fun initRecipientsFromDraftMessage(message: Message) {
addToAddresses(*message.getRecipients(RecipientType.TO))
addCcAddresses(*message.getRecipients(RecipientType.CC))
addBccAddresses(*message.getRecipients(RecipientType.BCC))
}
private fun initEncryptionStateFromDraftStateHeader(draftState: AutocryptDraftStateHeader) {
isForceTextMessageFormat = draftState.isPgpInline
isReplyToEncryptedMessage = draftState.isReply
if (!draftState.isByChoice) {
// TODO if it's not by choice, we're going with our defaults. should we do something here if those differ?
return
}
currentCryptoMode = when {
draftState.isSignOnly -> CryptoMode.SIGN_ONLY
draftState.isEncrypt -> CryptoMode.CHOICE_ENABLED
else -> CryptoMode.CHOICE_DISABLED
}
}
private fun initPgpInlineFromDraftMessage(message: Message) {
isForceTextMessageFormat = message.isSet(Flag.X_DRAFT_OPENPGP_INLINE)
}
private fun addToAddresses(vararg toAddresses: Address) {
addRecipientsFromAddresses(RecipientType.TO, *toAddresses)
}
private fun addCcAddresses(vararg ccAddresses: Address) {
if (ccAddresses.isNotEmpty()) {
addRecipientsFromAddresses(RecipientType.CC, *ccAddresses)
recipientMvpView.setCcVisibility(true)
updateRecipientExpanderVisibility()
}
}
private fun addBccAddresses(vararg bccRecipients: Address) {
if (bccRecipients.isNotEmpty()) {
addRecipientsFromAddresses(RecipientType.BCC, *bccRecipients)
recipientMvpView.setBccVisibility(true)
updateRecipientExpanderVisibility()
}
}
private fun addAlwaysBcc() {
val alwaysBccAddresses = Address.parse(account.alwaysBcc)
this.alwaysBccAddresses = alwaysBccAddresses
if (alwaysBccAddresses.isEmpty()) return
object : RecipientLoader(context, account.openPgpProvider, *alwaysBccAddresses) {
override fun deliverResult(result: List<Recipient>?) {
val recipientArray = result!!.toTypedArray()
recipientMvpView.silentlyAddBccAddresses(*recipientArray)
stopLoading()
abandon()
}
}.startLoading()
}
private fun removeAlwaysBcc() {
alwaysBccAddresses?.let { alwaysBccAddresses ->
recipientMvpView.silentlyRemoveBccAddresses(alwaysBccAddresses)
}
}
fun onPrepareOptionsMenu(menu: Menu) {
val currentCryptoStatus = currentCachedCryptoStatus
if (currentCryptoStatus != null && currentCryptoStatus.isProviderStateOk()) {
val isEncrypting = currentCryptoStatus.isEncryptionEnabled
menu.findItem(R.id.openpgp_encrypt_enable).isVisible = !isEncrypting
menu.findItem(R.id.openpgp_encrypt_disable).isVisible = isEncrypting
val showSignOnly = !account.isOpenPgpHideSignOnly
val isSignOnly = currentCryptoStatus.isSignOnly
menu.findItem(R.id.openpgp_sign_only).isVisible = showSignOnly && !isSignOnly
menu.findItem(R.id.openpgp_sign_only_disable).isVisible = showSignOnly && isSignOnly
val pgpInlineModeEnabled = currentCryptoStatus.isPgpInlineModeEnabled
val showPgpInlineEnable = (isEncrypting || isSignOnly) && !pgpInlineModeEnabled
menu.findItem(R.id.openpgp_inline_enable).isVisible = showPgpInlineEnable
menu.findItem(R.id.openpgp_inline_disable).isVisible = pgpInlineModeEnabled
} else {
menu.findItem(R.id.openpgp_inline_enable).isVisible = false
menu.findItem(R.id.openpgp_inline_disable).isVisible = false
menu.findItem(R.id.openpgp_encrypt_enable).isVisible = false
menu.findItem(R.id.openpgp_encrypt_disable).isVisible = false
menu.findItem(R.id.openpgp_sign_only).isVisible = false
menu.findItem(R.id.openpgp_sign_only_disable).isVisible = false
}
menu.findItem(R.id.add_from_contacts).isVisible = hasContactPermission() && hasContactPicker()
}
fun onSwitchAccount(account: Account) {
this.account = account
if (account.isAlwaysShowCcBcc) {
recipientMvpView.setCcVisibility(true)
recipientMvpView.setBccVisibility(true)
updateRecipientExpanderVisibility()
}
removeAlwaysBcc()
addAlwaysBcc()
val openPgpProvider = account.openPgpProvider
recipientMvpView.setCryptoProvider(openPgpProvider)
openPgpApiManager.setOpenPgpProvider(openPgpProvider, openPgpCallback)
}
fun onSwitchIdentity() {
// TODO decide what actually to do on identity switch?
asyncUpdateCryptoStatus()
}
fun onClickToLabel() {
recipientMvpView.requestFocusOnToField()
}
fun onClickCcLabel() {
recipientMvpView.requestFocusOnCcField()
}
fun onClickBccLabel() {
recipientMvpView.requestFocusOnBccField()
}
fun onClickRecipientExpander() {
recipientMvpView.setCcVisibility(true)
recipientMvpView.setBccVisibility(true)
updateRecipientExpanderVisibility()
}
private fun hideEmptyExtendedRecipientFields() {
if (recipientMvpView.ccAddresses.isEmpty() && recipientMvpView.isCcTextEmpty) {
recipientMvpView.setCcVisibility(false)
if (lastFocusedType == RecipientType.CC) {
lastFocusedType = RecipientType.TO
}
}
if (recipientMvpView.bccAddresses.isEmpty() && recipientMvpView.isBccTextEmpty) {
recipientMvpView.setBccVisibility(false)
if (lastFocusedType == RecipientType.BCC) {
lastFocusedType = RecipientType.TO
}
}
updateRecipientExpanderVisibility()
}
private fun updateRecipientExpanderVisibility() {
val notBothAreVisible = !(recipientMvpView.isCcVisible && recipientMvpView.isBccVisible)
recipientMvpView.setRecipientExpanderVisibility(notBothAreVisible)
}
fun asyncUpdateCryptoStatus() {
currentCachedCryptoStatus = null
val openPgpProviderState = openPgpApiManager.openPgpProviderState
var accountCryptoKey: Long? = account.openPgpKey
if (accountCryptoKey == Account.NO_OPENPGP_KEY) {
accountCryptoKey = null
}
val composeCryptoStatus = ComposeCryptoStatus(
openPgpProviderState = openPgpProviderState,
openPgpKeyId = accountCryptoKey,
recipientAddresses = allRecipients,
isPgpInlineModeEnabled = isForceTextMessageFormat,
isSenderPreferEncryptMutual = account.autocryptPreferEncryptMutual,
isReplyToEncrypted = isReplyToEncryptedMessage,
isEncryptAllDrafts = account.isOpenPgpEncryptAllDrafts,
isEncryptSubject = account.isOpenPgpEncryptSubject,
cryptoMode = currentCryptoMode
)
if (openPgpProviderState != OpenPgpProviderState.OK) {
currentCachedCryptoStatus = composeCryptoStatus
redrawCachedCryptoStatusIcon()
return
}
val recipientAddresses = composeCryptoStatus.recipientAddressesAsArray
object : AsyncTask<Void?, Void?, RecipientAutocryptStatus?>() {
override fun doInBackground(vararg params: Void?): RecipientAutocryptStatus? {
val openPgpApi = openPgpApiManager.openPgpApi ?: return null
return autocryptStatusInteractor.retrieveCryptoProviderRecipientStatus(openPgpApi, recipientAddresses)
}
override fun onPostExecute(recipientAutocryptStatus: RecipientAutocryptStatus?) {
currentCachedCryptoStatus = if (recipientAutocryptStatus != null) {
composeCryptoStatus.withRecipientAutocryptStatus(recipientAutocryptStatus)
} else {
composeCryptoStatus
}
redrawCachedCryptoStatusIcon()
}
}.execute()
}
private fun redrawCachedCryptoStatusIcon() {
val cryptoStatus = checkNotNull(currentCachedCryptoStatus) { "must have cached crypto status to redraw it!" }
recipientMvpView.setRecipientTokensShowCryptoEnabled(cryptoStatus.isEncryptionEnabled)
recipientMvpView.showCryptoStatus(cryptoStatus.displayType)
recipientMvpView.showCryptoSpecialMode(cryptoStatus.specialModeDisplayType)
}
fun onToTokenAdded() {
asyncUpdateCryptoStatus()
}
fun onToTokenRemoved() {
asyncUpdateCryptoStatus()
}
fun onToTokenChanged() {
asyncUpdateCryptoStatus()
}
fun onCcTokenAdded() {
asyncUpdateCryptoStatus()
}
fun onCcTokenRemoved() {
asyncUpdateCryptoStatus()
}
fun onCcTokenChanged() {
asyncUpdateCryptoStatus()
}
fun onBccTokenAdded() {
asyncUpdateCryptoStatus()
}
fun onBccTokenRemoved() {
asyncUpdateCryptoStatus()
}
fun onBccTokenChanged() {
asyncUpdateCryptoStatus()
}
fun onCryptoModeChanged(cryptoMode: CryptoMode) {
currentCryptoMode = cryptoMode
asyncUpdateCryptoStatus()
}
fun onCryptoPgpInlineChanged(enablePgpInline: Boolean) {
isForceTextMessageFormat = enablePgpInline
asyncUpdateCryptoStatus()
}
private fun addRecipientsFromAddresses(recipientType: RecipientType, vararg addresses: Address) {
object : RecipientLoader(context, account.openPgpProvider, *addresses) {
override fun deliverResult(result: List<Recipient>?) {
val recipientArray = result!!.toTypedArray()
recipientMvpView.addRecipients(recipientType, *recipientArray)
stopLoading()
abandon()
}
}.startLoading()
}
private fun addRecipientFromContactUri(recipientType: RecipientType, uri: Uri?) {
object : RecipientLoader(context, account.openPgpProvider, uri, false) {
override fun deliverResult(result: List<Recipient>?) {
// TODO handle multiple available mail addresses for a contact?
if (result!!.isEmpty()) {
recipientMvpView.showErrorContactNoAddress()
return
}
val recipient = result[0]
recipientMvpView.addRecipients(recipientType, recipient)
stopLoading()
abandon()
}
}.startLoading()
}
fun onToFocused() {
lastFocusedType = RecipientType.TO
}
fun onCcFocused() {
lastFocusedType = RecipientType.CC
}
fun onBccFocused() {
lastFocusedType = RecipientType.BCC
}
fun onMenuAddFromContacts() {
val requestCode = lastFocusedType.toRequestCode()
recipientMvpView.showContactPicker(requestCode)
}
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
CONTACT_PICKER_TO, CONTACT_PICKER_CC, CONTACT_PICKER_BCC -> {
if (resultCode != Activity.RESULT_OK || data == null) return
val recipientType = requestCode.toRecipientType()
addRecipientFromContactUri(recipientType, data.data)
}
OPENPGP_USER_INTERACTION -> {
openPgpApiManager.onUserInteractionResult()
}
REQUEST_CODE_AUTOCRYPT -> {
asyncUpdateCryptoStatus()
}
}
}
fun onNonRecipientFieldFocused() {
if (!account.isAlwaysShowCcBcc) {
hideEmptyExtendedRecipientFields()
}
}
fun onClickCryptoStatus() {
when (openPgpApiManager.openPgpProviderState) {
OpenPgpProviderState.UNCONFIGURED -> {
Timber.e("click on crypto status while unconfigured - this should not really happen?!")
}
OpenPgpProviderState.OK -> {
toggleEncryptionState(false)
}
OpenPgpProviderState.UI_REQUIRED -> {
// TODO show openpgp settings
val pendingIntent = openPgpApiManager.userInteractionPendingIntent
recipientMvpView.launchUserInteractionPendingIntent(pendingIntent, OPENPGP_USER_INTERACTION)
}
OpenPgpProviderState.UNINITIALIZED, OpenPgpProviderState.ERROR -> {
openPgpApiManager.refreshConnection()
}
}
}
private fun toggleEncryptionState(showGotIt: Boolean) {
val currentCryptoStatus = currentCachedCryptoStatus
if (currentCryptoStatus == null) {
Timber.e("click on crypto status while crypto status not available - should not really happen?!")
return
}
if (currentCryptoStatus.isEncryptionEnabled && !currentCryptoStatus.allRecipientsCanEncrypt()) {
recipientMvpView.showOpenPgpEnabledErrorDialog(false)
return
}
if (currentCryptoMode == CryptoMode.SIGN_ONLY) {
recipientMvpView.showErrorIsSignOnly()
return
}
val isEncryptOnNoChoice = currentCryptoStatus.canEncryptAndIsMutualDefault() ||
currentCryptoStatus.isReplyToEncrypted
if (currentCryptoMode == CryptoMode.NO_CHOICE) {
if (currentCryptoStatus.hasAutocryptPendingIntent()) {
recipientMvpView.launchUserInteractionPendingIntent(
currentCryptoStatus.autocryptPendingIntent,
REQUEST_CODE_AUTOCRYPT
)
} else if (isEncryptOnNoChoice) {
// TODO warning dialog if we override, especially from reply!
onCryptoModeChanged(CryptoMode.CHOICE_DISABLED)
} else {
onCryptoModeChanged(CryptoMode.CHOICE_ENABLED)
if (showGotIt) {
recipientMvpView.showOpenPgpEncryptExplanationDialog()
}
}
} else if (currentCryptoMode == CryptoMode.CHOICE_DISABLED && !isEncryptOnNoChoice) {
onCryptoModeChanged(CryptoMode.CHOICE_ENABLED)
} else {
onCryptoModeChanged(CryptoMode.NO_CHOICE)
}
}
/**
* Does the device actually have a Contacts application suitable for picking a contact.
* As hard as it is to believe, some vendors ship without it.
*/
private fun hasContactPicker(): Boolean {
return hasContactPicker ?: isContactPickerAvailable().also { hasContactPicker = it }
}
private fun isContactPickerAvailable(): Boolean {
val resolveInfoList = context.packageManager.queryIntentActivities(ContactIntentHelper.getContactPickerIntent(), 0)
return resolveInfoList.isNotEmpty()
}
private fun hasContactPermission(): Boolean {
val permissionState = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS)
return permissionState == PackageManager.PERMISSION_GRANTED
}
fun showPgpSendError(sendErrorState: SendErrorState) {
when (sendErrorState) {
SendErrorState.ENABLED_ERROR -> recipientMvpView.showOpenPgpEnabledErrorDialog(false)
SendErrorState.PROVIDER_ERROR -> recipientMvpView.showErrorOpenPgpConnection()
SendErrorState.KEY_CONFIG_ERROR -> recipientMvpView.showErrorNoKeyConfigured()
}
}
fun showPgpAttachError(attachErrorState: AttachErrorState) {
when (attachErrorState) {
AttachErrorState.IS_INLINE -> recipientMvpView.showErrorInlineAttach()
}
}
fun builderSetProperties(messageBuilder: MessageBuilder) {
require(messageBuilder !is PgpMessageBuilder) {
"PpgMessageBuilder must be called with ComposeCryptoStatus argument!"
}
messageBuilder.setTo(toAddresses)
messageBuilder.setCc(ccAddresses)
messageBuilder.setBcc(bccAddresses)
}
fun builderSetProperties(pgpMessageBuilder: PgpMessageBuilder, cryptoStatus: ComposeCryptoStatus) {
pgpMessageBuilder.setTo(toAddresses)
pgpMessageBuilder.setCc(ccAddresses)
pgpMessageBuilder.setBcc(bccAddresses)
pgpMessageBuilder.setOpenPgpApi(openPgpApiManager.openPgpApi)
pgpMessageBuilder.setCryptoStatus(cryptoStatus)
}
fun onMenuSetPgpInline(enablePgpInline: Boolean) {
onCryptoPgpInlineChanged(enablePgpInline)
if (enablePgpInline) {
val shouldShowPgpInlineDialog = checkAndIncrementPgpInlineDialogCounter()
if (shouldShowPgpInlineDialog) {
recipientMvpView.showOpenPgpInlineDialog(true)
}
}
}
fun onMenuSetSignOnly(enableSignOnly: Boolean) {
if (enableSignOnly) {
onCryptoModeChanged(CryptoMode.SIGN_ONLY)
val shouldShowPgpSignOnlyDialog = checkAndIncrementPgpSignOnlyDialogCounter()
if (shouldShowPgpSignOnlyDialog) {
recipientMvpView.showOpenPgpSignOnlyDialog(true)
}
} else {
onCryptoModeChanged(CryptoMode.NO_CHOICE)
}
}
fun onMenuToggleEncryption() {
toggleEncryptionState(true)
}
fun onCryptoPgpClickDisable() {
onCryptoModeChanged(CryptoMode.CHOICE_DISABLED)
}
fun onCryptoPgpSignOnlyDisabled() {
onCryptoPgpInlineChanged(false)
onCryptoModeChanged(CryptoMode.NO_CHOICE)
}
private fun checkAndIncrementPgpInlineDialogCounter(): Boolean {
val pgpInlineDialogCounter = K9.pgpInlineDialogCounter
if (pgpInlineDialogCounter < PGP_DIALOG_DISPLAY_THRESHOLD) {
K9.pgpInlineDialogCounter = pgpInlineDialogCounter + 1
K9.saveSettingsAsync()
return true
}
return false
}
private fun checkAndIncrementPgpSignOnlyDialogCounter(): Boolean {
val pgpSignOnlyDialogCounter = K9.pgpSignOnlyDialogCounter
if (pgpSignOnlyDialogCounter < PGP_DIALOG_DISPLAY_THRESHOLD) {
K9.pgpSignOnlyDialogCounter = pgpSignOnlyDialogCounter + 1
K9.saveSettingsAsync()
return true
}
return false
}
fun onClickCryptoSpecialModeIndicator() {
when {
currentCryptoMode == CryptoMode.SIGN_ONLY -> {
recipientMvpView.showOpenPgpSignOnlyDialog(false)
}
isForceTextMessageFormat -> {
recipientMvpView.showOpenPgpInlineDialog(false)
}
else -> {
error("This icon should not be clickable while no special mode is active!")
}
}
}
private val openPgpCallback = object : OpenPgpApiManagerCallback {
override fun onOpenPgpProviderStatusChanged() {
if (openPgpApiManager.openPgpProviderState == OpenPgpProviderState.UI_REQUIRED) {
recipientMvpView.showErrorOpenPgpUserInteractionRequired()
}
asyncUpdateCryptoStatus()
}
override fun onOpenPgpProviderError(error: OpenPgpProviderError) {
when (error) {
OpenPgpProviderError.ConnectionLost -> openPgpApiManager.refreshConnection()
OpenPgpProviderError.VersionIncompatible -> recipientMvpView.showErrorOpenPgpIncompatible()
OpenPgpProviderError.ConnectionFailed -> recipientMvpView.showErrorOpenPgpConnection()
else -> recipientMvpView.showErrorOpenPgpConnection()
}
}
}
private fun Array<String>.toAddressArray(): Array<Address> {
return flatMap { addressString ->
Address.parseUnencoded(addressString).toList()
}.toTypedArray()
}
private fun RecipientType.toRequestCode(): Int = when (this) {
RecipientType.TO -> CONTACT_PICKER_TO
RecipientType.CC -> CONTACT_PICKER_CC
RecipientType.BCC -> CONTACT_PICKER_BCC
else -> throw AssertionError("Unhandled case: $this")
}
private fun Int.toRecipientType(): RecipientType = when (this) {
CONTACT_PICKER_TO -> RecipientType.TO
CONTACT_PICKER_CC -> RecipientType.CC
CONTACT_PICKER_BCC -> RecipientType.BCC
else -> throw AssertionError("Unhandled case: $this")
}
enum class CryptoMode {
SIGN_ONLY, NO_CHOICE, CHOICE_DISABLED, CHOICE_ENABLED
}
}

View file

@ -0,0 +1,66 @@
package com.fsck.k9.activity.compose
import android.os.Bundle
import com.fsck.k9.Identity
import com.fsck.k9.mail.Address
import com.fsck.k9.mail.Message
private const val STATE_KEY_REPLY_TO_SHOWN = "com.fsck.k9.activity.compose.ReplyToPresenter.replyToShown"
class ReplyToPresenter(private val view: ReplyToView) {
private lateinit var identity: Identity
private var identityReplyTo: Array<Address>? = null
fun initFromDraftMessage(message: Message) {
message.replyTo.takeIf { it.isNotEmpty() }?.let { addresses ->
view.silentlyAddAddresses(addresses)
view.isVisible = true
}
}
fun getAddresses(): Array<Address> {
return view.getAddresses()
}
fun isNotReadyForSending(): Boolean {
return if (view.hasUncompletedText()) {
view.showError()
view.isVisible = true
true
} else {
false
}
}
fun setIdentity(identity: Identity) {
this.identity = identity
removeIdentityReplyTo()
addIdentityReplyTo()
}
private fun addIdentityReplyTo() {
identityReplyTo = Address.parse(identity.replyTo)?.takeIf { it.isNotEmpty() }
identityReplyTo?.let { addresses ->
view.silentlyAddAddresses(addresses)
}
}
private fun removeIdentityReplyTo() {
identityReplyTo?.let { addresses ->
view.silentlyRemoveAddresses(addresses)
}
}
fun onNonRecipientFieldFocused() {
view.hideIfBlank()
}
fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(STATE_KEY_REPLY_TO_SHOWN, view.isVisible)
}
fun onRestoreInstanceState(savedInstanceState: Bundle) {
view.isVisible = savedInstanceState.getBoolean(STATE_KEY_REPLY_TO_SHOWN)
}
}

View file

@ -0,0 +1,129 @@
package com.fsck.k9.activity.compose
import android.text.TextWatcher
import android.view.View
import android.widget.ViewAnimator
import androidx.core.view.isVisible
import com.fsck.k9.FontSizes
import com.fsck.k9.activity.MessageCompose
import com.fsck.k9.mail.Address
import com.fsck.k9.ui.R
import com.fsck.k9.view.RecipientSelectView
import com.fsck.k9.view.RecipientSelectView.Recipient
private const val VIEW_INDEX_REPLY_TO_EXPANDER_VISIBLE = 0
private const val VIEW_INDEX_REPLY_TO_EXPANDER_HIDDEN = 1
class ReplyToView(activity: MessageCompose) {
private val replyToView: RecipientSelectView = activity.findViewById(R.id.reply_to)
private val replyToWrapper: View = activity.findViewById(R.id.reply_to_wrapper)
private val replyToDivider: View = activity.findViewById(R.id.reply_to_divider)
private val replyToExpanderContainer: ViewAnimator = activity.findViewById(R.id.reply_to_expander_container)
private val replyToExpander: View = activity.findViewById(R.id.reply_to_expander)
private val textWatchers = mutableSetOf<TextWatcher>()
init {
replyToExpander.setOnClickListener {
isVisible = true
replyToView.requestFocus()
}
activity.findViewById<View>(R.id.reply_to_label).setOnClickListener {
replyToView.requestFocus()
}
}
var isVisible: Boolean
get() = replyToWrapper.isVisible
set(visible) {
replyToDivider.isVisible = visible
replyToView.isVisible = visible
replyToWrapper.isVisible = visible
if (visible && replyToExpanderContainer.displayedChild == VIEW_INDEX_REPLY_TO_EXPANDER_VISIBLE) {
replyToExpanderContainer.displayedChild = VIEW_INDEX_REPLY_TO_EXPANDER_HIDDEN
} else if (replyToExpanderContainer.displayedChild == VIEW_INDEX_REPLY_TO_EXPANDER_HIDDEN) {
replyToExpanderContainer.displayedChild = VIEW_INDEX_REPLY_TO_EXPANDER_VISIBLE
}
}
fun hideIfBlank() {
if (isVisible && replyToView.text.isBlank()) {
isVisible = false
}
}
fun hasUncompletedText(): Boolean {
replyToView.tryPerformCompletion()
return replyToView.hasUncompletedText()
}
fun showError() {
replyToView.error = replyToView.context.getString(R.string.compose_error_incomplete_recipient)
}
fun getAddresses(): Array<Address> {
return replyToView.addresses
}
fun silentlyAddAddresses(addresses: Array<Address>) {
removeAllTextChangedListeners()
val recipients = addresses.map { Recipient(it) }.toTypedArray()
replyToView.addRecipients(*recipients)
addAllTextChangedListeners()
}
fun silentlyRemoveAddresses(addresses: Array<Address>) {
val addressSet = addresses.toSet()
val recipientsToRemove = replyToView.objects.filter { it.address in addressSet }
if (recipientsToRemove.isNotEmpty()) {
removeAllTextChangedListeners()
for (recipient in recipientsToRemove) {
replyToView.removeObjectSync(recipient)
}
addAllTextChangedListeners()
}
}
fun setFontSizes(fontSizes: FontSizes, fontSize: Int) {
val tokenTextSize: Int = getTokenTextSize(fontSize)
replyToView.setTokenTextSize(tokenTextSize)
fontSizes.setViewTextSize(replyToView, fontSize)
}
private fun getTokenTextSize(fontSize: Int): Int {
return when (fontSize) {
FontSizes.FONT_10SP -> FontSizes.FONT_10SP
FontSizes.FONT_12SP -> FontSizes.FONT_12SP
FontSizes.SMALL -> FontSizes.SMALL
FontSizes.FONT_16SP -> 15
FontSizes.MEDIUM -> FontSizes.FONT_16SP
FontSizes.FONT_20SP -> FontSizes.MEDIUM
FontSizes.LARGE -> FontSizes.FONT_20SP
else -> FontSizes.FONT_DEFAULT
}
}
fun addTextChangedListener(textWatcher: TextWatcher) {
textWatchers.add(textWatcher)
replyToView.addTextChangedListener(textWatcher)
}
private fun removeAllTextChangedListeners() {
for (textWatcher in textWatchers) {
replyToView.removeTextChangedListener(textWatcher)
}
}
private fun addAllTextChangedListeners() {
for (textWatcher in textWatchers) {
replyToView.addTextChangedListener(textWatcher)
}
}
}

View file

@ -0,0 +1,39 @@
package com.fsck.k9.activity.compose;
import android.os.AsyncTask;
import android.os.Handler;
import com.fsck.k9.Account;
import com.fsck.k9.activity.MessageCompose;
import com.fsck.k9.controller.MessagingController;
import com.fsck.k9.mail.Message;
public class SaveMessageTask extends AsyncTask<Void, Void, Void> {
private final MessagingController messagingController;
private final Account account;
private final Handler handler;
private final Message message;
private final Long existingDraftId;
private final String plaintextSubject;
public SaveMessageTask(MessagingController messagingController, Account account, Handler handler, Message message,
Long existingDraftId, String plaintextSubject) {
this.messagingController = messagingController;
this.account = account;
this.handler = handler;
this.message = message;
this.existingDraftId = existingDraftId;
this.plaintextSubject = plaintextSubject;
}
@Override
protected Void doInBackground(Void... params) {
Long messageId = messagingController.saveDraft(account, message, existingDraftId, plaintextSubject);
android.os.Message msg = android.os.Message.obtain(handler, MessageCompose.MSG_SAVED_DRAFT, messageId);
handler.sendMessage(msg);
return null;
}
}

View file

@ -0,0 +1,97 @@
package com.fsck.k9.activity.loader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import android.content.ContentResolver;
import android.content.Context;
import androidx.loader.content.AsyncTaskLoader;
import com.fsck.k9.activity.misc.Attachment;
import com.fsck.k9.message.Attachment.LoadingState;
import de.cketti.safecontentresolver.SafeContentResolver;
import org.apache.commons.io.IOUtils;
import timber.log.Timber;
/**
* Loader to fetch the content of an attachment.
*
* This will copy the data to a temporary file in our app's cache directory.
*/
public class AttachmentContentLoader extends AsyncTaskLoader<Attachment> {
private static final String FILENAME_PREFIX = "attachment";
private final Attachment sourceAttachment;
private Attachment cachedResultAttachment;
public AttachmentContentLoader(Context context, Attachment attachment) {
super(context);
if (attachment.state != LoadingState.METADATA) {
throw new IllegalArgumentException("Attachment provided to content loader must be in METADATA state");
}
sourceAttachment = attachment;
}
@Override
protected void onStartLoading() {
if (cachedResultAttachment != null) {
deliverResult(sourceAttachment);
}
if (takeContentChanged() || cachedResultAttachment == null) {
forceLoad();
}
}
@Override
public Attachment loadInBackground() {
Context context = getContext();
try {
File file = File.createTempFile(FILENAME_PREFIX, null, context.getCacheDir());
file.deleteOnExit();
Timber.v("Saving attachment to %s", file.getAbsolutePath());
InputStream in;
if (sourceAttachment.internalAttachment) {
ContentResolver unsafeContentResolver = context.getContentResolver();
in = unsafeContentResolver.openInputStream(sourceAttachment.uri);
} else {
SafeContentResolver safeContentResolver = SafeContentResolver.newInstance(context);
in = safeContentResolver.openInputStream(sourceAttachment.uri);
}
if (in == null) {
Timber.w("Error opening attachment for reading: %s", sourceAttachment.uri);
cachedResultAttachment = sourceAttachment.deriveWithLoadCancelled();
return cachedResultAttachment;
}
try {
FileOutputStream out = new FileOutputStream(file);
try {
IOUtils.copy(in, out);
} finally {
out.close();
}
} finally {
in.close();
}
cachedResultAttachment = sourceAttachment.deriveWithLoadComplete(file.getAbsolutePath());
return cachedResultAttachment;
} catch (Exception e) {
Timber.e(e, "Error saving attachment!");
}
cachedResultAttachment = sourceAttachment.deriveWithLoadCancelled();
return cachedResultAttachment;
}
}

View file

@ -0,0 +1,115 @@
package com.fsck.k9.activity.loader;
import java.io.File;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import androidx.loader.content.AsyncTaskLoader;
import com.fsck.k9.helper.MimeTypeUtil;
import com.fsck.k9.message.Attachment.LoadingState;
import timber.log.Timber;
import com.fsck.k9.activity.misc.Attachment;
import com.fsck.k9.mail.internet.MimeUtility;
/**
* Loader to fetch metadata of an attachment.
*/
public class AttachmentInfoLoader extends AsyncTaskLoader<Attachment> {
private final Attachment sourceAttachment;
private Attachment cachedResultAttachment;
public AttachmentInfoLoader(Context context, Attachment attachment) {
super(context);
if (attachment.state != LoadingState.URI_ONLY) {
throw new IllegalArgumentException("Attachment provided to metadata loader must be in URI_ONLY state");
}
sourceAttachment = attachment;
}
@Override
protected void onStartLoading() {
if (cachedResultAttachment != null) {
deliverResult(cachedResultAttachment);
}
if (takeContentChanged() || cachedResultAttachment == null) {
forceLoad();
}
}
@Override
public Attachment loadInBackground() {
try {
Uri uri = sourceAttachment.uri;
String contentType = sourceAttachment.contentType;
long size = -1;
String name = null;
ContentResolver contentResolver = getContext().getContentResolver();
Cursor metadataCursor = contentResolver.query(
uri,
new String[] { OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE },
null,
null,
null);
if (metadataCursor != null) {
try {
if (metadataCursor.moveToFirst()) {
name = metadataCursor.getString(0);
size = metadataCursor.getInt(1);
}
} finally {
metadataCursor.close();
}
}
if (name == null) {
name = uri.getLastPathSegment();
}
String usableContentType = contentResolver.getType(uri);
if (usableContentType == null && contentType != null && contentType.indexOf('*') != -1) {
usableContentType = contentType;
}
if (usableContentType == null) {
usableContentType = MimeTypeUtil.getMimeTypeByExtension(name);
}
if (!sourceAttachment.allowMessageType && MimeUtility.isMessageType(usableContentType)) {
usableContentType = MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE;
}
if (size <= 0) {
String uriString = uri.toString();
if (uriString.startsWith("file://")) {
File f = new File(uriString.substring("file://".length()));
size = f.length();
} else {
Timber.v("Not a file: %s", uriString);
}
} else {
Timber.v("old attachment.size: %d", size);
}
Timber.v("new attachment.size: %d", size);
cachedResultAttachment = sourceAttachment.deriveWithMetadataLoaded(usableContentType, name, size);
return cachedResultAttachment;
} catch (Exception e) {
Timber.e(e, "Error getting attachment meta data");
cachedResultAttachment = sourceAttachment.deriveWithLoadCancelled();
return cachedResultAttachment;
}
}
}

View file

@ -0,0 +1,211 @@
package com.fsck.k9.activity.misc;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import com.fsck.k9.helper.MimeTypeUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Container class for information about an attachment.
*
* This is used by {@link com.fsck.k9.activity.MessageCompose} to fetch and manage attachments.
*/
public class Attachment implements Parcelable, com.fsck.k9.message.Attachment {
/**
* The URI pointing to the source of the attachment.
*
* In most cases this will be a {@code content://}-URI.
*/
public final Uri uri;
/**
* The current loading state.
*/
public final LoadingState state;
/**
* The ID of the loader that is used to load the metadata or contents.
*/
public final int loaderId;
/**
* The content type of the attachment.
*
* Valid iff {@link #state} is {@link LoadingState#METADATA} or {@link LoadingState#COMPLETE}.
*/
public final String contentType;
/**
* {@code true} if we allow MIME types of {@code message/*}, e.g. {@code message/rfc822}.
*/
public final boolean allowMessageType;
/**
* The (file)name of the attachment.
*
* Valid iff {@link #state} is {@link LoadingState#METADATA} or {@link LoadingState#COMPLETE}.
*/
public final String name;
/**
* The size of the attachment.
*
* Valid iff {@link #state} is {@link LoadingState#METADATA} or {@link LoadingState#COMPLETE}.
*/
public final Long size;
/**
* The name of the temporary file containing the local copy of the attachment.
*
* Valid iff {@link #state} is {@link LoadingState#COMPLETE}.
*/
public final String filename;
public final boolean internalAttachment;
@NotNull
@Override
public LoadingState getState() {
return state;
}
@Nullable
@Override
public String getFileName() {
return filename;
}
@Nullable
@Override
public String getContentType() {
return contentType;
}
@Nullable
@Override
public String getName() {
return name;
}
@Nullable
@Override
public Long getSize() {
return size;
}
@Override
public boolean isInternalAttachment() {
return internalAttachment;
}
public boolean isSupportedImage() {
if (contentType == null) {
return false;
}
return MimeTypeUtil.isSupportedImageType(contentType) || (
MimeTypeUtil.isSameMimeType(MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE, contentType) &&
MimeTypeUtil.isSupportedImageExtension(filename));
}
private Attachment(Uri uri, LoadingState state, int loaderId, String contentType, boolean allowMessageType,
String name, Long size, String filename, boolean internalAttachment) {
this.uri = uri;
this.state = state;
this.loaderId = loaderId;
this.contentType = contentType;
this.allowMessageType = allowMessageType;
this.name = name;
this.size = size;
this.filename = filename;
this.internalAttachment = internalAttachment;
}
private Attachment(Parcel in) {
uri = in.readParcelable(Uri.class.getClassLoader());
state = (LoadingState) in.readSerializable();
loaderId = in.readInt();
contentType = in.readString();
allowMessageType = in.readInt() != 0;
name = in.readString();
if (in.readInt() != 0) {
size = in.readLong();
} else {
size = null;
}
filename = in.readString();
internalAttachment = in.readInt() != 0;
}
public static Attachment createAttachment(Uri uri, int loaderId, String contentType, boolean allowMessageType, boolean internalAttachment) {
return new Attachment(uri, Attachment.LoadingState.URI_ONLY, loaderId, contentType, allowMessageType, null,
null, null, internalAttachment);
}
public Attachment deriveWithMetadataLoaded(String usableContentType, String name, long size) {
if (state != Attachment.LoadingState.URI_ONLY) {
throw new IllegalStateException("deriveWithMetadataLoaded can only be called on a URI_ONLY attachment!");
}
return new Attachment(uri, Attachment.LoadingState.METADATA, loaderId, usableContentType, allowMessageType,
name, size, null, internalAttachment);
}
public Attachment deriveWithLoadCancelled() {
if (state != Attachment.LoadingState.METADATA) {
throw new IllegalStateException("deriveWitLoadCancelled can only be called on a METADATA attachment!");
}
return new Attachment(uri, Attachment.LoadingState.CANCELLED, loaderId, contentType, allowMessageType, name,
size, null, internalAttachment);
}
public Attachment deriveWithLoadComplete(String absolutePath) {
if (state != Attachment.LoadingState.METADATA) {
throw new IllegalStateException("deriveWithLoadComplete can only be called on a METADATA attachment!");
}
return new Attachment(uri, Attachment.LoadingState.COMPLETE, loaderId, contentType, allowMessageType, name,
size, absolutePath, internalAttachment);
}
// === Parcelable ===
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(uri, flags);
dest.writeSerializable(state);
dest.writeInt(loaderId);
dest.writeString(contentType);
dest.writeInt(allowMessageType ? 1 : 0);
dest.writeString(name);
if (size != null) {
dest.writeInt(1);
dest.writeLong(size);
} else {
dest.writeInt(0);
}
dest.writeString(filename);
dest.writeInt(internalAttachment ? 1 : 0);
}
public static final Parcelable.Creator<Attachment> CREATOR =
new Parcelable.Creator<Attachment>() {
@Override
public Attachment createFromParcel(Parcel in) {
return new Attachment(in);
}
@Override
public Attachment[] newArray(int size) {
return new Attachment[size];
}
};
}

View file

@ -0,0 +1,13 @@
package com.fsck.k9.activity.misc;
import com.fsck.k9.DI;
import com.fsck.k9.contacts.ContactPictureLoader;
public class ContactPicture {
public static ContactPictureLoader getContactPictureLoader() {
return DI.get(ContactPictureLoader.class);
}
}

View file

@ -0,0 +1,3 @@
package com.fsck.k9.activity.misc
data class InlineAttachment(val contentId: String, val attachment: Attachment)

View file

@ -0,0 +1,131 @@
package com.fsck.k9.activity.setup
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import com.fsck.k9.Account
import com.fsck.k9.Preferences
import com.fsck.k9.helper.EmailHelper.getDomainFromEmailAddress
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mailstore.SpecialLocalFoldersCreator
import com.fsck.k9.preferences.Protocols
import com.fsck.k9.setup.ServerNameSuggester
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.K9Activity
import org.koin.android.ext.android.inject
/**
* Prompts the user to select an account type. The account type, along with the
* passed in email address, password and makeDefault are then passed on to the
* AccountSetupIncoming activity.
*/
class AccountSetupAccountType : K9Activity() {
private val preferences: Preferences by inject()
private val serverNameSuggester: ServerNameSuggester by inject()
private val localFoldersCreator: SpecialLocalFoldersCreator by inject()
private lateinit var account: Account
private var makeDefault = false
private lateinit var initialAccountSettings: InitialAccountSettings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setLayout(R.layout.account_setup_account_type)
setTitle(R.string.account_setup_account_type_title)
decodeArguments()
findViewById<View>(R.id.pop).setOnClickListener { setupPop3Account() }
findViewById<View>(R.id.imap).setOnClickListener { setupImapAccount() }
}
private fun decodeArguments() {
val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT) ?: error("No account UUID provided")
account = preferences.getAccount(accountUuid) ?: error("No account with given UUID found")
makeDefault = intent.getBooleanExtra(EXTRA_MAKE_DEFAULT, false)
initialAccountSettings = intent.getParcelableExtra(EXTRA_INITIAL_ACCOUNT_SETTINGS)
?: error("Initial account settings are missing")
}
private fun setupPop3Account() {
setupAccount(Protocols.POP3)
}
private fun setupImapAccount() {
setupAccount(Protocols.IMAP)
}
private fun setupAccount(serverType: String) {
setupStoreAndSmtpTransport(serverType)
createSpecialLocalFolders()
returnAccountTypeSelectionResult()
}
private fun setupStoreAndSmtpTransport(serverType: String) {
val domainPart = getDomainFromEmailAddress(account.email) ?: error("Couldn't get domain from email address")
initializeIncomingServerSettings(serverType, domainPart)
initializeOutgoingServerSettings(domainPart)
}
private fun initializeIncomingServerSettings(serverType: String, domainPart: String) {
val suggestedStoreServerName = serverNameSuggester.suggestServerName(serverType, domainPart)
val storeServer = ServerSettings(
serverType,
suggestedStoreServerName,
-1,
ConnectionSecurity.SSL_TLS_REQUIRED,
initialAccountSettings.authenticationType,
initialAccountSettings.email,
initialAccountSettings.password,
initialAccountSettings.clientCertificateAlias
)
account.incomingServerSettings = storeServer
}
private fun initializeOutgoingServerSettings(domainPart: String) {
val suggestedTransportServerName = serverNameSuggester.suggestServerName(Protocols.SMTP, domainPart)
val transportServer = ServerSettings(
Protocols.SMTP,
suggestedTransportServerName,
-1,
ConnectionSecurity.STARTTLS_REQUIRED,
initialAccountSettings.authenticationType,
initialAccountSettings.email,
initialAccountSettings.password,
initialAccountSettings.clientCertificateAlias
)
account.outgoingServerSettings = transportServer
}
private fun createSpecialLocalFolders() {
localFoldersCreator.createSpecialLocalFolders(account)
}
private fun returnAccountTypeSelectionResult() {
AccountSetupIncoming.actionIncomingSettings(this, account, makeDefault)
}
companion object {
private const val EXTRA_ACCOUNT = "account"
private const val EXTRA_MAKE_DEFAULT = "makeDefault"
private const val EXTRA_INITIAL_ACCOUNT_SETTINGS = "initialAccountSettings"
@JvmStatic
fun actionSelectAccountType(
context: Context,
account: Account,
makeDefault: Boolean,
initialAccountSettings: InitialAccountSettings
) {
val intent = Intent(context, AccountSetupAccountType::class.java).apply {
putExtra(EXTRA_ACCOUNT, account.uuid)
putExtra(EXTRA_MAKE_DEFAULT, makeDefault)
putExtra(EXTRA_INITIAL_ACCOUNT_SETTINGS, initialAccountSettings)
}
context.startActivity(intent)
}
}
}

View file

@ -0,0 +1,404 @@
package com.fsck.k9.activity.setup
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.Editable
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.CheckBox
import androidx.core.view.isVisible
import com.fsck.k9.Account
import com.fsck.k9.Core
import com.fsck.k9.EmailAddressValidator
import com.fsck.k9.Preferences
import com.fsck.k9.account.AccountCreator
import com.fsck.k9.activity.setup.AccountSetupCheckSettings.CheckDirection
import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings
import com.fsck.k9.autodiscovery.providersxml.ProvidersXmlDiscovery
import com.fsck.k9.helper.SimpleTextWatcher
import com.fsck.k9.helper.Utility.requiredFieldValid
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mailstore.SpecialLocalFoldersCreator
import com.fsck.k9.ui.ConnectionSettings
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.K9Activity
import com.fsck.k9.ui.getEnum
import com.fsck.k9.ui.putEnum
import com.fsck.k9.ui.settings.ExtraAccountDiscovery
import com.fsck.k9.view.ClientCertificateSpinner
import com.google.android.material.textfield.TextInputEditText
import org.koin.android.ext.android.inject
/**
* Prompts the user for the email address and password.
*
* Attempts to lookup default settings for the domain the user specified. If the domain is known, the settings are
* handed off to the [AccountSetupCheckSettings] activity. If no settings are found, the settings are handed off to the
* [AccountSetupAccountType] activity.
*/
class AccountSetupBasics : K9Activity() {
private val providersXmlDiscovery: ProvidersXmlDiscovery by inject()
private val accountCreator: AccountCreator by inject()
private val localFoldersCreator: SpecialLocalFoldersCreator by inject()
private val preferences: Preferences by inject()
private val emailValidator: EmailAddressValidator by inject()
private lateinit var emailView: TextInputEditText
private lateinit var passwordView: TextInputEditText
private lateinit var passwordLayout: View
private lateinit var clientCertificateCheckBox: CheckBox
private lateinit var clientCertificateSpinner: ClientCertificateSpinner
private lateinit var advancedOptionsContainer: View
private lateinit var nextButton: Button
private lateinit var manualSetupButton: Button
private lateinit var allowClientCertificateView: ViewGroup
private var uiState = UiState.EMAIL_ADDRESS_ONLY
private var account: Account? = null
private var checkedIncoming = false
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setLayout(R.layout.account_setup_basics)
setTitle(R.string.account_setup_basics_title)
emailView = findViewById(R.id.account_email)
passwordView = findViewById(R.id.account_password)
passwordLayout = findViewById(R.id.account_password_layout)
clientCertificateCheckBox = findViewById(R.id.account_client_certificate)
clientCertificateSpinner = findViewById(R.id.account_client_certificate_spinner)
allowClientCertificateView = findViewById(R.id.account_allow_client_certificate)
advancedOptionsContainer = findViewById(R.id.foldable_advanced_options)
nextButton = findViewById(R.id.next)
manualSetupButton = findViewById(R.id.manual_setup)
manualSetupButton.setOnClickListener { onManualSetup() }
val btn = findViewById<View>(R.id.create_monocles_account) as Button
btn.setOnClickListener(
object : View.OnClickListener {
override fun onClick(v: View?) {
val myWebLink = Intent(Intent.ACTION_VIEW)
myWebLink.data = Uri.parse("https://ocean.monocles.eu/apps/registration/")
startActivity(myWebLink)
}
},
)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
/*
* We wait until now to initialize the listeners because we didn't want the OnCheckedChangeListener active
* while the clientCertificateCheckBox state was being restored because it could trigger the pop-up of a
* ClientCertificateSpinner.chooseCertificate() dialog.
*/
initializeViewListeners()
validateFields()
updateUi()
}
private fun initializeViewListeners() {
val textWatcher = object : SimpleTextWatcher() {
override fun afterTextChanged(s: Editable) {
val checkPassword = uiState == UiState.PASSWORD_FLOW
validateFields(checkPassword)
}
}
emailView.addTextChangedListener(textWatcher)
passwordView.addTextChangedListener(textWatcher)
clientCertificateCheckBox.setOnCheckedChangeListener { _, isChecked ->
updateViewVisibility(isChecked)
validateFields()
// Have the user select the client certificate if not already selected
if (isChecked && clientCertificateSpinner.alias == null) {
clientCertificateSpinner.chooseCertificate()
}
}
clientCertificateSpinner.setOnClientCertificateChangedListener {
validateFields()
}
}
private fun updateUi() {
when (uiState) {
UiState.EMAIL_ADDRESS_ONLY -> {
passwordLayout.isVisible = false
advancedOptionsContainer.isVisible = false
nextButton.setOnClickListener { attemptAutoSetupUsingOnlyEmailAddress() }
}
UiState.PASSWORD_FLOW -> {
passwordLayout.isVisible = true
advancedOptionsContainer.isVisible = true
nextButton.setOnClickListener { attemptAutoSetup() }
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putEnum(STATE_KEY_UI_STATE, uiState)
outState.putString(EXTRA_ACCOUNT, account?.uuid)
outState.putBoolean(STATE_KEY_CHECKED_INCOMING, checkedIncoming)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
uiState = savedInstanceState.getEnum(STATE_KEY_UI_STATE, UiState.EMAIL_ADDRESS_ONLY)
val accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT)
if (accountUuid != null) {
account = preferences.getAccount(accountUuid)
}
checkedIncoming = savedInstanceState.getBoolean(STATE_KEY_CHECKED_INCOMING)
updateViewVisibility(clientCertificateCheckBox.isChecked)
}
private fun updateViewVisibility(usingCertificates: Boolean) {
allowClientCertificateView.isVisible = usingCertificates
}
private fun validateFields(checkPassword: Boolean = true) {
val email = emailView.text?.toString().orEmpty()
val valid = requiredFieldValid(emailView) && emailValidator.isValidAddressOnly(email) &&
(!checkPassword || isPasswordFieldValid())
nextButton.isEnabled = valid
nextButton.isFocusable = valid
manualSetupButton.isEnabled = valid
}
private fun isPasswordFieldValid(): Boolean {
val clientCertificateChecked = clientCertificateCheckBox.isChecked
val clientCertificateAlias = clientCertificateSpinner.alias
return !clientCertificateChecked && requiredFieldValid(passwordView) ||
clientCertificateChecked && clientCertificateAlias != null
}
private fun attemptAutoSetupUsingOnlyEmailAddress() {
val email = emailView.text?.toString() ?: error("Email missing")
val extraConnectionSettings = ExtraAccountDiscovery.discover(email)
if (extraConnectionSettings != null) {
finishAutoSetup(extraConnectionSettings)
return
}
val connectionSettings = providersXmlDiscoveryDiscover(email)
if (connectionSettings != null &&
connectionSettings.incoming.authenticationType == AuthType.XOAUTH2 &&
connectionSettings.outgoing.authenticationType == AuthType.XOAUTH2
) {
startOAuthFlow(connectionSettings)
} else {
startPasswordFlow()
}
}
private fun startOAuthFlow(connectionSettings: ConnectionSettings) {
val account = createAccount(connectionSettings)
val intent = OAuthFlowActivity.buildLaunchIntent(this, account.uuid)
startActivityForResult(intent, REQUEST_CODE_OAUTH)
}
private fun startPasswordFlow() {
uiState = UiState.PASSWORD_FLOW
updateUi()
validateFields()
passwordView.requestFocus()
}
private fun attemptAutoSetup() {
if (clientCertificateCheckBox.isChecked) {
// Auto-setup doesn't support client certificates.
onManualSetup()
return
}
val email = emailView.text?.toString() ?: error("Email missing")
val extraConnectionSettings = ExtraAccountDiscovery.discover(email)
if (extraConnectionSettings != null) {
finishAutoSetup(extraConnectionSettings)
return
}
val connectionSettings = providersXmlDiscoveryDiscover(email)
if (connectionSettings != null) {
finishAutoSetup(connectionSettings)
} else {
// We don't have default settings for this account, start the manual setup process.
onManualSetup()
}
}
private fun finishAutoSetup(connectionSettings: ConnectionSettings) {
val account = createAccount(connectionSettings)
// Check incoming here. Then check outgoing in onActivityResult()
AccountSetupCheckSettings.actionCheckSettings(this, account, CheckDirection.INCOMING)
}
private fun createAccount(connectionSettings: ConnectionSettings): Account {
val email = emailView.text?.toString() ?: error("Email missing")
val password = passwordView.text?.toString()
val account = initAccount(email)
val incomingServerSettings = connectionSettings.incoming.newPassword(password)
account.incomingServerSettings = incomingServerSettings
val outgoingServerSettings = connectionSettings.outgoing.newPassword(password)
account.outgoingServerSettings = outgoingServerSettings
account.deletePolicy = accountCreator.getDefaultDeletePolicy(incomingServerSettings.type)
localFoldersCreator.createSpecialLocalFolders(account)
return account
}
private fun onManualSetup() {
val email = emailView.text?.toString() ?: error("Email missing")
var password: String? = passwordView.text?.toString()
var clientCertificateAlias: String? = null
var authenticationType: AuthType = AuthType.PLAIN
if (clientCertificateCheckBox.isChecked) {
clientCertificateAlias = clientCertificateSpinner.alias
if (password.isNullOrEmpty()) {
authenticationType = AuthType.EXTERNAL
password = null
}
}
val account = initAccount(email)
val initialAccountSettings = InitialAccountSettings(
authenticationType = authenticationType,
email = email,
password = password,
clientCertificateAlias = clientCertificateAlias
)
AccountSetupAccountType.actionSelectAccountType(this, account, makeDefault = false, initialAccountSettings)
}
private fun initAccount(email: String): Account {
val account = this.account ?: createAccount().also { this.account = it }
account.senderName = getOwnerName()
account.email = email
return account
}
private fun createAccount(): Account {
return preferences.newAccount().apply {
chipColor = accountCreator.pickColor()
}
}
private fun getOwnerName(): String {
return preferences.defaultAccount?.senderName ?: ""
}
private fun providersXmlDiscoveryDiscover(email: String): ConnectionSettings? {
val discoveryResults = providersXmlDiscovery.discover(email)
if (discoveryResults == null || discoveryResults.incoming.isEmpty() || discoveryResults.outgoing.isEmpty()) {
return null
}
val incomingServerSettings = discoveryResults.incoming.first().toServerSettings() ?: return null
val outgoingServerSettings = discoveryResults.outgoing.first().toServerSettings() ?: return null
return ConnectionSettings(incomingServerSettings, outgoingServerSettings)
}
public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_CODE_CHECK_SETTINGS -> handleCheckSettingsResult(resultCode)
REQUEST_CODE_OAUTH -> handleSignInResult(resultCode)
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
private fun handleCheckSettingsResult(resultCode: Int) {
if (resultCode != RESULT_OK) return
val account = this.account ?: error("Account instance missing")
if (!checkedIncoming) {
// We've successfully checked incoming. Now check outgoing.
checkedIncoming = true
AccountSetupCheckSettings.actionCheckSettings(this, account, CheckDirection.OUTGOING)
} else {
// We've successfully checked outgoing as well.
preferences.saveAccount(account)
Core.setServicesEnabled(applicationContext)
AccountSetupNames.actionSetNames(this, account)
}
}
private fun handleSignInResult(resultCode: Int) {
if (resultCode != RESULT_OK) return
val account = this.account ?: error("Account instance missing")
AccountSetupCheckSettings.actionCheckSettings(this, account, CheckDirection.INCOMING)
}
private enum class UiState {
EMAIL_ADDRESS_ONLY,
PASSWORD_FLOW
}
companion object {
private const val EXTRA_ACCOUNT = "com.fsck.k9.AccountSetupBasics.account"
private const val STATE_KEY_UI_STATE = "com.fsck.k9.AccountSetupBasics.uiState"
private const val STATE_KEY_CHECKED_INCOMING = "com.fsck.k9.AccountSetupBasics.checkedIncoming"
private const val REQUEST_CODE_CHECK_SETTINGS = AccountSetupCheckSettings.ACTIVITY_REQUEST_CODE
private const val REQUEST_CODE_OAUTH = Activity.RESULT_FIRST_USER + 1
@JvmStatic
fun actionNewAccount(context: Context) {
val intent = Intent(context, AccountSetupBasics::class.java)
context.startActivity(intent)
}
}
}
private fun DiscoveredServerSettings.toServerSettings(): ServerSettings? {
val authType = this.authType ?: return null
val username = this.username ?: return null
return ServerSettings(
type = protocol,
host = host,
port = port,
connectionSecurity = security,
authenticationType = authType,
username = username,
password = null,
clientCertificateAlias = null
)
}

View file

@ -0,0 +1,514 @@
package com.fsck.k9.activity.setup
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.os.AsyncTask
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.commit
import com.fsck.k9.Account
import com.fsck.k9.LocalKeyStoreManager
import com.fsck.k9.Preferences
import com.fsck.k9.controller.MessagingController
import com.fsck.k9.fragment.ConfirmationDialogFragment
import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.CertificateValidationException
import com.fsck.k9.mail.MailServerDirection
import com.fsck.k9.mail.filter.Hex
import com.fsck.k9.preferences.Protocols
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.K9Activity
import com.fsck.k9.ui.observe
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.cert.CertificateEncodingException
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.Locale
import java.util.concurrent.Executors
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
/**
* Checks the given settings to make sure that they can be used to send and receive mail.
*
* XXX NOTE: The manifest for this app has it ignore config changes, because it doesn't correctly deal with restarting
* while its thread is running.
*/
class AccountSetupCheckSettings : K9Activity(), ConfirmationDialogFragmentListener {
private val authViewModel: AuthViewModel by viewModel()
private val messagingController: MessagingController by inject()
private val preferences: Preferences by inject()
private val localKeyStoreManager: LocalKeyStoreManager by inject()
private val handler = Handler(Looper.myLooper()!!)
private lateinit var progressBar: ProgressBar
private lateinit var messageView: TextView
private lateinit var account: Account
private lateinit var direction: CheckDirection
@Volatile
private var canceled = false
@Volatile
private var destroyed = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setLayout(R.layout.account_setup_check_settings)
messageView = findViewById(R.id.message)
progressBar = findViewById(R.id.progress)
findViewById<View>(R.id.cancel).setOnClickListener { onCancel() }
setMessage(R.string.account_setup_check_settings_retr_info_msg)
progressBar.isIndeterminate = true
val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT) ?: error("Missing account UUID")
account = preferences.getAccount(accountUuid) ?: error("Could not find account")
direction = intent.getSerializableExtra(EXTRA_CHECK_DIRECTION) as CheckDirection?
?: error("Missing CheckDirection")
authViewModel.init(activityResultRegistry, lifecycle, account)
authViewModel.uiState.observe(this) { state ->
when (state) {
AuthFlowState.Idle -> {
return@observe
}
AuthFlowState.Success -> {
startCheckServerSettings()
}
AuthFlowState.Canceled -> {
showErrorDialog(R.string.account_setup_failed_dlg_oauth_flow_canceled)
}
is AuthFlowState.Failed -> {
showErrorDialog(R.string.account_setup_failed_dlg_oauth_flow_failed, state)
}
AuthFlowState.NotSupported -> {
showErrorDialog(R.string.account_setup_failed_dlg_oauth_not_supported)
}
AuthFlowState.BrowserNotFound -> {
showErrorDialog(R.string.account_setup_failed_dlg_browser_not_found)
}
}
authViewModel.authResultConsumed()
}
if (savedInstanceState == null) {
if (needsAuthorization()) {
setMessage(R.string.account_setup_check_settings_authenticate)
authViewModel.login()
} else {
startCheckServerSettings()
}
}
}
private fun needsAuthorization(): Boolean {
return (
account.incomingServerSettings.authenticationType == AuthType.XOAUTH2 ||
account.outgoingServerSettings.authenticationType == AuthType.XOAUTH2
) &&
!authViewModel.isAuthorized(account)
}
private fun startCheckServerSettings() {
CheckAccountTask(account).executeOnExecutor(Executors.newSingleThreadExecutor(), direction)
}
private fun handleCertificateValidationException(exception: CertificateValidationException) {
Timber.e(exception, "Error while testing settings")
val chain = exception.certChain
// Avoid NullPointerException in acceptKeyDialog()
if (chain != null) {
acceptKeyDialog(
R.string.account_setup_failed_dlg_certificate_message_fmt,
exception
)
} else {
showErrorDialog(
R.string.account_setup_failed_dlg_server_message_fmt,
errorMessageForCertificateException(exception)!!
)
}
}
override fun onDestroy() {
super.onDestroy()
destroyed = true
canceled = true
}
private fun setMessage(resId: Int) {
messageView.text = getString(resId)
}
private fun acceptKeyDialog(msgResId: Int, exception: CertificateValidationException) {
handler.post {
if (destroyed) {
return@post
}
val errorMessage = exception.cause?.cause?.message ?: exception.cause?.message ?: exception.message
progressBar.isIndeterminate = false
val chainInfo = StringBuilder()
val chain = exception.certChain
// We already know chain != null (tested before calling this method)
for (i in chain.indices) {
// display certificate chain information
// TODO: localize this strings
chainInfo.append("Certificate chain[").append(i).append("]:\n")
chainInfo.append("Subject: ").append(chain[i].subjectDN.toString()).append("\n")
// display SubjectAltNames too
// (the user may be mislead into mistrusting a certificate
// by a subjectDN not matching the server even though a
// SubjectAltName matches)
try {
val subjectAlternativeNames = chain[i].subjectAlternativeNames
if (subjectAlternativeNames != null) {
// TODO: localize this string
val altNamesText = StringBuilder()
altNamesText.append("Subject has ")
.append(subjectAlternativeNames.size)
.append(" alternative names\n")
// we need these for matching
val incomingServerHost = account.incomingServerSettings.host!!
val outgoingServerHost = account.outgoingServerSettings.host!!
for (subjectAlternativeName in subjectAlternativeNames) {
val type = subjectAlternativeName[0] as Int
val value: Any? = subjectAlternativeName[1]
val name: String = when (type) {
0 -> {
Timber.w("SubjectAltName of type OtherName not supported.")
continue
}
1 -> value as String
2 -> value as String
3 -> {
Timber.w("unsupported SubjectAltName of type x400Address")
continue
}
4 -> {
Timber.w("unsupported SubjectAltName of type directoryName")
continue
}
5 -> {
Timber.w("unsupported SubjectAltName of type ediPartyName")
continue
}
6 -> value as String
7 -> value as String
else -> {
Timber.w("unsupported SubjectAltName of unknown type")
continue
}
}
// if some of the SubjectAltNames match the store or transport -host, display them
if (name.equals(incomingServerHost, ignoreCase = true) ||
name.equals(outgoingServerHost, ignoreCase = true)
) {
// TODO: localize this string
altNamesText.append("Subject(alt): ").append(name).append(",...\n")
} else if (name.startsWith("*.") &&
(
incomingServerHost.endsWith(name.substring(2)) ||
outgoingServerHost.endsWith(name.substring(2))
)
) {
// TODO: localize this string
altNamesText.append("Subject(alt): ").append(name).append(",...\n")
}
}
chainInfo.append(altNamesText)
}
} catch (e: Exception) {
// don't fail just because of subjectAltNames
Timber.w(e, "cannot display SubjectAltNames in dialog")
}
chainInfo.append("Issuer: ").append(chain[i].issuerDN.toString()).append("\n")
for (algorithm in arrayOf("SHA-1", "SHA-256", "SHA-512")) {
val digest = try {
MessageDigest.getInstance(algorithm)
} catch (e: NoSuchAlgorithmException) {
Timber.e(e, "Error while initializing MessageDigest ($algorithm)")
null
}
if (digest != null) {
digest.reset()
try {
val hash = Hex.encodeHex(digest.digest(chain[i].encoded))
chainInfo.append("Fingerprint ($algorithm): ").append("\n").append(hash).append("\n")
} catch (e: CertificateEncodingException) {
Timber.e(e, "Error while encoding certificate")
}
}
}
}
// TODO: refactor with DialogFragment.
// This is difficult because we need to pass through chain[0] for onClick()
AlertDialog.Builder(this@AccountSetupCheckSettings)
.setTitle(getString(R.string.account_setup_failed_dlg_invalid_certificate_title))
.setMessage(getString(msgResId, errorMessage) + " " + chainInfo.toString())
.setCancelable(true)
.setPositiveButton(R.string.account_setup_failed_dlg_invalid_certificate_accept) { _, _ ->
acceptCertificate(chain[0])
}
.setNegativeButton(R.string.account_setup_failed_dlg_invalid_certificate_reject) { _, _ ->
finish()
}
.show()
}
}
/**
* Permanently accepts a certificate for the INCOMING or OUTGOING direction by adding it to the local key store.
*/
private fun acceptCertificate(certificate: X509Certificate) {
try {
localKeyStoreManager.addCertificate(account, direction.toMailServerDirection(), certificate)
} catch (e: CertificateException) {
showErrorDialog(R.string.account_setup_failed_dlg_certificate_message_fmt, e.message.orEmpty())
}
actionCheckSettings(this@AccountSetupCheckSettings, account, direction)
}
override fun onActivityResult(reqCode: Int, resCode: Int, data: Intent?) {
if (reqCode == ACTIVITY_REQUEST_CODE) {
setResult(resCode)
finish()
} else {
super.onActivityResult(reqCode, resCode, data)
}
}
private fun onCancel() {
canceled = true
setMessage(R.string.account_setup_check_settings_canceling_msg)
setResult(RESULT_CANCELED)
finish()
}
private fun showErrorDialog(msgResId: Int, vararg args: Any) {
handler.post {
showDialogFragment(R.id.dialog_account_setup_error, getString(msgResId, *args))
}
}
private fun showDialogFragment(dialogId: Int, customMessage: String) {
if (destroyed) return
progressBar.isIndeterminate = false
val fragment: DialogFragment = if (dialogId == R.id.dialog_account_setup_error) {
ConfirmationDialogFragment.newInstance(
dialogId,
getString(R.string.account_setup_failed_dlg_title),
customMessage,
getString(R.string.account_setup_failed_dlg_edit_details_action),
getString(R.string.account_setup_failed_dlg_continue_action)
)
} else {
throw RuntimeException("Called showDialog(int) with unknown dialog id.")
}
// TODO: commitAllowingStateLoss() is used to prevent https://code.google.com/p/android/issues/detail?id=23761
// but is a bad...
supportFragmentManager.commit(allowStateLoss = true) {
add(fragment, getDialogTag(dialogId))
}
}
private fun getDialogTag(dialogId: Int): String {
return String.format(Locale.US, "dialog-%d", dialogId)
}
override fun doPositiveClick(dialogId: Int) {
if (dialogId == R.id.dialog_account_setup_error) {
finish()
}
}
override fun doNegativeClick(dialogId: Int) {
if (dialogId == R.id.dialog_account_setup_error) {
canceled = false
setResult(RESULT_OK)
finish()
}
}
override fun dialogCancelled(dialogId: Int) = Unit
private fun errorMessageForCertificateException(e: CertificateValidationException): String? {
return when (e.reason) {
CertificateValidationException.Reason.Expired -> {
getString(R.string.client_certificate_expired, e.alias, e.message)
}
CertificateValidationException.Reason.MissingCapability -> {
getString(R.string.auth_external_error)
}
CertificateValidationException.Reason.RetrievalFailure -> {
getString(R.string.client_certificate_retrieval_failure, e.alias)
}
CertificateValidationException.Reason.UseMessage -> {
e.message
}
else -> {
""
}
}
}
/**
* FIXME: Don't use an AsyncTask to perform network operations.
* See also discussion in https://github.com/thundernest/k-9/pull/560
*/
private inner class CheckAccountTask(private val account: Account) : AsyncTask<CheckDirection, Int, Unit>() {
override fun doInBackground(vararg params: CheckDirection) {
val direction = params[0]
try {
/*
* This task could be interrupted at any point, but network operations can block,
* so relying on InterruptedException is not enough. Instead, check after
* each potentially long-running operation.
*/
if (isCanceled()) {
return
}
clearCertificateErrorNotifications(direction)
checkServerSettings(direction)
if (isCanceled()) {
return
}
setResult(RESULT_OK)
finish()
} catch (e: AuthenticationFailedException) {
Timber.e(e, "Error while testing settings")
showErrorDialog(R.string.account_setup_failed_dlg_auth_message_fmt, e.messageFromServer.orEmpty())
} catch (e: CertificateValidationException) {
handleCertificateValidationException(e)
} catch (e: Exception) {
Timber.e(e, "Error while testing settings")
showErrorDialog(R.string.account_setup_failed_dlg_server_message_fmt, e.message.orEmpty())
}
}
private fun clearCertificateErrorNotifications(direction: CheckDirection) {
val incoming = direction == CheckDirection.INCOMING
messagingController.clearCertificateErrorNotifications(account, incoming)
}
private fun isCanceled(): Boolean {
if (destroyed) return true
if (canceled) {
finish()
return true
}
return false
}
private fun checkServerSettings(direction: CheckDirection) {
when (direction) {
CheckDirection.INCOMING -> checkIncoming()
CheckDirection.OUTGOING -> checkOutgoing()
}
}
private fun checkOutgoing() {
if (!isWebDavAccount) {
publishProgress(R.string.account_setup_check_settings_check_outgoing_msg)
}
messagingController.checkOutgoingServerSettings(account)
}
private fun checkIncoming() {
if (isWebDavAccount) {
publishProgress(R.string.account_setup_check_settings_authenticate)
} else {
publishProgress(R.string.account_setup_check_settings_check_incoming_msg)
}
messagingController.checkIncomingServerSettings(account)
if (isWebDavAccount) {
publishProgress(R.string.account_setup_check_settings_fetch)
}
messagingController.refreshFolderListSynchronous(account)
val inboxFolderId = account.inboxFolderId
if (inboxFolderId != null) {
messagingController.synchronizeMailbox(account, inboxFolderId, false, null)
}
}
private val isWebDavAccount: Boolean
get() = account.incomingServerSettings.type == Protocols.WEBDAV
override fun onProgressUpdate(vararg values: Int?) {
setMessage(values[0]!!)
}
}
enum class CheckDirection {
INCOMING, OUTGOING;
fun toMailServerDirection(): MailServerDirection {
return when (this) {
INCOMING -> MailServerDirection.INCOMING
OUTGOING -> MailServerDirection.OUTGOING
}
}
}
companion object {
const val ACTIVITY_REQUEST_CODE = 1
private const val EXTRA_ACCOUNT = "account"
private const val EXTRA_CHECK_DIRECTION = "checkDirection"
@JvmStatic
fun actionCheckSettings(context: Activity, account: Account, direction: CheckDirection) {
val intent = Intent(context, AccountSetupCheckSettings::class.java).apply {
putExtra(EXTRA_ACCOUNT, account.uuid)
putExtra(EXTRA_CHECK_DIRECTION, direction)
}
context.startActivityForResult(intent, ACTIVITY_REQUEST_CODE)
}
}
}

View file

@ -0,0 +1,146 @@
package com.fsck.k9.activity.setup;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import android.widget.CompoundButton;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import androidx.annotation.NonNull;
import com.fsck.k9.Account;
import com.fsck.k9.Preferences;
import com.fsck.k9.ui.R;
import com.fsck.k9.ui.base.K9Activity;
public class AccountSetupComposition extends K9Activity {
private static final String EXTRA_ACCOUNT = "account";
private Account mAccount;
private EditText mAccountSignature;
private EditText mAccountEmail;
private EditText mAccountAlwaysBcc;
private EditText mAccountName;
private CheckBox mAccountSignatureUse;
private RadioButton mAccountSignatureBeforeLocation;
private RadioButton mAccountSignatureAfterLocation;
private LinearLayout mAccountSignatureLayout;
public static void actionEditCompositionSettings(Activity context, String accountUuid) {
Intent intent = new Intent(context, AccountSetupComposition.class);
intent.setAction(Intent.ACTION_EDIT);
intent.putExtra(EXTRA_ACCOUNT, accountUuid);
context.startActivity(intent);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
setLayout(R.layout.account_setup_composition);
setTitle(R.string.account_settings_composition_title);
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
/*
* If we're being reloaded we override the original account with the one
* we saved
*/
if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) {
accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
}
mAccountName = findViewById(R.id.account_name);
mAccountName.setText(mAccount.getSenderName());
mAccountEmail = findViewById(R.id.account_email);
mAccountEmail.setText(mAccount.getEmail());
mAccountAlwaysBcc = findViewById(R.id.account_always_bcc);
mAccountAlwaysBcc.setText(mAccount.getAlwaysBcc());
mAccountSignatureLayout = findViewById(R.id.account_signature_layout);
mAccountSignatureUse = findViewById(R.id.account_signature_use);
boolean useSignature = mAccount.getSignatureUse();
mAccountSignatureUse.setChecked(useSignature);
mAccountSignatureUse.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
if (isChecked) {
mAccountSignatureLayout.setVisibility(View.VISIBLE);
mAccountSignature.setText(mAccount.getSignature());
boolean isSignatureBeforeQuotedText = mAccount.isSignatureBeforeQuotedText();
mAccountSignatureBeforeLocation.setChecked(isSignatureBeforeQuotedText);
mAccountSignatureAfterLocation.setChecked(!isSignatureBeforeQuotedText);
} else {
mAccountSignatureLayout.setVisibility(View.GONE);
}
}
});
mAccountSignature = findViewById(R.id.account_signature);
mAccountSignatureBeforeLocation = findViewById(R.id.account_signature_location_before_quoted_text);
mAccountSignatureAfterLocation = findViewById(R.id.account_signature_location_after_quoted_text);
if (useSignature) {
mAccountSignature.setText(mAccount.getSignature());
boolean isSignatureBeforeQuotedText = mAccount.isSignatureBeforeQuotedText();
mAccountSignatureBeforeLocation.setChecked(isSignatureBeforeQuotedText);
mAccountSignatureAfterLocation.setChecked(!isSignatureBeforeQuotedText);
} else {
mAccountSignatureLayout.setVisibility(View.GONE);
}
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
private void saveSettings() {
mAccount.setEmail(mAccountEmail.getText().toString());
mAccount.setAlwaysBcc(mAccountAlwaysBcc.getText().toString());
mAccount.setSenderName(mAccountName.getText().toString());
mAccount.setSignatureUse(mAccountSignatureUse.isChecked());
if (mAccountSignatureUse.isChecked()) {
mAccount.setSignature(mAccountSignature.getText().toString());
boolean isSignatureBeforeQuotedText = mAccountSignatureBeforeLocation.isChecked();
mAccount.setSignatureBeforeQuotedText(isSignatureBeforeQuotedText);
}
Preferences.getPreferences().saveAccount(mAccount);
}
@Override
public void onBackPressed() {
saveSettings();
super.onBackPressed();
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putSerializable(EXTRA_ACCOUNT, mAccount.getUuid());
}
}

View file

@ -0,0 +1,683 @@
package com.fsck.k9.activity.setup;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.text.method.DigitsKeyListener;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemSelectedListener;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.CompoundButton.OnCheckedChangeListener;
import android.widget.Spinner;
import android.widget.Toast;
import androidx.annotation.NonNull;
import com.fsck.k9.Account;
import com.fsck.k9.DI;
import com.fsck.k9.LocalKeyStoreManager;
import com.fsck.k9.Preferences;
import com.fsck.k9.account.AccountCreator;
import com.fsck.k9.helper.EmailHelper;
import com.fsck.k9.setup.ServerNameSuggester;
import com.fsck.k9.ui.base.K9Activity;
import com.fsck.k9.activity.setup.AccountSetupCheckSettings.CheckDirection;
import com.fsck.k9.helper.Utility;
import com.fsck.k9.mail.AuthType;
import com.fsck.k9.mail.ConnectionSecurity;
import com.fsck.k9.mail.MailServerDirection;
import com.fsck.k9.mail.ServerSettings;
import com.fsck.k9.mail.store.imap.ImapStoreSettings;
import com.fsck.k9.mail.store.webdav.WebDavStoreSettings;
import com.fsck.k9.preferences.Protocols;
import com.fsck.k9.ui.R;
import com.fsck.k9.ui.base.extensions.TextInputLayoutHelper;
import com.fsck.k9.view.ClientCertificateSpinner;
import com.fsck.k9.view.ClientCertificateSpinner.OnClientCertificateChangedListener;
import java.util.Locale;
import java.util.Map;
import com.google.android.material.textfield.TextInputEditText;
import com.google.android.material.textfield.TextInputLayout;
import timber.log.Timber;
import static java.util.Collections.emptyMap;
public class AccountSetupIncoming extends K9Activity implements OnClickListener {
private static final String EXTRA_ACCOUNT = "account";
private static final String EXTRA_MAKE_DEFAULT = "makeDefault";
private static final String STATE_SECURITY_TYPE_POSITION = "stateSecurityTypePosition";
private static final String STATE_AUTH_TYPE_POSITION = "authTypePosition";
private final AccountCreator accountCreator = DI.get(AccountCreator.class);
private final ServerNameSuggester serverNameSuggester = DI.get(ServerNameSuggester.class);
private String mStoreType;
private TextInputEditText mUsernameView;
private TextInputEditText mPasswordView;
private ClientCertificateSpinner mClientCertificateSpinner;
private TextInputLayout mPasswordLayoutView;
private TextInputEditText mServerView;
private TextInputEditText mPortView;
private String mCurrentPortViewSetting;
private Spinner mSecurityTypeView;
private int mCurrentSecurityTypeViewPosition;
private Spinner mAuthTypeView;
private int mCurrentAuthTypeViewPosition;
private CheckBox mImapAutoDetectNamespaceView;
private TextInputEditText mImapPathPrefixView;
private TextInputEditText mWebdavPathPrefixView;
private TextInputEditText mWebdavAuthPathView;
private TextInputEditText mWebdavMailboxPathView;
private ViewGroup mAllowClientCertificateView;
private Button mNextButton;
private Account mAccount;
private boolean mMakeDefault;
private CheckBox useCompressionCheckBox;
private CheckBox mSubscribedFoldersOnly;
private AuthTypeAdapter mAuthTypeAdapter;
private ConnectionSecurity[] mConnectionSecurityChoices = ConnectionSecurity.values();
private boolean editSettings;
public static void actionIncomingSettings(Activity context, Account account, boolean makeDefault) {
Intent i = new Intent(context, AccountSetupIncoming.class);
i.putExtra(EXTRA_ACCOUNT, account.getUuid());
i.putExtra(EXTRA_MAKE_DEFAULT, makeDefault);
context.startActivity(i);
}
public static void actionEditIncomingSettings(Context context, String accountUuid) {
Intent intent = new Intent(context, AccountSetupIncoming.class);
intent.setAction(Intent.ACTION_EDIT);
intent.putExtra(EXTRA_ACCOUNT, accountUuid);
context.startActivity(intent);
}
public static Intent intentActionEditIncomingSettings(Context context, Account account) {
Intent i = new Intent(context, AccountSetupIncoming.class);
i.setAction(Intent.ACTION_EDIT);
i.putExtra(EXTRA_ACCOUNT, account.getUuid());
return i;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setLayout(R.layout.account_setup_incoming);
setTitle(R.string.account_setup_incoming_title);
mUsernameView = findViewById(R.id.account_username);
mPasswordView = findViewById(R.id.account_password);
mClientCertificateSpinner = findViewById(R.id.account_client_certificate_spinner);
mPasswordLayoutView = findViewById(R.id.account_password_layout);
mServerView = findViewById(R.id.account_server);
mPortView = findViewById(R.id.account_port);
mSecurityTypeView = findViewById(R.id.account_security_type);
mAuthTypeView = findViewById(R.id.account_auth_type);
mImapAutoDetectNamespaceView = findViewById(R.id.imap_autodetect_namespace);
mImapPathPrefixView = findViewById(R.id.imap_path_prefix);
mWebdavPathPrefixView = findViewById(R.id.webdav_path_prefix);
mWebdavAuthPathView = findViewById(R.id.webdav_auth_path);
mWebdavMailboxPathView = findViewById(R.id.webdav_mailbox_path);
mNextButton = findViewById(R.id.next);
useCompressionCheckBox = findViewById(R.id.use_compression);
mSubscribedFoldersOnly = findViewById(R.id.subscribed_folders_only);
mAllowClientCertificateView = findViewById(R.id.account_allow_client_certificate);
TextInputLayout serverLayoutView = findViewById(R.id.account_server_layout);
mNextButton.setOnClickListener(this);
mImapAutoDetectNamespaceView.setOnCheckedChangeListener(new OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
mImapPathPrefixView.setEnabled(!isChecked);
if (isChecked && mImapPathPrefixView.hasFocus()) {
mImapPathPrefixView.focusSearch(View.FOCUS_UP).requestFocus();
} else if (!isChecked) {
mImapPathPrefixView.requestFocus();
}
}
});
/*
* Only allow digits in the port field.
*/
mPortView.setKeyListener(DigitsKeyListener.getInstance("0123456789"));
String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
mMakeDefault = getIntent().getBooleanExtra(EXTRA_MAKE_DEFAULT, false);
/*
* If we're being reloaded we override the original account with the one
* we saved
*/
if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) {
accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
}
boolean oAuthSupported = mAccount.getIncomingServerSettings().type.equals(Protocols.IMAP);
mAuthTypeAdapter = AuthTypeAdapter.get(this, oAuthSupported);
mAuthTypeView.setAdapter(mAuthTypeAdapter);
editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction());
if (editSettings) {
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
}
try {
ServerSettings settings = mAccount.getIncomingServerSettings();
if (savedInstanceState == null) {
// The first item is selected if settings.authenticationType is null or is not in mAuthTypeAdapter
mCurrentAuthTypeViewPosition = mAuthTypeAdapter.getAuthPosition(settings.authenticationType);
} else {
mCurrentAuthTypeViewPosition = savedInstanceState.getInt(STATE_AUTH_TYPE_POSITION);
}
mAuthTypeView.setSelection(mCurrentAuthTypeViewPosition, false);
updateViewFromAuthType();
mUsernameView.setText(settings.username);
if (settings.password != null) {
mPasswordView.setText(settings.password);
}
if (settings.clientCertificateAlias != null) {
mClientCertificateSpinner.setAlias(settings.clientCertificateAlias);
}
mStoreType = settings.type;
if (settings.type.equals(Protocols.POP3)) {
serverLayoutView.setHint(getString(R.string.account_setup_incoming_pop_server_label));
findViewById(R.id.imap_path_prefix_section).setVisibility(View.GONE);
findViewById(R.id.webdav_advanced_header).setVisibility(View.GONE);
findViewById(R.id.webdav_mailbox_alias_section).setVisibility(View.GONE);
findViewById(R.id.webdav_owa_path_section).setVisibility(View.GONE);
findViewById(R.id.webdav_auth_path_section).setVisibility(View.GONE);
useCompressionCheckBox.setVisibility(View.GONE);
mSubscribedFoldersOnly.setVisibility(View.GONE);
} else if (settings.type.equals(Protocols.IMAP)) {
serverLayoutView.setHint(getString(R.string.account_setup_incoming_imap_server_label));
boolean autoDetectNamespace = ImapStoreSettings.getAutoDetectNamespace(settings);
String pathPrefix = ImapStoreSettings.getPathPrefix(settings);
mImapAutoDetectNamespaceView.setChecked(autoDetectNamespace);
if (pathPrefix != null) {
mImapPathPrefixView.setText(pathPrefix);
}
findViewById(R.id.webdav_advanced_header).setVisibility(View.GONE);
findViewById(R.id.webdav_mailbox_alias_section).setVisibility(View.GONE);
findViewById(R.id.webdav_owa_path_section).setVisibility(View.GONE);
findViewById(R.id.webdav_auth_path_section).setVisibility(View.GONE);
if (!editSettings) {
findViewById(R.id.imap_folder_setup_section).setVisibility(View.GONE);
}
} else if (settings.type.equals(Protocols.WEBDAV)) {
serverLayoutView.setHint(getString(R.string.account_setup_incoming_webdav_server_label));
mConnectionSecurityChoices = new ConnectionSecurity[] {
ConnectionSecurity.NONE,
ConnectionSecurity.SSL_TLS_REQUIRED };
// Hide the unnecessary fields
findViewById(R.id.imap_path_prefix_section).setVisibility(View.GONE);
findViewById(R.id.account_auth_type_label).setVisibility(View.GONE);
findViewById(R.id.account_auth_type).setVisibility(View.GONE);
useCompressionCheckBox.setVisibility(View.GONE);
mSubscribedFoldersOnly.setVisibility(View.GONE);
String path = WebDavStoreSettings.getPath(settings);
if (path != null) {
mWebdavPathPrefixView.setText(path);
}
String authPath = WebDavStoreSettings.getAuthPath(settings);
if (authPath != null) {
mWebdavAuthPathView.setText(authPath);
}
String mailboxPath = WebDavStoreSettings.getMailboxPath(settings);
if (mailboxPath != null) {
mWebdavMailboxPathView.setText(mailboxPath);
}
} else {
throw new Exception("Unknown account type: " + settings.type);
}
if (!editSettings) {
mAccount.setDeletePolicy(accountCreator.getDefaultDeletePolicy(settings.type));
}
// Note that mConnectionSecurityChoices is configured above based on server type
ConnectionSecurityAdapter securityTypesAdapter =
ConnectionSecurityAdapter.get(this, mConnectionSecurityChoices);
mSecurityTypeView.setAdapter(securityTypesAdapter);
// Select currently configured security type
if (savedInstanceState == null) {
mCurrentSecurityTypeViewPosition = securityTypesAdapter.getConnectionSecurityPosition(settings.connectionSecurity);
} else {
/*
* Restore the spinner state now, before calling
* setOnItemSelectedListener(), thus avoiding a call to
* onItemSelected(). Then, when the system restores the state
* (again) in onRestoreInstanceState(), The system will see that
* the new state is the same as the current state (set here), so
* once again onItemSelected() will not be called.
*/
mCurrentSecurityTypeViewPosition = savedInstanceState.getInt(STATE_SECURITY_TYPE_POSITION);
}
mSecurityTypeView.setSelection(mCurrentSecurityTypeViewPosition, false);
updateAuthPlainTextFromSecurityType(settings.connectionSecurity);
updateViewFromSecurity();
useCompressionCheckBox.setChecked(mAccount.useCompression());
if (settings.host != null) {
mServerView.setText(settings.host);
}
if (settings.port != -1) {
mPortView.setText(String.format(Locale.ROOT, "%d", settings.port));
} else {
updatePortFromSecurityType();
}
mCurrentPortViewSetting = mPortView.getText().toString();
mSubscribedFoldersOnly.setChecked(mAccount.isSubscribedFoldersOnly());
} catch (Exception e) {
failure(e);
}
}
/**
* Called at the end of either {@code onCreate()} or
* {@code onRestoreInstanceState()}, after the views have been initialized,
* so that the listeners are not triggered during the view initialization.
* This avoids needless calls to {@code validateFields()} which is called
* immediately after this is called.
*/
private void initializeViewListeners() {
/*
* Updates the port when the user changes the security type. This allows
* us to show a reasonable default which the user can change.
*/
mSecurityTypeView.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position,
long id) {
/*
* We keep our own record of the spinner state so we
* know for sure that onItemSelected() was called
* because of user input, not because of spinner
* state initialization. This assures that the port
* will not be replaced with a default value except
* on user input.
*/
if (mCurrentSecurityTypeViewPosition != position) {
updatePortFromSecurityType();
validateFields();
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) { /* unused */ }
});
mAuthTypeView.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position,
long id) {
if (mCurrentAuthTypeViewPosition == position) {
return;
}
updateViewFromAuthType();
updateViewFromSecurity();
validateFields();
AuthType selection = getSelectedAuthType();
// Have the user select the client certificate if not already selected
if ((AuthType.EXTERNAL == selection) && (mClientCertificateSpinner.getAlias() == null)) {
// This may again invoke validateFields()
mClientCertificateSpinner.chooseCertificate();
} else {
mPasswordView.requestFocus();
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) { /* unused */ }
});
mClientCertificateSpinner.setOnClientCertificateChangedListener(clientCertificateChangedListener);
mUsernameView.addTextChangedListener(validationTextWatcher);
mPasswordView.addTextChangedListener(validationTextWatcher);
mServerView.addTextChangedListener(validationTextWatcher);
mPortView.addTextChangedListener(validationTextWatcher);
if (editSettings) {
TextInputLayoutHelper.configureAuthenticatedPasswordToggle(
mPasswordLayoutView,
this,
getString(R.string.account_setup_basics_show_password_biometrics_title),
getString(R.string.account_setup_basics_show_password_biometrics_subtitle),
getString(R.string.account_setup_basics_show_password_need_lock)
);
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString(EXTRA_ACCOUNT, mAccount.getUuid());
outState.putInt(STATE_SECURITY_TYPE_POSITION, mCurrentSecurityTypeViewPosition);
outState.putInt(STATE_AUTH_TYPE_POSITION, mCurrentAuthTypeViewPosition);
}
@Override
protected void onPostCreate(Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
/*
* We didn't want the listeners active while the state was being restored
* because they could overwrite the restored port with a default port when
* the security type was restored.
*/
initializeViewListeners();
validateFields();
}
/**
* Shows/hides password field and client certificate spinner
*/
private void updateViewFromAuthType() {
switch (getSelectedAuthType()) {
case EXTERNAL:
case XOAUTH2:
mPasswordLayoutView.setVisibility(View.GONE);
break;
default:
mPasswordLayoutView.setVisibility(View.VISIBLE);
break;
}
}
/**
* Shows/hides client certificate spinner
*/
private void updateViewFromSecurity() {
ConnectionSecurity security = getSelectedSecurity();
boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || (ConnectionSecurity.STARTTLS_REQUIRED == security));
boolean isUsingOAuth = getSelectedAuthType() == AuthType.XOAUTH2;
if (isUsingTLS && !isUsingOAuth) {
mAllowClientCertificateView.setVisibility(View.VISIBLE);
} else {
mAllowClientCertificateView.setVisibility(View.GONE);
}
}
/**
* This is invoked only when the user makes changes to a widget, not when
* widgets are changed programmatically. (The logic is simpler when you know
* that this is the last thing called after an input change.)
*/
private void validateFields() {
AuthType authType = getSelectedAuthType();
boolean isAuthTypeExternal = (AuthType.EXTERNAL == authType);
ConnectionSecurity connectionSecurity = getSelectedSecurity();
boolean hasConnectionSecurity = (connectionSecurity != ConnectionSecurity.NONE);
if (isAuthTypeExternal && !hasConnectionSecurity) {
// Notify user of an invalid combination of AuthType.EXTERNAL & ConnectionSecurity.NONE
String toastText = getString(R.string.account_setup_incoming_invalid_setting_combo_notice,
getString(R.string.account_setup_incoming_auth_type_label),
AuthType.EXTERNAL.toString(),
getString(R.string.account_setup_incoming_security_label),
ConnectionSecurity.NONE.toString());
Toast.makeText(this, toastText, Toast.LENGTH_LONG).show();
// Reset the views back to their previous settings without recursing through here again
OnItemSelectedListener onItemSelectedListener = mAuthTypeView.getOnItemSelectedListener();
mAuthTypeView.setOnItemSelectedListener(null);
mAuthTypeView.setSelection(mCurrentAuthTypeViewPosition, false);
mAuthTypeView.setOnItemSelectedListener(onItemSelectedListener);
updateViewFromAuthType();
updateViewFromSecurity();
onItemSelectedListener = mSecurityTypeView.getOnItemSelectedListener();
mSecurityTypeView.setOnItemSelectedListener(null);
mSecurityTypeView.setSelection(mCurrentSecurityTypeViewPosition, false);
mSecurityTypeView.setOnItemSelectedListener(onItemSelectedListener);
updateAuthPlainTextFromSecurityType(getSelectedSecurity());
updateViewFromSecurity();
mPortView.removeTextChangedListener(validationTextWatcher);
mPortView.setText(mCurrentPortViewSetting);
mPortView.addTextChangedListener(validationTextWatcher);
authType = getSelectedAuthType();
isAuthTypeExternal = (AuthType.EXTERNAL == authType);
connectionSecurity = getSelectedSecurity();
hasConnectionSecurity = (connectionSecurity != ConnectionSecurity.NONE);
} else {
mCurrentAuthTypeViewPosition = mAuthTypeView.getSelectedItemPosition();
mCurrentSecurityTypeViewPosition = mSecurityTypeView.getSelectedItemPosition();
mCurrentPortViewSetting = mPortView.getText().toString();
}
boolean hasValidCertificateAlias = mClientCertificateSpinner.getAlias() != null;
boolean hasValidUserName = Utility.requiredFieldValid(mUsernameView);
boolean hasValidPasswordSettings = hasValidUserName
&& !isAuthTypeExternal
&& Utility.requiredFieldValid(mPasswordView);
boolean hasValidExternalAuthSettings = hasValidUserName
&& isAuthTypeExternal
&& hasConnectionSecurity
&& hasValidCertificateAlias;
boolean hasValidOAuthSettings = hasValidUserName
&& hasConnectionSecurity
&& authType == AuthType.XOAUTH2;
mNextButton.setEnabled(Utility.domainFieldValid(mServerView)
&& Utility.requiredFieldValid(mPortView)
&& (hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidOAuthSettings));
Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128);
}
private void updatePortFromSecurityType() {
ConnectionSecurity securityType = getSelectedSecurity();
updateAuthPlainTextFromSecurityType(securityType);
updateViewFromSecurity();
// Remove listener so as not to trigger validateFields() which is called
// elsewhere as a result of user interaction.
mPortView.removeTextChangedListener(validationTextWatcher);
mPortView.setText(String.valueOf(accountCreator.getDefaultPort(securityType, mStoreType)));
mPortView.addTextChangedListener(validationTextWatcher);
}
private void updateAuthPlainTextFromSecurityType(ConnectionSecurity securityType) {
mAuthTypeAdapter.useInsecureText(securityType == ConnectionSecurity.NONE);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode != AccountSetupCheckSettings.ACTIVITY_REQUEST_CODE) {
super.onActivityResult(requestCode, resultCode, data);
return;
}
if (resultCode == RESULT_OK) {
if (editSettings) {
Preferences.getPreferences().saveAccount(mAccount);
finish();
} else {
/*
* Set the username and password for the outgoing settings to the username and
* password the user just set for incoming.
*/
String username = mUsernameView.getText().toString().trim();
String password = null;
String clientCertificateAlias = null;
AuthType authType = getSelectedAuthType();
if ((ConnectionSecurity.SSL_TLS_REQUIRED == getSelectedSecurity()) ||
(ConnectionSecurity.STARTTLS_REQUIRED == getSelectedSecurity()) ) {
clientCertificateAlias = mClientCertificateSpinner.getAlias();
}
if (AuthType.EXTERNAL != authType) {
password = mPasswordView.getText().toString();
}
String domain = EmailHelper.getDomainFromEmailAddress(mAccount.getEmail());
String host = serverNameSuggester.suggestServerName(Protocols.SMTP, domain);
ServerSettings transportServer = new ServerSettings(Protocols.SMTP, host,
-1, ConnectionSecurity.SSL_TLS_REQUIRED, authType, username, password,
clientCertificateAlias);
mAccount.setOutgoingServerSettings(transportServer);
AccountSetupOutgoing.actionOutgoingSettings(this, mAccount);
}
}
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
protected void onNext() {
try {
ConnectionSecurity connectionSecurity = getSelectedSecurity();
String username = mUsernameView.getText().toString().trim();
String password = null;
String clientCertificateAlias = null;
AuthType authType = getSelectedAuthType();
if ((ConnectionSecurity.SSL_TLS_REQUIRED == connectionSecurity) ||
(ConnectionSecurity.STARTTLS_REQUIRED == connectionSecurity) ) {
clientCertificateAlias = mClientCertificateSpinner.getAlias();
}
if (authType != AuthType.EXTERNAL) {
password = mPasswordView.getText().toString();
}
String host = mServerView.getText().toString();
int port = Integer.parseInt(mPortView.getText().toString());
Map<String, String> extra = emptyMap();
if (mStoreType.equals(Protocols.IMAP)) {
boolean autoDetectNamespace = mImapAutoDetectNamespaceView.isChecked();
String pathPrefix = mImapPathPrefixView.getText().toString();
extra = ImapStoreSettings.createExtra(autoDetectNamespace, pathPrefix);
} else if (mStoreType.equals(Protocols.WEBDAV)) {
String path = mWebdavPathPrefixView.getText().toString();
String authPath = mWebdavAuthPathView.getText().toString();
String mailboxPath = mWebdavMailboxPathView.getText().toString();
extra = WebDavStoreSettings.createExtra(null, path, authPath, mailboxPath);
}
DI.get(LocalKeyStoreManager.class).deleteCertificate(mAccount, host, port, MailServerDirection.INCOMING);
ServerSettings settings = new ServerSettings(mStoreType, host, port,
connectionSecurity, authType, username, password, clientCertificateAlias, extra);
mAccount.setIncomingServerSettings(settings);
mAccount.setUseCompression(useCompressionCheckBox.isChecked());
mAccount.setSubscribedFoldersOnly(mSubscribedFoldersOnly.isChecked());
AccountSetupCheckSettings.actionCheckSettings(this, mAccount, CheckDirection.INCOMING);
} catch (Exception e) {
failure(e);
}
}
public void onClick(View v) {
try {
if (v.getId() == R.id.next) {
onNext();
}
} catch (Exception e) {
failure(e);
}
}
private void failure(Exception use) {
Timber.e(use, "Failure");
String toastText = getString(R.string.account_setup_bad_uri, use.getMessage());
Toast toast = Toast.makeText(getApplication(), toastText, Toast.LENGTH_LONG);
toast.show();
}
/*
* Calls validateFields() which enables or disables the Next button
* based on the fields' validity.
*/
TextWatcher validationTextWatcher = new TextWatcher() {
public void afterTextChanged(Editable s) {
validateFields();
}
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
/* unused */
}
public void onTextChanged(CharSequence s, int start, int before, int count) {
/* unused */
}
};
OnClientCertificateChangedListener clientCertificateChangedListener = alias -> validateFields();
private AuthType getSelectedAuthType() {
AuthTypeHolder holder = (AuthTypeHolder) mAuthTypeView.getSelectedItem();
return holder.authType;
}
private ConnectionSecurity getSelectedSecurity() {
ConnectionSecurityHolder holder = (ConnectionSecurityHolder) mSecurityTypeView.getSelectedItem();
return holder.connectionSecurity;
}
}

View file

@ -0,0 +1,101 @@
package com.fsck.k9.activity.setup;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.text.method.TextKeyListener;
import android.text.method.TextKeyListener.Capitalize;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.EditText;
import com.fsck.k9.Account;
import com.fsck.k9.Preferences;
import com.fsck.k9.ui.base.K9Activity;
import com.fsck.k9.activity.MessageList;
import com.fsck.k9.ui.R;
import com.fsck.k9.helper.Utility;
public class AccountSetupNames extends K9Activity implements OnClickListener {
private static final String EXTRA_ACCOUNT = "account";
private EditText mDescription;
private EditText mName;
private Account mAccount;
private Button mDoneButton;
public static void actionSetNames(Context context, Account account) {
Intent i = new Intent(context, AccountSetupNames.class);
i.putExtra(EXTRA_ACCOUNT, account.getUuid());
context.startActivity(i);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setLayout(R.layout.account_setup_names);
setTitle(R.string.account_setup_names_title);
mDescription = findViewById(R.id.account_description);
mName = findViewById(R.id.account_name);
mDoneButton = findViewById(R.id.done);
mDoneButton.setOnClickListener(this);
TextWatcher validationTextWatcher = new TextWatcher() {
public void afterTextChanged(Editable s) {
validateFields();
}
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
};
mName.addTextChangedListener(validationTextWatcher);
mName.setKeyListener(TextKeyListener.getInstance(false, Capitalize.WORDS));
String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
String senderName = mAccount.getSenderName();
if (senderName != null) {
mName.setText(senderName);
}
if (!Utility.requiredFieldValid(mName)) {
mDoneButton.setEnabled(false);
}
}
private void validateFields() {
mDoneButton.setEnabled(Utility.requiredFieldValid(mName));
Utility.setCompoundDrawablesAlpha(mDoneButton, mDoneButton.isEnabled() ? 255 : 128);
}
protected void onNext() {
if (Utility.requiredFieldValid(mDescription)) {
mAccount.setName(mDescription.getText().toString());
}
mAccount.setSenderName(mName.getText().toString());
mAccount.markSetupFinished();
Preferences.getPreferences().saveAccount(mAccount);
finishAffinity();
MessageList.launch(this, mAccount);
}
public void onClick(View v) {
if (v.getId() == R.id.done) {
onNext();
}
}
}

View file

@ -0,0 +1,124 @@
package com.fsck.k9.activity.setup;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.ArrayAdapter;
import android.widget.CheckBox;
import android.widget.Spinner;
import com.fsck.k9.Account;
import com.fsck.k9.Core;
import com.fsck.k9.Preferences;
import com.fsck.k9.ui.R;
import com.fsck.k9.ui.base.K9Activity;
public class AccountSetupOptions extends K9Activity implements OnClickListener {
private static final String EXTRA_ACCOUNT = "account";
private Spinner mCheckFrequencyView;
private Spinner mDisplayCountView;
private CheckBox mNotifyView;
private Account mAccount;
public static void actionOptions(Context context, Account account) {
Intent i = new Intent(context, AccountSetupOptions.class);
i.putExtra(EXTRA_ACCOUNT, account.getUuid());
context.startActivity(i);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setLayout(R.layout.account_setup_options);
setTitle(R.string.account_setup_options_title);
mCheckFrequencyView = findViewById(R.id.account_check_frequency);
mDisplayCountView = findViewById(R.id.account_display_count);
mNotifyView = findViewById(R.id.account_notify);
findViewById(R.id.next).setOnClickListener(this);
SpinnerOption checkFrequencies[] = {
new SpinnerOption(-1,
getString(R.string.account_setup_options_mail_check_frequency_never)),
new SpinnerOption(15,
getString(R.string.account_setup_options_mail_check_frequency_15min)),
new SpinnerOption(30,
getString(R.string.account_setup_options_mail_check_frequency_30min)),
new SpinnerOption(60,
getString(R.string.account_setup_options_mail_check_frequency_1hour)),
new SpinnerOption(120,
getString(R.string.account_setup_options_mail_check_frequency_2hour)),
new SpinnerOption(180,
getString(R.string.account_setup_options_mail_check_frequency_3hour)),
new SpinnerOption(360,
getString(R.string.account_setup_options_mail_check_frequency_6hour)),
new SpinnerOption(720,
getString(R.string.account_setup_options_mail_check_frequency_12hour)),
new SpinnerOption(1440,
getString(R.string.account_setup_options_mail_check_frequency_24hour)),
};
ArrayAdapter<SpinnerOption> checkFrequenciesAdapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, checkFrequencies);
checkFrequenciesAdapter
.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
mCheckFrequencyView.setAdapter(checkFrequenciesAdapter);
SpinnerOption displayCounts[] = {
new SpinnerOption(10, getString(R.string.account_setup_options_mail_display_count_10)),
new SpinnerOption(25, getString(R.string.account_setup_options_mail_display_count_25)),
new SpinnerOption(50, getString(R.string.account_setup_options_mail_display_count_50)),
new SpinnerOption(100, getString(R.string.account_setup_options_mail_display_count_100)),
new SpinnerOption(250, getString(R.string.account_setup_options_mail_display_count_250)),
new SpinnerOption(500, getString(R.string.account_setup_options_mail_display_count_500)),
new SpinnerOption(1000, getString(R.string.account_setup_options_mail_display_count_1000)),
};
ArrayAdapter<SpinnerOption> displayCountsAdapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, displayCounts);
displayCountsAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
mDisplayCountView.setAdapter(displayCountsAdapter);
String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
mNotifyView.setChecked(mAccount.isNotifyNewMail());
SpinnerOption.setSpinnerOptionValue(mCheckFrequencyView, mAccount
.getAutomaticCheckIntervalMinutes());
SpinnerOption.setSpinnerOptionValue(mDisplayCountView, mAccount
.getDisplayCount());
}
private void onDone() {
mAccount.setName(mAccount.getEmail());
mAccount.setNotifyNewMail(mNotifyView.isChecked());
mAccount.setAutomaticCheckIntervalMinutes((Integer)((SpinnerOption)mCheckFrequencyView
.getSelectedItem()).value);
mAccount.setDisplayCount((Integer)((SpinnerOption)mDisplayCountView
.getSelectedItem()).value);
mAccount.setFolderPushMode(Account.FolderMode.NONE);
Preferences.getPreferences().saveAccount(mAccount);
Core.setServicesEnabled(this);
AccountSetupNames.actionSetNames(this, mAccount);
}
public void onClick(View v) {
if (v.getId() == R.id.next) {
onDone();
}
}
}

View file

@ -0,0 +1,583 @@
package com.fsck.k9.activity.setup;
import java.util.Locale;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.text.method.DigitsKeyListener;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemSelectedListener;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.CompoundButton.OnCheckedChangeListener;
import android.widget.Spinner;
import android.widget.Toast;
import androidx.annotation.NonNull;
import com.fsck.k9.Account;
import com.fsck.k9.DI;
import com.fsck.k9.LocalKeyStoreManager;
import com.fsck.k9.Preferences;
import com.fsck.k9.preferences.Protocols;
import com.fsck.k9.ui.R;
import com.fsck.k9.account.AccountCreator;
import com.fsck.k9.ui.base.K9Activity;
import com.fsck.k9.activity.setup.AccountSetupCheckSettings.CheckDirection;
import com.fsck.k9.helper.Utility;
import com.fsck.k9.mail.AuthType;
import com.fsck.k9.mail.ConnectionSecurity;
import com.fsck.k9.mail.MailServerDirection;
import com.fsck.k9.mail.ServerSettings;
import com.fsck.k9.ui.base.extensions.TextInputLayoutHelper;
import com.fsck.k9.view.ClientCertificateSpinner;
import com.fsck.k9.view.ClientCertificateSpinner.OnClientCertificateChangedListener;
import com.google.android.material.textfield.TextInputEditText;
import com.google.android.material.textfield.TextInputLayout;
import timber.log.Timber;
public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
OnCheckedChangeListener {
private static final String EXTRA_ACCOUNT = "account";
private static final String STATE_SECURITY_TYPE_POSITION = "stateSecurityTypePosition";
private static final String STATE_AUTH_TYPE_POSITION = "authTypePosition";
private final AccountCreator accountCreator = DI.get(AccountCreator.class);
private TextInputEditText mUsernameView;
private TextInputEditText mPasswordView;
private TextInputLayout mPasswordLayoutView;
private ClientCertificateSpinner mClientCertificateSpinner;
private TextInputEditText mServerView;
private TextInputEditText mPortView;
private String mCurrentPortViewSetting;
private CheckBox mRequireLoginView;
private ViewGroup mRequireLoginSettingsView;
private ViewGroup mAllowClientCertificateView;
private Spinner mSecurityTypeView;
private int mCurrentSecurityTypeViewPosition;
private Spinner mAuthTypeView;
private int mCurrentAuthTypeViewPosition;
private AuthTypeAdapter mAuthTypeAdapter;
private Button mNextButton;
private Account mAccount;
private boolean editSettings;
public static void actionOutgoingSettings(Context context, Account account) {
Intent i = new Intent(context, AccountSetupOutgoing.class);
i.putExtra(EXTRA_ACCOUNT, account.getUuid());
context.startActivity(i);
}
public static Intent intentActionEditOutgoingSettings(Context context, Account account) {
Intent i = new Intent(context, AccountSetupOutgoing.class);
i.setAction(Intent.ACTION_EDIT);
i.putExtra(EXTRA_ACCOUNT, account.getUuid());
return i;
}
public static void actionEditOutgoingSettings(Context context, String accountUuid) {
Intent intent = new Intent(context, AccountSetupOutgoing.class);
intent.setAction(Intent.ACTION_EDIT);
intent.putExtra(EXTRA_ACCOUNT, accountUuid);
context.startActivity(intent);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setLayout(R.layout.account_setup_outgoing);
setTitle(R.string.account_setup_outgoing_title);
String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
ServerSettings incomingServerSettings = mAccount.getIncomingServerSettings();
if (incomingServerSettings.type.equals(Protocols.WEBDAV)) {
mAccount.setOutgoingServerSettings(incomingServerSettings);
AccountSetupCheckSettings.actionCheckSettings(this, mAccount, CheckDirection.OUTGOING);
}
mUsernameView = findViewById(R.id.account_username);
mPasswordView = findViewById(R.id.account_password);
mClientCertificateSpinner = findViewById(R.id.account_client_certificate_spinner);
mPasswordLayoutView = findViewById(R.id.account_password_layout);
mServerView = findViewById(R.id.account_server);
mPortView = findViewById(R.id.account_port);
mRequireLoginView = findViewById(R.id.account_require_login);
mRequireLoginSettingsView = findViewById(R.id.account_require_login_settings);
mAllowClientCertificateView = findViewById(R.id.account_allow_client_certificate);
mSecurityTypeView = findViewById(R.id.account_security_type);
mAuthTypeView = findViewById(R.id.account_auth_type);
mNextButton = findViewById(R.id.next);
mNextButton.setOnClickListener(this);
mSecurityTypeView.setAdapter(ConnectionSecurityAdapter.get(this));
mAuthTypeAdapter = AuthTypeAdapter.get(this, true);
mAuthTypeView.setAdapter(mAuthTypeAdapter);
/*
* Only allow digits in the port field.
*/
mPortView.setKeyListener(DigitsKeyListener.getInstance("0123456789"));
//FIXME: get Account object again?
accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
/*
* If we're being reloaded we override the original account with the one
* we saved
*/
if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) {
accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT);
mAccount = Preferences.getPreferences().getAccount(accountUuid);
}
editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction());
if (editSettings) {
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
}
try {
ServerSettings settings = mAccount.getOutgoingServerSettings();
if (savedInstanceState == null) {
// The first item is selected if settings.authenticationType is null or is not in mAuthTypeAdapter
mCurrentAuthTypeViewPosition = mAuthTypeAdapter.getAuthPosition(settings.authenticationType);
} else {
mCurrentAuthTypeViewPosition = savedInstanceState.getInt(STATE_AUTH_TYPE_POSITION);
}
mAuthTypeView.setSelection(mCurrentAuthTypeViewPosition, false);
updateViewFromAuthType();
// Select currently configured security type
if (savedInstanceState == null) {
mCurrentSecurityTypeViewPosition = settings.connectionSecurity.ordinal();
} else {
/*
* Restore the spinner state now, before calling
* setOnItemSelectedListener(), thus avoiding a call to
* onItemSelected(). Then, when the system restores the state
* (again) in onRestoreInstanceState(), The system will see that
* the new state is the same as the current state (set here), so
* once again onItemSelected() will not be called.
*/
mCurrentSecurityTypeViewPosition = savedInstanceState.getInt(STATE_SECURITY_TYPE_POSITION);
}
mSecurityTypeView.setSelection(mCurrentSecurityTypeViewPosition, false);
updateAuthPlainTextFromSecurityType(getSelectedSecurity());
updateViewFromSecurity();
if (!settings.username.isEmpty()) {
mUsernameView.setText(settings.username);
mRequireLoginView.setChecked(true);
mRequireLoginSettingsView.setVisibility(View.VISIBLE);
}
if (settings.password != null) {
mPasswordView.setText(settings.password);
}
if (settings.clientCertificateAlias != null) {
mClientCertificateSpinner.setAlias(settings.clientCertificateAlias);
}
if (settings.host != null) {
mServerView.setText(settings.host);
}
if (settings.port != -1) {
mPortView.setText(String.format(Locale.ROOT, "%d", settings.port));
} else {
updatePortFromSecurityType();
}
mCurrentPortViewSetting = mPortView.getText().toString();
} catch (Exception e) {
/*
* We should always be able to parse our own settings.
*/
failure(e);
}
}
/**
* Called at the end of either {@code onCreate()} or
* {@code onRestoreInstanceState()}, after the views have been initialized,
* so that the listeners are not triggered during the view initialization.
* This avoids needless calls to {@code validateFields()} which is called
* immediately after this is called.
*/
private void initializeViewListeners() {
/*
* Updates the port when the user changes the security type. This allows
* us to show a reasonable default which the user can change.
*/
mSecurityTypeView.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position,
long id) {
/*
* We keep our own record of the spinner state so we
* know for sure that onItemSelected() was called
* because of user input, not because of spinner
* state initialization. This assures that the port
* will not be replaced with a default value except
* on user input.
*/
if (mCurrentSecurityTypeViewPosition != position) {
updatePortFromSecurityType();
updateViewFromSecurity();
boolean isInsecure = (ConnectionSecurity.NONE == getSelectedSecurity());
boolean isAuthExternal = (AuthType.EXTERNAL == getSelectedAuthType());
boolean loginNotRequired = !mRequireLoginView.isChecked();
/*
* If the user selects ConnectionSecurity.NONE, a
* warning would normally pop up if the authentication
* is AuthType.EXTERNAL (i.e., using client
* certificates). But such a warning is irrelevant if
* login is not required. So to avoid such a warning
* (generated in validateFields()) under those
* conditions, we change the (irrelevant) authentication
* method to PLAIN.
*/
if (isInsecure && isAuthExternal && loginNotRequired) {
OnItemSelectedListener onItemSelectedListener = mAuthTypeView.getOnItemSelectedListener();
mAuthTypeView.setOnItemSelectedListener(null);
mCurrentAuthTypeViewPosition = mAuthTypeAdapter.getAuthPosition(AuthType.PLAIN);
mAuthTypeView.setSelection(mCurrentAuthTypeViewPosition, false);
mAuthTypeView.setOnItemSelectedListener(onItemSelectedListener);
updateViewFromAuthType();
}
validateFields();
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) { /* unused */ }
});
mAuthTypeView.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position,
long id) {
if (mCurrentAuthTypeViewPosition == position) {
return;
}
updateViewFromAuthType();
updateViewFromSecurity();
validateFields();
AuthType selection = getSelectedAuthType();
// Have the user select the client certificate if not already selected
if ((AuthType.EXTERNAL == selection) && (mClientCertificateSpinner.getAlias() == null)) {
// This may again invoke validateFields()
mClientCertificateSpinner.chooseCertificate();
} else {
mPasswordView.requestFocus();
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) { /* unused */ }
});
mRequireLoginView.setOnCheckedChangeListener(this);
mClientCertificateSpinner.setOnClientCertificateChangedListener(clientCertificateChangedListener);
mUsernameView.addTextChangedListener(validationTextWatcher);
mPasswordView.addTextChangedListener(validationTextWatcher);
mServerView.addTextChangedListener(validationTextWatcher);
mPortView.addTextChangedListener(validationTextWatcher);
if (editSettings) {
TextInputLayoutHelper.configureAuthenticatedPasswordToggle(
mPasswordLayoutView,
this,
getString(R.string.account_setup_basics_show_password_biometrics_title),
getString(R.string.account_setup_basics_show_password_biometrics_subtitle),
getString(R.string.account_setup_basics_show_password_need_lock)
);
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString(EXTRA_ACCOUNT, mAccount.getUuid());
outState.putInt(STATE_SECURITY_TYPE_POSITION, mCurrentSecurityTypeViewPosition);
outState.putInt(STATE_AUTH_TYPE_POSITION, mCurrentAuthTypeViewPosition);
}
@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
if (mRequireLoginView.isChecked()) {
mRequireLoginSettingsView.setVisibility(View.VISIBLE);
} else {
mRequireLoginSettingsView.setVisibility(View.GONE);
}
}
@Override
protected void onPostCreate(Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
/*
* We didn't want the listeners active while the state was being restored
* because they could overwrite the restored port with a default port when
* the security type was restored.
*/
initializeViewListeners();
validateFields();
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
/**
* Shows/hides password field
*/
private void updateViewFromAuthType() {
switch (getSelectedAuthType()) {
case EXTERNAL:
case XOAUTH2:
mPasswordLayoutView.setVisibility(View.GONE);
break;
default:
mPasswordLayoutView.setVisibility(View.VISIBLE);
break;
}
}
/**
* Shows/hides client certificate spinner
*/
private void updateViewFromSecurity() {
ConnectionSecurity security = getSelectedSecurity();
boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || (ConnectionSecurity.STARTTLS_REQUIRED == security));
boolean isUsingOAuth = getSelectedAuthType() == AuthType.XOAUTH2;
if (isUsingTLS && !isUsingOAuth) {
mAllowClientCertificateView.setVisibility(View.VISIBLE);
} else {
mAllowClientCertificateView.setVisibility(View.GONE);
}
}
/**
* This is invoked only when the user makes changes to a widget, not when
* widgets are changed programmatically. (The logic is simpler when you know
* that this is the last thing called after an input change.)
*/
private void validateFields() {
AuthType authType = getSelectedAuthType();
boolean isAuthTypeExternal = (AuthType.EXTERNAL == authType);
ConnectionSecurity connectionSecurity = getSelectedSecurity();
boolean hasConnectionSecurity = (connectionSecurity != ConnectionSecurity.NONE);
if (isAuthTypeExternal && !hasConnectionSecurity) {
// Notify user of an invalid combination of AuthType.EXTERNAL & ConnectionSecurity.NONE
String toastText = getString(R.string.account_setup_outgoing_invalid_setting_combo_notice,
getString(R.string.account_setup_incoming_auth_type_label),
AuthType.EXTERNAL.toString(),
getString(R.string.account_setup_incoming_security_label),
ConnectionSecurity.NONE.toString());
Toast.makeText(this, toastText, Toast.LENGTH_LONG).show();
// Reset the views back to their previous settings without recursing through here again
OnItemSelectedListener onItemSelectedListener = mAuthTypeView.getOnItemSelectedListener();
mAuthTypeView.setOnItemSelectedListener(null);
mAuthTypeView.setSelection(mCurrentAuthTypeViewPosition, false);
mAuthTypeView.setOnItemSelectedListener(onItemSelectedListener);
updateViewFromAuthType();
updateViewFromSecurity();
onItemSelectedListener = mSecurityTypeView.getOnItemSelectedListener();
mSecurityTypeView.setOnItemSelectedListener(null);
mSecurityTypeView.setSelection(mCurrentSecurityTypeViewPosition, false);
mSecurityTypeView.setOnItemSelectedListener(onItemSelectedListener);
updateAuthPlainTextFromSecurityType(getSelectedSecurity());
mPortView.removeTextChangedListener(validationTextWatcher);
mPortView.setText(mCurrentPortViewSetting);
mPortView.addTextChangedListener(validationTextWatcher);
authType = getSelectedAuthType();
isAuthTypeExternal = (AuthType.EXTERNAL == authType);
connectionSecurity = getSelectedSecurity();
hasConnectionSecurity = (connectionSecurity != ConnectionSecurity.NONE);
} else {
mCurrentAuthTypeViewPosition = mAuthTypeView.getSelectedItemPosition();
mCurrentSecurityTypeViewPosition = mSecurityTypeView.getSelectedItemPosition();
mCurrentPortViewSetting = mPortView.getText().toString();
}
boolean hasValidCertificateAlias = mClientCertificateSpinner.getAlias() != null;
boolean hasValidUserName = Utility.requiredFieldValid(mUsernameView);
boolean hasValidPasswordSettings = hasValidUserName
&& !isAuthTypeExternal
&& Utility.requiredFieldValid(mPasswordView);
boolean hasValidExternalAuthSettings = hasValidUserName
&& isAuthTypeExternal
&& hasConnectionSecurity
&& hasValidCertificateAlias;
boolean hasValidOAuthSettings = hasValidUserName
&& hasConnectionSecurity
&& authType == AuthType.XOAUTH2;
mNextButton
.setEnabled(Utility.domainFieldValid(mServerView)
&& Utility.requiredFieldValid(mPortView)
&& (!mRequireLoginView.isChecked()
|| hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidOAuthSettings));
Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128);
}
private void updatePortFromSecurityType() {
ConnectionSecurity securityType = getSelectedSecurity();
updateAuthPlainTextFromSecurityType(securityType);
// Remove listener so as not to trigger validateFields() which is called
// elsewhere as a result of user interaction.
mPortView.removeTextChangedListener(validationTextWatcher);
mPortView.setText(String.valueOf(accountCreator.getDefaultPort(securityType, Protocols.SMTP)));
mPortView.addTextChangedListener(validationTextWatcher);
}
private void updateAuthPlainTextFromSecurityType(ConnectionSecurity securityType) {
mAuthTypeAdapter.useInsecureText(securityType == ConnectionSecurity.NONE);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode != AccountSetupCheckSettings.ACTIVITY_REQUEST_CODE) {
super.onActivityResult(requestCode, resultCode, data);
return;
}
if (resultCode == RESULT_OK) {
if (editSettings) {
Preferences.getPreferences().saveAccount(mAccount);
finish();
} else {
AccountSetupOptions.actionOptions(this, mAccount);
}
}
}
protected void onNext() {
ConnectionSecurity securityType = getSelectedSecurity();
String username = "";
String password = null;
String clientCertificateAlias = null;
AuthType authType = AuthType.AUTOMATIC;
if ((ConnectionSecurity.STARTTLS_REQUIRED == securityType) ||
(ConnectionSecurity.SSL_TLS_REQUIRED == securityType)) {
clientCertificateAlias = mClientCertificateSpinner.getAlias();
}
if (mRequireLoginView.isChecked()) {
username = mUsernameView.getText().toString().trim();
authType = getSelectedAuthType();
if (AuthType.EXTERNAL != authType) {
password = mPasswordView.getText().toString();
}
}
String newHost = mServerView.getText().toString();
int newPort = Integer.parseInt(mPortView.getText().toString());
ServerSettings server = new ServerSettings(Protocols.SMTP, newHost, newPort, securityType, authType, username,
password, clientCertificateAlias);
DI.get(LocalKeyStoreManager.class).deleteCertificate(mAccount, newHost, newPort, MailServerDirection.OUTGOING);
mAccount.setOutgoingServerSettings(server);
AccountSetupCheckSettings.actionCheckSettings(this, mAccount, CheckDirection.OUTGOING);
}
public void onClick(View v) {
if (v.getId() == R.id.next) {
onNext();
}
}
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
mRequireLoginSettingsView.setVisibility(isChecked ? View.VISIBLE : View.GONE);
validateFields();
}
private void failure(Exception use) {
Timber.e(use, "Failure");
String toastText = getString(R.string.account_setup_bad_uri, use.getMessage());
Toast toast = Toast.makeText(getApplication(), toastText, Toast.LENGTH_LONG);
toast.show();
}
/*
* Calls validateFields() which enables or disables the Next button
* based on the fields' validity.
*/
TextWatcher validationTextWatcher = new TextWatcher() {
public void afterTextChanged(Editable s) {
validateFields();
}
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
};
OnClientCertificateChangedListener clientCertificateChangedListener = alias -> validateFields();
private AuthType getSelectedAuthType() {
AuthTypeHolder holder = (AuthTypeHolder) mAuthTypeView.getSelectedItem();
return holder.authType;
}
private ConnectionSecurity getSelectedSecurity() {
ConnectionSecurityHolder holder = (ConnectionSecurityHolder) mSecurityTypeView.getSelectedItem();
return holder.connectionSecurity;
}
}

View file

@ -0,0 +1,58 @@
package com.fsck.k9.activity.setup;
import android.content.Context;
import android.widget.ArrayAdapter;
import com.fsck.k9.mail.AuthType;
class AuthTypeAdapter extends ArrayAdapter<AuthTypeHolder> {
public AuthTypeAdapter(Context context, int resource, AuthTypeHolder[] holders) {
super(context, resource, holders);
}
public static AuthTypeAdapter get(Context context, boolean oAuthSupported) {
AuthType[] authTypes;
if (oAuthSupported) {
authTypes = new AuthType[] { AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL, AuthType.XOAUTH2 };
} else {
authTypes = new AuthType[] { AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL };
}
AuthTypeHolder[] holders = new AuthTypeHolder[authTypes.length];
for (int i = 0; i < authTypes.length; i++) {
holders[i] = new AuthTypeHolder(authTypes[i], context.getResources());
}
AuthTypeAdapter authTypesAdapter = new AuthTypeAdapter(context,
android.R.layout.simple_spinner_item, holders);
authTypesAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
return authTypesAdapter;
}
/**
* Used to select an appropriate localized text label for the
* {@code AuthType.PLAIN} option presented to users.
*
* @param insecure
* <p>
* A value of {@code true} will use "Normal password".
* <p>
* A value of {@code false} will use
* "Password, transmitted insecurely"
*/
public void useInsecureText(boolean insecure) {
for (int i=0; i<getCount(); i++) {
getItem(i).setInsecure(insecure);
}
notifyDataSetChanged();
}
public int getAuthPosition(AuthType authenticationType) {
for (int i=0; i<getCount(); i++) {
if (getItem(i).authType == authenticationType) {
return i;
}
}
return -1;
}
}

View file

@ -0,0 +1,52 @@
package com.fsck.k9.activity.setup;
import android.content.res.Resources;
import com.fsck.k9.ui.R;
import com.fsck.k9.mail.AuthType;
class AuthTypeHolder {
final AuthType authType;
private final Resources resources;
private boolean insecure;
public AuthTypeHolder(AuthType authType, Resources resources) {
this.authType = authType;
this.resources = resources;
}
public void setInsecure(boolean insecure) {
this.insecure = insecure;
}
@Override
public String toString() {
final int resourceId = resourceId();
if (resourceId == 0) {
return authType.name();
} else {
return resources.getString(resourceId);
}
}
private int resourceId() {
switch (authType) {
case PLAIN:
if (insecure) {
return R.string.account_setup_auth_type_insecure_password;
} else {
return R.string.account_setup_auth_type_normal_password;
}
case CRAM_MD5:
return R.string.account_setup_auth_type_encrypted_password;
case EXTERNAL:
return R.string.account_setup_auth_type_tls_client_certificate;
case XOAUTH2:
return R.string.account_setup_auth_type_oauth2;
case AUTOMATIC:
case LOGIN:
default:
return 0;
}
}
}

View file

@ -0,0 +1,262 @@
package com.fsck.k9.activity.setup
import android.app.Activity
import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewModelScope
import com.fsck.k9.Account
import com.fsck.k9.oauth.OAuthConfiguration
import com.fsck.k9.oauth.OAuthConfigurationProvider
import com.fsck.k9.preferences.AccountManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import timber.log.Timber
private const val KEY_AUTHORIZATION = "app.k9mail_auth"
class AuthViewModel(
application: Application,
private val accountManager: AccountManager,
private val oAuthConfigurationProvider: OAuthConfigurationProvider
) : AndroidViewModel(application) {
private var authService: AuthorizationService? = null
private val authState = AuthState()
private var account: Account? = null
private lateinit var resultObserver: AppAuthResultObserver
private val _uiState = MutableStateFlow<AuthFlowState>(AuthFlowState.Idle)
val uiState: StateFlow<AuthFlowState> = _uiState.asStateFlow()
@Synchronized
private fun getAuthService(): AuthorizationService {
return authService ?: AuthorizationService(getApplication<Application>()).also { authService = it }
}
fun init(activityResultRegistry: ActivityResultRegistry, lifecycle: Lifecycle, account: Account) {
this.account = account
resultObserver = AppAuthResultObserver(activityResultRegistry)
lifecycle.addObserver(resultObserver)
}
fun authResultConsumed() {
_uiState.update { AuthFlowState.Idle }
}
fun isAuthorized(account: Account): Boolean {
val authState = getOrCreateAuthState(account)
return authState.isAuthorized
}
fun isUsingGoogle(account: Account): Boolean {
return oAuthConfigurationProvider.isGoogle(account.incomingServerSettings.host!!)
}
private fun getOrCreateAuthState(account: Account): AuthState {
return try {
account.oAuthState?.let { AuthState.jsonDeserialize(it) } ?: AuthState()
} catch (e: Exception) {
Timber.e(e, "Error deserializing AuthState")
AuthState()
}
}
fun login() {
val account = checkNotNull(account)
viewModelScope.launch {
val config = findOAuthConfiguration(account)
if (config == null) {
_uiState.update { AuthFlowState.NotSupported }
return@launch
}
try {
startLogin(account, config)
} catch (e: ActivityNotFoundException) {
_uiState.update { AuthFlowState.BrowserNotFound }
}
}
}
private suspend fun startLogin(account: Account, config: OAuthConfiguration) {
val authRequestIntent = withContext(Dispatchers.IO) {
createAuthorizationRequestIntent(account.email, config)
}
resultObserver.login(authRequestIntent)
}
private fun createAuthorizationRequestIntent(email: String, config: OAuthConfiguration): Intent {
val serviceConfig = AuthorizationServiceConfiguration(
config.authorizationEndpoint.toUri(),
config.tokenEndpoint.toUri()
)
val authRequestBuilder = AuthorizationRequest.Builder(
serviceConfig,
config.clientId,
ResponseTypeValues.CODE,
config.redirectUri.toUri()
)
val scopeString = config.scopes.joinToString(separator = " ")
val authRequest = authRequestBuilder
.setScope(scopeString)
.setLoginHint(email)
.build()
val authService = getAuthService()
return authService.getAuthorizationRequestIntent(authRequest)
}
private fun findOAuthConfiguration(account: Account): OAuthConfiguration? {
return oAuthConfigurationProvider.getConfiguration(account.incomingServerSettings.host!!)
}
private fun onLoginResult(authorizationResult: AuthorizationResult?) {
if (authorizationResult == null) {
_uiState.update { AuthFlowState.Canceled }
return
}
authorizationResult.response?.let { response ->
authState.update(authorizationResult.response, authorizationResult.exception)
exchangeToken(response)
}
authorizationResult.exception?.let { authorizationException ->
_uiState.update {
AuthFlowState.Failed(
errorCode = authorizationException.error,
errorMessage = authorizationException.errorDescription
)
}
}
}
private fun exchangeToken(response: AuthorizationResponse) {
viewModelScope.launch(Dispatchers.IO) {
val authService = getAuthService()
val tokenRequest = response.createTokenExchangeRequest()
authService.performTokenRequest(tokenRequest) { tokenResponse, authorizationException ->
authState.update(tokenResponse, authorizationException)
val account = account!!
account.oAuthState = authState.jsonSerializeString()
viewModelScope.launch(Dispatchers.IO) {
accountManager.saveAccount(account)
}
if (authorizationException != null) {
_uiState.update {
AuthFlowState.Failed(
errorCode = authorizationException.error,
errorMessage = authorizationException.errorDescription
)
}
} else {
_uiState.update { AuthFlowState.Success }
}
}
}
}
@Synchronized
override fun onCleared() {
authService?.dispose()
authService = null
}
inner class AppAuthResultObserver(private val registry: ActivityResultRegistry) : DefaultLifecycleObserver {
private var authorizationLauncher: ActivityResultLauncher<Intent>? = null
private var authRequestIntent: Intent? = null
override fun onCreate(owner: LifecycleOwner) {
authorizationLauncher = registry.register(KEY_AUTHORIZATION, AuthorizationContract(), ::onLoginResult)
authRequestIntent?.let { intent ->
authRequestIntent = null
login(intent)
}
}
override fun onDestroy(owner: LifecycleOwner) {
authorizationLauncher = null
}
fun login(authRequestIntent: Intent) {
val launcher = authorizationLauncher
if (launcher != null) {
launcher.launch(authRequestIntent)
} else {
this.authRequestIntent = authRequestIntent
}
}
}
}
private class AuthorizationContract : ActivityResultContract<Intent, AuthorizationResult?>() {
override fun createIntent(context: Context, input: Intent): Intent {
return input
}
override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResult? {
return if (resultCode == Activity.RESULT_OK && intent != null) {
AuthorizationResult(
response = AuthorizationResponse.fromIntent(intent),
exception = AuthorizationException.fromIntent(intent)
)
} else {
null
}
}
}
private data class AuthorizationResult(
val response: AuthorizationResponse?,
val exception: AuthorizationException?
)
sealed interface AuthFlowState {
object Idle : AuthFlowState
object Success : AuthFlowState
object NotSupported : AuthFlowState
object BrowserNotFound : AuthFlowState
object Canceled : AuthFlowState
data class Failed(val errorCode: String?, val errorMessage: String?) : AuthFlowState {
override fun toString(): String {
return listOfNotNull(errorCode, errorMessage).joinToString(separator = " - ")
}
}
}

View file

@ -0,0 +1,38 @@
package com.fsck.k9.activity.setup;
import android.content.Context;
import android.widget.ArrayAdapter;
import com.fsck.k9.mail.ConnectionSecurity;
class ConnectionSecurityAdapter extends ArrayAdapter<ConnectionSecurityHolder> {
public ConnectionSecurityAdapter(Context context, int resource, ConnectionSecurityHolder[] securityTypes) {
super(context, resource, securityTypes);
}
public static ConnectionSecurityAdapter get(Context context) {
return get(context, ConnectionSecurity.values());
}
public static ConnectionSecurityAdapter get(Context context,
ConnectionSecurity[] items) {
ConnectionSecurityHolder[] holders = new ConnectionSecurityHolder[items.length];
for (int i = 0; i < items.length; i++) {
holders[i] = new ConnectionSecurityHolder(items[i], context.getResources());
}
ConnectionSecurityAdapter securityTypesAdapter = new ConnectionSecurityAdapter(context,
android.R.layout.simple_spinner_item, holders);
securityTypesAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
return securityTypesAdapter;
}
public int getConnectionSecurityPosition(ConnectionSecurity connectionSecurity) {
for (int i=0; i<getCount(); i++) {
if (getItem(i).connectionSecurity == connectionSecurity) {
return i;
}
}
return -1;
}
}

View file

@ -0,0 +1,34 @@
package com.fsck.k9.activity.setup;
import android.content.res.Resources;
import com.fsck.k9.ui.R;
import com.fsck.k9.mail.ConnectionSecurity;
class ConnectionSecurityHolder {
final ConnectionSecurity connectionSecurity;
private final Resources resources;
public ConnectionSecurityHolder(ConnectionSecurity connectionSecurity, Resources resources) {
this.connectionSecurity = connectionSecurity;
this.resources = resources;
}
public String toString() {
final int resourceId = resourceId();
if (resourceId == 0) {
return connectionSecurity.name();
} else {
return resources.getString(resourceId);
}
}
private int resourceId() {
switch (connectionSecurity) {
case NONE: return R.string.account_setup_incoming_security_none_label;
case STARTTLS_REQUIRED: return R.string.account_setup_incoming_security_tls_label;
case SSL_TLS_REQUIRED: return R.string.account_setup_incoming_security_ssl_label;
default: return 0;
}
}
}

View file

@ -0,0 +1,13 @@
package com.fsck.k9.activity.setup
import android.os.Parcelable
import com.fsck.k9.mail.AuthType
import kotlinx.parcelize.Parcelize
@Parcelize
data class InitialAccountSettings(
val authenticationType: AuthType,
val email: String,
val password: String?,
val clientCertificateAlias: String?
) : Parcelable

View file

@ -0,0 +1,113 @@
package com.fsck.k9.activity.setup
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.view.isVisible
import com.fsck.k9.preferences.AccountManager
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.K9Activity
import com.fsck.k9.ui.observe
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
class OAuthFlowActivity : K9Activity() {
private val authViewModel: AuthViewModel by viewModel()
private val accountManager: AccountManager by inject()
private lateinit var errorText: TextView
private lateinit var signInButton: Button
private lateinit var signInProgress: ProgressBar
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setLayout(R.layout.account_setup_oauth)
setTitle(R.string.account_setup_basics_title)
val accountUUid = intent.getStringExtra(EXTRA_ACCOUNT_UUID) ?: error("Missing account UUID")
val account = accountManager.getAccount(accountUUid) ?: error("Account not found")
errorText = findViewById(R.id.error_text)
signInProgress = findViewById(R.id.sign_in_progress)
signInButton = if (authViewModel.isUsingGoogle(account)) {
findViewById(R.id.google_sign_in_button)
} else {
findViewById(R.id.oauth_sign_in_button)
}
signInButton.isVisible = true
signInButton.setOnClickListener { startOAuthFlow() }
savedInstanceState?.let {
val signInRunning = it.getBoolean(STATE_PROGRESS)
signInButton.isVisible = !signInRunning
signInProgress.isVisible = signInRunning
}
authViewModel.init(activityResultRegistry, lifecycle, account)
authViewModel.uiState.observe(this) { state ->
handleUiUpdates(state)
}
}
private fun handleUiUpdates(state: AuthFlowState) {
when (state) {
AuthFlowState.Idle -> {
return
}
AuthFlowState.Success -> {
setResult(RESULT_OK)
finish()
}
AuthFlowState.Canceled -> {
displayErrorText(R.string.account_setup_failed_dlg_oauth_flow_canceled)
}
is AuthFlowState.Failed -> {
displayErrorText(R.string.account_setup_failed_dlg_oauth_flow_failed, state)
}
AuthFlowState.NotSupported -> {
displayErrorText(R.string.account_setup_failed_dlg_oauth_not_supported)
}
AuthFlowState.BrowserNotFound -> {
displayErrorText(R.string.account_setup_failed_dlg_browser_not_found)
}
}
authViewModel.authResultConsumed()
}
private fun displayErrorText(errorTextResId: Int, vararg args: Any?) {
signInProgress.isVisible = false
signInButton.isVisible = true
errorText.text = getString(errorTextResId, *args)
}
private fun startOAuthFlow() {
signInButton.isVisible = false
signInProgress.isVisible = true
errorText.text = ""
authViewModel.login()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(STATE_PROGRESS, signInProgress.isVisible)
}
companion object {
private const val EXTRA_ACCOUNT_UUID = "accountUuid"
private const val STATE_PROGRESS = "signInProgress"
fun buildLaunchIntent(context: Context, accountUuid: String): Intent {
return Intent(context, OAuthFlowActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT_UUID, accountUuid)
}
}
}
}

View file

@ -0,0 +1,33 @@
/**
*
*/
package com.fsck.k9.activity.setup;
import android.widget.Spinner;
public class SpinnerOption {
public Object value;
public String label;
public static void setSpinnerOptionValue(Spinner spinner, Object value) {
for (int i = 0, count = spinner.getCount(); i < count; i++) {
SpinnerOption so = (SpinnerOption)spinner.getItemAtPosition(i);
if (so.value.equals(value)) {
spinner.setSelection(i, true);
return;
}
}
}
public SpinnerOption(Object value, String label) {
this.value = value;
this.label = label;
}
@Override
public String toString() {
return label;
}
}

View file

@ -0,0 +1,52 @@
package com.fsck.k9.contacts
import com.bumptech.glide.load.Key
import com.fsck.k9.mail.Address
import java.security.MessageDigest
/**
* Contains all information necessary for [ContactImageBitmapDecoder] to load the contact picture in the desired format.
*/
class ContactImage(
val contactLetterOnly: Boolean,
val backgroundCacheId: String,
val contactLetterBitmapCreator: ContactLetterBitmapCreator,
val address: Address
) : Key {
private val contactLetterSignature = contactLetterBitmapCreator.signatureOf(address)
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(toString().toByteArray(Key.CHARSET))
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ContactImage
if (contactLetterOnly != other.contactLetterOnly) return false
if (backgroundCacheId != other.backgroundCacheId) return false
if (address != other.address) return false
if (contactLetterSignature != other.contactLetterSignature) return false
return true
}
override fun hashCode(): Int {
var result = contactLetterOnly.hashCode()
result = 31 * result + backgroundCacheId.hashCode()
result = 31 * result + address.hashCode()
result = 31 * result + contactLetterSignature.hashCode()
return result
}
override fun toString(): String {
return "ContactImage(" +
"contactLetterOnly=$contactLetterOnly, " +
"backgroundCacheId='$backgroundCacheId', " +
"address=$address, " +
"contactLetterSignature='$contactLetterSignature'" +
")"
}
}

View file

@ -0,0 +1,46 @@
package com.fsck.k9.contacts
import android.graphics.Bitmap
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.ResourceDecoder
import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapResource
import kotlin.math.max
/**
* [ResourceDecoder] implementation that takes a [ContactImage] and fetches the corresponding contact photo using
* [ContactPhotoLoader] or generates a fallback image using [ContactLetterBitmapCreator].
*/
internal class ContactImageBitmapDecoder(
private val contactPhotoLoader: ContactPhotoLoader,
private val bitmapPool: BitmapPool
) : ResourceDecoder<ContactImage, Bitmap> {
override fun decode(contactImage: ContactImage, width: Int, height: Int, options: Options): Resource<Bitmap>? {
val size = max(width, height)
val bitmap = loadContactPhoto(contactImage) ?: createContactLetterBitmap(contactImage, size)
return BitmapResource.obtain(bitmap, bitmapPool)
}
private fun loadContactPhoto(contactImage: ContactImage): Bitmap? {
if (contactImage.contactLetterOnly) return null
return contactPhotoLoader.loadContactPhoto(contactImage.address.address)
}
private fun createContactLetterBitmap(contactImage: ContactImage, size: Int): Bitmap {
val bitmap = bitmapPool.getDirty(size, size, Bitmap.Config.ARGB_8888)
return contactImage.contactLetterBitmapCreator.drawBitmap(bitmap, size, contactImage.address)
}
override fun handles(source: ContactImage, options: Options) = true
}
internal class ContactImageBitmapDecoderFactory(private val contactPhotoLoader: ContactPhotoLoader) {
fun create(bitmapPool: BitmapPool): ContactImageBitmapDecoder {
return ContactImageBitmapDecoder(contactPhotoLoader, bitmapPool)
}
}

View file

@ -0,0 +1,49 @@
package com.fsck.k9.contacts
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.ResourceDecoder
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
/**
* [ModelLoader] implementation that does nothing put pass through [ContactImage] to be handled by our custom
* [ResourceDecoder] implementation, [ContactImageBitmapDecoder].
*/
class ContactImageModelLoader : ModelLoader<ContactImage, ContactImage> {
override fun buildLoadData(
contactImage: ContactImage,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<ContactImage> {
return ModelLoader.LoadData(contactImage, ContactImageDataFetcher(contactImage))
}
override fun handles(model: ContactImage) = true
}
class ContactImageDataFetcher(private val contactImage: ContactImage) : DataFetcher<ContactImage> {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in ContactImage>) {
callback.onDataReady(contactImage)
}
override fun getDataClass() = ContactImage::class.java
override fun getDataSource() = DataSource.LOCAL
override fun cleanup() = Unit
override fun cancel() = Unit
}
class ContactImageModelLoaderFactory : ModelLoaderFactory<ContactImage, ContactImage> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<ContactImage, ContactImage> {
return ContactImageModelLoader()
}
override fun teardown() = Unit
}

View file

@ -0,0 +1,28 @@
package com.fsck.k9.contacts
import android.content.Context
import android.view.ContextThemeWrapper
import com.fsck.k9.K9
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.ThemeManager
import com.fsck.k9.ui.getIntArray
import com.fsck.k9.ui.resolveColorAttribute
class ContactLetterBitmapConfig(context: Context, themeManager: ThemeManager) {
val hasDefaultBackgroundColor: Boolean = !K9.isColorizeMissingContactPictures
val defaultBackgroundColor: Int
val backgroundColors: IntArray
init {
val themedContext = ContextThemeWrapper(context, themeManager.appThemeResourceId)
val theme = themedContext.theme
if (hasDefaultBackgroundColor) {
defaultBackgroundColor = theme.resolveColorAttribute(R.attr.contactPictureFallbackDefaultBackgroundColor)
backgroundColors = intArrayOf()
} else {
defaultBackgroundColor = 0
backgroundColors = theme.getIntArray(R.attr.contactPictureFallbackBackgroundColors)
}
}
}

View file

@ -0,0 +1,59 @@
package com.fsck.k9.contacts
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import com.fsck.k9.mail.Address
/**
* Draw a `Bitmap` containing the "contact letter" obtained by [ContactLetterExtractor].
*/
class ContactLetterBitmapCreator(
private val letterExtractor: ContactLetterExtractor,
val config: ContactLetterBitmapConfig
) {
fun drawBitmap(bitmap: Bitmap, pictureSizeInPx: Int, address: Address): Bitmap {
val canvas = Canvas(bitmap)
val backgroundColor = calcUnknownContactColor(address)
bitmap.eraseColor(backgroundColor)
val letter = letterExtractor.extractContactLetter(address)
val paint = Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
setARGB(255, 255, 255, 255)
textSize = pictureSizeInPx.toFloat() * 0.65f
}
val rect = Rect()
paint.getTextBounds(letter, 0, 1, rect)
val width = paint.measureText(letter)
canvas.drawText(
letter,
pictureSizeInPx / 2f - width / 2f,
pictureSizeInPx / 2f + rect.height() / 2f,
paint
)
return bitmap
}
private fun calcUnknownContactColor(address: Address): Int {
if (config.hasDefaultBackgroundColor) {
return config.defaultBackgroundColor
}
val hash = address.hashCode()
val backgroundColors = config.backgroundColors
val colorIndex = (hash and Integer.MAX_VALUE) % backgroundColors.size
return backgroundColors[colorIndex]
}
fun signatureOf(address: Address): String {
return calcUnknownContactColor(address).toString()
}
}

View file

@ -0,0 +1,17 @@
package com.fsck.k9.contacts
import com.fsck.k9.mail.Address
class ContactLetterExtractor {
fun extractContactLetter(address: Address): String {
val displayName = address.personal ?: address.address
val matchResult = EXTRACT_LETTER_PATTERN.find(displayName)
return matchResult?.value?.uppercase() ?: FALLBACK_CONTACT_LETTER
}
companion object {
private val EXTRACT_LETTER_PATTERN = Regex("\\p{L}\\p{M}*")
private const val FALLBACK_CONTACT_LETTER = "?"
}
}

View file

@ -0,0 +1,25 @@
package com.fsck.k9.contacts
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.k9mail.core.android.common.contact.ContactRepository
import app.k9mail.core.common.mail.EmailAddress
import timber.log.Timber
internal class ContactPhotoLoader(
private val contentResolver: ContentResolver,
private val contactRepository: ContactRepository,
) {
fun loadContactPhoto(emailAddress: String): Bitmap? {
val photoUri = contactRepository.getContactFor(EmailAddress(emailAddress))?.photoUri ?: return null
return try {
contentResolver.openInputStream(photoUri).use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
} catch (e: Exception) {
Timber.e(e, "Couldn't load contact photo: $photoUri")
null
}
}
}

View file

@ -0,0 +1,35 @@
package com.fsck.k9.contacts;
import android.content.Context;
import android.graphics.Bitmap;
import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.LibraryGlideModule;
import com.fsck.k9.DI;
import com.fsck.k9.ui.account.AccountImage;
import com.fsck.k9.ui.account.AccountImageModelLoaderFactory;
import org.jetbrains.annotations.NotNull;
@GlideModule
public class ContactPictureGlideModule extends LibraryGlideModule {
@Override
public void registerComponents(@NotNull Context context, @NotNull Glide glide, @NotNull Registry registry) {
registerContactImage(glide, registry);
registerAccountImage(registry);
}
private void registerContactImage(@NotNull Glide glide, @NotNull Registry registry) {
registry.append(ContactImage.class, ContactImage.class, new ContactImageModelLoaderFactory());
ContactImageBitmapDecoderFactory factory = DI.get(ContactImageBitmapDecoderFactory.class);
ContactImageBitmapDecoder contactImageBitmapDecoder = factory.create(glide.getBitmapPool());
registry.append(ContactImage.class, Bitmap.class, contactImageBitmapDecoder);
}
private void registerAccountImage(@NotNull Registry registry) {
AccountImageModelLoaderFactory factory = DI.get(AccountImageModelLoaderFactory.class);
registry.append(AccountImage.class, Bitmap.class, factory);
}
}

View file

@ -0,0 +1,117 @@
package com.fsck.k9.contacts
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.widget.ImageView
import androidx.annotation.WorkerThread
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.FutureTarget
import com.fsck.k9.mail.Address
import com.fsck.k9.ui.R
import com.fsck.k9.view.RecipientSelectView.Recipient
class ContactPictureLoader(
private val context: Context,
private val contactLetterBitmapCreator: ContactLetterBitmapCreator
) {
private val pictureSizeInPx: Int = PICTURE_SIZE.toDip(context)
private val backgroundCacheId: String = with(contactLetterBitmapCreator.config) {
if (hasDefaultBackgroundColor) defaultBackgroundColor.toString() else "*"
}
fun setContactPicture(imageView: ImageView, address: Address) {
Glide.with(imageView.context)
.load(createContactImage(address, contactLetterOnly = false))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(imageView)
}
fun setContactPicture(imageView: ImageView, recipient: Recipient) {
val contactPictureUri = recipient.photoThumbnailUri
if (contactPictureUri != null) {
setContactPicture(imageView, contactPictureUri)
} else {
setFallbackPicture(imageView, recipient.address)
}
}
private fun setContactPicture(imageView: ImageView, contactPictureUri: Uri) {
Glide.with(imageView.context)
.load(contactPictureUri)
.placeholder(R.drawable.ic_contact_picture)
.error(R.drawable.ic_contact_picture)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(imageView)
}
private fun setFallbackPicture(imageView: ImageView, address: Address) {
Glide.with(imageView.context)
.load(createContactImage(address, contactLetterOnly = true))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(imageView)
}
@WorkerThread
fun getContactPicture(recipient: Recipient): Bitmap? {
val contactPictureUri = recipient.photoThumbnailUri
val address = recipient.address
return if (contactPictureUri != null) {
getContactPicture(contactPictureUri)
} else {
getFallbackPicture(address)
}
}
private fun getContactPicture(contactPictureUri: Uri): Bitmap? {
return Glide.with(context)
.asBitmap()
.load(contactPictureUri)
.error(R.drawable.ic_contact_picture)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.submit(pictureSizeInPx, pictureSizeInPx)
.getOrNull()
}
private fun getFallbackPicture(address: Address): Bitmap? {
return Glide.with(context)
.asBitmap()
.load(createContactImage(address, contactLetterOnly = true))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.submit(pictureSizeInPx, pictureSizeInPx)
.getOrNull()
}
private fun createContactImage(address: Address, contactLetterOnly: Boolean): ContactImage {
return ContactImage(
contactLetterOnly = contactLetterOnly,
backgroundCacheId = backgroundCacheId,
contactLetterBitmapCreator = contactLetterBitmapCreator,
address = address
)
}
private fun <T> FutureTarget<T>.getOrNull(): T? {
return try {
get()
} catch (e: Exception) {
null
}
}
private fun Int.toDip(context: Context): Int = (this * context.resources.displayMetrics.density).toInt()
companion object {
/**
* Resize the pictures to the following value (device-independent pixels).
*/
private const val PICTURE_SIZE = 40
}
}

View file

@ -0,0 +1,12 @@
package com.fsck.k9.contacts
import org.koin.dsl.module
val contactsModule = module {
single { ContactLetterExtractor() }
factory { ContactLetterBitmapConfig(context = get(), themeManager = get()) }
factory { ContactLetterBitmapCreator(letterExtractor = get(), config = get()) }
factory { ContactPhotoLoader(contentResolver = get(), contactRepository = get()) }
factory { ContactPictureLoader(context = get(), contactLetterBitmapCreator = get()) }
factory { ContactImageBitmapDecoderFactory(contactPhotoLoader = get()) }
}

View file

@ -0,0 +1,119 @@
package com.fsck.k9.fragment;
import android.app.Activity;
import android.app.Dialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import androidx.fragment.app.DialogFragment;
import com.fsck.k9.controller.MessagingController;
import com.fsck.k9.controller.MessagingListener;
import com.fsck.k9.controller.SimpleMessagingListener;
public class AttachmentDownloadDialogFragment extends DialogFragment {
private static final String ARG_SIZE = "size";
private static final String ARG_MESSAGE = "message";
private ProgressDialog dialog;
private MessagingListener messagingListener;
private MessagingController messagingController;
public static AttachmentDownloadDialogFragment newInstance(long size, String message) {
AttachmentDownloadDialogFragment fragment = new AttachmentDownloadDialogFragment();
Bundle args = new Bundle();
args.putLong(ARG_SIZE, size);
args.putString(ARG_MESSAGE, message);
fragment.setArguments(args);
return fragment;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle args = getArguments();
long size = args.getLong(ARG_SIZE);
String message = args.getString(ARG_MESSAGE);
final SizeUnit sizeUnit = SizeUnit.getAppropriateFor(size);
messagingListener = new SimpleMessagingListener() {
@Override
public void updateProgress(int progress) {
dialog.setProgress(sizeUnit.valueInSizeUnit(progress));
}
};
messagingController = MessagingController.getInstance(getActivity());
messagingController.addListener(messagingListener);
dialog = new ProgressDialog(getActivity());
dialog.setMessage(message);
dialog.setMax(sizeUnit.valueInSizeUnit(size));
dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
dialog.setProgress(0);
dialog.setProgressNumberFormat("%1d/%2d " + sizeUnit.shortName);
dialog.show();
return dialog;
}
@Override
public void onDestroyView() {
messagingController.removeListener(messagingListener);
super.onDestroyView();
}
@Override
public void onCancel(DialogInterface dialog) {
Activity activity = getActivity();
if (activity != null && activity instanceof AttachmentDownloadCancelListener) {
AttachmentDownloadCancelListener listener = (AttachmentDownloadCancelListener) activity;
listener.onProgressCancel(this);
}
super.onCancel(dialog);
}
private enum SizeUnit {
BYTE("B", 1L),
KIBIBYTE("KiB", 1024L),
MEBIBYTE("MiB", 1024L * 1024L),
GIBIBYTE("GiB", 1024L * 1024L * 1024L),
TEBIBYTE("TiB", 1024L * 1024L * 1024L * 1024L),
PEBIBYTE("PiB", 1024L * 1024L * 1024L * 1024L * 1024L);
public final String shortName;
public final long size;
static SizeUnit getAppropriateFor(long value) {
for (SizeUnit sizeUnit : values()) {
if (value < 1024L * 10L * sizeUnit.size) {
return sizeUnit;
}
}
return SizeUnit.BYTE;
}
SizeUnit(String shortName, long size) {
this.shortName = shortName;
this.size = size;
}
int valueInSizeUnit(long value) {
return (int) (value / size);
}
}
public interface AttachmentDownloadCancelListener {
void onProgressCancel(AttachmentDownloadDialogFragment fragment);
}
}

View file

@ -0,0 +1,127 @@
package com.fsck.k9.fragment;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnCancelListener;
import android.content.DialogInterface.OnClickListener;
import android.os.Bundle;
import androidx.fragment.app.DialogFragment;
import timber.log.Timber;
public class ConfirmationDialogFragment extends DialogFragment implements OnClickListener,
OnCancelListener {
private ConfirmationDialogFragmentListener mListener;
private static final String ARG_DIALOG_ID = "dialog_id";
private static final String ARG_TITLE = "title";
private static final String ARG_MESSAGE = "message";
private static final String ARG_CONFIRM_TEXT = "confirm";
private static final String ARG_CANCEL_TEXT = "cancel";
public static ConfirmationDialogFragment newInstance(int dialogId, String title, String message,
String confirmText, String cancelText) {
ConfirmationDialogFragment fragment = new ConfirmationDialogFragment();
Bundle args = new Bundle();
args.putInt(ARG_DIALOG_ID, dialogId);
args.putString(ARG_TITLE, title);
args.putString(ARG_MESSAGE, message);
args.putString(ARG_CONFIRM_TEXT, confirmText);
args.putString(ARG_CANCEL_TEXT, cancelText);
fragment.setArguments(args);
return fragment;
}
public static ConfirmationDialogFragment newInstance(int dialogId, String title, String message,
String cancelText) {
return newInstance(dialogId, title, message, null, cancelText);
}
public interface ConfirmationDialogFragmentListener {
void doPositiveClick(int dialogId);
void doNegativeClick(int dialogId);
void dialogCancelled(int dialogId);
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle args = getArguments();
String title = args.getString(ARG_TITLE);
String message = args.getString(ARG_MESSAGE);
String confirmText = args.getString(ARG_CONFIRM_TEXT);
String cancelText = args.getString(ARG_CANCEL_TEXT);
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(title);
builder.setMessage(message);
if (confirmText != null && cancelText != null) {
builder.setPositiveButton(confirmText, this);
builder.setNegativeButton(cancelText, this);
} else if (cancelText != null) {
builder.setNeutralButton(cancelText, this);
} else {
throw new RuntimeException("Set at least cancelText!");
}
return builder.create();
}
@Override
public void onClick(DialogInterface dialog, int which) {
switch (which) {
case DialogInterface.BUTTON_POSITIVE: {
getListener().doPositiveClick(getDialogId());
break;
}
case DialogInterface.BUTTON_NEGATIVE: {
getListener().doNegativeClick(getDialogId());
break;
}
case DialogInterface.BUTTON_NEUTRAL: {
getListener().doNegativeClick(getDialogId());
break;
}
}
}
@Override
public void onCancel(DialogInterface dialog) {
super.onCancel(dialog);
getListener().dialogCancelled(getDialogId());
}
private int getDialogId() {
return getArguments().getInt(ARG_DIALOG_ID);
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
try {
mListener = (ConfirmationDialogFragmentListener) activity;
} catch (ClassCastException e) {
Timber.d("%s did not implement ConfirmationDialogFragmentListener", activity);
}
}
private ConfirmationDialogFragmentListener getListener() {
if (mListener != null) {
return mListener;
}
// fallback to getTargetFragment...
try {
return (ConfirmationDialogFragmentListener) getTargetFragment();
} catch (ClassCastException e) {
throw new ClassCastException(getTargetFragment().getClass() +
" must implement ConfirmationDialogFragmentListener");
}
}
}

View file

@ -0,0 +1,55 @@
package com.fsck.k9.fragment;
import android.app.Activity;
import android.app.Dialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import androidx.fragment.app.DialogFragment;
public class ProgressDialogFragment extends DialogFragment {
protected static final String ARG_TITLE = "title";
protected static final String ARG_MESSAGE = "message";
public static ProgressDialogFragment newInstance(String title, String message) {
ProgressDialogFragment fragment = new ProgressDialogFragment();
Bundle args = new Bundle();
args.putString(ARG_TITLE, title);
args.putString(ARG_MESSAGE, message);
fragment.setArguments(args);
return fragment;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle args = getArguments();
String title = args.getString(ARG_TITLE);
String message = args.getString(ARG_MESSAGE);
ProgressDialog dialog = new ProgressDialog(getActivity());
dialog.setIndeterminate(true);
dialog.setTitle(title);
dialog.setMessage(message);
return dialog;
}
@Override
public void onCancel(DialogInterface dialog) {
Activity activity = getActivity();
if (activity != null && activity instanceof CancelListener) {
CancelListener listener = (CancelListener) activity;
listener.onProgressCancel(this);
}
super.onCancel(dialog);
}
public interface CancelListener {
void onProgressCancel(ProgressDialogFragment fragment);
}
}

View file

@ -0,0 +1,17 @@
package com.fsck.k9.ui
import android.os.Bundle
fun <T : Enum<T>> Bundle.putEnum(key: String, value: T) {
putString(key, value.name)
}
inline fun <reified T : Enum<T>> Bundle.getEnum(key: String, defaultValue: T): T {
val value = getString(key) ?: return defaultValue
return enumValueOf(value)
}
inline fun <reified T : Enum<T>> Bundle.getEnum(key: String): T {
val value = getString(key) ?: error("Missing enum value for key '$key'")
return enumValueOf(value)
}

View file

@ -0,0 +1,5 @@
package com.fsck.k9.ui
import com.fsck.k9.mail.ServerSettings
data class ConnectionSettings(val incoming: ServerSettings, val outgoing: ServerSettings)

View file

@ -0,0 +1,227 @@
package com.fsck.k9.ui;
import android.content.ActivityNotFoundException;
import android.content.AsyncQueryHandler;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Email;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.Intents;
import android.provider.ContactsContract.QuickContact;
import android.provider.ContactsContract.RawContacts;
import android.util.AttributeSet;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.Toast;
import com.fsck.k9.mail.Address;
import de.hdodenhof.circleimageview.CircleImageView;
/**
* ContactBadge replaces the android ContactBadge for custom drawing.
* <p>
* Based on QuickContactBadge:
* https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/QuickContactBadge.java
*/
public class ContactBadge extends CircleImageView implements OnClickListener {
private static final int TOKEN_EMAIL_LOOKUP = 0;
private static final int TOKEN_EMAIL_LOOKUP_AND_TRIGGER = 1;
private static final String EXTRA_URI_CONTENT = "uri_content";
private static final String[] EMAIL_LOOKUP_PROJECTION = new String[] {
RawContacts.CONTACT_ID,
Contacts.LOOKUP_KEY,
};
private static final int EMAIL_ID_COLUMN_INDEX = 0;
private static final int EMAIL_LOOKUP_STRING_COLUMN_INDEX = 1;
private Uri contactUri;
private String contactEmail;
private QueryHandler queryHandler;
private Bundle extras = null;
public ContactBadge(Context context) {
this(context, null);
}
public ContactBadge(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ContactBadge(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
queryHandler = new QueryHandler(context.getContentResolver());
setOnClickListener(this);
}
/**
* True if a contact, an email address or a phone number has been assigned
*/
private boolean isAssigned() {
return contactUri != null || contactEmail != null;
}
/**
* Assign the contact uri that this ContactBadge should be associated
* with. Note that this is only used for displaying the QuickContact window and
* won't bind the contact's photo for you. Call {@link #setImageDrawable(Drawable)} to set the
* photo.
*
* @param contactUri
* Either a {@link Contacts#CONTENT_URI} or
* {@link Contacts#CONTENT_LOOKUP_URI} style URI.
*/
public void assignContactUri(Uri contactUri) {
this.contactUri = contactUri;
contactEmail = null;
onContactUriChanged();
}
/**
* Assign a contact based on an email address. This should only be used when
* the contact's URI is not available, as an extra query will have to be
* performed to lookup the URI based on the email.
*
* @param emailAddress
* The email address of the contact.
* @param lazyLookup
* If this is true, the lookup query will not be performed
* until this view is clicked.
* @param extras
* A bundle of extras to populate the contact edit page with if the contact
* is not found and the user chooses to add the email address to an existing contact or
* create a new contact. Uses the same string constants as those found in
* {@link android.provider.ContactsContract.Intents.Insert}
*/
public void assignContactFromEmail(String emailAddress, boolean lazyLookup, Bundle extras) {
contactEmail = emailAddress;
this.extras = extras;
if (!lazyLookup) {
queryHandler.startQuery(TOKEN_EMAIL_LOOKUP, null,
Uri.withAppendedPath(Email.CONTENT_LOOKUP_URI, Uri.encode(contactEmail)),
EMAIL_LOOKUP_PROJECTION, null, null, null);
} else {
contactUri = null;
onContactUriChanged();
}
}
private void onContactUriChanged() {
setEnabled(isAssigned());
}
@Override
public void onClick(View v) {
// If contact has been assigned, extras should no longer be null, but do a null check
// anyway just in case assignContactFromPhone or Email was called with a null bundle or
// wasn't assigned previously.
final Bundle extras = (this.extras == null) ? new Bundle() : this.extras;
if (contactUri != null) {
QuickContact.showQuickContact(getContext(), ContactBadge.this, contactUri,
QuickContact.MODE_LARGE, null);
} else if (contactEmail != null) {
extras.putString(EXTRA_URI_CONTENT, contactEmail);
queryHandler.startQuery(TOKEN_EMAIL_LOOKUP_AND_TRIGGER, extras,
Uri.withAppendedPath(Email.CONTENT_LOOKUP_URI, Uri.encode(contactEmail)),
EMAIL_LOOKUP_PROJECTION, null, null, null);
}
}
@Override
public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(event);
event.setClassName(ContactBadge.class.getName());
}
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.setClassName(ContactBadge.class.getName());
}
/**
* Assign the contact to the badge.
*
* On 4.3, we pass the address name as extra info so that if the contact doesn't exist
* the name is auto-populated.
*
* @param address the address to look for a contact for.
*/
public void setContact(Address address) {
Bundle extraContactInfo = new Bundle();
extraContactInfo.putString(ContactsContract.Intents.Insert.NAME, address.getPersonal());
assignContactFromEmail(address.getAddress(), true, extraContactInfo);
}
private class QueryHandler extends AsyncQueryHandler {
QueryHandler(ContentResolver cr) {
super(cr);
}
@Override
protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
Uri lookupUri = null;
Uri createUri = null;
boolean trigger = false;
Bundle extras = (cookie != null) ? (Bundle) cookie : new Bundle();
try {
switch (token) {
case TOKEN_EMAIL_LOOKUP_AND_TRIGGER:
trigger = true;
createUri = Uri.fromParts("mailto",
extras.getString(EXTRA_URI_CONTENT), null);
//$FALL-THROUGH$
case TOKEN_EMAIL_LOOKUP: {
if (cursor != null && cursor.moveToFirst()) {
long contactId = cursor.getLong(EMAIL_ID_COLUMN_INDEX);
String lookupKey = cursor.getString(EMAIL_LOOKUP_STRING_COLUMN_INDEX);
lookupUri = Contacts.getLookupUri(contactId, lookupKey);
}
break;
}
}
} finally {
if (cursor != null) {
cursor.close();
}
}
contactUri = lookupUri;
onContactUriChanged();
if (trigger && lookupUri != null) {
// Found contact, so trigger QuickContact
QuickContact.showQuickContact(
getContext(), ContactBadge.this, lookupUri, QuickContact.MODE_LARGE, null);
} else if (createUri != null) {
// Prompt user to add this person to contacts
try {
final Intent intent = new Intent(Intents.SHOW_OR_CREATE_CONTACT, createUri);
extras.remove(EXTRA_URI_CONTENT);
intent.putExtras(extras);
getContext().startActivity(intent);
} catch (ActivityNotFoundException e) {
Toast.makeText(getContext(), R.string.error_activity_not_found, Toast.LENGTH_LONG).show();
}
}
}
}
}

View file

@ -0,0 +1,17 @@
package com.fsck.k9.ui
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.launch
fun <T> Flow<T>.observe(lifecycleOwner: LifecycleOwner, collector: FlowCollector<T>) {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect(collector)
}
}
}

View file

@ -0,0 +1,27 @@
package com.fsck.k9.ui
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentTransaction
inline fun FragmentActivity.fragmentTransaction(crossinline block: FragmentTransaction.() -> Unit) {
with(supportFragmentManager.beginTransaction()) {
block()
commit()
}
}
inline fun FragmentActivity.fragmentTransactionWithBackStack(
name: String? = null,
crossinline block: FragmentTransaction.() -> Unit
) {
fragmentTransaction {
block()
addToBackStack(name)
}
}
fun <T : Fragment> T.withArguments(vararg argumentPairs: Pair<String, Any?>) = apply {
arguments = bundleOf(*argumentPairs)
}

View file

@ -0,0 +1,560 @@
package com.fsck.k9.ui
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Resources
import android.graphics.PorterDuff
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.widget.ImageView
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.fsck.k9.Account
import com.fsck.k9.K9
import com.fsck.k9.activity.MessageList
import com.fsck.k9.controller.MessagingController
import com.fsck.k9.controller.SimpleMessagingListener
import com.fsck.k9.mailstore.DisplayFolder
import com.fsck.k9.mailstore.Folder
import com.fsck.k9.ui.account.AccountImageLoader
import com.fsck.k9.ui.account.AccountsViewModel
import com.fsck.k9.ui.account.DisplayAccount
import com.fsck.k9.ui.base.Theme
import com.fsck.k9.ui.base.ThemeManager
import com.fsck.k9.ui.folders.DisplayUnifiedInbox
import com.fsck.k9.ui.folders.FolderIconProvider
import com.fsck.k9.ui.folders.FolderList
import com.fsck.k9.ui.folders.FolderNameFormatter
import com.fsck.k9.ui.folders.FoldersViewModel
import com.fsck.k9.ui.settings.SettingsActivity
import com.mikepenz.materialdrawer.holder.BadgeStyle
import com.mikepenz.materialdrawer.holder.ImageHolder
import com.mikepenz.materialdrawer.model.DividerDrawerItem
import com.mikepenz.materialdrawer.model.PrimaryDrawerItem
import com.mikepenz.materialdrawer.model.ProfileDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.IDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.IProfile
import com.mikepenz.materialdrawer.model.interfaces.badgeText
import com.mikepenz.materialdrawer.model.interfaces.descriptionText
import com.mikepenz.materialdrawer.model.interfaces.iconRes
import com.mikepenz.materialdrawer.model.interfaces.nameRes
import com.mikepenz.materialdrawer.model.interfaces.nameText
import com.mikepenz.materialdrawer.model.interfaces.selectedColorInt
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.mikepenz.materialdrawer.util.addItems
import com.mikepenz.materialdrawer.util.addStickyFooterItem
import com.mikepenz.materialdrawer.util.getDrawerItem
import com.mikepenz.materialdrawer.util.removeAllItems
import com.mikepenz.materialdrawer.widget.AccountHeaderView
import com.mikepenz.materialdrawer.widget.MaterialDrawerSliderView
import java.util.ArrayList
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import com.fsck.k9.core.R as CoreR
import com.mikepenz.materialdrawer.R as MaterialDrawerR
private const val UNREAD_SYMBOL = "\u2B24"
private const val STARRED_SYMBOL = "\u2605"
private const val THIN_SPACE = "\u2009"
private const val EN_SPACE = "\u2000"
class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : KoinComponent {
private val foldersViewModel: FoldersViewModel by parent.viewModel()
private val accountsViewModel: AccountsViewModel by parent.viewModel()
private val folderNameFormatter: FolderNameFormatter by inject()
private val themeManager: ThemeManager by inject()
private val resources: Resources by inject()
private val messagingController: MessagingController by inject()
private val accountImageLoader: AccountImageLoader by inject()
private val drawer: DrawerLayout = parent.findViewById(R.id.drawerLayout)
private val sliderView: MaterialDrawerSliderView = parent.findViewById(R.id.material_drawer_slider)
private val headerView: AccountHeaderView = AccountHeaderView(parent).apply {
attachToSliderView(this@K9Drawer.sliderView)
dividerBelowHeader = false
displayBadgesOnCurrentProfileImage = false
}
private val folderIconProvider: FolderIconProvider = FolderIconProvider(parent.theme)
private val swipeRefreshLayout: SwipeRefreshLayout
private val userFolderDrawerIds = ArrayList<Long>()
private var unifiedInboxSelected: Boolean = false
private val textColor: Int
private var selectedTextColor: ColorStateList? = null
private var selectedBackgroundColor: Int = 0
private var folderBadgeStyle: BadgeStyle? = null
private var openedAccountUuid: String? = null
private var openedFolderId: Long? = null
private var latestFolderList: FolderList? = null
val layout: DrawerLayout
get() = drawer
val isOpen: Boolean
get() = drawer.isOpen
init {
textColor = parent.obtainDrawerTextColor()
initializeImageLoader()
configureAccountHeader()
drawer.addDrawerListener(parent.createDrawerListener())
sliderView.tintStatusBar = true
sliderView.onDrawerItemClickListener = { _, item, _ ->
handleItemClickListener(item)
false
}
sliderView.setSavedInstance(savedInstanceState)
headerView.withSavedInstance(savedInstanceState)
swipeRefreshLayout = parent.findViewById(R.id.material_drawer_swipe_refresh)
headerView.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
val densityMultiplier = view.resources.displayMetrics.density
val progressViewStart = view.measuredHeight
val progressViewEnd = progressViewStart + (PROGRESS_VIEW_END_OFFSET * densityMultiplier).toInt()
val progressViewStartOld = swipeRefreshLayout.progressViewStartOffset
val progressViewEndOld = swipeRefreshLayout.progressViewEndOffset
if (progressViewStart != progressViewStartOld || progressViewEnd != progressViewEndOld) {
swipeRefreshLayout.setProgressViewOffset(true, progressViewStart, progressViewEnd)
val slingshotDistance = (PROGRESS_VIEW_SLINGSHOT_DISTANCE * densityMultiplier).toInt()
swipeRefreshLayout.setSlingshotDistance(slingshotDistance)
}
}
addFooterItems()
accountsViewModel.displayAccountsLiveData.observeNotNull(parent) { accounts ->
setAccounts(accounts)
}
foldersViewModel.getFolderListLiveData().observe(parent) { folderList ->
setUserFolders(folderList)
}
}
private fun initializeImageLoader() {
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
val email = uri.getQueryParameter(QUERY_EMAIL) ?: error("Missing '$QUERY_EMAIL' parameter in $uri")
val color = uri.getQueryParameter(QUERY_COLOR)?.toInt()
?: error("Missing '$QUERY_COLOR' parameter in $uri")
accountImageLoader.setAccountImage(imageView, email, color)
}
override fun cancel(imageView: ImageView) {
accountImageLoader.cancel(imageView)
}
}).apply {
handledProtocols = listOf(INTERNAL_URI_SCHEME)
}
}
private fun configureAccountHeader() {
headerView.headerBackground = ImageHolder(R.drawable.drawer_header_background)
headerView.onAccountHeaderListener = { _, profile, _ ->
val account = (profile as ProfileDrawerItem).tag as Account
openedAccountUuid = account.uuid
val eventHandled = !parent.openRealAccount(account)
updateUserAccountsAndFolders(account)
eventHandled
}
}
private fun buildBadgeText(displayAccount: DisplayAccount): String? {
return buildBadgeText(displayAccount.unreadMessageCount, displayAccount.starredMessageCount)
}
private fun buildBadgeText(displayFolder: DisplayFolder): String? {
return buildBadgeText(displayFolder.unreadMessageCount, displayFolder.starredMessageCount)
}
private fun buildBadgeText(unifiedInbox: DisplayUnifiedInbox): String? {
return buildBadgeText(unifiedInbox.unreadMessageCount, unifiedInbox.starredMessageCount)
}
private fun buildBadgeText(unreadCount: Int, starredCount: Int): String? {
return if (K9.isShowStarredCount) {
buildBadgeTextWithStarredCount(unreadCount, starredCount)
} else {
buildBadgeTextWithUnreadCount(unreadCount)
}
}
private fun buildBadgeTextWithStarredCount(unreadCount: Int, starredCount: Int): String? {
if (unreadCount == 0 && starredCount == 0) return null
return buildString {
val hasUnreadCount = unreadCount > 0
if (hasUnreadCount) {
append(UNREAD_SYMBOL)
append(THIN_SPACE)
append(unreadCount)
}
if (starredCount > 0) {
if (hasUnreadCount) {
append(EN_SPACE)
}
append(STARRED_SYMBOL)
append(THIN_SPACE)
append(starredCount)
}
}
}
private fun buildBadgeTextWithUnreadCount(unreadCount: Int): String? {
return if (unreadCount > 0) unreadCount.toString() else null
}
private fun setAccounts(displayAccounts: List<DisplayAccount>) {
val oldSelectedBackgroundColor = selectedBackgroundColor
var newActiveProfile: IProfile? = null
val accountItems = displayAccounts.map { displayAccount ->
val account = displayAccount.account
val drawerColors = getDrawerColorsForAccount(account)
val selectedTextColor = drawerColors.accentColor.toSelectedColorStateList()
val accountItem = ProfileDrawerItem().apply {
account.name.let { accountName ->
isNameShown = accountName != null
nameText = accountName.orEmpty()
}
descriptionText = account.email
identifier = account.drawerId
tag = account
textColor = selectedTextColor
descriptionTextColor = selectedTextColor
selectedColorInt = drawerColors.selectedColor
icon = ImageHolder(createAccountImageUri(account))
buildBadgeText(displayAccount)?.let { text ->
badgeText = text
badgeStyle = BadgeStyle().apply {
textColorStateList = selectedTextColor
}
}
}
if (account.uuid == openedAccountUuid) {
initializeWithAccountColor(account)
newActiveProfile = accountItem
}
accountItem
}.toTypedArray()
headerView.clear()
headerView.addProfiles(*accountItems)
newActiveProfile?.let { profile ->
headerView.activeProfile = profile
}
if (oldSelectedBackgroundColor != selectedBackgroundColor) {
// Recreate list of folders with updated account color
setUserFolders(latestFolderList)
}
}
private fun addFooterItems() {
sliderView.addStickyFooterItem(
PrimaryDrawerItem().apply {
nameRes = R.string.folders_action
iconRes = folderIconProvider.iconFolderResId
identifier = DRAWER_ID_FOLDERS
isSelectable = false
}
)
sliderView.addStickyFooterItem(
PrimaryDrawerItem().apply {
nameRes = R.string.preferences_action
iconRes = getResId(R.attr.iconActionSettings)
identifier = DRAWER_ID_PREFERENCES
isSelectable = false
}
)
}
private fun getResId(resAttribute: Int): Int {
val typedValue = TypedValue()
val found = parent.theme.resolveAttribute(resAttribute, typedValue, true)
if (!found) {
throw AssertionError("Couldn't find resource with attribute $resAttribute")
}
return typedValue.resourceId
}
private fun getFolderDisplayName(folder: Folder): String {
return folderNameFormatter.displayName(folder)
}
fun updateUserAccountsAndFolders(account: Account?) {
if (account != null) {
initializeWithAccountColor(account)
headerView.setActiveProfile(account.drawerId)
foldersViewModel.loadFolders(account)
}
// Account can be null to refresh all (unified inbox or account list).
swipeRefreshLayout.setOnRefreshListener {
val accountToRefresh = if (headerView.selectionListShown) null else account
messagingController.checkMail(
accountToRefresh,
true,
true,
true,
object : SimpleMessagingListener() {
override fun checkMailFinished(context: Context?, account: Account?) {
swipeRefreshLayout.post {
swipeRefreshLayout.isRefreshing = false
}
}
}
)
}
}
private fun initializeWithAccountColor(account: Account) {
getDrawerColorsForAccount(account).let { drawerColors ->
selectedBackgroundColor = drawerColors.selectedColor
val selectedTextColor = drawerColors.accentColor.toSelectedColorStateList()
this.selectedTextColor = selectedTextColor
folderBadgeStyle = BadgeStyle().apply {
textColorStateList = selectedTextColor
}
}
headerView.accountHeaderBackground.setColorFilter(account.chipColor, PorterDuff.Mode.MULTIPLY)
}
private fun handleItemClickListener(drawerItem: IDrawerItem<*>) {
when (drawerItem.identifier) {
DRAWER_ID_PREFERENCES -> SettingsActivity.launch(parent)
DRAWER_ID_FOLDERS -> parent.launchManageFoldersScreen()
DRAWER_ID_UNIFIED_INBOX -> parent.openUnifiedInbox()
else -> {
val folder = drawerItem.tag as Folder
parent.openFolder(folder.id)
}
}
}
private fun setUserFolders(folderList: FolderList?) {
this.latestFolderList = folderList
clearUserFolders()
var openedFolderDrawerId: Long = -1
if (folderList == null) {
return
}
folderList.unifiedInbox?.let { unifiedInbox ->
val unifiedInboxItem = PrimaryDrawerItem().apply {
iconRes = R.drawable.ic_inbox_multiple
identifier = DRAWER_ID_UNIFIED_INBOX
nameRes = R.string.integrated_inbox_title
selectedColorInt = selectedBackgroundColor
textColor = selectedTextColor
isSelected = unifiedInboxSelected
buildBadgeText(unifiedInbox)?.let { text ->
badgeText = text
badgeStyle = folderBadgeStyle
}
}
sliderView.addItems(unifiedInboxItem)
sliderView.addItems(FixedDividerDrawerItem(identifier = DRAWER_ID_DIVIDER))
if (unifiedInboxSelected) {
openedFolderDrawerId = DRAWER_ID_UNIFIED_INBOX
}
}
val accountOffset = folderList.accountId.toLong() shl DRAWER_ACCOUNT_SHIFT
for (displayFolder in folderList.folders) {
val folder = displayFolder.folder
val drawerId = accountOffset + folder.id
val drawerItem = FolderDrawerItem().apply {
iconRes = folderIconProvider.getFolderIcon(folder.type)
identifier = drawerId
tag = folder
nameText = getFolderDisplayName(folder)
buildBadgeText(displayFolder)?.let { text ->
badgeText = text
badgeStyle = folderBadgeStyle
}
selectedColorInt = selectedBackgroundColor
textColor = selectedTextColor
}
sliderView.addItems(drawerItem)
userFolderDrawerIds.add(drawerId)
if (folder.id == openedFolderId) {
openedFolderDrawerId = drawerId
}
}
if (openedFolderDrawerId != -1L) {
sliderView.setSelection(openedFolderDrawerId, false)
}
}
private fun clearUserFolders() {
// remove old items first
sliderView.selectExtension.deselect()
sliderView.removeAllItems()
userFolderDrawerIds.clear()
}
fun selectAccount(accountUuid: String) {
openedAccountUuid = accountUuid
headerView.profiles?.firstOrNull { it.accountUuid == accountUuid }?.let { profile ->
headerView.activeProfile = profile
}
}
fun selectFolder(folderId: Long) {
deselect()
openedFolderId = folderId
for (drawerId in userFolderDrawerIds) {
val folder = sliderView.getDrawerItem(drawerId)?.tag as? Folder
if (folder?.id == folderId) {
sliderView.setSelection(drawerId, false)
return
}
}
}
fun deselect() {
unifiedInboxSelected = false
openedFolderId = null
sliderView.selectExtension.deselect()
}
fun selectUnifiedInbox() {
headerView.selectionListShown = false
deselect()
unifiedInboxSelected = true
sliderView.setSelection(DRAWER_ID_UNIFIED_INBOX, false)
}
private data class DrawerColors(
val accentColor: Int,
val selectedColor: Int
)
private fun getDrawerColorsForAccount(account: Account): DrawerColors {
val baseColor = if (themeManager.appTheme == Theme.DARK) {
getDarkThemeAccentColor(account.chipColor)
} else {
account.chipColor
}
return DrawerColors(
accentColor = baseColor,
selectedColor = baseColor.and(0xffffff).or(0x22000000)
)
}
private fun getDarkThemeAccentColor(color: Int): Int {
val lightColors = resources.getIntArray(CoreR.array.account_colors)
val darkColors = resources.getIntArray(CoreR.array.drawer_account_accent_color_dark_theme)
val index = lightColors.indexOf(color)
return if (index == -1) color else darkColors[index]
}
fun open() {
drawer.openDrawer(GravityCompat.START)
}
fun close() {
drawer.closeDrawer(GravityCompat.START)
}
fun lock() {
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
}
fun unlock() {
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
}
private fun createAccountImageUri(account: Account): Uri {
return Uri.parse("$INTERNAL_URI_SCHEME://account-image/")
.buildUpon()
.appendQueryParameter(QUERY_EMAIL, account.email)
.appendQueryParameter(QUERY_COLOR, account.chipColor.toString())
.build()
}
private fun Int.toSelectedColorStateList(): ColorStateList {
val states = arrayOf(
intArrayOf(android.R.attr.state_selected),
intArrayOf()
)
val colors = intArrayOf(
this,
textColor
)
return ColorStateList(states, colors)
}
private val IProfile.accountUuid: String?
get() = (this.tag as? Account)?.uuid
private val Account.drawerId: Long
get() = (accountNumber + 1).toLong()
companion object {
// Use the lower 48 bits for the folder ID, the upper bits for the account's drawer ID
private const val DRAWER_ACCOUNT_SHIFT: Int = 48
private const val DRAWER_ID_UNIFIED_INBOX: Long = 0
private const val DRAWER_ID_DIVIDER: Long = 1
private const val DRAWER_ID_PREFERENCES: Long = 2
private const val DRAWER_ID_FOLDERS: Long = 3
private const val PROGRESS_VIEW_END_OFFSET = 32
private const val PROGRESS_VIEW_SLINGSHOT_DISTANCE = 48
private const val INTERNAL_URI_SCHEME = "app-internal"
private const val QUERY_EMAIL = "email"
private const val QUERY_COLOR = "color"
}
}
private fun Context.obtainDrawerTextColor(): Int {
val styledAttributes = obtainStyledAttributes(
null,
MaterialDrawerR.styleable.MaterialDrawerSliderView,
MaterialDrawerR.attr.materialDrawerStyle,
MaterialDrawerR.style.Widget_MaterialDrawerStyle
)
val textColor = styledAttributes.getColor(MaterialDrawerR.styleable.MaterialDrawerSliderView_materialDrawerPrimaryText, 0)
styledAttributes.recycle()
return textColor
}
private class FixedDividerDrawerItem(override var identifier: Long) : DividerDrawerItem()
// We ellipsize long folder names in the middle for better readability
private class FolderDrawerItem : PrimaryDrawerItem() {
override val type: Int = R.id.drawer_list_folder_item
override val layoutRes: Int = R.layout.drawer_folder_list_item
}

View file

@ -0,0 +1,12 @@
package com.fsck.k9.ui
import com.fsck.k9.ui.base.ThemeProvider
// TODO: Move this class and the theme resources to the main app module
class K9ThemeProvider : ThemeProvider {
override val appThemeResourceId = R.style.Theme_K9_DayNight
override val appLightThemeResourceId = R.style.Theme_K9_Light
override val appDarkThemeResourceId = R.style.Theme_K9_Dark
override val dialogThemeResourceId = R.style.Theme_K9_Dialog_DayNight
override val translucentDialogThemeResourceId = R.style.Theme_K9_Dialog_Translucent_DayNight
}

View file

@ -0,0 +1,23 @@
package com.fsck.k9.ui
import android.content.Context
import com.fsck.k9.ui.base.ThemeProvider
import com.fsck.k9.ui.helper.DisplayHtmlUiFactory
import com.fsck.k9.ui.helper.HtmlSettingsProvider
import com.fsck.k9.ui.helper.HtmlToSpanned
import com.fsck.k9.ui.helper.SizeFormatter
import com.fsck.k9.ui.messageview.LinkTextHandler
import com.fsck.k9.ui.share.ShareIntentBuilder
import org.koin.core.qualifier.named
import org.koin.dsl.module
val uiModule = module {
single { HtmlToSpanned() }
single<ThemeProvider> { K9ThemeProvider() }
single { HtmlSettingsProvider(get()) }
single { DisplayHtmlUiFactory(get()) }
factory(named("MessageView")) { get<DisplayHtmlUiFactory>().createForMessageView() }
factory { (context: Context) -> SizeFormatter(context.resources) }
factory { ShareIntentBuilder(resourceProvider = get(), textPartFinder = get(), quoteDateFormatter = get()) }
factory { LinkTextHandler(context = get(), clipboardManager = get()) }
}

View file

@ -0,0 +1,8 @@
package com.fsck.k9.ui
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
fun <T> LiveData<T>.observeNotNull(owner: LifecycleOwner, observer: (T) -> Unit) {
this.observe(owner) { observer(it!!) }
}

View file

@ -0,0 +1,65 @@
package com.fsck.k9.ui
import android.content.res.Resources.Theme
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.util.TypedValue
fun Theme.resolveColorAttribute(attrId: Int): Int {
val typedValue = TypedValue()
val found = resolveAttribute(attrId, typedValue, true)
if (!found) {
throw IllegalStateException("Couldn't resolve attribute ($attrId)")
}
return typedValue.data
}
fun Theme.resolveColorAttribute(colorAttrId: Int, alphaFractionAttrId: Int, backgroundColorAttrId: Int): Int {
val typedValue = TypedValue()
if (!resolveAttribute(colorAttrId, typedValue, true)) {
error("Couldn't resolve attribute ($colorAttrId)")
}
val color = typedValue.data
if (!resolveAttribute(alphaFractionAttrId, typedValue, true)) {
error("Couldn't resolve attribute ($alphaFractionAttrId)")
}
val colorPercentage = TypedValue.complexToFloat(typedValue.data)
val backgroundPercentage = 1 - colorPercentage
if (!resolveAttribute(backgroundColorAttrId, typedValue, true)) {
error("Couldn't resolve attribute ($colorAttrId)")
}
val backgroundColor = typedValue.data
val red = colorPercentage * Color.red(color) + backgroundPercentage * Color.red(backgroundColor)
val green = colorPercentage * Color.green(color) + backgroundPercentage * Color.green(backgroundColor)
val blue = colorPercentage * Color.blue(color) + backgroundPercentage * Color.blue(backgroundColor)
return Color.rgb(red.toInt(), green.toInt(), blue.toInt())
}
fun Theme.resolveDrawableAttribute(attrId: Int): Drawable {
val typedValue = TypedValue()
val found = resolveAttribute(attrId, typedValue, true)
if (!found) {
throw IllegalStateException("Couldn't resolve attribute ($attrId)")
}
return getDrawable(typedValue.resourceId)
}
fun Theme.getIntArray(attrId: Int): IntArray {
val typedValue = TypedValue()
val found = resolveAttribute(attrId, typedValue, true)
if (!found) {
throw IllegalStateException("Couldn't resolve attribute ($attrId)")
}
return resources.getIntArray(typedValue.resourceId)
}

View file

@ -0,0 +1,22 @@
package com.fsck.k9.ui.account
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.core.content.ContextCompat
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import com.fsck.k9.ui.R
/**
* Provides a [Drawable] for the account using the account's color as background color.
*/
class AccountFallbackImageProvider(private val context: Context) {
fun getDrawable(color: Int): Drawable {
val drawable = ContextCompat.getDrawable(context, R.drawable.drawer_account_fallback)
?: error("Error loading drawable")
return drawable.mutate().apply {
colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.DST_OVER)
}
}
}

View file

@ -0,0 +1,37 @@
package com.fsck.k9.ui.account
import android.content.Context
import android.widget.ImageView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.fsck.k9.ui.helper.findActivity
/**
* Load the account image into an [ImageView].
*/
class AccountImageLoader(private val accountFallbackImageProvider: AccountFallbackImageProvider) {
fun setAccountImage(imageView: ImageView, email: String, color: Int) {
imageView.context.ifNotDestroyed { context ->
Glide.with(context)
.load(AccountImage(email, color))
.placeholder(accountFallbackImageProvider.getDrawable(color))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(imageView)
}
}
fun cancel(imageView: ImageView) {
imageView.context.ifNotDestroyed { context ->
Glide.with(context).clear(imageView)
}
}
private inline fun Context.ifNotDestroyed(block: (Context) -> Unit) {
if (findActivity()?.isDestroyed == true) {
// Do nothing because Glide would throw an exception
} else {
block(this)
}
}
}

View file

@ -0,0 +1,91 @@
package com.fsck.k9.ui.account
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Key
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.fsck.k9.contacts.ContactPhotoLoader
import java.security.MessageDigest
/**
* A custom [ModelLoader] so we can use [AccountImageDataFetcher] to load the account image.
*/
internal class AccountImageModelLoader(
private val contactPhotoLoader: ContactPhotoLoader,
private val accountFallbackImageProvider: AccountFallbackImageProvider
) : ModelLoader<AccountImage, Bitmap> {
override fun buildLoadData(
accountImage: AccountImage,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<Bitmap> {
val dataFetcher = AccountImageDataFetcher(
contactPhotoLoader,
accountFallbackImageProvider,
accountImage
)
return ModelLoader.LoadData(accountImage, dataFetcher)
}
override fun handles(model: AccountImage) = true
}
data class AccountImage(val email: String, val color: Int) : Key {
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(toString().toByteArray(Key.CHARSET))
}
}
/**
* Load an account image.
*
* Uses [ContactPhotoLoader] to try to load the user's contact photo (using the account's email address). If there's no
* such contact or it doesn't have a picture use the fallback image provided by [AccountFallbackImageProvider].
*
* We're not using Glide's own fallback mechanism because negative responses aren't cached and the next time the
* account image is requested another attempt will be made to load the contact photo.
*/
internal class AccountImageDataFetcher(
private val contactPhotoLoader: ContactPhotoLoader,
private val accountFallbackImageProvider: AccountFallbackImageProvider,
private val accountImage: AccountImage
) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
val bitmap = loadAccountImage() ?: createFallbackBitmap()
callback.onDataReady(bitmap)
}
private fun loadAccountImage(): Bitmap? {
return contactPhotoLoader.loadContactPhoto(accountImage.email)
}
private fun createFallbackBitmap(): Bitmap {
return accountFallbackImageProvider.getDrawable(accountImage.color).toBitmap()
}
override fun getDataClass() = Bitmap::class.java
override fun getDataSource() = DataSource.LOCAL
override fun cleanup() = Unit
override fun cancel() = Unit
}
internal class AccountImageModelLoaderFactory(
private val contactPhotoLoader: ContactPhotoLoader,
private val accountFallbackImageProvider: AccountFallbackImageProvider
) : ModelLoaderFactory<AccountImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<AccountImage, Bitmap> {
return AccountImageModelLoader(contactPhotoLoader, accountFallbackImageProvider)
}
override fun teardown() = Unit
}

View file

@ -0,0 +1,63 @@
package com.fsck.k9.ui.account
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import com.fsck.k9.Account
import com.fsck.k9.controller.MessageCounts
import com.fsck.k9.controller.MessageCountsProvider
import com.fsck.k9.mailstore.MessageListChangedListener
import com.fsck.k9.mailstore.MessageListRepository
import com.fsck.k9.preferences.AccountManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class AccountsViewModel(
accountManager: AccountManager,
private val messageCountsProvider: MessageCountsProvider,
private val messageListRepository: MessageListRepository
) : ViewModel() {
private val displayAccountFlow: Flow<List<DisplayAccount>> = accountManager.getAccountsFlow()
.flatMapLatest { accounts ->
val messageCountsFlows: List<Flow<MessageCounts>> = accounts.map { account ->
getMessageCountsFlow(account)
}
combine(messageCountsFlows) { messageCountsList ->
messageCountsList.mapIndexed { index, messageCounts ->
DisplayAccount(
account = accounts[index],
unreadMessageCount = messageCounts.unread,
starredMessageCount = messageCounts.starred
)
}
}
}
private fun getMessageCountsFlow(account: Account): Flow<MessageCounts> {
return callbackFlow {
send(messageCountsProvider.getMessageCounts(account))
val listener = MessageListChangedListener {
launch {
send(messageCountsProvider.getMessageCounts(account))
}
}
messageListRepository.addListener(account.uuid, listener)
awaitClose {
messageListRepository.removeListener(listener)
}
}.flowOn(Dispatchers.IO)
}
val displayAccountsLiveData: LiveData<List<DisplayAccount>> = displayAccountFlow.asLiveData()
}

View file

@ -0,0 +1,9 @@
package com.fsck.k9.ui.account
import com.fsck.k9.Account
data class DisplayAccount(
val account: Account,
val unreadMessageCount: Int,
val starredMessageCount: Int
)

View file

@ -0,0 +1,13 @@
package com.fsck.k9.ui.account
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val accountUiModule = module {
viewModel {
AccountsViewModel(accountManager = get(), messageCountsProvider = get(), messageListRepository = get())
}
factory { AccountImageLoader(accountFallbackImageProvider = get()) }
factory { AccountFallbackImageProvider(context = get()) }
factory { AccountImageModelLoaderFactory(contactPhotoLoader = get(), accountFallbackImageProvider = get()) }
}

View file

@ -0,0 +1,46 @@
package com.fsck.k9.ui.changelog
import android.content.Context
import de.cketti.changelog.ChangeLog
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Manages a [ChangeLog] instance and notifies when its state changes.
*/
class ChangeLogManager(
private val context: Context,
private val appCoroutineScope: CoroutineScope,
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private val mutableChangeLogFlow = MutableSharedFlow<ChangeLog>(replay = 1)
val changeLog: ChangeLog by lazy {
ChangeLog.newInstance(context).also { changeLog ->
mutableChangeLogFlow.tryEmit(changeLog)
}
}
val changeLogFlow: Flow<ChangeLog> by lazy {
mutableChangeLogFlow.onSubscription {
withContext(backgroundDispatcher) {
// Make sure the changeLog property is initialized now if it hasn't happened before
changeLog
}
}
}
fun writeCurrentVersion() {
appCoroutineScope.launch(backgroundDispatcher) {
changeLog.writeCurrentVersion()
mutableChangeLogFlow.emit(changeLog)
}
}
}

View file

@ -0,0 +1,113 @@
package com.fsck.k9.ui.changelog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.loader.observeLoading
import de.cketti.changelog.ReleaseItem
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
/**
* Displays the changelog entries in a scrolling list
*/
class ChangelogFragment : Fragment() {
private val viewModel: ChangelogViewModel by viewModel {
val mode = arguments?.getSerializable(ARG_MODE) as? ChangeLogMode ?: error("Missing argument '$ARG_MODE'")
parametersOf(mode)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_changelog, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val listView = view.findViewById<RecyclerView>(R.id.changelog_list)
viewModel.changelogState.observeLoading(
owner = viewLifecycleOwner,
loadingView = view.findViewById(R.id.changelog_loading),
errorView = view.findViewById(R.id.changelog_error),
dataView = listView
) { changeLog ->
listView.adapter = ChangelogAdapter(changeLog)
}
setUpShowRecentChangesCheckbox(view)
}
private fun setUpShowRecentChangesCheckbox(view: View) {
val showRecentChangesCheckBox = view.findViewById<CheckBox>(R.id.show_recent_changes_checkbox)
var isInitialValue = true
viewModel.showRecentChangesState.observe(viewLifecycleOwner) { showRecentChanges ->
showRecentChangesCheckBox.isChecked = showRecentChanges
if (isInitialValue) {
// Don't animate when setting initial value
showRecentChangesCheckBox.jumpDrawablesToCurrentState()
isInitialValue = false
}
}
showRecentChangesCheckBox.setOnCheckedChangeListener { _, isChecked ->
viewModel.setShowRecentChanges(isChecked)
}
}
companion object {
const val ARG_MODE = "mode"
}
}
class ChangelogAdapter(releaseItems: List<ReleaseItem>) : RecyclerView.Adapter<ViewHolder>() {
private val items = releaseItems.flatMap { listOf(it) + it.changes }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val view = layoutInflater.inflate(viewType, parent, false)
return when (viewType) {
R.layout.changelog_list_release_item -> ReleaseItemViewHolder(view)
R.layout.changelog_list_change_item -> ChangeItemViewHolder(view)
else -> error("Unsupported view type: $viewType")
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
when (val item = items[position]) {
is ReleaseItem -> {
val viewHolder = holder as ReleaseItemViewHolder
val context = viewHolder.versionName.context
viewHolder.versionName.text = context.getString(R.string.changelog_version_title, item.versionName)
viewHolder.versionDate.text = item.date
}
is String -> {
val viewHolder = holder as ChangeItemViewHolder
viewHolder.changeText.text = item
}
}
}
override fun getItemViewType(position: Int): Int {
return when (items[position]) {
is ReleaseItem -> R.layout.changelog_list_release_item
is String -> R.layout.changelog_list_change_item
else -> error("Unsupported item type: ${items[position]}")
}
}
override fun getItemCount(): Int = items.size
}
class ReleaseItemViewHolder(view: View) : ViewHolder(view) {
val versionName: TextView = view.findViewById(R.id.version_name)
val versionDate: TextView = view.findViewById(R.id.version_date)
}
class ChangeItemViewHolder(view: View) : ViewHolder(view) {
val changeText: TextView = view.findViewById(R.id.change_text)
}

View file

@ -0,0 +1,46 @@
package com.fsck.k9.ui.changelog
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import com.fsck.k9.preferences.GeneralSettingsManager
import com.fsck.k9.ui.base.loader.LoaderState
import com.fsck.k9.ui.base.loader.liveDataLoader
import de.cketti.changelog.ReleaseItem
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
private typealias ChangeLogState = LoaderState<List<ReleaseItem>>
class ChangelogViewModel(
private val generalSettingsManager: GeneralSettingsManager,
private val changeLogManager: ChangeLogManager,
private val mode: ChangeLogMode
) : ViewModel() {
val showRecentChangesState: LiveData<Boolean> =
generalSettingsManager.getSettingsFlow()
.map { it.showRecentChanges }
.distinctUntilChanged()
.asLiveData()
val changelogState: LiveData<ChangeLogState> = liveDataLoader {
val changeLog = changeLogManager.changeLog
when (mode) {
ChangeLogMode.CHANGE_LOG -> changeLog.changeLog
ChangeLogMode.RECENT_CHANGES -> changeLog.recentChanges
}
}
fun setShowRecentChanges(showRecentChanges: Boolean) {
generalSettingsManager.setShowRecentChanges(showRecentChanges)
}
override fun onCleared() {
changeLogManager.writeCurrentVersion()
}
}
enum class ChangeLogMode {
CHANGE_LOG,
RECENT_CHANGES
}

View file

@ -0,0 +1,13 @@
package com.fsck.k9.ui.changelog
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val changelogUiModule = module {
single { ChangeLogManager(context = get(), appCoroutineScope = get(named("AppCoroutineScope"))) }
viewModel { (mode: ChangeLogMode) ->
ChangelogViewModel(generalSettingsManager = get(), changeLogManager = get(), mode = mode)
}
viewModel { RecentChangesViewModel(generalSettingsManager = get(), changeLogManager = get()) }
}

View file

@ -0,0 +1,35 @@
package com.fsck.k9.ui.changelog
import android.os.Bundle
import android.view.MenuItem
import androidx.core.os.bundleOf
import androidx.fragment.app.commit
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.K9Activity
class RecentChangesActivity : K9Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setLayout(R.layout.activity_recent_changes)
setTitle(R.string.changelog_recent_changes_title)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
val fragment = ChangelogFragment().apply {
arguments = bundleOf(ChangelogFragment.ARG_MODE to ChangeLogMode.RECENT_CHANGES)
}
supportFragmentManager.commit {
add(R.id.fragment_container, fragment)
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == android.R.id.home) {
finish()
true
} else {
super.onOptionsItemSelected(item)
}
}
}

View file

@ -0,0 +1,35 @@
package com.fsck.k9.ui.changelog
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import com.fsck.k9.preferences.GeneralSettingsManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@OptIn(ExperimentalCoroutinesApi::class)
class RecentChangesViewModel(
private val generalSettingsManager: GeneralSettingsManager,
private val changeLogManager: ChangeLogManager
) : ViewModel() {
val shouldShowRecentChangesHint = changeLogManager.changeLogFlow.flatMapLatest { changeLog ->
if (changeLog.isFirstRun && !changeLog.isFirstRunEver) {
getShowRecentChangesFlow()
} else {
flowOf(false)
}
}.asLiveData()
private fun getShowRecentChangesFlow(): Flow<Boolean> {
return generalSettingsManager.getSettingsFlow()
.map { generalSettings -> generalSettings.showRecentChanges }
.distinctUntilChanged()
}
fun onRecentChangesHintDismissed() {
changeLogManager.writeCurrentVersion()
}
}

Some files were not shown because too many files have changed in this diff Show more