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" /> +