Repo created
This commit is contained in:
parent
a629de6271
commit
3cef7c5092
2161 changed files with 246605 additions and 2 deletions
83
app/ui/legacy/build.gradle.kts
Normal file
83
app/ui/legacy/build.gradle.kts
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/ui/legacy/sampledata/accounts.json
Normal file
19
app/ui/legacy/sampledata/accounts.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
48
app/ui/legacy/sampledata/folders.json
Normal file
48
app/ui/legacy/sampledata/folders.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/ui/legacy/src/main/AndroidManifest.xml
Normal file
48
app/ui/legacy/src/main/AndroidManifest.xml
Normal 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>
|
||||
BIN
app/ui/legacy/src/main/icon-playstore.png
Normal file
BIN
app/ui/legacy/src/main/icon-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
41
app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt
Normal file
41
app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt
Normal 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
|
||||
)
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
126
app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt
Normal file
126
app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
1974
app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java
Normal file
1974
app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java
Normal file
File diff suppressed because it is too large
Load diff
1551
app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt
Normal file
1551
app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
21
app/ui/legacy/src/main/java/com/fsck/k9/activity/Search.java
Normal file
21
app/ui/legacy/src/main/java/com/fsck/k9/activity/Search.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.fsck.k9.activity.misc
|
||||
|
||||
data class InlineAttachment(val contentId: String, val attachment: Attachment)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = " - ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'" +
|
||||
")"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = "?"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9.ui
|
||||
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
|
||||
data class ConnectionSettings(val incoming: ServerSettings, val outgoing: ServerSettings)
|
||||
227
app/ui/legacy/src/main/java/com/fsck/k9/ui/ContactBadge.java
Normal file
227
app/ui/legacy/src/main/java/com/fsck/k9/ui/ContactBadge.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
app/ui/legacy/src/main/java/com/fsck/k9/ui/FlowExtensions.kt
Normal file
17
app/ui/legacy/src/main/java/com/fsck/k9/ui/FlowExtensions.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/ui/legacy/src/main/java/com/fsck/k9/ui/FragmentExtras.kt
Normal file
27
app/ui/legacy/src/main/java/com/fsck/k9/ui/FragmentExtras.kt
Normal 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)
|
||||
}
|
||||
560
app/ui/legacy/src/main/java/com/fsck/k9/ui/K9Drawer.kt
Normal file
560
app/ui/legacy/src/main/java/com/fsck/k9/ui/K9Drawer.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
23
app/ui/legacy/src/main/java/com/fsck/k9/ui/KoinModule.kt
Normal file
23
app/ui/legacy/src/main/java/com/fsck/k9/ui/KoinModule.kt
Normal 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()) }
|
||||
}
|
||||
|
|
@ -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!!) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue