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
This commit is contained in:
Ensar Sarajčić 2023-07-11 16:52:47 +02:00
parent 9942fb788a
commit 47861f605d
22 changed files with 846 additions and 191 deletions

View File

@ -51,6 +51,13 @@
android:configChanges="orientation"
android:exported="true" />
<activity
android:name=".activities.ArchivedConversationsActivity"
android:configChanges="orientation"
android:exported="true"
android:label="Archived Conversations"
android:parentActivityName=".activities.MainActivity" />
<activity
android:name=".activities.ThreadActivity"
android:configChanges="orientation"

View File

@ -0,0 +1,150 @@
package com.simplemobiletools.smsmessenger.activities
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.*
import com.simplemobiletools.smsmessenger.R
import com.simplemobiletools.smsmessenger.adapters.ArchivedConversationsAdapter
import com.simplemobiletools.smsmessenger.extensions.*
import com.simplemobiletools.smsmessenger.helpers.*
import com.simplemobiletools.smsmessenger.models.Conversation
import com.simplemobiletools.smsmessenger.models.Events
import kotlinx.android.synthetic.main.activity_archived_conversations.*
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class ArchivedConversationsActivity : SimpleActivity() {
private var bus: EventBus? = null
@SuppressLint("InlinedApi")
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_archived_conversations)
setupOptionsMenu()
updateMaterialActivityViews(archive_coordinator, conversations_list, useTransparentNavigation = true, useTopSearchMenu = false)
setupMaterialScrollListener(conversations_list, archive_toolbar)
loadArchivedConversations()
}
override fun onResume() {
super.onResume()
setupToolbar(archive_toolbar, NavigationIcon.Arrow)
updateMenuColors()
loadArchivedConversations()
}
override fun onDestroy() {
super.onDestroy()
bus?.unregister(this)
}
private fun setupOptionsMenu() {
archive_toolbar.inflateMenu(R.menu.archive_menu)
archive_toolbar.setOnMenuItemClickListener { menuItem ->
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<Conversation>
} 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<Conversation>) {
val sortedConversations = conversations.sortedWith(
compareByDescending<Conversation> { config.pinnedConversations.contains(it.threadId.toString()) }
.thenByDescending { it.date }
).toMutableList() as ArrayList<Conversation>
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()
}
}

View File

@ -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<Conversation>
conversationsDB.getNonArchived().toMutableList() as ArrayList<Conversation>
} 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<Conversation>)
}
conversations.forEach {
clearExpiredScheduledMessages(it.threadId)
@ -351,7 +358,7 @@ class MainActivity : SimpleActivity() {
}
}
val allConversations = conversationsDB.getAll() as ArrayList<Conversation>
val allConversations = conversationsDB.getNonArchived() as ArrayList<Conversation>
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))

View File

@ -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 {

View File

@ -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

View File

@ -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<Conversation>
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<Conversation>
conversationsToUnarchive.forEach {
activity.conversationsDB.deleteThreadFromArchivedConversations(it.threadId)
}
removeConversationsFromList(conversationsToUnarchive)
}
private fun removeConversationsFromList(removedConversations: List<Conversation>) {
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()
}
}
}
}
}

View File

@ -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<Conversation>(activity, recyclerView, ConversationDiffCallback(), itemClick, onRefresh),
RecyclerViewFastScroller.OnPopupTextUpdate {
private var fontSize = activity.getTextSize()
private var drafts = HashMap<Long, String?>()
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<Conversation>, commitCallback: (() -> Unit)? = null) {
saveRecyclerViewState()
submitList(newConversations.toList(), commitCallback)
}
fun updateDrafts() {
ensureBackgroundThread {
val newDrafts = HashMap<Long, String?>()
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<Conversation>
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<Long, String?>) {
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<TextView>(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<Conversation>() {
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)
}
}
}

View File

@ -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<Conversation>(activity, recyclerView, ConversationDiffCallback(), itemClick, onRefresh),
RecyclerViewFastScroller.OnPopupTextUpdate {
private var fontSize = activity.getTextSize()
private var drafts = HashMap<Long, String?>()
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<Conversation>
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<Conversation>
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<Long, String?>) {
drafts.clear()
for ((threadId, draft) in activity.getAllDrafts()) {
drafts[threadId] = draft
}
}
fun updateFontSize() {
fontSize = activity.getTextSize()
notifyDataSetChanged()
}
fun updateConversations(newConversations: ArrayList<Conversation>, commitCallback: (() -> Unit)? = null) {
saveRecyclerViewState()
submitList(newConversations.toList(), commitCallback)
}
fun updateDrafts() {
ensureBackgroundThread {
val newDrafts = HashMap<Long, String?>()
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<TextView>(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<Conversation>() {
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)
}
}
}

View File

@ -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)")
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -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} = ?"

View File

@ -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()
}

View File

@ -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"

View File

@ -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<Conversation>
@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<Conversation>
@Query("SELECT conversations.* FROM archived_conversations INNER JOIN conversations ON conversations.thread_id = archived_conversations.thread_id")
fun getAllArchived(): List<Conversation>
@Query("SELECT COUNT(*) FROM archived_conversations")
fun getArchivedCount(): Int
@Query("SELECT * FROM archived_conversations WHERE deleted_ts < :timestamp")
fun getOldArchived(timestamp: Long): List<ArchivedConversation>
@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)
}
}

