Add support for recycle bin for messages

This adds support for moving messages to recycle bin instead of
deleting them right away. The feature is not active by default.

This closes #451
This commit is contained in:
Ensar Sarajčić 2023-07-18 11:34:25 +02:00
parent 53aa4495a0
commit 555b6ebea3
13 changed files with 274 additions and 20 deletions

View File

@ -64,6 +64,7 @@ class MainActivity : SimpleActivity() {
updateMaterialActivityViews(main_coordinator, conversations_list, useTransparentNavigation = true, useTopSearchMenu = true)
if (savedInstanceState == null) {
checkAndDeleteOldRecycleBinMessages()
handleAppPasswordProtection {
wasProtectionHandled = it
if (it) {

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.emptyMessagesRecycleBin
import com.simplemobiletools.smsmessenger.extensions.messagesDB
import com.simplemobiletools.smsmessenger.helpers.*
import kotlinx.android.synthetic.main.activity_settings.*
import java.util.*
class SettingsActivity : SimpleActivity() {
private var blockedNumbersAtPause = -1
private var recycleBinMessages = 0
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
@ -48,6 +51,8 @@ class SettingsActivity : SimpleActivity() {
setupGroupMessageAsMMS()
setupLockScreenVisibility()
setupMMSFileSizeLimit()
setupUseRecycleBin()
setupEmptyRecycleBin()
setupAppPasswordProtection()
updateTextColors(settings_nested_scrollview)
@ -60,6 +65,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())
@ -259,6 +265,43 @@ class SettingsActivity : SimpleActivity() {
}
}
private fun setupUseRecycleBin() {
updateRecycleBinButtons()
settings_use_recycle_bin.isChecked = config.useRecycleBin
settings_use_recycle_bin_holder.setOnClickListener {
settings_use_recycle_bin.toggle()
config.useRecycleBin = settings_use_recycle_bin.isChecked
updateRecycleBinButtons()
}
}
private fun updateRecycleBinButtons() {
settings_empty_recycle_bin_holder.beVisibleIf(config.useRecycleBin)
}
private fun setupEmptyRecycleBin() {
ensureBackgroundThread {
recycleBinMessages = messagesDB.getArchivedCount()
runOnUiThread {
settings_empty_recycle_bin_size.text =
resources.getQuantityString(R.plurals.delete_messages, recycleBinMessages, recycleBinMessages)
}
}
settings_empty_recycle_bin_holder.setOnClickListener {
if (recycleBinMessages == 0) {
toast(R.string.recycle_bin_empty)
} else {
ConfirmationDialog(this, "", R.string.empty_recycle_bin_confirmation, R.string.yes, R.string.no) {
emptyMessagesRecycleBin()
recycleBinMessages = 0
settings_empty_recycle_bin_size.text =
resources.getQuantityString(R.plurals.delete_messages, recycleBinMessages, recycleBinMessages)
}
}
}
}
private fun setupAppPasswordProtection() {
settings_app_password_protection.isChecked = config.isAppPasswordProtectionOn
settings_app_password_protection_holder.setOnClickListener {

View File

@ -402,7 +402,7 @@ class ThreadActivity : SimpleActivity() {
activity = this,
recyclerView = thread_messages_list,
itemClick = { handleItemClick(it) },
deleteMessages = { deleteMessages(it) }
deleteMessages = { messages, toRecycleBin -> deleteMessages(messages, toRecycleBin) }
)
thread_messages_list.adapter = currAdapter
@ -489,7 +489,7 @@ class ThreadActivity : SimpleActivity() {
}
}
private fun deleteMessages(messagesToRemove: List<Message>) {
private fun deleteMessages(messagesToRemove: List<Message>, toRecycleBin: Boolean) {
val deletePosition = threadItems.indexOf(messagesToRemove.first())
messages.removeAll(messagesToRemove.toSet())
threadItems = getThreadItems()
@ -508,12 +508,17 @@ class ThreadActivity : SimpleActivity() {
messagesToRemove.forEach { message ->
val messageId = message.id
if (message.isScheduled) {
// TODO: Moving scheduled messages to recycle bin maybe doesn't make sense
deleteScheduledMessage(messageId)
cancelScheduleSendPendingIntent(messageId)
} else {
if (toRecycleBin) {
moveMessageToRecycleBin(messageId)
} else {
deleteMessage(messageId, message.isMMS)
}
}
}
updateLastConversationMessage(threadId)
// move all scheduled messages to a temporary thread when there are no real messages left

View File

@ -23,7 +23,6 @@ import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import com.simplemobiletools.commons.adapters.MyRecyclerViewListAdapter
import com.simplemobiletools.commons.dialogs.ConfirmationDialog
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.SimpleContactsHelper
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
@ -33,6 +32,7 @@ import com.simplemobiletools.smsmessenger.activities.NewConversationActivity
import com.simplemobiletools.smsmessenger.activities.SimpleActivity
import com.simplemobiletools.smsmessenger.activities.ThreadActivity
import com.simplemobiletools.smsmessenger.activities.VCardViewerActivity
import com.simplemobiletools.smsmessenger.dialogs.DeleteConfirmationDialog
import com.simplemobiletools.smsmessenger.dialogs.MessageDetailsDialog
import com.simplemobiletools.smsmessenger.dialogs.SelectTextDialog
import com.simplemobiletools.smsmessenger.extensions.*
@ -58,7 +58,7 @@ class ThreadAdapter(
activity: SimpleActivity,
recyclerView: MyRecyclerView,
itemClick: (Any) -> Unit,
val deleteMessages: (messages: List<Message>) -> Unit
val deleteMessages: (messages: List<Message>, toRecycleBin: Boolean) -> Unit
) : MyRecyclerViewListAdapter<ThreadItem>(activity, recyclerView, ThreadItemDiffCallback(), itemClick) {
private var fontSize = activity.getTextSize()
@ -206,11 +206,12 @@ class ThreadAdapter(
val baseString = R.string.deletion_confirmation
val question = String.format(resources.getString(baseString), items)
ConfirmationDialog(activity, question) {
DeleteConfirmationDialog(activity, question, activity.config.useRecycleBin) { skipRecycleBin ->
ensureBackgroundThread {
val messagesToRemove = getSelectedItems()
if (messagesToRemove.isNotEmpty()) {
deleteMessages(messagesToRemove.filterIsInstance<Message>())
val toRecycleBin = !skipRecycleBin && activity.config.useRecycleBin
deleteMessages(messagesToRemove.filterIsInstance<Message>(), toRecycleBin)
}
}
}

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, Attachment::class, MessageAttachment::class, Message::class, RecycleBinMessage::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,14 @@ abstract class MessagesDatabase : RoomDatabase() {
}
}
}
private val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.apply {
execSQL("CREATE TABLE IF NOT EXISTS `recycle_bin_messages` (`id` INTEGER PRIMARY KEY, `deleted_ts` INTEGER NOT NULL)")
execSQL("CREATE UNIQUE INDEX `index_recycle_bin_messages_id` ON `recycle_bin_messages` (`id`)")
}
}
}
}
}

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

@ -641,6 +641,36 @@ fun Context.deleteConversation(threadId: Long) {
messagesDB.deleteThreadMessages(threadId)
}
fun Context.checkAndDeleteOldRecycleBinMessages(callback: (() -> Unit)? = null) {
if (config.useRecycleBin && config.lastRecycleBinCheck < System.currentTimeMillis() - DAY_SECONDS * 1000) {
config.lastRecycleBinCheck = System.currentTimeMillis()
ensureBackgroundThread {
try {
for (message in messagesDB.getOldRecycleBinMessages(System.currentTimeMillis() - MONTH_SECONDS * 1000L)) {
deleteMessage(message.id, message.isMMS)
}
callback?.invoke()
} catch (e: Exception) {
}
}
}
}
fun Context.emptyMessagesRecycleBin() {
val messages = messagesDB.getAllRecycleBinMessages()
for (message in messages) {
deleteMessage(message.id, message.isMMS)
}
}
fun Context.moveMessageToRecycleBin(id: Long) {
try {
messagesDB.insertRecycleBinEntry(RecycleBinMessage(id, System.currentTimeMillis()))
} catch (e: Exception) {
showErrorToast(e)
}
}
fun Context.deleteMessage(id: Long, isMMS: Boolean) {
val uri = if (isMMS) Mms.CONTENT_URI else Sms.CONTENT_URI
val selection = "${Sms._ID} = ?"

View File

@ -103,4 +103,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 useRecycleBin: Boolean
get() = prefs.getBoolean(USE_RECYCLE_BIN, false)
set(useRecycleBin) = prefs.edit().putBoolean(USE_RECYCLE_BIN, useRecycleBin).apply()
var lastRecycleBinCheck: Long
get() = prefs.getLong(LAST_RECYCLE_BIN_CHECK, 0L)
set(lastRecycleBinCheck) = prefs.edit().putLong(LAST_RECYCLE_BIN_CHECK, lastRecycleBinCheck).apply()
}

View File

@ -40,6 +40,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_RECYCLE_BIN = "use_recycle_bin"
const val LAST_RECYCLE_BIN_CHECK = "last_recycle_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.RecycleBinMessage
import com.simplemobiletools.smsmessenger.models.Message
@Dao
@ -11,6 +9,9 @@ interface MessagesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrUpdate(message: Message)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertRecycleBinEntry(recycleBinMessage: RecycleBinMessage)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertOrIgnore(message: Message): Long
@ -20,15 +21,24 @@ interface MessagesDao {
@Query("SELECT * FROM messages")
fun getAll(): List<Message>
@Query("SELECT * FROM messages WHERE thread_id = :threadId")
@Query("SELECT messages.* FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NOT NULL")
fun getAllRecycleBinMessages(): List<Message>
@Query("SELECT messages.* FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NOT NULL AND recycle_bin_messages.deleted_ts < :timestamp")
fun getOldRecycleBinMessages(timestamp: Long): List<Message>
@Query("SELECT messages.* FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NULL AND thread_id = :threadId")
fun getThreadMessages(threadId: Long): List<Message>
@Query("SELECT * FROM messages WHERE thread_id = :threadId AND is_scheduled = 1")
@Query("SELECT messages.* FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NULL AND thread_id = :threadId AND is_scheduled = 1")
fun getScheduledThreadMessages(threadId: Long): List<Message>
@Query("SELECT * FROM messages WHERE thread_id = :threadId AND id = :messageId AND is_scheduled = 1")
fun getScheduledMessageWithId(threadId: Long, messageId: Long): Message
@Query("SELECT COUNT(*) FROM recycle_bin_messages")
fun getArchivedCount(): Int
@Query("SELECT * FROM messages WHERE body LIKE :text")
fun getMessagesWithText(text: String): List<Message>
@ -44,11 +54,29 @@ interface MessagesDao {
@Query("UPDATE messages SET status = :status WHERE id = :id")
fun updateStatus(id: Long, status: Int): Int
@Transaction
fun delete(id: Long) {
deleteFromMessages(id)
deleteFromRecycleBin(id)
}
@Query("DELETE FROM messages WHERE id = :id")
fun delete(id: Long)
fun deleteFromMessages(id: Long)
@Query("DELETE FROM recycle_bin_messages WHERE id = :id")
fun deleteFromRecycleBin(id: Long)
@Transaction
fun deleteThreadMessages(threadId: Long) {
deleteThreadMessagesFromRecycleBin(threadId)
deleteAllThreadMessages(threadId)
}
@Query("DELETE FROM messages WHERE thread_id = :threadId")
fun deleteThreadMessages(threadId: Long)
fun deleteAllThreadMessages(threadId: Long)
@Query("DELETE FROM recycle_bin_messages WHERE id IN (SELECT id FROM messages WHERE thread_id = :threadId)")
fun deleteThreadMessagesFromRecycleBin(threadId: Long)
@Query("DELETE FROM messages")
fun deleteAll()

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 = "recycle_bin_messages",
indices = [(Index(value = ["id"], unique = true))]
)
data class RecycleBinMessage(
@PrimaryKey val id: Long,
@ColumnInfo(name = "deleted_ts") var deletedTS: Long
)

View File

@ -361,6 +361,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>