diff --git a/FlowCrypt/build.gradle b/FlowCrypt/build.gradle index 88cb4299cd..518cab7f0f 100644 --- a/FlowCrypt/build.gradle +++ b/FlowCrypt/build.gradle @@ -210,7 +210,8 @@ android { kotlinOptions { jvmTarget = "11" //need for ACRA, maybe will be deleted in the upcoming updates - freeCompilerArgs = ['-Xjvm-default=enable'] + freeCompilerArgs += ['-Xjvm-default=enable'] + freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"] } packagingOptions { @@ -382,6 +383,7 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.0-rc01' implementation 'androidx.room:room-runtime:2.4.2' implementation 'androidx.room:room-ktx:2.4.2' + implementation 'androidx.room:room-paging:2.4.2' implementation 'androidx.paging:paging-runtime-ktx:3.1.1' implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.core:core-ktx:1.7.0' diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/FoldersManager.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/FoldersManager.kt index 74245f52c8..5c828709f1 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/FoldersManager.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/FoldersManager.kt @@ -222,7 +222,7 @@ class FoldersManager constructor(val account: String) { sentFolder?.let { return it } for (localFolder in allFolders) { - if (localFolder.fullName.toUpperCase(Locale.US) in arrayOf("INBOX/SENT", "SENT")) { + if (localFolder.fullName.uppercase(Locale.US) in arrayOf("INBOX/SENT", "SENT")) { sentFolder = localFolder } } @@ -404,8 +404,8 @@ class FoldersManager constructor(val account: String) { * @return [FolderType]. */ fun getFolderType(localFolder: LocalFolder?): FolderType? { - val folderTypes = FolderType.values() val attributes = localFolder?.attributes ?: emptyList() + val folderTypes = FolderType.values() for (attribute in attributes) { for (folderType in folderTypes) { diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/JavaEmailConstants.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/JavaEmailConstants.kt index 1687bb4e28..26993de8c4 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/JavaEmailConstants.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/JavaEmailConstants.kt @@ -18,7 +18,6 @@ class JavaEmailConstants { const val DEFAULT_FETCH_BUFFER = 1024 * 1024 const val ATTACHMENTS_FETCH_BUFFER = 1024 * 256 - const val COUNT_OF_LOADED_EMAILS_BY_STEP = 45 /*IMAP*/ const val PROPERTY_NAME_MAIL_IMAP_SSL_ENABLE = "mail.imap.ssl.enable" diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/GmailApiHelper.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/GmailApiHelper.kt index a445610f96..d617912d15 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/GmailApiHelper.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/GmailApiHelper.kt @@ -109,8 +109,6 @@ class GmailApiHelper { "CATEGORY_PROMOTIONS", "CATEGORY_SOCIAL" ) - private const val COUNT_OF_LOADED_EMAILS_BY_STEP = - JavaEmailConstants.COUNT_OF_LOADED_EMAILS_BY_STEP.toLong() private val FULL_INFO_WITHOUT_DATA = listOf( "id", @@ -220,8 +218,11 @@ class GmailApiHelper { } suspend fun loadMsgsBaseInfo( - context: Context, accountEntity: AccountEntity, localFolder: - LocalFolder, nextPageToken: String? = null + context: Context, + accountEntity: AccountEntity, + localFolder: LocalFolder, + maxResults: Long, + nextPageToken: String? = null ): ListMessagesResponse = withContext(Dispatchers.IO) { val gmailApiService = generateGmailApiService(context, accountEntity) val request = gmailApiService @@ -229,7 +230,7 @@ class GmailApiHelper { .messages() .list(DEFAULT_USER_ID) .setPageToken(nextPageToken) - .setMaxResults(COUNT_OF_LOADED_EMAILS_BY_STEP) + .setMaxResults(maxResults) if (!localFolder.isAll()) { request.labelIds = listOf(localFolder.fullName) @@ -583,8 +584,11 @@ class GmailApiHelper { } suspend fun loadMsgsBaseInfoUsingSearch( - context: Context, accountEntity: AccountEntity, - localFolder: LocalFolder, nextPageToken: String? = null + context: Context, + accountEntity: AccountEntity, + localFolder: LocalFolder, + maxResults: Long, + nextPageToken: String? = null ): ListMessagesResponse = withContext(Dispatchers.IO) { @@ -601,7 +605,7 @@ class GmailApiHelper { ) as? GmailRawSearchTerm)?.pattern ) .setPageToken(nextPageToken) - .setMaxResults(COUNT_OF_LOADED_EMAILS_BY_STEP) + .setMaxResults(maxResults) return@withContext list.execute() } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/data/MessagesRemoteMediator.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/data/MessagesRemoteMediator.kt new file mode 100644 index 0000000000..574c0a998c --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/data/MessagesRemoteMediator.kt @@ -0,0 +1,549 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.data + +import android.content.Context +import android.util.Log +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.flowcrypt.email.R +import com.flowcrypt.email.api.email.EmailUtil +import com.flowcrypt.email.api.email.IMAPStoreManager +import com.flowcrypt.email.api.email.JavaEmailConstants +import com.flowcrypt.email.api.email.gmail.GmailApiHelper +import com.flowcrypt.email.api.email.gmail.api.GmaiAPIMimeMessage +import com.flowcrypt.email.api.email.model.LocalFolder +import com.flowcrypt.email.api.retrofit.response.base.Result +import com.flowcrypt.email.database.FlowCryptRoomDatabase +import com.flowcrypt.email.database.entity.AccountEntity +import com.flowcrypt.email.database.entity.AttachmentEntity +import com.flowcrypt.email.database.entity.LabelEntity +import com.flowcrypt.email.database.entity.MessageEntity +import com.flowcrypt.email.jetpack.viewmodel.AccountViewModel +import com.flowcrypt.email.jetpack.workmanager.sync.CheckIsLoadedMessagesEncryptedWorker +import com.flowcrypt.email.model.EmailAndNamePair +import com.flowcrypt.email.service.EmailAndNameUpdaterService +import com.flowcrypt.email.util.exception.ExceptionUtil +import com.sun.mail.imap.IMAPFolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Arrays +import java.util.Properties +import javax.mail.FetchProfile +import javax.mail.Folder +import javax.mail.Message +import javax.mail.MessagingException +import javax.mail.Session +import javax.mail.Store +import javax.mail.UIDFolder +import javax.mail.internet.InternetAddress + +/** + * @author Denis Bondarenko + * Date: 5/17/22 + * Time: 11:13 AM + * E-mail: DenBond7@gmail.com + */ +@OptIn(ExperimentalPagingApi::class) +class MessagesRemoteMediator( + private val context: Context, + private val roomDatabase: FlowCryptRoomDatabase, + private val localFolder: LocalFolder? = null, + private val progressNotifier: (resultCode: Int, progress: Double) -> Unit +) : RemoteMediator() { + private var searchNextPageToken: String? = null + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + Log.d( + "DDDDD", + "${this.hashCode()} + folder = ${localFolder?.fullName} + state.pages.size = ${state.pages.size} + loadType = $loadType" + ) + try { + if (loadType == LoadType.PREPEND || localFolder == null || localFolder.isOutbox()) { + return MediatorResult.Success(endOfPaginationReached = true) + } + progressNotifier.invoke(R.id.progress_id_start_of_loading_new_messages, 0.0) + val activeAccountWithProtectedData = roomDatabase.accountDao().getActiveAccountSuspend() + val accountEntity = + AccountViewModel.getAccountEntityWithDecryptedInfoSuspend(activeAccountWithProtectedData) + ?: return MediatorResult.Success(endOfPaginationReached = true) + + val totalItemsCount = roomDatabase.msgDao().getMsgsCount( + account = accountEntity.email, + label = if (localFolder.searchQuery.isNullOrEmpty()) { + localFolder.fullName + } else { + JavaEmailConstants.FOLDER_SEARCH + } + ) + + if (loadType == LoadType.REFRESH && totalItemsCount != 0) { + return MediatorResult.Success(endOfPaginationReached = false) + } + + progressNotifier.invoke(R.id.progress_id_start_of_loading_new_messages, 10.0) + val actionResult = fetchAndCacheMessages( + accountEntity = accountEntity, + localFolder = localFolder, + totalItemsCount = totalItemsCount, + pageSize = state.config.pageSize + ) + + if (actionResult.status == Result.Status.EXCEPTION) { + return MediatorResult.Error(requireNotNull(actionResult.exception)) + } + + Log.d("DDDDD", "count of fetched msgs = ${actionResult.data}") + return MediatorResult.Success(endOfPaginationReached = (actionResult.data ?: 0) == 0) + } catch (exception: Exception) { + return MediatorResult.Error(exception) + } finally { + progressNotifier.invoke(R.id.progress_id_done, 100.0) + } + } + + private suspend fun fetchAndCacheMessages( + accountEntity: AccountEntity, + localFolder: LocalFolder, + totalItemsCount: Int, + pageSize: Int + ) = if (accountEntity.useAPI) { + GmailApiHelper.executeWithResult { + if (localFolder.searchQuery.isNullOrEmpty()) { + loadMsgsFromRemoteServerAndStoreLocally( + accountEntity, + localFolder, + totalItemsCount, + pageSize + ) + } else { + searchMsgsOnRemoteServerAndStoreLocally( + accountEntity, + localFolder, + totalItemsCount, + pageSize + ) + } + } + } else { + IMAPStoreManager.getConnection(accountEntity.id)?.executeWithResult { store -> + if (localFolder.searchQuery.isNullOrEmpty()) { + loadMsgsFromRemoteServerAndStoreLocally( + accountEntity, + store, + localFolder, + totalItemsCount, + pageSize + ) + } else { + searchMsgsOnRemoteServerAndStoreLocally( + accountEntity, + store, + localFolder, + totalItemsCount, + pageSize + ) + } + } + ?: Result.exception(NullPointerException("There is no active connection for ${accountEntity.email}")) + } + + private suspend fun loadMsgsFromRemoteServerAndStoreLocally( + accountEntity: AccountEntity, + store: Store, + localFolder: LocalFolder, + countOfAlreadyLoadedMsgs: Int, + pageSize: Int + ): Result = withContext(Dispatchers.IO) { + var countOfFetchedMsgs = 0 + store.getFolder(localFolder.fullName).use { folder -> + val imapFolder = folder as IMAPFolder + progressNotifier.invoke(R.id.progress_id_opening_store, 20.0) + imapFolder.open(Folder.READ_ONLY) + progressNotifier.invoke(R.id.progress_id_opening_store, 40.0) + + val countOfLoadedMsgs = when { + countOfAlreadyLoadedMsgs < 0 -> 0 + else -> countOfAlreadyLoadedMsgs + } + + val isEncryptedModeEnabled = accountEntity.showOnlyEncrypted + var foundMsgs: Array = emptyArray() + var msgsCount = 0 + + if (isEncryptedModeEnabled == true) { + foundMsgs = imapFolder.search(EmailUtil.genEncryptedMsgsSearchTerm(accountEntity)) + foundMsgs?.let { + msgsCount = foundMsgs.size + } + } else { + msgsCount = imapFolder.messageCount + } + + val end = msgsCount - countOfLoadedMsgs + val startCandidate = end - pageSize + 1 + val start = when { + startCandidate < 1 -> 1 + else -> startCandidate + } + val folderName = imapFolder.fullName + + roomDatabase.labelDao() + .getLabelSuspend(accountEntity.email, accountEntity.accountType, folderName)?.let { + roomDatabase.labelDao().updateSuspend(it.copy(messagesTotal = msgsCount)) + } + progressNotifier.invoke(R.id.progress_id_getting_list_of_emails, 60.0) + if (end < 1) { + handleReceivedMsgs(accountEntity, localFolder, imapFolder, arrayOf()) + } else { + val msgs: Array = if (isEncryptedModeEnabled == true) { + foundMsgs.copyOfRange(start - 1, end) + } else { + imapFolder.getMessages(start, end) + } + + val fetchProfile = FetchProfile() + fetchProfile.add(FetchProfile.Item.ENVELOPE) + fetchProfile.add(FetchProfile.Item.FLAGS) + fetchProfile.add(FetchProfile.Item.CONTENT_INFO) + fetchProfile.add(UIDFolder.FetchProfileItem.UID) + imapFolder.fetch(msgs, fetchProfile) + + countOfFetchedMsgs = msgs.size + handleReceivedMsgs(accountEntity, localFolder, folder, msgs) + } + } + progressNotifier.invoke(R.id.progress_id_getting_list_of_emails, 80.0) + return@withContext Result.success(countOfFetchedMsgs) + } + + private suspend fun loadMsgsFromRemoteServerAndStoreLocally( + accountEntity: AccountEntity, + localFolder: LocalFolder, + totalItemsCount: Int, + pageSize: Int + ): Result = withContext(Dispatchers.IO) { + var countOfFetchedMsgs = 0 + when (accountEntity.accountType) { + AccountEntity.ACCOUNT_TYPE_GOOGLE -> { + val labelEntity: LabelEntity? = roomDatabase.labelDao() + .getLabelSuspend(accountEntity.email, accountEntity.accountType, localFolder.fullName) + progressNotifier.invoke(R.id.progress_id_gmail_list, 20.0) + val messagesBaseInfo = GmailApiHelper.loadMsgsBaseInfo( + context = context, + accountEntity = accountEntity, + localFolder = localFolder, + maxResults = pageSize.toLong(), + nextPageToken = if (totalItemsCount > 0) labelEntity?.nextPageToken else null + ) + progressNotifier.invoke(R.id.progress_id_gmail_msgs_info, 60.0) + if (messagesBaseInfo.messages?.isNotEmpty() == true) { + val msgs = GmailApiHelper.loadMsgsInParallel( + context, accountEntity, messagesBaseInfo.messages + ?: emptyList(), localFolder + ) + countOfFetchedMsgs = msgs.size + progressNotifier.invoke(R.id.progress_id_gmail_msgs_info, 90.0) + handleReceivedMsgs(accountEntity, localFolder, msgs) + } else { + progressNotifier.invoke(R.id.progress_id_gmail_msgs_info, 90.0) + } + + labelEntity?.let { + roomDatabase.labelDao() + .updateSuspend(it.copy(nextPageToken = messagesBaseInfo.nextPageToken)) + } + } + } + + return@withContext Result.success(countOfFetchedMsgs) + } + + private suspend fun handleReceivedMsgs( + account: AccountEntity, localFolder: LocalFolder, + msgs: List + ) = withContext(Dispatchers.IO) { + val email = account.email + val folder = localFolder.fullName + + val isEncryptedModeEnabled = account.showOnlyEncrypted ?: false + val msgEntities = MessageEntity.genMessageEntities( + context = context, + email = email, + label = folder, + msgsList = msgs, + isNew = false, + areAllMsgsEncrypted = isEncryptedModeEnabled + ) + + roomDatabase.msgDao().insertWithReplaceSuspend(msgEntities) + GmailApiHelper.identifyAttachments(msgEntities, msgs, account, localFolder, roomDatabase) + val session = Session.getInstance(Properties()) + updateLocalContactsIfNeeded(messages = msgs + .filter { it.labelIds.contains(GmailApiHelper.LABEL_SENT) } + .map { GmaiAPIMimeMessage(session, it) }.toTypedArray() + ) + } + + private suspend fun handleReceivedMsgs( + account: AccountEntity, localFolder: LocalFolder, + remoteFolder: IMAPFolder, msgs: Array + ) = withContext(Dispatchers.IO) { + val email = account.email + val folder = localFolder.fullName + + val isEncryptedModeEnabled = account.showOnlyEncrypted ?: false + val msgEntities = MessageEntity.genMessageEntities( + context = context, + email = email, + label = folder, + folder = remoteFolder, + msgs = msgs, + isNew = false, + areAllMsgsEncrypted = isEncryptedModeEnabled + ) + + roomDatabase.msgDao().insertWithReplaceSuspend(msgEntities) + + if (!isEncryptedModeEnabled) { + CheckIsLoadedMessagesEncryptedWorker.enqueue(context, localFolder) + } + + identifyAttachments(msgEntities, msgs, remoteFolder, account, localFolder, roomDatabase) + updateLocalContactsIfNeeded(remoteFolder, msgs) + } + + private suspend fun identifyAttachments( + msgEntities: List, msgs: Array, + remoteFolder: IMAPFolder, account: AccountEntity, localFolder: + LocalFolder, roomDatabase: FlowCryptRoomDatabase + ) = withContext(Dispatchers.IO) { + try { + val savedMsgUIDsSet = msgEntities.map { it.uid }.toSet() + val attachments = mutableListOf() + for (msg in msgs) { + if (remoteFolder.getUID(msg) in savedMsgUIDsSet) { + val uid = remoteFolder.getUID(msg) + attachments.addAll(EmailUtil.getAttsInfoFromPart(msg).mapNotNull { + AttachmentEntity.fromAttInfo(it.apply { + this.email = account.email + this.folder = localFolder.fullName + this.uid = uid + }) + }) + } + } + + roomDatabase.attachmentDao().insertWithReplaceSuspend(attachments) + } catch (e: Exception) { + e.printStackTrace() + ExceptionUtil.handleError(e) + } + } + + private suspend fun updateLocalContactsIfNeeded( + imapFolder: IMAPFolder? = null, + messages: Array + ) = withContext(Dispatchers.IO) { + try { + val isSentFolder = imapFolder?.attributes?.contains("\\Sent") ?: true + + if (isSentFolder) { + val emailAndNamePairs = ArrayList() + for (message in messages) { + emailAndNamePairs.addAll(getEmailAndNamePairs(message)) + } + + EmailAndNameUpdaterService.enqueueWork(context, emailAndNamePairs) + } + } catch (e: MessagingException) { + e.printStackTrace() + ExceptionUtil.handleError(e) + } + } + + /** + * Generate a list of [EmailAndNamePair] objects from the input message. + * This information will be retrieved from "to" and "cc" headers. + * + * @param msg The input [javax.mail.Message]. + * @return [List] of EmailAndNamePair objects, which contains information + * about + * emails and names. + * @throws MessagingException when retrieve information about recipients. + */ + private fun getEmailAndNamePairs(msg: Message): List { + val pairs = ArrayList() + + val addressesTo = msg.getRecipients(Message.RecipientType.TO) + if (addressesTo != null) { + for (address in addressesTo) { + val internetAddress = address as InternetAddress + pairs.add(EmailAndNamePair(internetAddress.address, internetAddress.personal)) + } + } + + val addressesCC = msg.getRecipients(Message.RecipientType.CC) + if (addressesCC != null) { + for (address in addressesCC) { + val internetAddress = address as InternetAddress + pairs.add(EmailAndNamePair(internetAddress.address, internetAddress.personal)) + } + } + + return pairs + } + + private suspend fun searchMsgsOnRemoteServerAndStoreLocally( + accountEntity: AccountEntity, + localFolder: LocalFolder, + totalItemsCount: Int, + pageSize: Int + ): Result = withContext(Dispatchers.IO) { + var countOfFetchedMsgs = 0 + when (accountEntity.accountType) { + AccountEntity.ACCOUNT_TYPE_GOOGLE -> { + progressNotifier.invoke(R.id.progress_id_gmail_list, 20.0) + val messagesBaseInfo = GmailApiHelper.loadMsgsBaseInfoUsingSearch( + context = context, + accountEntity = accountEntity, + localFolder = localFolder, + maxResults = pageSize.toLong(), + nextPageToken = if (totalItemsCount > 0) searchNextPageToken else null + ) + progressNotifier.invoke(R.id.progress_id_gmail_msgs_info, 70.0) + if (messagesBaseInfo.messages?.isNotEmpty() == true) { + val msgs = GmailApiHelper.loadMsgsInParallel( + context, accountEntity, messagesBaseInfo.messages + ?: emptyList(), localFolder + ) + countOfFetchedMsgs = msgs.size + progressNotifier.invoke(R.id.progress_id_gmail_msgs_info, 90.0) + handleSearchResults( + accountEntity, + localFolder.copy(fullName = JavaEmailConstants.FOLDER_SEARCH), + msgs + ) + } else { + progressNotifier.invoke(R.id.progress_id_gmail_msgs_info, 90.0) + } + + searchNextPageToken = messagesBaseInfo.nextPageToken + } + } + + return@withContext Result.success(countOfFetchedMsgs) + } + + private suspend fun searchMsgsOnRemoteServerAndStoreLocally( + accountEntity: AccountEntity, store: Store, + localFolder: LocalFolder, + countOfAlreadyLoadedMsgs: Int, + pageSize: Int + ): Result = withContext(Dispatchers.IO) { + var countOfFetchedMsgs = 0 + store.getFolder(localFolder.fullName).use { folder -> + val imapFolder = folder as IMAPFolder + progressNotifier.invoke(R.id.progress_id_opening_store, 20.0) + imapFolder.open(Folder.READ_ONLY) + progressNotifier.invoke(R.id.progress_id_opening_store, 40.0) + + val countOfLoadedMsgs = when { + countOfAlreadyLoadedMsgs < 0 -> 0 + else -> countOfAlreadyLoadedMsgs + } + + val foundMsgs = imapFolder.search(EmailUtil.generateSearchTerm(accountEntity, localFolder)) + + val messagesCount = foundMsgs.size + val end = messagesCount - countOfLoadedMsgs + val startCandidate = end - pageSize + 1 + val start = when { + startCandidate < 1 -> 1 + else -> startCandidate + } + progressNotifier.invoke(R.id.progress_id_getting_list_of_emails, 60.0) + + if (end < 1) { + handleSearchResults(accountEntity, localFolder, imapFolder, arrayOf()) + } else { + val bufferedMsgs = Arrays.copyOfRange(foundMsgs, start - 1, end) + + val fetchProfile = FetchProfile() + fetchProfile.add(FetchProfile.Item.ENVELOPE) + fetchProfile.add(FetchProfile.Item.FLAGS) + fetchProfile.add(FetchProfile.Item.CONTENT_INFO) + fetchProfile.add(UIDFolder.FetchProfileItem.UID) + + imapFolder.fetch(bufferedMsgs, fetchProfile) + countOfFetchedMsgs = bufferedMsgs.size + handleSearchResults(accountEntity, localFolder, imapFolder, bufferedMsgs) + } + + progressNotifier.invoke(R.id.progress_id_getting_list_of_emails, 80.0) + } + + return@withContext Result.success(countOfFetchedMsgs) + } + + private suspend fun handleSearchResults( + account: AccountEntity, localFolder: LocalFolder, + remoteFolder: IMAPFolder, msgs: Array + ) = withContext(Dispatchers.IO) { + val email = account.email + val isEncryptedModeEnabled = account.showOnlyEncrypted ?: false + val searchLabel = JavaEmailConstants.FOLDER_SEARCH + + val msgEntities = MessageEntity.genMessageEntities( + context = context, + email = email, + label = searchLabel, + folder = remoteFolder, + msgs = msgs, + isNew = false, + areAllMsgsEncrypted = isEncryptedModeEnabled + ) + + roomDatabase.msgDao().insertWithReplaceSuspend(msgEntities) + + if (!isEncryptedModeEnabled) { + CheckIsLoadedMessagesEncryptedWorker.enqueue(context, localFolder) + } + + updateLocalContactsIfNeeded(remoteFolder, msgs) + } + + private suspend fun handleSearchResults( + account: AccountEntity, localFolder: LocalFolder, + msgs: List + ) = withContext(Dispatchers.IO) { + val email = account.email + val label = localFolder.fullName + + val isEncryptedModeEnabled = account.showOnlyEncrypted ?: false + val msgEntities = MessageEntity.genMessageEntities( + context = context, + email = email, + label = label, + msgsList = msgs, + isNew = false, + areAllMsgsEncrypted = isEncryptedModeEnabled + ) + + roomDatabase.msgDao().insertWithReplaceSuspend(msgEntities) + GmailApiHelper.identifyAttachments(msgEntities, msgs, account, localFolder, roomDatabase) + val session = Session.getInstance(Properties()) + updateLocalContactsIfNeeded(messages = msgs + .filter { it.labelIds.contains(GmailApiHelper.LABEL_SENT) } + .map { GmaiAPIMimeMessage(session, it) }.toTypedArray() + ) + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/data/MessagesRepository.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/data/MessagesRepository.kt new file mode 100644 index 0000000000..8eaeb85b9a --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/data/MessagesRepository.kt @@ -0,0 +1,51 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.data + +import android.content.Context +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import com.flowcrypt.email.api.email.model.LocalFolder +import com.flowcrypt.email.database.FlowCryptRoomDatabase +import com.flowcrypt.email.database.entity.MessageEntity + +/** + * @author Denis Bondarenko + * Date: 5/16/22 + * Time: 10:38 AM + * E-mail: DenBond7@gmail.com + */ +object MessagesRepository { + const val PAGE_SIZE = 20 + + fun getMessagesPager( + context: Context, + localFolder: LocalFolder?, + remoteMediatorProgressNotifier: (resultCode: Int, progress: Double) -> Unit + ): Pager { + val roomDatabase = FlowCryptRoomDatabase.getDatabase(context) + @OptIn(ExperimentalPagingApi::class) + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + enablePlaceholders = false + ), + pagingSourceFactory = { + roomDatabase.msgDao().getMessagesPagingDataSourceFactory( + account = localFolder?.account ?: "", + folder = localFolder?.fullName ?: "" + ) + }, + remoteMediator = MessagesRemoteMediator( + context = context, + roomDatabase = roomDatabase, + localFolder = localFolder, + progressNotifier = remoteMediatorProgressNotifier + ) + ) + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/MessageDao.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/MessageDao.kt index d76dd29c2a..83bbee667c 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/MessageDao.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/MessageDao.kt @@ -6,7 +6,7 @@ package com.flowcrypt.email.database.dao import androidx.lifecycle.LiveData -import androidx.paging.DataSource +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction @@ -18,9 +18,8 @@ import com.flowcrypt.email.database.dao.BaseDao.Companion.getEntitiesViaStepsSus import com.flowcrypt.email.database.entity.MessageEntity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.util.* +import java.util.Locale import javax.mail.Flags -import kotlin.collections.ArrayList /** * This class describes available methods for [MessageEntity] @@ -82,10 +81,10 @@ abstract class MessageDao : BaseDao { ): List @Query("SELECT * FROM messages WHERE email = :account AND folder = :folder ORDER BY received_date DESC") - abstract fun getMessagesDataSourceFactory( + abstract fun getMessagesPagingDataSourceFactory( account: String, folder: String - ): DataSource.Factory + ): PagingSource @Query("SELECT * FROM messages WHERE email = :account AND folder = :folder AND uid = :uid") abstract fun getMsgLiveData(account: String, folder: String, uid: Long): LiveData diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/MessageEntity.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/MessageEntity.kt index 446e4de91b..94273ef5fc 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/MessageEntity.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/MessageEntity.kt @@ -30,7 +30,6 @@ import com.flowcrypt.email.ui.activity.fragment.preferences.NotificationsSetting import com.flowcrypt.email.util.SharedPreferencesHelper import com.google.android.gms.common.util.CollectionUtils import com.sun.mail.imap.IMAPFolder -import java.util.ArrayList import java.util.Properties import javax.mail.Flags import javax.mail.Message @@ -225,6 +224,7 @@ data class MessageEntity( if (msgState != other.msgState) return false if (isSeen != other.isSeen) return false if (uidAsHEX != other.uidAsHEX) return false + if (isPasswordProtected != other.isPasswordProtected) return false return true } @@ -259,6 +259,7 @@ data class MessageEntity( result = 31 * result + msgState.hashCode() result = 31 * result + isSeen.hashCode() result = 31 * result + uidAsHEX.hashCode() + result = 31 * result + isPasswordProtected.hashCode() return result } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/MessagesViewModel.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/MessagesViewModel.kt index bf20992db2..836f5f5ffc 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/MessagesViewModel.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/MessagesViewModel.kt @@ -9,13 +9,9 @@ import android.app.Application import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations -import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope -import androidx.paging.Config -import androidx.paging.PagedList -import androidx.paging.toLiveData +import androidx.paging.cachedIn import com.flowcrypt.email.Constants -import com.flowcrypt.email.R import com.flowcrypt.email.api.email.EmailUtil import com.flowcrypt.email.api.email.FoldersManager import com.flowcrypt.email.api.email.IMAPStoreManager @@ -25,10 +21,9 @@ import com.flowcrypt.email.api.email.gmail.api.GmaiAPIMimeMessage import com.flowcrypt.email.api.email.model.LocalFolder import com.flowcrypt.email.api.email.model.MessageFlag import com.flowcrypt.email.api.retrofit.response.base.Result -import com.flowcrypt.email.database.FlowCryptRoomDatabase +import com.flowcrypt.email.data.MessagesRepository import com.flowcrypt.email.database.MessageState import com.flowcrypt.email.database.entity.AccountEntity -import com.flowcrypt.email.database.entity.AttachmentEntity import com.flowcrypt.email.database.entity.LabelEntity import com.flowcrypt.email.database.entity.MessageEntity import com.flowcrypt.email.extensions.kotlin.toHex @@ -44,13 +39,17 @@ import com.google.api.client.googleapis.json.GoogleJsonResponseException import com.google.api.services.gmail.model.History import com.sun.mail.imap.IMAPFolder import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.IOException import java.math.BigInteger import java.net.HttpURLConnection -import java.util.Arrays import java.util.Properties import javax.mail.FetchProfile import javax.mail.Folder @@ -69,45 +68,26 @@ import javax.mail.internet.InternetAddress * E-mail: DenBond7@gmail.com */ class MessagesViewModel(application: Application) : AccountViewModel(application) { - private var searchNextPageToken: String? = null private val controlledRunnerForRefreshing = ControlledRunner>() - private val controlledRunnerForLoadNextMessages = ControlledRunner>() - private val boundaryCallback = object : PagedList.BoundaryCallback() { - override fun onZeroItemsLoaded() { - super.onZeroItemsLoaded() - loadMsgsFromRemoteServer() - } - - override fun onItemAtEndLoaded(itemAtEnd: MessageEntity) { - super.onItemAtEndLoaded(itemAtEnd) - loadMsgsFromRemoteServer() - } - } + private val activeLocalFolderMutableStateFlow: MutableStateFlow = + MutableStateFlow(null) + private val activeLocalFolderStateFlow: StateFlow = + activeLocalFolderMutableStateFlow.asStateFlow() - private val foldersLiveData = MutableLiveData() - - var msgsLiveData: LiveData>? = - Transformations.switchMap(foldersLiveData) { localFolder -> - liveData { - cancelActionsForPreviousFolder() - val account = roomDatabase.accountDao().getActiveAccountSuspend()?.email ?: "" - - val label = if (localFolder.searchQuery.isNullOrEmpty()) { - localFolder.fullName - } else { - JavaEmailConstants.FOLDER_SEARCH - } + private val loadMsgsListProgressMutableStateFlow: MutableStateFlow> = + MutableStateFlow(Pair(0, 0.0)) + val loadMsgsListProgressStateFlow: StateFlow> = + loadMsgsListProgressMutableStateFlow.asStateFlow() - emitSource( - roomDatabase.msgDao().getMessagesDataSourceFactory(account, label) - .toLiveData( - config = Config(pageSize = JavaEmailConstants.COUNT_OF_LOADED_EMAILS_BY_STEP / 3), - boundaryCallback = boundaryCallback - ) - ) + @ExperimentalCoroutinesApi + val pagerFlow = activeLocalFolderStateFlow.flatMapLatest { localFolder -> + val pager = + MessagesRepository.getMessagesPager(application, localFolder) { resultCode, progress -> + loadMsgsListProgressMutableStateFlow.value = Pair(resultCode, progress) } - } + pager.flow.cachedIn(viewModelScope) + } val msgStatesLiveData = MutableLiveData() var outboxMsgsLiveData: LiveData> = @@ -115,25 +95,10 @@ class MessagesViewModel(application: Application) : AccountViewModel(application roomDatabase.msgDao().getOutboxMsgsLD(it?.email ?: "") } - val loadMsgsFromRemoteServerLiveData = MutableLiveData>() val refreshMsgsLiveData = MutableLiveData>() - val msgsCountLiveData = Transformations.switchMap(loadMsgsFromRemoteServerLiveData) { - liveData { - if (it.status != Result.Status.SUCCESS) return@liveData - val account = roomDatabase.accountDao().getActiveAccountSuspend()?.email ?: return@liveData - val folder = foldersLiveData.value ?: return@liveData - val label = if (folder.searchQuery.isNullOrEmpty()) { - folder.fullName - } else { - JavaEmailConstants.FOLDER_SEARCH - } - emit(roomDatabase.msgDao().countSuspend(account, label)) - } - } - fun switchFolder(newFolder: LocalFolder, deleteAllMsgs: Boolean, forceClearFolderCache: Boolean) { - if (foldersLiveData.value == newFolder) { + if (activeLocalFolderMutableStateFlow.value == newFolder) { return } @@ -165,7 +130,7 @@ class MessagesViewModel(application: Application) : AccountViewModel(application } } - foldersLiveData.value = newFolder + activeLocalFolderMutableStateFlow.value = newFolder } } @@ -190,70 +155,6 @@ class MessagesViewModel(application: Application) : AccountViewModel(application } } - fun loadMsgsFromRemoteServer() { - viewModelScope.launch { - val localFolder = foldersLiveData.value ?: return@launch - if (localFolder.isOutbox()) { - loadMsgsFromRemoteServerLiveData.value = Result.success(true) - return@launch - } - - val accountEntity = getActiveAccountSuspend() - accountEntity?.let { - val totalItemsCount = roomDatabase.msgDao().getMsgsCount( - accountEntity.email, - if (localFolder.searchQuery.isNullOrEmpty()) localFolder.fullName else JavaEmailConstants.FOLDER_SEARCH - ) - if (totalItemsCount % JavaEmailConstants.COUNT_OF_LOADED_EMAILS_BY_STEP != 0) return@launch - - loadMsgsFromRemoteServerLiveData.value = Result.loading() - loadMsgsFromRemoteServerLiveData.value = Result.loading( - progress = 10.0, - resultCode = R.id.progress_id_start_of_loading_new_messages - ) - loadMsgsFromRemoteServerLiveData.value = - controlledRunnerForLoadNextMessages.cancelPreviousThenRun { - return@cancelPreviousThenRun if (accountEntity.useAPI) { - GmailApiHelper.executeWithResult { - if (localFolder.searchQuery.isNullOrEmpty()) { - loadMsgsFromRemoteServerAndStoreLocally( - accountEntity, - localFolder, - totalItemsCount - ) - } else { - searchMsgsOnRemoteServerAndStoreLocally( - accountEntity, - localFolder, - totalItemsCount - ) - } - } - } else { - IMAPStoreManager.getConnection(accountEntity.id)?.executeWithResult { store -> - if (localFolder.searchQuery.isNullOrEmpty()) { - loadMsgsFromRemoteServerAndStoreLocally( - accountEntity, - store, - localFolder, - totalItemsCount - ) - } else { - searchMsgsOnRemoteServerAndStoreLocally( - accountEntity, - store, - localFolder, - totalItemsCount - ) - } - } - ?: Result.exception(NullPointerException("There is no active connection for ${accountEntity.email}")) - } - } - } - } - } - fun deleteOutgoingMsgs(entities: Iterable) { val app = getApplication() @@ -355,219 +256,6 @@ class MessagesViewModel(application: Application) : AccountViewModel(application return candidates } - private suspend fun loadMsgsFromRemoteServerAndStoreLocally( - accountEntity: AccountEntity, store: Store, - localFolder: LocalFolder, - countOfAlreadyLoadedMsgs: Int - ): Result = withContext(Dispatchers.IO) { - store.getFolder(localFolder.fullName).use { folder -> - val imapFolder = folder as IMAPFolder - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 70.0, - resultCode = R.id.progress_id_opening_store - ) - ) - imapFolder.open(Folder.READ_ONLY) - - val countOfLoadedMsgs = when { - countOfAlreadyLoadedMsgs < 0 -> 0 - else -> countOfAlreadyLoadedMsgs - } - - val isEncryptedModeEnabled = accountEntity.showOnlyEncrypted - var foundMsgs: Array = emptyArray() - var msgsCount = 0 - - if (isEncryptedModeEnabled == true) { - foundMsgs = imapFolder.search(EmailUtil.genEncryptedMsgsSearchTerm(accountEntity)) - foundMsgs?.let { - msgsCount = foundMsgs.size - } - } else { - msgsCount = imapFolder.messageCount - } - - val end = msgsCount - countOfLoadedMsgs - val startCandidate = end - JavaEmailConstants.COUNT_OF_LOADED_EMAILS_BY_STEP + 1 - val start = when { - startCandidate < 1 -> 1 - else -> startCandidate - } - val folderName = imapFolder.fullName - - roomDatabase.labelDao() - .getLabelSuspend(accountEntity.email, accountEntity.accountType, folderName)?.let { - roomDatabase.labelDao().updateSuspend(it.copy(messagesTotal = msgsCount)) - } - - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 80.0, - resultCode = R.id.progress_id_getting_list_of_emails - ) - ) - if (end < 1) { - handleReceivedMsgs(accountEntity, localFolder, imapFolder, arrayOf()) - } else { - val msgs: Array = if (isEncryptedModeEnabled == true) { - foundMsgs.copyOfRange(start - 1, end) - } else { - imapFolder.getMessages(start, end) - } - - val fetchProfile = FetchProfile() - fetchProfile.add(FetchProfile.Item.ENVELOPE) - fetchProfile.add(FetchProfile.Item.FLAGS) - fetchProfile.add(FetchProfile.Item.CONTENT_INFO) - fetchProfile.add(UIDFolder.FetchProfileItem.UID) - imapFolder.fetch(msgs, fetchProfile) - - handleReceivedMsgs(accountEntity, localFolder, folder, msgs) - } - } - - return@withContext Result.success(true) - } - - private suspend fun loadMsgsFromRemoteServerAndStoreLocally( - accountEntity: AccountEntity, - localFolder: LocalFolder, - totalItemsCount: Int - ): Result = withContext(Dispatchers.IO) { - when (accountEntity.accountType) { - AccountEntity.ACCOUNT_TYPE_GOOGLE -> { - val labelEntity: LabelEntity? = roomDatabase.labelDao() - .getLabelSuspend(accountEntity.email, accountEntity.accountType, localFolder.fullName) - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 20.0, - resultCode = R.id.progress_id_gmail_list - ) - ) - val messagesBaseInfo = GmailApiHelper.loadMsgsBaseInfo( - getApplication(), accountEntity, - localFolder, if (totalItemsCount > 0) labelEntity?.nextPageToken else null - ) - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 70.0, - resultCode = R.id.progress_id_gmail_msgs_info - ) - ) - - if (messagesBaseInfo.messages?.isNotEmpty() == true) { - val msgs = GmailApiHelper.loadMsgsInParallel( - getApplication(), accountEntity, messagesBaseInfo.messages - ?: emptyList(), localFolder - ) - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 90.0, - resultCode = R.id.progress_id_gmail_msgs_info - ) - ) - handleReceivedMsgs(accountEntity, localFolder, msgs) - } else { - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 90.0, - resultCode = R.id.progress_id_gmail_msgs_info - ) - ) - } - labelEntity?.let { - roomDatabase.labelDao() - .updateSuspend(it.copy(nextPageToken = messagesBaseInfo.nextPageToken)) - } - } - } - - return@withContext Result.success(true) - } - - private suspend fun handleReceivedMsgs( - account: AccountEntity, localFolder: LocalFolder, - msgs: List - ) = withContext(Dispatchers.IO) { - val email = account.email - val folder = localFolder.fullName - - val isEncryptedModeEnabled = account.showOnlyEncrypted ?: false - val msgEntities = MessageEntity.genMessageEntities( - context = getApplication(), - email = email, - label = folder, - msgsList = msgs, - isNew = false, - areAllMsgsEncrypted = isEncryptedModeEnabled - ) - - roomDatabase.msgDao().insertWithReplaceSuspend(msgEntities) - GmailApiHelper.identifyAttachments(msgEntities, msgs, account, localFolder, roomDatabase) - val session = Session.getInstance(Properties()) - updateLocalContactsIfNeeded(messages = msgs - .filter { it.labelIds.contains(GmailApiHelper.LABEL_SENT) } - .map { GmaiAPIMimeMessage(session, it) }.toTypedArray() - ) - } - - private suspend fun handleReceivedMsgs( - account: AccountEntity, localFolder: LocalFolder, - remoteFolder: IMAPFolder, msgs: Array - ) = withContext(Dispatchers.IO) { - val email = account.email - val folder = localFolder.fullName - - val isEncryptedModeEnabled = account.showOnlyEncrypted ?: false - val msgEntities = MessageEntity.genMessageEntities( - context = getApplication(), - email = email, - label = folder, - folder = remoteFolder, - msgs = msgs, - isNew = false, - areAllMsgsEncrypted = isEncryptedModeEnabled - ) - - roomDatabase.msgDao().insertWithReplaceSuspend(msgEntities) - - if (!isEncryptedModeEnabled) { - CheckIsLoadedMessagesEncryptedWorker.enqueue(getApplication(), localFolder) - } - - identifyAttachments(msgEntities, msgs, remoteFolder, account, localFolder, roomDatabase) - updateLocalContactsIfNeeded(remoteFolder, msgs) - } - - private suspend fun identifyAttachments( - msgEntities: List, msgs: Array, - remoteFolder: IMAPFolder, account: AccountEntity, localFolder: - LocalFolder, roomDatabase: FlowCryptRoomDatabase - ) = withContext(Dispatchers.IO) { - try { - val savedMsgUIDsSet = msgEntities.map { it.uid }.toSet() - val attachments = mutableListOf() - for (msg in msgs) { - if (remoteFolder.getUID(msg) in savedMsgUIDsSet) { - val uid = remoteFolder.getUID(msg) - attachments.addAll(EmailUtil.getAttsInfoFromPart(msg).mapNotNull { - AttachmentEntity.fromAttInfo(it.apply { - this.email = account.email - this.folder = localFolder.fullName - this.uid = uid - }) - }) - } - } - - roomDatabase.attachmentDao().insertWithReplaceSuspend(attachments) - } catch (e: Exception) { - e.printStackTrace() - ExceptionUtil.handleError(e) - } - } - private suspend fun updateLocalContactsIfNeeded( imapFolder: IMAPFolder? = null, messages: Array @@ -621,167 +309,6 @@ class MessagesViewModel(application: Application) : AccountViewModel(application return pairs } - private suspend fun searchMsgsOnRemoteServerAndStoreLocally( - accountEntity: AccountEntity, - localFolder: LocalFolder, - totalItemsCount: Int - ): Result = withContext(Dispatchers.IO) { - when (accountEntity.accountType) { - AccountEntity.ACCOUNT_TYPE_GOOGLE -> { - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 20.0, - resultCode = R.id.progress_id_gmail_list - ) - ) - val messagesBaseInfo = GmailApiHelper.loadMsgsBaseInfoUsingSearch( - getApplication(), accountEntity, - localFolder, if (totalItemsCount > 0) searchNextPageToken else null - ) - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 70.0, - resultCode = R.id.progress_id_gmail_msgs_info - ) - ) - - if (messagesBaseInfo.messages?.isNotEmpty() == true) { - val msgs = GmailApiHelper.loadMsgsInParallel( - getApplication(), accountEntity, messagesBaseInfo.messages - ?: emptyList(), localFolder - ) - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 90.0, - resultCode = R.id.progress_id_gmail_msgs_info - ) - ) - handleSearchResults( - accountEntity, - localFolder.copy(fullName = JavaEmailConstants.FOLDER_SEARCH), - msgs - ) - } else { - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 90.0, - resultCode = R.id.progress_id_gmail_msgs_info - ) - ) - } - - searchNextPageToken = messagesBaseInfo.nextPageToken - } - } - - return@withContext Result.success(true) - } - - - private suspend fun searchMsgsOnRemoteServerAndStoreLocally( - accountEntity: AccountEntity, store: Store, - localFolder: LocalFolder, - countOfAlreadyLoadedMsgs: Int - ): Result = withContext(Dispatchers.IO) { - store.getFolder(localFolder.fullName).use { folder -> - val imapFolder = folder as IMAPFolder - imapFolder.open(Folder.READ_ONLY) - - val countOfLoadedMsgs = when { - countOfAlreadyLoadedMsgs < 0 -> 0 - else -> countOfAlreadyLoadedMsgs - } - - val foundMsgs = imapFolder.search(EmailUtil.generateSearchTerm(accountEntity, localFolder)) - - val messagesCount = foundMsgs.size - val end = messagesCount - countOfLoadedMsgs - val startCandidate = end - JavaEmailConstants.COUNT_OF_LOADED_EMAILS_BY_STEP + 1 - val start = when { - startCandidate < 1 -> 1 - else -> startCandidate - } - - loadMsgsFromRemoteServerLiveData.postValue( - Result.loading( - progress = 80.0, - resultCode = R.id.progress_id_getting_list_of_emails - ) - ) - - if (end < 1) { - handleSearchResults(accountEntity, localFolder, imapFolder, arrayOf()) - } else { - val bufferedMsgs = Arrays.copyOfRange(foundMsgs, start - 1, end) - - val fetchProfile = FetchProfile() - fetchProfile.add(FetchProfile.Item.ENVELOPE) - fetchProfile.add(FetchProfile.Item.FLAGS) - fetchProfile.add(FetchProfile.Item.CONTENT_INFO) - fetchProfile.add(UIDFolder.FetchProfileItem.UID) - - imapFolder.fetch(bufferedMsgs, fetchProfile) - - handleSearchResults(accountEntity, localFolder, imapFolder, bufferedMsgs) - } - } - - return@withContext Result.success(true) - } - - private suspend fun handleSearchResults( - account: AccountEntity, localFolder: LocalFolder, - remoteFolder: IMAPFolder, msgs: Array - ) = withContext(Dispatchers.IO) { - val email = account.email - val isEncryptedModeEnabled = account.showOnlyEncrypted ?: false - val searchLabel = JavaEmailConstants.FOLDER_SEARCH - - val msgEntities = MessageEntity.genMessageEntities( - context = getApplication(), - email = email, - label = searchLabel, - folder = remoteFolder, - msgs = msgs, - isNew = false, - areAllMsgsEncrypted = isEncryptedModeEnabled - ) - - roomDatabase.msgDao().insertWithReplaceSuspend(msgEntities) - - if (!isEncryptedModeEnabled) { - CheckIsLoadedMessagesEncryptedWorker.enqueue(getApplication(), localFolder) - } - - updateLocalContactsIfNeeded(remoteFolder, msgs) - } - - private suspend fun handleSearchResults( - account: AccountEntity, localFolder: LocalFolder, - msgs: List - ) = withContext(Dispatchers.IO) { - val email = account.email - val label = localFolder.fullName - - val isEncryptedModeEnabled = account.showOnlyEncrypted ?: false - val msgEntities = MessageEntity.genMessageEntities( - context = getApplication(), - email = email, - label = label, - msgsList = msgs, - isNew = false, - areAllMsgsEncrypted = isEncryptedModeEnabled - ) - - roomDatabase.msgDao().insertWithReplaceSuspend(msgEntities) - GmailApiHelper.identifyAttachments(msgEntities, msgs, account, localFolder, roomDatabase) - val session = Session.getInstance(Properties()) - updateLocalContactsIfNeeded(messages = msgs - .filter { it.labelIds.contains(GmailApiHelper.LABEL_SENT) } - .map { GmaiAPIMimeMessage(session, it) }.toTypedArray() - ) - } - private suspend fun refreshMsgsInternal( accountEntity: AccountEntity, localFolder: LocalFolder @@ -1001,14 +528,4 @@ class MessagesViewModel(application: Application) : AccountViewModel(application roomDatabase.labelDao().getLabelSuspend(accountEntity.email, accountEntity.accountType, label) labelEntity?.let { roomDatabase.labelDao().updateSuspend(it.copy(historyId = null)) } } - - private suspend fun cancelActionsForPreviousFolder() { - refreshMsgsLiveData.value = controlledRunnerForRefreshing.cancelPreviousThenRun { - return@cancelPreviousThenRun Result.none() - } - loadMsgsFromRemoteServerLiveData.value = - controlledRunnerForLoadNextMessages.cancelPreviousThenRun { - return@cancelPreviousThenRun Result.none() - } - } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MessagesListFragment.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MessagesListFragment.kt index bc3d74046d..efbfe26c81 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MessagesListFragment.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MessagesListFragment.kt @@ -29,6 +29,7 @@ import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState import androidx.recyclerview.selection.SelectionTracker import androidx.recyclerview.selection.StorageStrategy import androidx.recyclerview.widget.DividerItemDecoration @@ -72,6 +73,7 @@ import com.flowcrypt.email.ui.activity.fragment.base.BaseFragment import com.flowcrypt.email.ui.activity.fragment.base.ListProgressBehaviour import com.flowcrypt.email.ui.activity.fragment.dialog.InfoDialogFragment import com.flowcrypt.email.ui.activity.fragment.dialog.TwoWayDialogFragment +import com.flowcrypt.email.ui.adapter.MsgsLoadStateAdapter import com.flowcrypt.email.ui.adapter.MsgsPagedListAdapter import com.flowcrypt.email.ui.adapter.selection.CustomStableIdKeyProvider import com.flowcrypt.email.ui.adapter.selection.MsgItemDetailsLookup @@ -81,6 +83,8 @@ import com.google.android.gms.auth.UserRecoverableAuthException import com.google.android.material.snackbar.Snackbar import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException import com.sun.mail.imap.protocol.SearchSequence +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import me.everything.android.ui.overscroll.IOverScrollDecor import me.everything.android.ui.overscroll.IOverScrollState @@ -98,8 +102,8 @@ import javax.mail.AuthenticationFailedException * Time: 15:39 * E-mail: DenBond7@gmail.com */ -class MessagesListFragment : BaseFragment(), ListProgressBehaviour, - SwipeRefreshLayout.OnRefreshListener, MsgsPagedListAdapter.OnMessageClickListener { +class MessagesListFragment : BaseFragment(), + ListProgressBehaviour, SwipeRefreshLayout.OnRefreshListener { override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) = FragmentMessagesListBinding.inflate(inflater, container, false) @@ -120,13 +124,27 @@ class MessagesListFragment : BaseFragment(), ListPr private var footerProgressView: View? = null private var tracker: SelectionTracker? = null - private var keyProvider: CustomStableIdKeyProvider? = null private var actionMode: ActionMode? = null private var activeMsgEntity: MessageEntity? = null private val currentFolder: LocalFolder? get() = labelsViewModel.activeFolderLiveData.value - private lateinit var adapter: MsgsPagedListAdapter + private val adapter by lazy { + MsgsPagedListAdapter(object : MsgsPagedListAdapter.OnMessagesActionListener { + override fun onMsgClick(msgEntity: MessageEntity) { + onMsgClicked(msgEntity) + } + + override fun onExistingMsgsChanged(snapshotOfExistingIds: Set) { + val selectedIds = tracker?.selection?.mapNotNull { it }?.toSet() ?: emptySet() + val irrelevantSelectedIds = selectedIds - snapshotOfExistingIds + tracker?.setItemsSelected(irrelevantSelectedIds, false) + } + }) { key -> tracker?.isSelected(key) ?: false } + } + + private val keyProvider by lazy { CustomStableIdKeyProvider(adapter) } + private var keepSelectionInMemory = false private var isForceSendingEnabled: Boolean = true @@ -161,7 +179,6 @@ class MessagesListFragment : BaseFragment(), ListPr override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setHasOptionsMenu(true) - adapter = MsgsPagedListAdapter(this) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -342,58 +359,6 @@ class MessagesListFragment : BaseFragment(), ListPr } } - override fun onMsgClick(msgEntity: MessageEntity) { - activeMsgEntity = msgEntity - if (tracker?.hasSelection() == true) { - return - } - - val isOutbox = - JavaEmailConstants.FOLDER_OUTBOX.equals(currentFolder?.fullName, ignoreCase = true) - val isRawMsgAvailable = msgEntity.rawMessageWithoutAttachments?.isNotEmpty() ?: false - if (isOutbox || isRawMsgAvailable || GeneralUtil.isConnected(context)) { - when (msgEntity.msgState) { - MessageState.ERROR_ORIGINAL_MESSAGE_MISSING, - MessageState.ERROR_ORIGINAL_ATTACHMENT_NOT_FOUND, - MessageState.ERROR_CACHE_PROBLEM, - MessageState.ERROR_DURING_CREATION, - MessageState.ERROR_SENDING_FAILED, - MessageState.ERROR_PRIVATE_KEY_NOT_FOUND, - MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER, - MessageState.ERROR_PASSWORD_PROTECTED -> handleOutgoingMsgWhichHasSomeError( - msgEntity - ) - else -> { - if (isOutbox && !isRawMsgAvailable) { - showTwoWayDialog( - requestCode = REQUEST_CODE_MESSAGE_DETAILS_UNAVAILABLE, - dialogTitle = "", - dialogMsg = getString(R.string.message_failed_to_create), - positiveButtonTitle = getString(R.string.delete_message), - negativeButtonTitle = getString(R.string.cancel), - isCancelable = true - ) - } else { - currentFolder?.let { localFolder -> - navController?.navigate( - MessagesListFragmentDirections.actionMessagesListFragmentToMessageDetailsFragment( - messageEntity = msgEntity, - localFolder = localFolder - ) - ) - } - } - } - } - } else { - showInfoSnackbar( - view, - getString(R.string.internet_connection_is_not_available), - Snackbar.LENGTH_LONG - ) - } - } - fun onDrawerStateChanged(slideOffset: Float, isOpened: Boolean) { when { slideOffset > 0 -> { @@ -513,9 +478,6 @@ class MessagesListFragment : BaseFragment(), ListPr } } - /** - * Try to load a next messages from an IMAP server. - */ private fun loadNextMsgs() { if (isOutboxFolder) { return @@ -527,8 +489,7 @@ class MessagesListFragment : BaseFragment(), ListPr if (currentFolder == null) { labelsViewModel.loadLabels() } else { - adapter.changeProgress(true) - msgsViewModel.loadMsgsFromRemoteServer() + adapter.refresh() } } else { footerProgressView?.visibility = View.GONE @@ -569,35 +530,30 @@ class MessagesListFragment : BaseFragment(), ListPr binding?.recyclerViewMsgs?.addItemDecoration( DividerItemDecoration(context, layoutManager.orientation) ) - binding?.recyclerViewMsgs?.adapter = adapter + binding?.recyclerViewMsgs?.adapter = adapter.withLoadStateFooter(MsgsLoadStateAdapter()) setupItemTouchHelper() setupSelectionTracker() setupBottomOverScroll() } private fun setupSelectionTracker() { - adapter.tracker = null binding?.recyclerViewMsgs?.let { recyclerView -> - keyProvider = CustomStableIdKeyProvider(recyclerView) - keyProvider?.let { - tracker = SelectionTracker.Builder( - MessagesListFragment::class.java.simpleName, - recyclerView, - it, - MsgItemDetailsLookup(recyclerView), - StorageStrategy.createLongStorage() - ).withSelectionPredicate(object : SelectionTracker.SelectionPredicate() { - override fun canSetStateForKey(key: Long, nextState: Boolean): Boolean = - currentFolder?.searchQuery == null - - override fun canSetStateAtPosition(position: Int, nextState: Boolean): Boolean = - currentFolder?.searchQuery == null - - override fun canSelectMultiple(): Boolean = true - }).build() - tracker?.addObserver(selectionObserver) - adapter.tracker = tracker - } + tracker = SelectionTracker.Builder( + MessagesListFragment::class.java.simpleName, + recyclerView, + keyProvider, + MsgItemDetailsLookup(recyclerView), + StorageStrategy.createLongStorage() + ).withSelectionPredicate(object : SelectionTracker.SelectionPredicate() { + override fun canSetStateForKey(key: Long, nextState: Boolean): Boolean = + currentFolder?.searchQuery == null + + override fun canSetStateAtPosition(position: Int, nextState: Boolean): Boolean = + currentFolder?.searchQuery == null + + override fun canSelectMultiple(): Boolean = true + }).build() + tracker?.addObserver(selectionObserver) } } @@ -623,7 +579,7 @@ class MessagesListFragment : BaseFragment(), ListPr ): Int { val position = viewHolder.bindingAdapterPosition return if (position != RecyclerView.NO_POSITION) { - val msgEntity = adapter.getMsgEntity(position) + val msgEntity: MessageEntity? = adapter.getMessageEntity(position) if (msgEntity?.msgState == MessageState.PENDING_ARCHIVING) { 0 } else @@ -636,11 +592,13 @@ class MessagesListFragment : BaseFragment(), ListPr override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val position = viewHolder.bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { - val item = adapter.getItemId(position) + val itemId = adapter.getMessageEntity(position)?.id ?: return currentFolder?.let { msgsViewModel.changeMsgsState( - listOf(item), it, MessageState - .PENDING_ARCHIVING, false + ids = listOf(itemId), + localFolder = it, + newMsgState = MessageState.PENDING_ARCHIVING, + notifyMsgStatesListener = false ) } @@ -651,7 +609,12 @@ class MessagesListFragment : BaseFragment(), ListPr duration = Snackbar.LENGTH_LONG ) { currentFolder?.let { - msgsViewModel.changeMsgsState(listOf(item), it, MessageState.NONE, false) + msgsViewModel.changeMsgsState( + ids = listOf(itemId), + localFolder = it, + newMsgState = MessageState.NONE, + notifyMsgStatesListener = false + ) //we should force archiving action because we can have other messages in the pending archiving states msgsViewModel.msgStatesLiveData.postValue(MessageState.PENDING_ARCHIVING) } @@ -763,9 +726,9 @@ class MessagesListFragment : BaseFragment(), ListPr if (oldState == IOverScrollState.STATE_DRAG_END_SIDE && System.currentTimeMillis() - lastCallTime >= TIMEOUT_BETWEEN_ACTIONS ) { - if (msgsViewModel.loadMsgsFromRemoteServerLiveData.value?.status != Result.Status.LOADING) { - msgsViewModel.loadMsgsFromRemoteServer() - } + /*if (msgsViewModel.loadMsgsFromRemoteServerLiveData.value?.status != Result.Status.LOADING) { + adapter.refresh() + }*/ } } } @@ -842,7 +805,7 @@ class MessagesListFragment : BaseFragment(), ListPr if (isChangeSeenStateActionEnabled()) { val id = tracker?.selection?.first() ?: return true - val msgEntity = adapter.getMsgEntity(keyProvider?.getPosition(id)) + val msgEntity = adapter.getMessageEntity(keyProvider.getPosition(id)) menuActionMarkUnread?.isVisible = msgEntity?.isSeen == true menuActionMarkRead?.isVisible = msgEntity?.isSeen != true @@ -862,74 +825,73 @@ class MessagesListFragment : BaseFragment(), ListPr } private fun setupMsgsViewModel() { - msgsViewModel.msgsCountLiveData.observe(viewLifecycleOwner) { - if (it ?: 0 == 0) { - showEmptyView() - } else { - showContent() + @OptIn(ExperimentalCoroutinesApi::class) + lifecycleScope.launch { + msgsViewModel.pagerFlow.collectLatest { pagingData -> + adapter.submitData(pagingData) + actionMode?.invalidate() } } - msgsViewModel.msgsLiveData?.observe(viewLifecycleOwner) { - if (it?.size ?: 0 != 0) { - showContent() - } - - adapter.submitList(it) - actionMode?.invalidate() - } + lifecycleScope.launchWhenStarted { + msgsViewModel.loadMsgsListProgressStateFlow.collect { + val resultCode = it.first + val progress = it.second.toInt() - msgsViewModel.loadMsgsFromRemoteServerLiveData.observe(viewLifecycleOwner) { - when (it.status) { - Result.Status.LOADING -> { - if (it.progress == null) { - countingIdlingResource?.incrementSafely() - } - - if (binding?.recyclerViewMsgs?.adapter?.itemCount == 0) { - showProgress() - } - - val progress = it.progress?.toInt() ?: 0 + if (progress == 0) { + countingIdlingResource?.incrementSafely() + } - when (it.resultCode) { - R.id.progress_id_start_of_loading_new_messages -> setActionProgress( - progress, - "Starting" - ) + if (binding?.recyclerViewMsgs?.adapter?.itemCount == 0) { + showProgress() + } - R.id.progress_id_adding_task_to_queue -> setActionProgress(progress, "Queuing") + when (resultCode) { + R.id.progress_id_start_of_loading_new_messages -> setActionProgress( + progress, + "Starting" + ) - R.id.progress_id_running_task -> setActionProgress(progress, "Running task") + R.id.progress_id_opening_store -> setActionProgress(progress, "Opening store") - R.id.progress_id_resetting_connection -> setActionProgress( - progress, - "Resetting connection" - ) + R.id.progress_id_getting_list_of_emails -> setActionProgress( + progress, + "Getting list of emails" + ) - R.id.progress_id_connecting_to_email_server -> setActionProgress(progress, "Connecting") + R.id.progress_id_gmail_list -> setActionProgress(progress, "Getting list of emails") - R.id.progress_id_running_smtp_action -> setActionProgress( - progress, - "Running SMTP action" - ) + R.id.progress_id_gmail_msgs_info -> setActionProgress(progress, "Getting emails info") + R.id.progress_id_done -> { + setActionProgress(progress, "Done") + countingIdlingResource?.decrementSafely() + } + } + } + } - R.id.progress_id_running_imap_action -> setActionProgress( - progress, - "Running IMAP action" - ) + lifecycleScope.launch { + adapter.loadStateFlow.collect { loadState -> + when { + loadState.mediator?.refresh is LoadState.Loading && adapter.itemCount == 0 -> { + showProgress() + } - R.id.progress_id_opening_store -> setActionProgress(progress, "Opening store") + loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 -> { + showEmptyView() + } - R.id.progress_id_getting_list_of_emails -> setActionProgress( - progress, - "Getting list of emails" - ) + loadState.refresh is LoadState.NotLoading && adapter.itemCount > 0 -> { + showContent() + } + } + } + } - R.id.progress_id_gmail_list -> setActionProgress(progress, "Getting list of emails") + /*msgsViewModel.loadMsgsFromRemoteServerLiveData.observe(viewLifecycleOwner) { + when (it.status) { + Result.Status.LOADING -> { - R.id.progress_id_gmail_msgs_info -> setActionProgress(progress, "Getting emails info") - } } Result.Status.SUCCESS -> { @@ -967,7 +929,7 @@ class MessagesListFragment : BaseFragment(), ListPr countingIdlingResource?.decrementSafely() } } - } + }*/ msgsViewModel.msgStatesLiveData.observe(viewLifecycleOwner) { when (it) { @@ -1137,9 +1099,9 @@ class MessagesListFragment : BaseFragment(), ListPr val ids = tracker?.selection?.map { it } ?: emptyList() if (ids.isNotEmpty()) { msgsViewModel.changeMsgsState( - ids, - localFolder, - MessageState.PENDING_DELETING_PERMANENTLY + ids = ids, + localFolder = localFolder, + newMsgState = MessageState.PENDING_DELETING_PERMANENTLY ) } } @@ -1238,8 +1200,7 @@ class MessagesListFragment : BaseFragment(), ListPr tracker?.clearSelection() val newFolder = currentFolder - adapter.currentFolder = newFolder - adapter.submitList(null) + adapter.switchFolder(newFolder) val isFolderNameEmpty = newFolder?.fullName?.isEmpty() val isItSyncOrOutboxFolder = isItSyncOrOutboxFolder(newFolder) @@ -1265,7 +1226,58 @@ class MessagesListFragment : BaseFragment(), ListPr getString(R.string.progress_message, progress, message) } else { binding?.textViewActionProgress?.text = null - adapter.changeProgress(false) + } + } + + private fun onMsgClicked(msgEntity: MessageEntity) { + activeMsgEntity = msgEntity + if (tracker?.hasSelection() == true) { + return + } + + val isOutbox = + JavaEmailConstants.FOLDER_OUTBOX.equals(currentFolder?.fullName, ignoreCase = true) + val isRawMsgAvailable = msgEntity.rawMessageWithoutAttachments?.isNotEmpty() ?: false + if (isOutbox || isRawMsgAvailable || GeneralUtil.isConnected(context)) { + when (msgEntity.msgState) { + MessageState.ERROR_ORIGINAL_MESSAGE_MISSING, + MessageState.ERROR_ORIGINAL_ATTACHMENT_NOT_FOUND, + MessageState.ERROR_CACHE_PROBLEM, + MessageState.ERROR_DURING_CREATION, + MessageState.ERROR_SENDING_FAILED, + MessageState.ERROR_PRIVATE_KEY_NOT_FOUND, + MessageState.ERROR_COPY_NOT_SAVED_IN_SENT_FOLDER, + MessageState.ERROR_PASSWORD_PROTECTED -> handleOutgoingMsgWhichHasSomeError( + msgEntity + ) + else -> { + if (isOutbox && !isRawMsgAvailable) { + showTwoWayDialog( + requestCode = REQUEST_CODE_MESSAGE_DETAILS_UNAVAILABLE, + dialogTitle = "", + dialogMsg = getString(R.string.message_failed_to_create), + positiveButtonTitle = getString(R.string.delete_message), + negativeButtonTitle = getString(R.string.cancel), + isCancelable = true + ) + } else { + currentFolder?.let { localFolder -> + navController?.navigate( + MessagesListFragmentDirections.actionMessagesListFragmentToMessageDetailsFragment( + messageEntity = msgEntity, + localFolder = localFolder + ) + ) + } + } + } + } + } else { + showInfoSnackbar( + view, + getString(R.string.internet_connection_is_not_available), + Snackbar.LENGTH_LONG + ) } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsLoadStateAdapter.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsLoadStateAdapter.kt new file mode 100644 index 0000000000..5dc12dfe95 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsLoadStateAdapter.kt @@ -0,0 +1,43 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.ui.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter +import androidx.recyclerview.widget.RecyclerView +import com.flowcrypt.email.R +import com.flowcrypt.email.databinding.LoadMsgsProgressBinding +import com.flowcrypt.email.extensions.visibleOrGone + +/** + * @author Denis Bondarenko + * Date: 5/18/22 + * Time: 6:23 PM + * E-mail: DenBond7@gmail.com + */ +class MsgsLoadStateAdapter : LoadStateAdapter() { + override fun onBindViewHolder(holder: ViewHolder, loadState: LoadState) { + holder.bind(loadState) + } + + override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ViewHolder { + return ViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.load_msgs_progress, parent, false) + ) + } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding: LoadMsgsProgressBinding = LoadMsgsProgressBinding.bind(itemView) + + fun bind(loadState: LoadState) { + binding.progressBar.visibleOrGone(loadState is LoadState.Loading) + } + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsPagedListAdapter.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsPagedListAdapter.kt index d3d14b7902..13e146ed4f 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsPagedListAdapter.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MsgsPagedListAdapter.kt @@ -15,15 +15,10 @@ import android.text.style.ForegroundColorSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.TextView -import androidx.annotation.IntDef +import androidx.annotation.IntRange import androidx.core.content.ContextCompat -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.SelectionTracker import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.flowcrypt.email.R @@ -32,10 +27,12 @@ import com.flowcrypt.email.api.email.JavaEmailConstants import com.flowcrypt.email.api.email.model.LocalFolder import com.flowcrypt.email.database.MessageState import com.flowcrypt.email.database.entity.MessageEntity +import com.flowcrypt.email.databinding.MessagesListItemBinding +import com.flowcrypt.email.extensions.gone +import com.flowcrypt.email.extensions.visible +import com.flowcrypt.email.extensions.visibleOrGone import com.flowcrypt.email.util.DateTimeUtil -import com.flowcrypt.email.util.LogsUtil import com.flowcrypt.email.util.UIUtil -import java.util.ArrayList import java.util.regex.Pattern import javax.mail.internet.InternetAddress @@ -47,193 +44,50 @@ import javax.mail.internet.InternetAddress * Time: 4:48 PM * E-mail: DenBond7@gmail.com */ -class MsgsPagedListAdapter(private val onMessageClickListener: OnMessageClickListener? = null) : - PagedListAdapter(DIFF_CALLBACK) { - private val senderNamePattern: Pattern - var tracker: SelectionTracker? = null +class MsgsPagedListAdapter( + private val onMessagesActionListener: OnMessagesActionListener? = null, + private val selectionChecker: (key: Long) -> Boolean +) : PagingDataAdapter(ITEM_CALLBACK) { + private val senderNamePattern: Pattern = prepareSenderNamePattern() + private val keyPositionsMap = mutableMapOf() var currentFolder: LocalFolder? = null + private set init { - this.senderNamePattern = prepareSenderNamePattern() - setHasStableIds(true) - } - - override fun onCreateViewHolder(parent: ViewGroup, @ItemType viewType: Int): BaseViewHolder { - return when (viewType) { - FOOTER -> object : BaseViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.list_view_progress_footer, parent, false) - ) { - override val itemType = FOOTER - } - - MESSAGE -> MessageViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.messages_list_item, parent, false) - ) - - else -> object : BaseViewHolder(ProgressBar(parent.context)) { - override val itemType = NONE - } - } - } - - override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { - holder.setActivatedState(tracker?.isSelected(getItem(position)?.id) ?: false) - - when (holder.itemType) { - MESSAGE -> { - val msgEntity = getItem(position) - updateItem(msgEntity, holder as MessageViewHolder) - holder.itemView.setOnClickListener { - msgEntity?.let { onMessageClickListener?.onMsgClick(it) } - } - } + addOnPagesUpdatedListener { + val ids = snapshot().items.mapNotNull { it.id }.toSet() + onMessagesActionListener?.onExistingMsgsChanged(ids) } } - override fun getItemViewType(position: Int): Int { - return MESSAGE + override fun onBindViewHolder(holder: MessageViewHolder, position: Int) { + val msgEntity = getItem(position) ?: return + msgEntity.id?.let { keyPositionsMap[msgEntity.id] = holder.absoluteAdapterPosition } + holder.bind(msgEntity) } - override fun getItemId(position: Int): Long { - return getItem(position)?.id ?: super.getItemId(position) - } - - override fun onCurrentListChanged( - previousList: PagedList?, - currentList: PagedList? - ) { - super.onCurrentListChanged(previousList, currentList) - val currentIds = currentList?.map { it?.id }?.toSet() - val ids = tracker?.selection?.map { it } ?: emptyList() - - for (id in ids) { - if (currentIds?.contains(id) == false) { - tracker?.deselect(id) - } - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder { + return MessageViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.messages_list_item, parent, false) + ) } - fun getMsgEntity(position: Int?): MessageEntity? { - position ?: return null - - if (position == RecyclerView.NO_POSITION) { - return null - } - + fun getMessageEntity(@IntRange(from = 0) position: Int): MessageEntity? { return getItem(position) } - fun changeProgress(isFooterEnabled: Boolean) { - LogsUtil.d(javaClass.name, isFooterEnabled.toString()) - } - - private fun updateItem(messageEntity: MessageEntity?, viewHolder: MessageViewHolder) { - val context = viewHolder.itemView.context - if (messageEntity != null) { - val subject = if (TextUtils.isEmpty(messageEntity.subject)) { - context.getString(R.string.no_subject) - } else { - messageEntity.subject - } - - val folderType = FoldersManager.getFolderType(currentFolder) - if (folderType != null) { - when (folderType) { - FoldersManager.FolderType.SENT -> viewHolder.textViewSenderAddress?.text = - generateAddresses(messageEntity.to) - - FoldersManager.FolderType.OUTBOX -> { - val status = generateOutboxStatus( - viewHolder.textViewSenderAddress?.context, - messageEntity.msgState - ) - viewHolder.textViewSenderAddress?.text = status - } - - else -> viewHolder.textViewSenderAddress?.text = generateAddresses(messageEntity.from) - } - } else { - viewHolder.textViewSenderAddress?.text = generateAddresses(messageEntity.from) - } - - viewHolder.textViewSubject?.text = subject - if (folderType === FoldersManager.FolderType.OUTBOX) { - viewHolder.textViewDate?.text = - DateTimeUtil.formatSameDayTime(context, messageEntity.sentDate) - } else { - viewHolder.textViewDate?.text = - DateTimeUtil.formatSameDayTime(context, messageEntity.receivedDate) - } - - if (messageEntity.isSeen) { - changeViewsTypeface(viewHolder, Typeface.NORMAL) - viewHolder.textViewSenderAddress?.setTextColor(UIUtil.getColor(context, R.color.dark)) - viewHolder.textViewDate?.setTextColor(UIUtil.getColor(context, R.color.gray)) - } else { - changeViewsTypeface(viewHolder, Typeface.BOLD) - viewHolder.textViewSenderAddress?.setTextColor( - UIUtil.getColor( - context, - android.R.color.black - ) - ) - viewHolder.textViewDate?.setTextColor(UIUtil.getColor(context, android.R.color.black)) - } - - viewHolder.imageViewAtts?.visibility = - if (messageEntity.hasAttachments == true) View.VISIBLE else View.GONE - viewHolder.viewIsEncrypted?.visibility = - if (messageEntity.isEncrypted == true) View.VISIBLE else View.GONE - - when (messageEntity.msgState) { - MessageState.PENDING_ARCHIVING -> { - with(viewHolder.imageViewStatus) { - this?.visibility = View.VISIBLE - this?.setBackgroundResource(R.drawable.ic_archive_blue_16dp) - } - } - - MessageState.PENDING_MARK_UNREAD -> { - with(viewHolder.imageViewStatus) { - this?.visibility = View.VISIBLE - this?.setBackgroundResource(R.drawable.ic_markunread_blue_16dp) - } - } - - MessageState.PENDING_DELETING, - MessageState.PENDING_DELETING_PERMANENTLY, - MessageState.PENDING_EMPTY_TRASH -> { - with(viewHolder.imageViewStatus) { - this?.visibility = View.VISIBLE - this?.setBackgroundResource(R.drawable.ic_delete_blue_16dp) - } - } - - MessageState.PENDING_MOVE_TO_INBOX -> { - with(viewHolder.imageViewStatus) { - this?.visibility = View.VISIBLE - this?.setBackgroundResource(R.drawable.ic_move_to_inbox_blue_16dp) - } - } - - else -> viewHolder.imageViewStatus?.visibility = View.GONE - } - - } else { - clearItem(viewHolder) - } + fun getPositionByIds(ids: Long): Int? { + return keyPositionsMap[ids] } - private fun changeViewsTypeface(viewHolder: MessageViewHolder, typeface: Int) { - viewHolder.textViewSenderAddress?.setTypeface(null, typeface) - viewHolder.textViewDate?.setTypeface(null, typeface) + fun switchFolder(newFolder: LocalFolder?) { + keyPositionsMap.clear() + currentFolder = newFolder } /** - * Prepare a [Pattern] which will be used for finding some information in the sender name. This pattern is - * case insensitive. + * Prepare a [Pattern] which will be used for finding some information in the sender name. + * This pattern is case insensitive. * * @return A generated [Pattern]. */ @@ -271,22 +125,6 @@ class MsgsPagedListAdapter(private val onMessageClickListener: OnMessageClickLis return senderNamePattern.matcher(name).replaceFirst("") } - /** - * Clear all views in the item. - * - * @param viewHolder A View holder object which consist links to views. - */ - private fun clearItem(viewHolder: MessageViewHolder) { - viewHolder.textViewSenderAddress?.text = null - viewHolder.textViewSubject?.text = null - viewHolder.textViewDate?.text = null - viewHolder.imageViewAtts?.visibility = View.GONE - viewHolder.viewIsEncrypted?.visibility = View.GONE - viewHolder.imageViewStatus?.visibility = View.GONE - - changeViewsTypeface(viewHolder, Typeface.NORMAL) - } - private fun generateAddresses(internetAddresses: List?): String { if (internetAddresses == null) { return "null" @@ -409,51 +247,119 @@ class MsgsPagedListAdapter(private val onMessageClickListener: OnMessageClickLis return TextUtils.concat(spannableStringMe, " ", status) } - abstract inner class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - @ItemType - abstract val itemType: Int + inner class MessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding: MessagesListItemBinding = MessagesListItemBinding.bind(itemView) + private var messageEntity: MessageEntity? = null fun getItemDetails(): ItemDetailsLookup.ItemDetails = object : ItemDetailsLookup.ItemDetails() { - override fun getPosition(): Int = adapterPosition - override fun getSelectionKey(): Long? = itemId + override fun getPosition(): Int = absoluteAdapterPosition + override fun getSelectionKey(): Long? = messageEntity?.id } - fun setActivatedState(isActivated: Boolean) { - itemView.isActivated = isActivated - } - } + fun bind(value: MessageEntity) { + messageEntity = value + val context = itemView.context + + itemView.isActivated = value.id?.let { selectionChecker.invoke(it) } ?: false + itemView.setOnClickListener { + onMessagesActionListener?.onMsgClick(value) + } + + val subject = if (TextUtils.isEmpty(value.subject)) { + context.getString(R.string.no_subject) + } else { + value.subject + } + + val folderType = FoldersManager.getFolderType(currentFolder) + if (folderType != null) { + when (folderType) { + FoldersManager.FolderType.SENT -> { + binding.textViewSenderAddress.text = generateAddresses(value.to) + } + + FoldersManager.FolderType.OUTBOX -> { + val status = generateOutboxStatus( + binding.textViewSenderAddress.context, + value.msgState + ) + binding.textViewSenderAddress.text = status + } + + else -> { + binding.textViewSenderAddress.text = generateAddresses(value.from) + } + } + } else { + binding.textViewSenderAddress.text = generateAddresses(value.from) + } + + binding.textViewSubject.text = subject + if (folderType === FoldersManager.FolderType.OUTBOX) { + binding.textViewDate.text = DateTimeUtil.formatSameDayTime(context, value.sentDate) + } else { + binding.textViewDate.text = DateTimeUtil.formatSameDayTime(context, value.receivedDate) + } - inner class MessageViewHolder(itemView: View) : BaseViewHolder(itemView) { - override val itemType = MESSAGE + if (value.isSeen) { + changeViewsTypeface(Typeface.NORMAL) + binding.textViewSenderAddress.setTextColor(UIUtil.getColor(context, R.color.dark)) + binding.textViewDate.setTextColor(UIUtil.getColor(context, R.color.gray)) + } else { + changeViewsTypeface(Typeface.BOLD) + binding.textViewSenderAddress.setTextColor(UIUtil.getColor(context, android.R.color.black)) + binding.textViewDate.setTextColor(UIUtil.getColor(context, android.R.color.black)) + } + + binding.imageViewAtts.visibleOrGone(value.hasAttachments == true) + binding.viewIsEncrypted.visibleOrGone(value.isEncrypted == true) + + when (value.msgState) { + MessageState.PENDING_ARCHIVING -> { + binding.imageViewStatus.visible() + binding.imageViewStatus.setBackgroundResource(R.drawable.ic_archive_blue_16dp) + } - var textViewSenderAddress: TextView? = itemView.findViewById(R.id.textViewSenderAddress) - var textViewDate: TextView? = itemView.findViewById(R.id.textViewDate) - var textViewSubject: TextView? = itemView.findViewById(R.id.textViewSubject) - var imageViewAtts: ImageView? = itemView.findViewById(R.id.imageViewAtts) - var imageViewStatus: ImageView? = itemView.findViewById(R.id.imageViewStatus) - var viewIsEncrypted: View? = itemView.findViewById(R.id.viewIsEncrypted) + MessageState.PENDING_MARK_UNREAD -> { + binding.imageViewStatus.visible() + binding.imageViewStatus.setBackgroundResource(R.drawable.ic_markunread_blue_16dp) + } + + MessageState.PENDING_DELETING, + MessageState.PENDING_DELETING_PERMANENTLY, + MessageState.PENDING_EMPTY_TRASH -> { + binding.imageViewStatus.visible() + binding.imageViewStatus.setBackgroundResource(R.drawable.ic_delete_blue_16dp) + } + + MessageState.PENDING_MOVE_TO_INBOX -> { + binding.imageViewStatus.visible() + binding.imageViewStatus.setBackgroundResource(R.drawable.ic_move_to_inbox_blue_16dp) + } + + else -> binding.imageViewStatus.gone() + } + } + + private fun changeViewsTypeface(typeface: Int) { + binding.textViewSenderAddress.setTypeface(null, typeface) + binding.textViewDate.setTypeface(null, typeface) + } } - interface OnMessageClickListener { + interface OnMessagesActionListener { fun onMsgClick(msgEntity: MessageEntity) + fun onExistingMsgsChanged(snapshotOfExistingIds: Set) } companion object { - private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + private val ITEM_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldMsg: MessageEntity, newMsg: MessageEntity) = oldMsg.id == newMsg.id override fun areContentsTheSame(oldMsg: MessageEntity, newMsg: MessageEntity) = oldMsg == newMsg } - - @IntDef(NONE, FOOTER, MESSAGE) - @Retention(AnnotationRetention.SOURCE) - annotation class ItemType - - const val NONE = 0 - const val FOOTER = 1 - const val MESSAGE = 2 } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/selection/CustomStableIdKeyProvider.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/selection/CustomStableIdKeyProvider.kt index 6aa0de2c6c..5102f7d823 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/selection/CustomStableIdKeyProvider.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/selection/CustomStableIdKeyProvider.kt @@ -7,6 +7,7 @@ package com.flowcrypt.email.ui.adapter.selection import androidx.recyclerview.selection.ItemKeyProvider import androidx.recyclerview.widget.RecyclerView +import com.flowcrypt.email.ui.adapter.MsgsPagedListAdapter /** * @author Denis Bondarenko @@ -14,20 +15,14 @@ import androidx.recyclerview.widget.RecyclerView * Time: 4:44 PM * E-mail: DenBond7@gmail.com */ -class CustomStableIdKeyProvider(private val recyclerView: RecyclerView) : +class CustomStableIdKeyProvider(private val adapter: MsgsPagedListAdapter) : ItemKeyProvider(SCOPE_CACHED) { - init { - requireNotNull(recyclerView.adapter) - require(recyclerView.adapter?.hasStableIds() == true) { - "Adapter should have stable ids" - } - } override fun getKey(position: Int): Long? { - return recyclerView.adapter?.getItemId(position) + return adapter.getMessageEntity(position)?.id } override fun getPosition(key: Long): Int { - return recyclerView.findViewHolderForItemId(key)?.adapterPosition ?: RecyclerView.NO_POSITION + return adapter.getPositionByIds(key) ?: RecyclerView.NO_POSITION } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/selection/MsgItemDetailsLookup.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/selection/MsgItemDetailsLookup.kt index 65d89d444f..a189e609fe 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/selection/MsgItemDetailsLookup.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/selection/MsgItemDetailsLookup.kt @@ -19,7 +19,7 @@ import com.flowcrypt.email.ui.adapter.MsgsPagedListAdapter class MsgItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup() { override fun getItemDetails(e: MotionEvent): ItemDetails? { return recyclerView.findChildViewUnder(e.x, e.y)?.let { - (recyclerView.getChildViewHolder(it) as? MsgsPagedListAdapter.BaseViewHolder)?.getItemDetails() + (recyclerView.getChildViewHolder(it) as? MsgsPagedListAdapter.MessageViewHolder)?.getItemDetails() } } } diff --git a/FlowCrypt/src/main/res/layout/load_msgs_progress.xml b/FlowCrypt/src/main/res/layout/load_msgs_progress.xml new file mode 100644 index 0000000000..0006c910a8 --- /dev/null +++ b/FlowCrypt/src/main/res/layout/load_msgs_progress.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/FlowCrypt/src/main/res/values/ids.xml b/FlowCrypt/src/main/res/values/ids.xml index ee30a2bf72..e502141b16 100644 --- a/FlowCrypt/src/main/res/values/ids.xml +++ b/FlowCrypt/src/main/res/values/ids.xml @@ -17,12 +17,6 @@ - - - - - - @@ -31,6 +25,7 @@ +