From 47861f605d6a5bb7e7050c522a254901fdabae76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ensar=20Saraj=C4=8Di=C4=87?= Date: Tue, 11 Jul 2023 16:52:47 +0200 Subject: [PATCH] Add support for archiving conversations Archiving messages currently acts like recycle bin in Simple Gallery, meaning that after 30 days, conversations will be deleted permanently. Any updates to the conversation (new messages) removes it from archive. This closes #177 --- app/src/main/AndroidManifest.xml | 7 + .../ArchivedConversationsActivity.kt | 150 ++++++++++++++ .../smsmessenger/activities/MainActivity.kt | 18 +- .../activities/SettingsActivity.kt | 43 ++++ .../smsmessenger/activities/ThreadActivity.kt | 10 +- .../adapters/ArchivedConversationsAdapter.kt | 94 +++++++++ .../adapters/BaseConversationsAdapter.kt | 183 +++++++++++++++++ .../adapters/ConversationsAdapter.kt | 184 +----------------- .../databases/MessagesDatabase.kt | 16 +- .../dialogs/DeleteConfirmationDialog.kt | 39 ++++ .../smsmessenger/extensions/Context.kt | 38 ++++ .../smsmessenger/helpers/Config.kt | 8 + .../smsmessenger/helpers/Constants.kt | 2 + .../interfaces/ConversationsDao.kt | 33 +++- .../models/ArchivedConversation.kt | 15 ++ .../smsmessenger/receivers/SmsReceiver.kt | 1 + .../activity_archived_conversations.xml | 83 ++++++++ app/src/main/res/layout/activity_settings.xml | 49 +++++ .../res/layout/dialog_delete_confirmation.xml | 26 +++ app/src/main/res/menu/archive_menu.xml | 11 ++ .../res/menu/cab_archived_conversations.xml | 22 +++ app/src/main/res/menu/menu_main.xml | 5 + 22 files changed, 846 insertions(+), 191 deletions(-) create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ArchivedConversationsActivity.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ArchivedConversationsAdapter.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/BaseConversationsAdapter.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/DeleteConfirmationDialog.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/ArchivedConversation.kt create mode 100644 app/src/main/res/layout/activity_archived_conversations.xml create mode 100644 app/src/main/res/layout/dialog_delete_confirmation.xml create mode 100644 app/src/main/res/menu/archive_menu.xml create mode 100644 app/src/main/res/menu/cab_archived_conversations.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3820008d..c94576e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,13 @@ android:configChanges="orientation" android:exported="true" /> + + + when (menuItem.itemId) { + R.id.empty_archive -> removeAll() + else -> return@setOnMenuItemClickListener false + } + return@setOnMenuItemClickListener true + } + } + + private fun updateMenuColors() { + updateStatusbarColor(getProperBackgroundColor()) + } + + private fun loadArchivedConversations() { + ensureBackgroundThread { + val conversations = try { + conversationsDB.getAllArchived().toMutableList() as ArrayList + } catch (e: Exception) { + ArrayList() + } + + runOnUiThread { + setupConversations(conversations) + } + } + + bus = EventBus.getDefault() + try { + bus!!.register(this) + } catch (e: Exception) { + } + } + + private fun removeAll() { + removeAllArchivedConversations { + loadArchivedConversations() + } + } + + private fun getOrCreateConversationsAdapter(): ArchivedConversationsAdapter { + var currAdapter = conversations_list.adapter + if (currAdapter == null) { + hideKeyboard() + currAdapter = ArchivedConversationsAdapter( + activity = this, + recyclerView = conversations_list, + onRefresh = { notifyDatasetChanged() }, + itemClick = { handleConversationClick(it) } + ) + + conversations_list.adapter = currAdapter + if (areSystemAnimationsEnabled) { + conversations_list.scheduleLayoutAnimation() + } + } + return currAdapter as ArchivedConversationsAdapter + } + + private fun setupConversations(conversations: ArrayList) { + val sortedConversations = conversations.sortedWith( + compareByDescending { config.pinnedConversations.contains(it.threadId.toString()) } + .thenByDescending { it.date } + ).toMutableList() as ArrayList + + showOrHidePlaceholder(conversations.isEmpty()) + + try { + getOrCreateConversationsAdapter().apply { + updateConversations(sortedConversations) + } + } catch (ignored: Exception) { + } + } + + private fun showOrHidePlaceholder(show: Boolean) { + conversations_fastscroller.beGoneIf(show) + no_conversations_placeholder.beVisibleIf(show) + no_conversations_placeholder.text = getString(R.string.no_conversations_found) + } + + @SuppressLint("NotifyDataSetChanged") + private fun notifyDatasetChanged() { + getOrCreateConversationsAdapter().notifyDataSetChanged() + } + + private fun handleConversationClick(any: Any) { + Intent(this, ThreadActivity::class.java).apply { + val conversation = any as Conversation + putExtra(THREAD_ID, conversation.threadId) + putExtra(THREAD_TITLE, conversation.title) + putExtra(WAS_PROTECTION_HANDLED, true) + startActivity(this) + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun refreshMessages(event: Events.RefreshMessages) { + loadArchivedConversations() + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt index 1204d748..119b357b 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt @@ -64,6 +64,7 @@ class MainActivity : SimpleActivity() { updateMaterialActivityViews(main_coordinator, conversations_list, useTransparentNavigation = true, useTopSearchMenu = true) if (savedInstanceState == null) { + checkAndDeleteOldArchivedConversations() handleAppPasswordProtection { wasProtectionHandled = it if (it) { @@ -177,6 +178,7 @@ class MainActivity : SimpleActivity() { R.id.import_messages -> tryImportMessages() R.id.export_messages -> tryToExportMessages() R.id.more_apps_from_us -> launchMoreAppsFromUsIntent() + R.id.show_archived -> launchArchivedConversations() R.id.settings -> launchSettings() R.id.about -> launchAbout() else -> return@setOnMenuItemClickListener false @@ -289,15 +291,20 @@ class MainActivity : SimpleActivity() { private fun getCachedConversations() { ensureBackgroundThread { val conversations = try { - conversationsDB.getAll().toMutableList() as ArrayList + conversationsDB.getNonArchived().toMutableList() as ArrayList } catch (e: Exception) { ArrayList() } + val archived = try { + conversationsDB.getAllArchived() + } catch (e: Exception) { + listOf() + } updateUnreadCountBadge(conversations) runOnUiThread { setupConversations(conversations, cached = true) - getNewConversations(conversations) + getNewConversations((conversations + archived).toMutableList() as ArrayList) } conversations.forEach { clearExpiredScheduledMessages(it.threadId) @@ -351,7 +358,7 @@ class MainActivity : SimpleActivity() { } } - val allConversations = conversationsDB.getAll() as ArrayList + val allConversations = conversationsDB.getNonArchived() as ArrayList runOnUiThread { setupConversations(allConversations) } @@ -556,6 +563,11 @@ class MainActivity : SimpleActivity() { } } + private fun launchArchivedConversations() { + hideKeyboard() + startActivity(Intent(applicationContext, ArchivedConversationsActivity::class.java)) + } + private fun launchSettings() { hideKeyboard() startActivity(Intent(applicationContext, SettingsActivity::class.java)) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/SettingsActivity.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/SettingsActivity.kt index d97e67d0..5cdd53a6 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/SettingsActivity.kt @@ -11,12 +11,15 @@ import com.simplemobiletools.commons.helpers.* import com.simplemobiletools.commons.models.RadioItem import com.simplemobiletools.smsmessenger.R import com.simplemobiletools.smsmessenger.extensions.config +import com.simplemobiletools.smsmessenger.extensions.conversationsDB +import com.simplemobiletools.smsmessenger.extensions.removeAllArchivedConversations import com.simplemobiletools.smsmessenger.helpers.* import kotlinx.android.synthetic.main.activity_settings.* import java.util.* class SettingsActivity : SimpleActivity() { private var blockedNumbersAtPause = -1 + private var recycleBinConversations = 0 override fun onCreate(savedInstanceState: Bundle?) { isMaterialActivity = true @@ -47,6 +50,8 @@ class SettingsActivity : SimpleActivity() { setupGroupMessageAsMMS() setupLockScreenVisibility() setupMMSFileSizeLimit() + setupUseRecycleBin() + setupEmptyRecycleBin() setupAppPasswordProtection() updateTextColors(settings_nested_scrollview) @@ -59,6 +64,7 @@ class SettingsActivity : SimpleActivity() { settings_general_settings_label, settings_outgoing_messages_label, settings_notifications_label, + settings_recycle_bin_label, settings_security_label ).forEach { it.setTextColor(getProperPrimaryColor()) @@ -244,6 +250,43 @@ class SettingsActivity : SimpleActivity() { } } + private fun setupUseRecycleBin() { + updateRecycleBinButtons() + settings_use_recycle_bin.isChecked = config.useArchive + settings_use_recycle_bin_holder.setOnClickListener { + settings_use_recycle_bin.toggle() + config.useArchive = settings_use_recycle_bin.isChecked + updateRecycleBinButtons() + } + } + + private fun updateRecycleBinButtons() { + settings_empty_recycle_bin_holder.beVisibleIf(config.useArchive) + } + + private fun setupEmptyRecycleBin() { + ensureBackgroundThread { + recycleBinConversations = conversationsDB.getArchivedCount() + runOnUiThread { + settings_empty_recycle_bin_size.text = + resources.getQuantityString(R.plurals.delete_conversations, recycleBinConversations, recycleBinConversations) + } + } + + settings_empty_recycle_bin_holder.setOnClickListener { + if (recycleBinConversations == 0) { + toast(R.string.recycle_bin_empty) + } else { + ConfirmationDialog(this, "", R.string.empty_recycle_bin_confirmation, R.string.yes, R.string.no) { + removeAllArchivedConversations() + recycleBinConversations = 0 + settings_empty_recycle_bin_size.text = + resources.getQuantityString(R.plurals.delete_conversations, recycleBinConversations, recycleBinConversations) + } + } + } + } + private fun setupAppPasswordProtection() { settings_app_password_protection.isChecked = config.isAppPasswordProtectionOn settings_app_password_protection_holder.setOnClickListener { diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt index 62e03a05..e524f349 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt @@ -55,6 +55,7 @@ import com.simplemobiletools.smsmessenger.R import com.simplemobiletools.smsmessenger.adapters.AttachmentsAdapter import com.simplemobiletools.smsmessenger.adapters.AutoCompleteTextViewAdapter import com.simplemobiletools.smsmessenger.adapters.ThreadAdapter +import com.simplemobiletools.smsmessenger.dialogs.DeleteConfirmationDialog import com.simplemobiletools.smsmessenger.dialogs.InvalidNumberDialog import com.simplemobiletools.smsmessenger.dialogs.RenameConversationDialog import com.simplemobiletools.smsmessenger.dialogs.ScheduleMessageDialog @@ -888,9 +889,13 @@ class ThreadActivity : SimpleActivity() { } private fun askConfirmDelete() { - ConfirmationDialog(this, getString(R.string.delete_whole_conversation_confirmation)) { + DeleteConfirmationDialog(this, getString(R.string.delete_whole_conversation_confirmation), config.useArchive) { skipRecycleBin -> ensureBackgroundThread { - deleteConversation(threadId) + if (skipRecycleBin || config.useArchive.not()) { + deleteConversation(threadId) + } else { + moveConversationToRecycleBin(threadId) + } runOnUiThread { refreshMessages() finish() @@ -1327,6 +1332,7 @@ class ThreadActivity : SimpleActivity() { } } messagesDB.insertOrUpdate(message) + conversationsDB.deleteThreadFromArchivedConversations(message.threadId) } // show selected contacts, properly split to new lines when appropriate diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ArchivedConversationsAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ArchivedConversationsAdapter.kt new file mode 100644 index 00000000..b1050197 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ArchivedConversationsAdapter.kt @@ -0,0 +1,94 @@ +package com.simplemobiletools.smsmessenger.adapters + +import android.view.Menu +import com.simplemobiletools.commons.extensions.notificationManager +import com.simplemobiletools.commons.helpers.ensureBackgroundThread +import com.simplemobiletools.commons.views.MyRecyclerView +import com.simplemobiletools.smsmessenger.R +import com.simplemobiletools.smsmessenger.activities.SimpleActivity +import com.simplemobiletools.smsmessenger.dialogs.DeleteConfirmationDialog +import com.simplemobiletools.smsmessenger.extensions.conversationsDB +import com.simplemobiletools.smsmessenger.extensions.deleteConversation +import com.simplemobiletools.smsmessenger.helpers.refreshMessages +import com.simplemobiletools.smsmessenger.models.Conversation + +class ArchivedConversationsAdapter( + activity: SimpleActivity, recyclerView: MyRecyclerView, onRefresh: () -> Unit, itemClick: (Any) -> Unit +) : BaseConversationsAdapter(activity, recyclerView, onRefresh, itemClick) { + override fun getActionMenuId() = R.menu.cab_archived_conversations + + override fun prepareActionMode(menu: Menu) {} + + override fun actionItemPressed(id: Int) { + if (selectedKeys.isEmpty()) { + return + } + + when (id) { + R.id.cab_delete -> askConfirmDelete() + R.id.cab_unarchive -> ensureBackgroundThread { unarchiveConversation() } + R.id.cab_select_all -> selectAll() + } + } + + private fun askConfirmDelete() { + val itemsCnt = selectedKeys.size + val items = resources.getQuantityString(R.plurals.delete_conversations, itemsCnt, itemsCnt) + + val baseString = R.string.deletion_confirmation + val question = String.format(resources.getString(baseString), items) + + DeleteConfirmationDialog(activity, question, showSkipRecycleBinOption = false) { _ -> + ensureBackgroundThread { + deleteConversations() + } + } + } + + private fun deleteConversations() { + if (selectedKeys.isEmpty()) { + return + } + + val conversationsToRemove = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList + conversationsToRemove.forEach { + activity.deleteConversation(it.threadId) + activity.notificationManager.cancel(it.threadId.hashCode()) + } + + removeConversationsFromList(conversationsToRemove) + } + + private fun unarchiveConversation() { + if (selectedKeys.isEmpty()) { + return + } + + val conversationsToUnarchive = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList + conversationsToUnarchive.forEach { + activity.conversationsDB.deleteThreadFromArchivedConversations(it.threadId) + } + + removeConversationsFromList(conversationsToUnarchive) + } + + private fun removeConversationsFromList(removedConversations: List) { + val newList = try { + currentList.toMutableList().apply { removeAll(removedConversations) } + } catch (ignored: Exception) { + currentList.toMutableList() + } + + activity.runOnUiThread { + if (newList.none { selectedKeys.contains(it.hashCode()) }) { + refreshMessages() + finishActMode() + } else { + submitList(newList) + if (newList.isEmpty()) { + refreshMessages() + } + } + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/BaseConversationsAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/BaseConversationsAdapter.kt new file mode 100644 index 00000000..17ba7623 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/BaseConversationsAdapter.kt @@ -0,0 +1,183 @@ +package com.simplemobiletools.smsmessenger.adapters + +import android.graphics.Typeface +import android.os.Parcelable +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller +import com.simplemobiletools.commons.adapters.MyRecyclerViewListAdapter +import com.simplemobiletools.commons.extensions.* +import com.simplemobiletools.commons.helpers.SimpleContactsHelper +import com.simplemobiletools.commons.helpers.ensureBackgroundThread +import com.simplemobiletools.commons.views.MyRecyclerView +import com.simplemobiletools.smsmessenger.R +import com.simplemobiletools.smsmessenger.activities.SimpleActivity +import com.simplemobiletools.smsmessenger.extensions.* +import com.simplemobiletools.smsmessenger.models.Conversation +import kotlinx.android.synthetic.main.item_conversation.view.* + +@Suppress("LeakingThis") +abstract class BaseConversationsAdapter( + activity: SimpleActivity, recyclerView: MyRecyclerView, onRefresh: () -> Unit, itemClick: (Any) -> Unit +) : MyRecyclerViewListAdapter(activity, recyclerView, ConversationDiffCallback(), itemClick, onRefresh), + RecyclerViewFastScroller.OnPopupTextUpdate { + private var fontSize = activity.getTextSize() + private var drafts = HashMap() + + private var recyclerViewState: Parcelable? = null + + init { + setupDragListener(true) + ensureBackgroundThread { + fetchDrafts(drafts) + } + setHasStableIds(true) + + registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onChanged() = restoreRecyclerViewState() + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) = restoreRecyclerViewState() + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) = restoreRecyclerViewState() + }) + } + + fun updateFontSize() { + fontSize = activity.getTextSize() + notifyDataSetChanged() + } + + fun updateConversations(newConversations: ArrayList, commitCallback: (() -> Unit)? = null) { + saveRecyclerViewState() + submitList(newConversations.toList(), commitCallback) + } + + fun updateDrafts() { + ensureBackgroundThread { + val newDrafts = HashMap() + fetchDrafts(newDrafts) + if (drafts.hashCode() != newDrafts.hashCode()) { + drafts = newDrafts + activity.runOnUiThread { + notifyDataSetChanged() + } + } + } + } + + override fun getSelectableItemCount() = itemCount + + protected fun getSelectedItems() = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList + + override fun getIsItemSelectable(position: Int) = true + + override fun getItemSelectionKey(position: Int) = currentList.getOrNull(position)?.hashCode() + + override fun getItemKeyPosition(key: Int) = currentList.indexOfFirst { it.hashCode() == key } + + override fun onActionModeCreated() {} + + override fun onActionModeDestroyed() {} + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = createViewHolder(R.layout.item_conversation, parent) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val conversation = getItem(position) + holder.bindView(conversation, allowSingleClick = true, allowLongClick = true) { itemView, _ -> + setupView(itemView, conversation) + } + bindViewHolder(holder) + } + + override fun getItemId(position: Int) = getItem(position).threadId + + override fun onViewRecycled(holder: ViewHolder) { + super.onViewRecycled(holder) + if (!activity.isDestroyed && !activity.isFinishing) { + Glide.with(activity).clear(holder.itemView.conversation_image) + } + } + + private fun fetchDrafts(drafts: HashMap) { + drafts.clear() + for ((threadId, draft) in activity.getAllDrafts()) { + drafts[threadId] = draft + } + } + + private fun setupView(view: View, conversation: Conversation) { + view.apply { + setupViewBackground(activity) + val smsDraft = drafts[conversation.threadId] + draft_indicator.beVisibleIf(smsDraft != null) + draft_indicator.setTextColor(properPrimaryColor) + + pin_indicator.beVisibleIf(activity.config.pinnedConversations.contains(conversation.threadId.toString())) + pin_indicator.applyColorFilter(textColor) + + conversation_frame.isSelected = selectedKeys.contains(conversation.hashCode()) + + conversation_address.apply { + text = conversation.title + setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 1.2f) + } + + conversation_body_short.apply { + text = smsDraft ?: conversation.snippet + setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.9f) + } + + conversation_date.apply { + text = conversation.date.formatDateOrTime(context, true, false) + setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f) + } + + val style = if (conversation.read) { + conversation_body_short.alpha = 0.7f + if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL + } else { + conversation_body_short.alpha = 1f + if (conversation.isScheduled) Typeface.BOLD_ITALIC else Typeface.BOLD + + } + conversation_address.setTypeface(null, style) + conversation_body_short.setTypeface(null, style) + + arrayListOf(conversation_address, conversation_body_short, conversation_date).forEach { + it.setTextColor(textColor) + } + + // at group conversations we use an icon as the placeholder, not any letter + val placeholder = if (conversation.isGroupConversation) { + SimpleContactsHelper(context).getColoredGroupIcon(conversation.title) + } else { + null + } + + SimpleContactsHelper(context).loadContactImage(conversation.photoUri, conversation_image, conversation.title, placeholder) + } + } + + override fun onChange(position: Int) = currentList.getOrNull(position)?.title ?: "" + + private fun saveRecyclerViewState() { + recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() + } + + private fun restoreRecyclerViewState() { + recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) + } + + private class ConversationDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Conversation, newItem: Conversation): Boolean { + return Conversation.areItemsTheSame(oldItem, newItem) + } + + override fun areContentsTheSame(oldItem: Conversation, newItem: Conversation): Boolean { + return Conversation.areContentsTheSame(oldItem, newItem) + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ConversationsAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ConversationsAdapter.kt index 4ba2f9e4..1b79472b 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ConversationsAdapter.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ConversationsAdapter.kt @@ -1,59 +1,27 @@ package com.simplemobiletools.smsmessenger.adapters import android.content.Intent -import android.graphics.Typeface -import android.os.Parcelable import android.text.TextUtils -import android.util.TypedValue import android.view.Menu -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller -import com.simplemobiletools.commons.adapters.MyRecyclerViewListAdapter import com.simplemobiletools.commons.dialogs.ConfirmationDialog import com.simplemobiletools.commons.dialogs.FeatureLockedDialog import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.KEY_PHONE -import com.simplemobiletools.commons.helpers.SimpleContactsHelper import com.simplemobiletools.commons.helpers.ensureBackgroundThread import com.simplemobiletools.commons.helpers.isNougatPlus import com.simplemobiletools.commons.views.MyRecyclerView import com.simplemobiletools.smsmessenger.R import com.simplemobiletools.smsmessenger.activities.SimpleActivity +import com.simplemobiletools.smsmessenger.dialogs.DeleteConfirmationDialog import com.simplemobiletools.smsmessenger.dialogs.RenameConversationDialog import com.simplemobiletools.smsmessenger.extensions.* import com.simplemobiletools.smsmessenger.helpers.refreshMessages import com.simplemobiletools.smsmessenger.messaging.isShortCodeWithLetters import com.simplemobiletools.smsmessenger.models.Conversation -import kotlinx.android.synthetic.main.item_conversation.view.* class ConversationsAdapter( activity: SimpleActivity, recyclerView: MyRecyclerView, onRefresh: () -> Unit, itemClick: (Any) -> Unit -) : MyRecyclerViewListAdapter(activity, recyclerView, ConversationDiffCallback(), itemClick, onRefresh), - RecyclerViewFastScroller.OnPopupTextUpdate { - private var fontSize = activity.getTextSize() - private var drafts = HashMap() - - private var recyclerViewState: Parcelable? = null - - init { - setupDragListener(true) - ensureBackgroundThread { - fetchDrafts(drafts) - } - setHasStableIds(true) - - registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onChanged() = restoreRecyclerViewState() - override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) = restoreRecyclerViewState() - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) = restoreRecyclerViewState() - }) - } - +) : BaseConversationsAdapter(activity, recyclerView, onRefresh, itemClick) { override fun getActionMenuId() = R.menu.cab_conversations override fun prepareActionMode(menu: Menu) { @@ -95,37 +63,6 @@ class ConversationsAdapter( } } - override fun getSelectableItemCount() = itemCount - - override fun getIsItemSelectable(position: Int) = true - - override fun getItemSelectionKey(position: Int) = currentList.getOrNull(position)?.hashCode() - - override fun getItemKeyPosition(key: Int) = currentList.indexOfFirst { it.hashCode() == key } - - override fun onActionModeCreated() {} - - override fun onActionModeDestroyed() {} - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = createViewHolder(R.layout.item_conversation, parent) - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val conversation = getItem(position) - holder.bindView(conversation, allowSingleClick = true, allowLongClick = true) { itemView, _ -> - setupView(itemView, conversation) - } - bindViewHolder(holder) - } - - override fun getItemId(position: Int) = getItem(position).threadId - - override fun onViewRecycled(holder: ViewHolder) { - super.onViewRecycled(holder) - if (!activity.isDestroyed && !activity.isFinishing) { - Glide.with(activity).clear(holder.itemView.conversation_image) - } - } - private fun tryBlocking() { if (activity.isOrWasThankYouInstalled()) { askConfirmBlock() @@ -184,21 +121,25 @@ class ConversationsAdapter( val baseString = R.string.deletion_confirmation val question = String.format(resources.getString(baseString), items) - ConfirmationDialog(activity, question) { + DeleteConfirmationDialog(activity, question, activity.config.useArchive) { skipRecycleBin -> ensureBackgroundThread { - deleteConversations() + deleteConversations(skipRecycleBin) } } } - private fun deleteConversations() { + private fun deleteConversations(skipRecycleBin: Boolean) { if (selectedKeys.isEmpty()) { return } val conversationsToRemove = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList conversationsToRemove.forEach { - activity.deleteConversation(it.threadId) + if (skipRecycleBin || activity.config.useArchive.not()) { + activity.deleteConversation(it.threadId) + } else { + activity.moveConversationToRecycleBin(it.threadId) + } activity.notificationManager.cancel(it.threadId.hashCode()) } @@ -276,8 +217,6 @@ class ConversationsAdapter( } } - private fun getSelectedItems() = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList - private fun pinConversation(pin: Boolean) { val conversations = getSelectedItems() if (conversations.isEmpty()) { @@ -303,113 +242,10 @@ class ConversationsAdapter( menu.findItem(R.id.cab_unpin_conversation).isVisible = selectedConversations.any { pinnedConversations.contains(it.threadId.toString()) } } - private fun fetchDrafts(drafts: HashMap) { - drafts.clear() - for ((threadId, draft) in activity.getAllDrafts()) { - drafts[threadId] = draft - } - } - - fun updateFontSize() { - fontSize = activity.getTextSize() - notifyDataSetChanged() - } - - fun updateConversations(newConversations: ArrayList, commitCallback: (() -> Unit)? = null) { - saveRecyclerViewState() - submitList(newConversations.toList(), commitCallback) - } - - fun updateDrafts() { - ensureBackgroundThread { - val newDrafts = HashMap() - fetchDrafts(newDrafts) - if (drafts.hashCode() != newDrafts.hashCode()) { - drafts = newDrafts - activity.runOnUiThread { - notifyDataSetChanged() - } - } - } - } - - private fun setupView(view: View, conversation: Conversation) { - view.apply { - setupViewBackground(activity) - val smsDraft = drafts[conversation.threadId] - draft_indicator.beVisibleIf(smsDraft != null) - draft_indicator.setTextColor(properPrimaryColor) - - pin_indicator.beVisibleIf(activity.config.pinnedConversations.contains(conversation.threadId.toString())) - pin_indicator.applyColorFilter(textColor) - - conversation_frame.isSelected = selectedKeys.contains(conversation.hashCode()) - - conversation_address.apply { - text = conversation.title - setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 1.2f) - } - - conversation_body_short.apply { - text = smsDraft ?: conversation.snippet - setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.9f) - } - - conversation_date.apply { - text = conversation.date.formatDateOrTime(context, true, false) - setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f) - } - - val style = if (conversation.read) { - conversation_body_short.alpha = 0.7f - if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL - } else { - conversation_body_short.alpha = 1f - if (conversation.isScheduled) Typeface.BOLD_ITALIC else Typeface.BOLD - - } - conversation_address.setTypeface(null, style) - conversation_body_short.setTypeface(null, style) - - arrayListOf(conversation_address, conversation_body_short, conversation_date).forEach { - it.setTextColor(textColor) - } - - // at group conversations we use an icon as the placeholder, not any letter - val placeholder = if (conversation.isGroupConversation) { - SimpleContactsHelper(context).getColoredGroupIcon(conversation.title) - } else { - null - } - - SimpleContactsHelper(context).loadContactImage(conversation.photoUri, conversation_image, conversation.title, placeholder) - } - } - - override fun onChange(position: Int) = currentList.getOrNull(position)?.title ?: "" - private fun refreshConversations() { activity.runOnUiThread { refreshMessages() finishActMode() } } - - private fun saveRecyclerViewState() { - recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() - } - - private fun restoreRecyclerViewState() { - recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) - } - - private class ConversationDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Conversation, newItem: Conversation): Boolean { - return Conversation.areItemsTheSame(oldItem, newItem) - } - - override fun areContentsTheSame(oldItem: Conversation, newItem: Conversation): Boolean { - return Conversation.areContentsTheSame(oldItem, newItem) - } - } } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/databases/MessagesDatabase.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/databases/MessagesDatabase.kt index fca0754b..db698255 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/databases/MessagesDatabase.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/databases/MessagesDatabase.kt @@ -12,12 +12,9 @@ import com.simplemobiletools.smsmessenger.interfaces.AttachmentsDao import com.simplemobiletools.smsmessenger.interfaces.ConversationsDao import com.simplemobiletools.smsmessenger.interfaces.MessageAttachmentsDao import com.simplemobiletools.smsmessenger.interfaces.MessagesDao -import com.simplemobiletools.smsmessenger.models.Attachment -import com.simplemobiletools.smsmessenger.models.Conversation -import com.simplemobiletools.smsmessenger.models.Message -import com.simplemobiletools.smsmessenger.models.MessageAttachment +import com.simplemobiletools.smsmessenger.models.* -@Database(entities = [Conversation::class, Attachment::class, MessageAttachment::class, Message::class], version = 7) +@Database(entities = [Conversation::class, ArchivedConversation::class, Attachment::class, MessageAttachment::class, Message::class], version = 8) @TypeConverters(Converters::class) abstract class MessagesDatabase : RoomDatabase() { @@ -44,6 +41,7 @@ abstract class MessagesDatabase : RoomDatabase() { .addMigrations(MIGRATION_4_5) .addMigrations(MIGRATION_5_6) .addMigrations(MIGRATION_6_7) + .addMigrations(MIGRATION_7_8) .build() } } @@ -115,5 +113,13 @@ abstract class MessagesDatabase : RoomDatabase() { } } } + + private val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL("CREATE TABLE archived_conversations (`thread_id` INTEGER NOT NULL PRIMARY KEY, `deleted_ts` INTEGER NOT NULL)") + } + } + } } } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/DeleteConfirmationDialog.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/DeleteConfirmationDialog.kt new file mode 100644 index 00000000..f72a7513 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/DeleteConfirmationDialog.kt @@ -0,0 +1,39 @@ +package com.simplemobiletools.smsmessenger.dialogs + +import android.app.Activity +import androidx.appcompat.app.AlertDialog +import com.simplemobiletools.commons.extensions.beGoneIf +import com.simplemobiletools.commons.extensions.getAlertDialogBuilder +import com.simplemobiletools.commons.extensions.setupDialogStuff +import com.simplemobiletools.smsmessenger.R +import kotlinx.android.synthetic.main.dialog_delete_confirmation.view.delete_remember_title +import kotlinx.android.synthetic.main.dialog_delete_confirmation.view.skip_the_recycle_bin_checkbox + +class DeleteConfirmationDialog( + private val activity: Activity, + private val message: String, + private val showSkipRecycleBinOption: Boolean, + private val callback: (skipRecycleBin: Boolean) -> Unit +) { + + private var dialog: AlertDialog? = null + val view = activity.layoutInflater.inflate(R.layout.dialog_delete_confirmation, null)!! + + init { + view.delete_remember_title.text = message + view.skip_the_recycle_bin_checkbox.beGoneIf(!showSkipRecycleBinOption) + activity.getAlertDialogBuilder() + .setPositiveButton(R.string.yes) { _, _ -> dialogConfirmed() } + .setNegativeButton(R.string.no, null) + .apply { + activity.setupDialogStuff(view, this) { alertDialog -> + dialog = alertDialog + } + } + } + + private fun dialogConfirmed() { + dialog?.dismiss() + callback(view.skip_the_recycle_bin_checkbox.isChecked) + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt index c9619701..f9228f8e 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt @@ -581,6 +581,35 @@ fun Context.insertNewSMS(address: String, subject: String, body: String, date: L } } +fun Context.checkAndDeleteOldArchivedConversations(callback: (() -> Unit)? = null) { + if (config.useArchive && config.lastArchiveCheck < System.currentTimeMillis() - DAY_SECONDS * 1000) { + config.lastArchiveCheck = System.currentTimeMillis() + ensureBackgroundThread { + try { + for (conversation in conversationsDB.getOldArchived(System.currentTimeMillis() - MONTH_SECONDS * 1000L)) { + deleteConversation(conversation.threadId) + } + callback?.invoke() + } catch (e: Exception) { + } + } + } +} + +fun Context.removeAllArchivedConversations(callback: (() -> Unit)? = null) { + ensureBackgroundThread { + try { + for (conversation in conversationsDB.getAllArchived()) { + deleteConversation(conversation.threadId) + } + toast(R.string.recycle_bin_emptied) + callback?.invoke() + } catch (e: Exception) { + toast(R.string.unknown_error_occurred) + } + } +} + fun Context.deleteConversation(threadId: Long) { var uri = Sms.CONTENT_URI val selection = "${Sms.THREAD_ID} = ?" @@ -602,6 +631,15 @@ fun Context.deleteConversation(threadId: Long) { messagesDB.deleteThreadMessages(threadId) } +fun Context.moveConversationToRecycleBin(threadId: Long) { + conversationsDB.archiveConversation( + ArchivedConversation( + threadId = threadId, + deletedTs = System.currentTimeMillis() + ) + ) +} + fun Context.deleteMessage(id: Long, isMMS: Boolean) { val uri = if (isMMS) Mms.CONTENT_URI else Sms.CONTENT_URI val selection = "${Sms._ID} = ?" diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Config.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Config.kt index b9b5c85b..a08c1466 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Config.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Config.kt @@ -91,4 +91,12 @@ class Config(context: Context) : BaseConfig(context) { var keyboardHeight: Int get() = prefs.getInt(SOFT_KEYBOARD_HEIGHT, context.getDefaultKeyboardHeight()) set(keyboardHeight) = prefs.edit().putInt(SOFT_KEYBOARD_HEIGHT, keyboardHeight).apply() + + var useArchive: Boolean + get() = prefs.getBoolean(USE_ARCHIVE, true) + set(useArchive) = prefs.edit().putBoolean(USE_ARCHIVE, useArchive).apply() + + var lastArchiveCheck: Long + get() = prefs.getLong(LAST_ARCHIVE_CHECK, 0L) + set(lastArchiveCheck) = prefs.edit().putLong(LAST_ARCHIVE_CHECK, lastArchiveCheck).apply() } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Constants.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Constants.kt index 4db9a2ad..dcc3e3f7 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Constants.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Constants.kt @@ -37,6 +37,8 @@ const val SCHEDULED_MESSAGE_ID = "scheduled_message_id" const val SOFT_KEYBOARD_HEIGHT = "soft_keyboard_height" const val IS_MMS = "is_mms" const val MESSAGE_ID = "message_id" +const val USE_ARCHIVE = "use_recycle_bin" +const val LAST_ARCHIVE_CHECK = "last_bin_check" private const val PATH = "com.simplemobiletools.smsmessenger.action." const val MARK_AS_READ = PATH + "mark_as_read" diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/ConversationsDao.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/ConversationsDao.kt index 0c8db25c..7c321f82 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/ConversationsDao.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/ConversationsDao.kt @@ -1,9 +1,7 @@ package com.simplemobiletools.smsmessenger.interfaces -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query +import androidx.room.* +import com.simplemobiletools.smsmessenger.models.ArchivedConversation import com.simplemobiletools.smsmessenger.models.Conversation @Dao @@ -11,8 +9,20 @@ interface ConversationsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertOrUpdate(conversation: Conversation): Long - @Query("SELECT * FROM conversations") - fun getAll(): List + @Query("SELECT conversations.* FROM conversations LEFT OUTER JOIN archived_conversations ON conversations.thread_id = archived_conversations.thread_id WHERE archived_conversations.deleted_ts is NULL") + fun getNonArchived(): List + + @Query("SELECT conversations.* FROM archived_conversations INNER JOIN conversations ON conversations.thread_id = archived_conversations.thread_id") + fun getAllArchived(): List + + @Query("SELECT COUNT(*) FROM archived_conversations") + fun getArchivedCount(): Int + + @Query("SELECT * FROM archived_conversations WHERE deleted_ts < :timestamp") + fun getOldArchived(timestamp: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun archiveConversation(archivedConversation: ArchivedConversation) @Query("SELECT * FROM conversations WHERE thread_id = :threadId") fun getConversationWithThreadId(threadId: Long): Conversation? @@ -30,5 +40,14 @@ interface ConversationsDao { fun markUnread(threadId: Long) @Query("DELETE FROM conversations WHERE thread_id = :threadId") - fun deleteThreadId(threadId: Long) + fun deleteThreadFromConversations(threadId: Long) + + @Query("DELETE FROM archived_conversations WHERE thread_id = :threadId") + fun deleteThreadFromArchivedConversations(threadId: Long) + + @Transaction + fun deleteThreadId(threadId: Long) { + deleteThreadFromConversations(threadId) + deleteThreadFromArchivedConversations(threadId) + } } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/ArchivedConversation.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/ArchivedConversation.kt new file mode 100644 index 00000000..bcf43692 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/ArchivedConversation.kt @@ -0,0 +1,15 @@ +package com.simplemobiletools.smsmessenger.models + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "archived_conversations", + indices = [(Index(value = ["thread_id"], unique = true))] +) +data class ArchivedConversation( + @PrimaryKey @ColumnInfo(name = "thread_id") var threadId: Long, + @ColumnInfo(name = "deleted_ts") var deletedTs: Long +) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/SmsReceiver.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/SmsReceiver.kt index d947190e..2b0fb852 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/SmsReceiver.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/SmsReceiver.kt @@ -100,6 +100,7 @@ class SmsReceiver : BroadcastReceiver() { subscriptionId ) context.messagesDB.insertOrUpdate(message) + context.conversationsDB.deleteThreadFromArchivedConversations(threadId) refreshMessages() context.showReceivedMessageNotification(newMessageId, address, body, threadId, bitmap) } diff --git a/app/src/main/res/layout/activity_archived_conversations.xml b/app/src/main/res/layout/activity_archived_conversations.xml new file mode 100644 index 00000000..d699f386 --- /dev/null +++ b/app/src/main/res/layout/activity_archived_conversations.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 99dfaa9b..eb7cef7a 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -346,6 +346,55 @@ android:id="@+id/settings_outgoing_messages_divider" layout="@layout/divider" /> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/archive_menu.xml b/app/src/main/res/menu/archive_menu.xml new file mode 100644 index 00000000..a3f11163 --- /dev/null +++ b/app/src/main/res/menu/archive_menu.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/menu/cab_archived_conversations.xml b/app/src/main/res/menu/cab_archived_conversations.xml new file mode 100644 index 00000000..19adebc2 --- /dev/null +++ b/app/src/main/res/menu/cab_archived_conversations.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index d60f5871..b403927f 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -13,6 +13,11 @@ android:showAsAction="never" android:title="@string/export_messages" app:showAsAction="never" /> +