From 0a66c46f0d92d04b4a01c84a2af2622db9232936 Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Wed, 15 Oct 2025 20:33:54 +0530 Subject: [PATCH] feat: add unread badge count for conversations (#560) * feat: add unread badge count for conversations * fix: mark all messages read on thread open * fix: address some lint issues * fix: mark as read/unread in the local db as well * docs: update changelog Refs: https://github.com/FossifyOrg/Messages/issues/264, https://github.com/FossifyOrg/Messages/issues/177 --- CHANGELOG.md | 6 +++ .../messages/activities/ThreadActivity.kt | 3 ++ .../adapters/BaseConversationsAdapter.kt | 33 ++++++++++++--- .../messages/databases/MessagesDatabase.kt | 12 +++++- .../fossify/messages/extensions/Context.kt | 40 +++++++++++++++++-- .../fossify/messages/models/Conversation.kt | 6 ++- app/src/main/res/layout/item_conversation.xml | 24 +++++++++-- detekt.yml | 2 +- 8 files changed, 110 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2518cb0e..4ee7b1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Unread badge count for conversations ([#177]) + ### Changed - Optimized loading messages in conversations - Updated conversation item design to be more compact ([#376]) @@ -13,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed position reset when opening attachments in conversations ([#82]) - Fixed automatic scroll to searched message in conversations ([#350]) - Fixed non-standard text and avatar sizes in list items +- Fixed "Mark as read" not working in some cases ([#264]) ## [1.4.0] - 2025-10-12 ### Added @@ -157,12 +161,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#135]: https://github.com/FossifyOrg/Messages/issues/135 [#153]: https://github.com/FossifyOrg/Messages/issues/153 [#165]: https://github.com/FossifyOrg/Messages/issues/165 +[#177]: https://github.com/FossifyOrg/Messages/issues/177 [#180]: https://github.com/FossifyOrg/Messages/issues/180 [#209]: https://github.com/FossifyOrg/Messages/issues/209 [#217]: https://github.com/FossifyOrg/Messages/issues/217 [#225]: https://github.com/FossifyOrg/Messages/issues/225 [#243]: https://github.com/FossifyOrg/Messages/issues/243 [#262]: https://github.com/FossifyOrg/Messages/issues/262 +[#264]: https://github.com/FossifyOrg/Messages/issues/264 [#274]: https://github.com/FossifyOrg/Messages/issues/274 [#279]: https://github.com/FossifyOrg/Messages/issues/279 [#287]: https://github.com/FossifyOrg/Messages/issues/287 diff --git a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt index 76c7a3fd..b616dcd2 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt @@ -138,6 +138,7 @@ import org.fossify.messages.extensions.isGifMimeType import org.fossify.messages.extensions.isImageMimeType import org.fossify.messages.extensions.launchConversationDetails import org.fossify.messages.extensions.markMessageRead +import org.fossify.messages.extensions.markThreadMessagesRead import org.fossify.messages.extensions.markThreadMessagesUnread import org.fossify.messages.extensions.messagesDB import org.fossify.messages.extensions.moveMessageToRecycleBin @@ -302,6 +303,8 @@ class ThreadActivity : SimpleActivity() { binding.messageHolder.threadTypeMessage.setSelection(smsDraft.length) } } + + markThreadMessagesRead(threadId) } val bottomBarColor = getBottomBarColor() diff --git a/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt b/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt index b782bc5c..3a425719 100644 --- a/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt @@ -6,6 +6,7 @@ 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 @@ -14,6 +15,7 @@ import org.fossify.commons.adapters.MyRecyclerViewListAdapter import org.fossify.commons.extensions.applyColorFilter import org.fossify.commons.extensions.beVisibleIf import org.fossify.commons.extensions.formatDateOrTime +import org.fossify.commons.extensions.getContrastColor import org.fossify.commons.extensions.getTextSize import org.fossify.commons.extensions.setupViewBackground import org.fossify.commons.helpers.SimpleContactsHelper @@ -171,21 +173,23 @@ abstract class BaseConversationsAdapter( setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f) } - val style = if (conversation.read) { - conversationBodyShort.alpha = 0.7f - if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL - } else { + val isUnread = !conversation.read + val style = if (isUnread) { conversationBodyShort.alpha = 1f if (conversation.isScheduled) Typeface.BOLD_ITALIC else Typeface.BOLD - + } else { + conversationBodyShort.alpha = 0.7f + if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL } conversationAddress.setTypeface(null, style) conversationBodyShort.setTypeface(null, style) + conversationDate.setTypeface(null, style) arrayListOf(conversationAddress, conversationBodyShort, conversationDate).forEach { it.setTextColor(textColor) } + setupBadgeCount(unreadCountBadge, isUnread, conversation.unreadCount) // at group conversations we use an icon as the placeholder, not any letter val placeholder = if (conversation.isGroupConversation) { SimpleContactsHelper(activity).getColoredGroupIcon(conversation.title) @@ -202,6 +206,21 @@ abstract class BaseConversationsAdapter( } } + private fun setupBadgeCount(view: TextView, isUnread: Boolean, count: Int) { + view.apply { + beVisibleIf(isUnread) + if (isUnread) { + text = when { + count > MAX_UNREAD_BADGE_COUNT -> "$MAX_UNREAD_BADGE_COUNT+" + count == 0 -> "" + else -> count.toString() + } + setTextColor(properPrimaryColor.getContrastColor()) + background?.applyColorFilter(properPrimaryColor) + } + } + } + override fun onChange(position: Int) = currentList.getOrNull(position)?.title ?: "" private fun saveRecyclerViewState() { @@ -221,4 +240,8 @@ abstract class BaseConversationsAdapter( return Conversation.areContentsTheSame(oldItem, newItem) } } + + companion object { + private const val MAX_UNREAD_BADGE_COUNT = 99 + } } diff --git a/app/src/main/kotlin/org/fossify/messages/databases/MessagesDatabase.kt b/app/src/main/kotlin/org/fossify/messages/databases/MessagesDatabase.kt index 87a0dade..98bcbecf 100644 --- a/app/src/main/kotlin/org/fossify/messages/databases/MessagesDatabase.kt +++ b/app/src/main/kotlin/org/fossify/messages/databases/MessagesDatabase.kt @@ -1,3 +1,4 @@ +@file:Suppress("MagicNumber") package org.fossify.messages.databases import android.content.Context @@ -29,7 +30,7 @@ import org.fossify.messages.models.RecycleBinMessage RecycleBinMessage::class, Draft::class ], - version = 9 + version = 10 ) @TypeConverters(Converters::class) abstract class MessagesDatabase : RoomDatabase() { @@ -65,6 +66,7 @@ abstract class MessagesDatabase : RoomDatabase() { .addMigrations(MIGRATION_6_7) .addMigrations(MIGRATION_7_8) .addMigrations(MIGRATION_8_9) + .addMigrations(MIGRATION_9_10) .build() } } @@ -154,5 +156,13 @@ abstract class MessagesDatabase : RoomDatabase() { } } } + + private val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.apply { + execSQL("ALTER TABLE conversations ADD COLUMN unread_count INTEGER NOT NULL DEFAULT 0") + } + } + } } } diff --git a/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt b/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt index d0872a1a..aee395f8 100644 --- a/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt @@ -318,6 +318,34 @@ fun Context.getMMSSender(msgId: Long): String { return "" } +fun Context.getUnreadCountsByThread(): Map { + val result = HashMap(128) + + fun bump(id: Long) { + result[id] = (result[id] ?: 0) + 1 + } + + // Unread SMS + queryCursor( + uri = Sms.CONTENT_URI, + projection = arrayOf(Sms.THREAD_ID), + selection = "${Sms.READ}=0 AND ${Sms.TYPE}=${Sms.MESSAGE_TYPE_INBOX}", + selectionArgs = null, + showErrors = false + ) { bump(it.getLongValue(Sms.THREAD_ID)) } + + // Unread MMS + queryCursor( + uri = Mms.CONTENT_URI, + projection = arrayOf(Mms.THREAD_ID), + selection = "${Mms.READ}=0 AND ${Mms.MESSAGE_BOX}=${Mms.MESSAGE_BOX_INBOX}", + selectionArgs = null, + showErrors = false + ) { bump(it.getLongValue(Mms.THREAD_ID)) } + + return result +} + fun Context.getConversations( threadId: Long? = null, privateContacts: ArrayList = ArrayList(), @@ -349,6 +377,7 @@ fun Context.getConversations( val conversations = ArrayList() val simpleContactHelper = SimpleContactsHelper(this) val blockedNumbers = getBlockedNumbers() + val unreadMap = getUnreadCountsByThread() try { queryCursorUnsafe( uri, @@ -397,6 +426,7 @@ fun Context.getConversations( val read = cursor.getIntValue(Threads.READ) == 1 val archived = if (archiveAvailable) cursor.getIntValue(Threads.ARCHIVED) == 1 else false + val unreadCount = if (!read) unreadMap[id] ?: 0 else 0 val conversation = Conversation( threadId = id, snippet = snippet, @@ -406,7 +436,8 @@ fun Context.getConversations( photoUri = photoUri, isGroupConversation = isGroupConversation, phoneNumber = phoneNumbers.first(), - isArchived = archived + isArchived = archived, + unreadCount = unreadCount, ) conversations.add(conversation) } @@ -973,6 +1004,7 @@ fun Context.markThreadMessagesRead(threadId: Long) { contentResolver.update(uri, contentValues, selection, selectionArgs) } messagesDB.markThreadRead(threadId) + conversationsDB.markRead(threadId) } fun Context.markThreadMessagesUnread(threadId: Long) { @@ -985,7 +1017,8 @@ fun Context.markThreadMessagesUnread(threadId: Long) { val selectionArgs = arrayOf(threadId.toString()) contentResolver.update(uri, contentValues, selection, selectionArgs) } -} + conversationsDB.markUnread(threadId) +} @SuppressLint("NewApi") fun Context.getThreadId(address: String): Long { @@ -1258,7 +1291,8 @@ fun Context.createTemporaryThread( phoneNumber = addresses.first(), isScheduled = true, usesCustomTitle = cachedConv?.usesCustomTitle == true, - isArchived = false + isArchived = false, + unreadCount = 0, ) try { conversationsDB.insertOrUpdate(conversation) diff --git a/app/src/main/kotlin/org/fossify/messages/models/Conversation.kt b/app/src/main/kotlin/org/fossify/messages/models/Conversation.kt index 558b5919..fb4c6101 100644 --- a/app/src/main/kotlin/org/fossify/messages/models/Conversation.kt +++ b/app/src/main/kotlin/org/fossify/messages/models/Conversation.kt @@ -17,7 +17,8 @@ data class Conversation( @ColumnInfo(name = "phone_number") var phoneNumber: String, @ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false, @ColumnInfo(name = "uses_custom_title") var usesCustomTitle: Boolean = false, - @ColumnInfo(name = "archived") var isArchived: Boolean = false + @ColumnInfo(name = "archived") var isArchived: Boolean = false, + @ColumnInfo(name = "unread_count") var unreadCount: Int = 0, ) { companion object { @@ -32,7 +33,8 @@ data class Conversation( old.title == new.title && old.photoUri == new.photoUri && old.isGroupConversation == new.isGroupConversation && - old.phoneNumber == new.phoneNumber + old.phoneNumber == new.phoneNumber && + old.unreadCount == new.unreadCount } } } diff --git a/app/src/main/res/layout/item_conversation.xml b/app/src/main/res/layout/item_conversation.xml index 8bdd52b1..af3e2dd2 100644 --- a/app/src/main/res/layout/item_conversation.xml +++ b/app/src/main/res/layout/item_conversation.xml @@ -88,12 +88,28 @@ + + - + app:layout_constraintTop_toTopOf="@id/conversation_body_short" + tools:text="42" + tools:visibility="visible" /> diff --git a/detekt.yml b/detekt.yml index 73ddc0c1..5eab97a1 100644 --- a/detekt.yml +++ b/detekt.yml @@ -34,7 +34,7 @@ style: active: true ignoreAnnotated: ["Composable"] ignoreEnums: true - ignoreNumbers: ["-1", "0", "1", "2", "42", "1000"] + ignoreNumbers: ["-1", "0", "1", "2", "42", "128", "256", "1000"] MaxLineLength: active: true maxLineLength: 120