View File

@ -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
)

View File

@ -100,6 +100,7 @@ class SmsReceiver : BroadcastReceiver() {
subscriptionId
)
context.messagesDB.insertOrUpdate(message)
context.conversationsDB.deleteThreadFromArchivedConversations(threadId)
refreshMessages()
context.showReceivedMessageNotification(newMessageId, address, body, threadId, bitmap)
}

View File

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/archive_coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/archive_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/color_primary"
app:title="Archived messages"
app:titleTextAppearance="@style/AppTheme.ActionBar.TitleTextStyle" />
<RelativeLayout
android:id="@+id/archive_nested_scrollview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="?attr/actionBarSize"
android:fillViewport="true"
android:scrollbars="none"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/archive_coordinator_wrapper"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:id="@+id/archive_holder"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/conversations_progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:indeterminate="true"
android:visibility="gone"
app:hideAnimationBehavior="outward"
app:showAnimationBehavior="inward"
app:showDelay="250"
tools:visibility="visible" />
<com.simplemobiletools.commons.views.MyTextView
android:id="@+id/no_conversations_placeholder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="@dimen/bigger_margin"
android:alpha="0.8"
android:gravity="center"
android:paddingLeft="@dimen/activity_margin"
android:paddingRight="@dimen/activity_margin"
android:text="@string/no_conversations_found"
android:textSize="@dimen/bigger_text_size"
android:textStyle="italic"
android:visibility="gone" />
<com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
android:id="@+id/conversations_fastscroller"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.simplemobiletools.commons.views.MyRecyclerView
android:id="@+id/conversations_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:layoutAnimation="@anim/layout_animation"
android:overScrollMode="ifContentScrolls"
android:scrollbars="none"
app:layoutManager="com.simplemobiletools.commons.views.MyLinearLayoutManager" />
</com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller>
</RelativeLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</RelativeLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -346,6 +346,55 @@
android:id="@+id/settings_outgoing_messages_divider"
layout="@layout/divider" />
<TextView
android:id="@+id/settings_recycle_bin_label"
style="@style/SettingsSectionLabelStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/recycle_bin" />
<RelativeLayout
android:id="@+id/settings_use_recycle_bin_holder"
style="@style/SettingsHolderCheckboxStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.simplemobiletools.commons.views.MyAppCompatCheckbox
android:id="@+id/settings_use_recycle_bin"
style="@style/SettingsCheckboxStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/move_items_into_recycle_bin" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/settings_empty_recycle_bin_holder"
style="@style/SettingsHolderTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.simplemobiletools.commons.views.MyTextView
android:id="@+id/settings_empty_recycle_bin_label"
style="@style/SettingsTextLabelStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/empty_recycle_bin" />
<com.simplemobiletools.commons.views.MyTextView
android:id="@+id/settings_empty_recycle_bin_size"
style="@style/SettingsTextValueStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/settings_empty_recycle_bin_label"
tools:text="0 B" />
</RelativeLayout>
<include
android:id="@+id/settings_recycle_bin_divider"
layout="@layout/divider" />
<TextView
android:id="@+id/settings_security_label"
style="@style/SettingsSectionLabelStyle"

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/delete_remember_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/big_margin"
android:paddingTop="@dimen/big_margin"
android:paddingRight="@dimen/big_margin">
<com.simplemobiletools.commons.views.MyTextView
android:id="@+id/delete_remember_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/small_margin"
android:paddingBottom="@dimen/activity_margin"
android:text="@string/delete_whole_conversation_confirmation"
android:textSize="@dimen/bigger_text_size" />
<com.simplemobiletools.commons.views.MyAppCompatCheckbox
android:id="@+id/skip_the_recycle_bin_checkbox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/delete_remember_title"
android:text="@string/skip_the_recycle_bin" />
</RelativeLayout>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AppCompatResource,AlwaysShowAction">
<item
android:id="@+id/empty_archive"
android:showAsAction="never"
android:title="Empty archive messages"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AppCompatResource,AlwaysShowAction">
<item
android:id="@+id/cab_delete"
android:icon="@drawable/ic_delete_vector"
android:showAsAction="always"
android:title="@string/delete"
app:showAsAction="always" />
<item
android:id="@+id/cab_select_all"
android:icon="@drawable/ic_select_all_vector"
android:title="@string/select_all"
app:showAsAction="ifRoom" />
<item
android:id="@+id/cab_unarchive"
android:showAsAction="never"
android:title="Unarchive"
app:showAsAction="never" />
</menu>

View File

@ -13,6 +13,11 @@
android:showAsAction="never"
android:title="@string/export_messages"
app:showAsAction="never" />
<item
android:id="@+id/show_archived"
android:showAsAction="never"
android:title="Show archived messages"
app:showAsAction="never" />
<item
android:id="@+id/settings"
android:icon="@drawable/ic_settings_cog_vector"