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
This commit is contained in:
Naveen Singh 2025-10-15 20:33:54 +05:30 committed by GitHub
parent 627f19471e
commit 0a66c46f0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 110 additions and 16 deletions

View file

@ -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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- Unread badge count for conversations ([#177])
### Changed ### Changed
- Optimized loading messages in conversations - Optimized loading messages in conversations
- Updated conversation item design to be more compact ([#376]) - 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 position reset when opening attachments in conversations ([#82])
- Fixed automatic scroll to searched message in conversations ([#350]) - Fixed automatic scroll to searched message in conversations ([#350])
- Fixed non-standard text and avatar sizes in list items - 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 ## [1.4.0] - 2025-10-12
### Added ### 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 [#135]: https://github.com/FossifyOrg/Messages/issues/135
[#153]: https://github.com/FossifyOrg/Messages/issues/153 [#153]: https://github.com/FossifyOrg/Messages/issues/153
[#165]: https://github.com/FossifyOrg/Messages/issues/165 [#165]: https://github.com/FossifyOrg/Messages/issues/165
[#177]: https://github.com/FossifyOrg/Messages/issues/177
[#180]: https://github.com/FossifyOrg/Messages/issues/180 [#180]: https://github.com/FossifyOrg/Messages/issues/180
[#209]: https://github.com/FossifyOrg/Messages/issues/209 [#209]: https://github.com/FossifyOrg/Messages/issues/209
[#217]: https://github.com/FossifyOrg/Messages/issues/217 [#217]: https://github.com/FossifyOrg/Messages/issues/217
[#225]: https://github.com/FossifyOrg/Messages/issues/225 [#225]: https://github.com/FossifyOrg/Messages/issues/225
[#243]: https://github.com/FossifyOrg/Messages/issues/243 [#243]: https://github.com/FossifyOrg/Messages/issues/243
[#262]: https://github.com/FossifyOrg/Messages/issues/262 [#262]: https://github.com/FossifyOrg/Messages/issues/262
[#264]: https://github.com/FossifyOrg/Messages/issues/264
[#274]: https://github.com/FossifyOrg/Messages/issues/274 [#274]: https://github.com/FossifyOrg/Messages/issues/274
[#279]: https://github.com/FossifyOrg/Messages/issues/279 [#279]: https://github.com/FossifyOrg/Messages/issues/279
[#287]: https://github.com/FossifyOrg/Messages/issues/287 [#287]: https://github.com/FossifyOrg/Messages/issues/287

View file

@ -138,6 +138,7 @@ import org.fossify.messages.extensions.isGifMimeType
import org.fossify.messages.extensions.isImageMimeType import org.fossify.messages.extensions.isImageMimeType
import org.fossify.messages.extensions.launchConversationDetails import org.fossify.messages.extensions.launchConversationDetails
import org.fossify.messages.extensions.markMessageRead import org.fossify.messages.extensions.markMessageRead
import org.fossify.messages.extensions.markThreadMessagesRead
import org.fossify.messages.extensions.markThreadMessagesUnread import org.fossify.messages.extensions.markThreadMessagesUnread
import org.fossify.messages.extensions.messagesDB import org.fossify.messages.extensions.messagesDB
import org.fossify.messages.extensions.moveMessageToRecycleBin import org.fossify.messages.extensions.moveMessageToRecycleBin
@ -302,6 +303,8 @@ class ThreadActivity : SimpleActivity() {
binding.messageHolder.threadTypeMessage.setSelection(smsDraft.length) binding.messageHolder.threadTypeMessage.setSelection(smsDraft.length)
} }
} }
markThreadMessagesRead(threadId)
} }
val bottomBarColor = getBottomBarColor() val bottomBarColor = getBottomBarColor()

View file

@ -6,6 +6,7 @@ import android.os.Parcelable
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide 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.applyColorFilter
import org.fossify.commons.extensions.beVisibleIf import org.fossify.commons.extensions.beVisibleIf
import org.fossify.commons.extensions.formatDateOrTime import org.fossify.commons.extensions.formatDateOrTime
import org.fossify.commons.extensions.getContrastColor
import org.fossify.commons.extensions.getTextSize import org.fossify.commons.extensions.getTextSize
import org.fossify.commons.extensions.setupViewBackground import org.fossify.commons.extensions.setupViewBackground
import org.fossify.commons.helpers.SimpleContactsHelper import org.fossify.commons.helpers.SimpleContactsHelper
@ -171,21 +173,23 @@ abstract class BaseConversationsAdapter(
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f) setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f)
} }
val style = if (conversation.read) { val isUnread = !conversation.read
conversationBodyShort.alpha = 0.7f val style = if (isUnread) {
if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL
} else {
conversationBodyShort.alpha = 1f conversationBodyShort.alpha = 1f
if (conversation.isScheduled) Typeface.BOLD_ITALIC else Typeface.BOLD 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) conversationAddress.setTypeface(null, style)
conversationBodyShort.setTypeface(null, style) conversationBodyShort.setTypeface(null, style)
conversationDate.setTypeface(null, style)
arrayListOf(conversationAddress, conversationBodyShort, conversationDate).forEach { arrayListOf(conversationAddress, conversationBodyShort, conversationDate).forEach {
it.setTextColor(textColor) it.setTextColor(textColor)
} }
setupBadgeCount(unreadCountBadge, isUnread, conversation.unreadCount)
// at group conversations we use an icon as the placeholder, not any letter // at group conversations we use an icon as the placeholder, not any letter
val placeholder = if (conversation.isGroupConversation) { val placeholder = if (conversation.isGroupConversation) {
SimpleContactsHelper(activity).getColoredGroupIcon(conversation.title) 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 ?: "" override fun onChange(position: Int) = currentList.getOrNull(position)?.title ?: ""
private fun saveRecyclerViewState() { private fun saveRecyclerViewState() {
@ -221,4 +240,8 @@ abstract class BaseConversationsAdapter(
return Conversation.areContentsTheSame(oldItem, newItem) return Conversation.areContentsTheSame(oldItem, newItem)
} }
} }
companion object {
private const val MAX_UNREAD_BADGE_COUNT = 99
}
} }

View file

@ -1,3 +1,4 @@
@file:Suppress("MagicNumber")
package org.fossify.messages.databases package org.fossify.messages.databases
import android.content.Context import android.content.Context
@ -29,7 +30,7 @@ import org.fossify.messages.models.RecycleBinMessage
RecycleBinMessage::class, RecycleBinMessage::class,
Draft::class Draft::class
], ],
version = 9 version = 10
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class MessagesDatabase : RoomDatabase() { abstract class MessagesDatabase : RoomDatabase() {
@ -65,6 +66,7 @@ abstract class MessagesDatabase : RoomDatabase() {
.addMigrations(MIGRATION_6_7) .addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8) .addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9) .addMigrations(MIGRATION_8_9)
.addMigrations(MIGRATION_9_10)
.build() .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")
}
}
}
} }
} }

View file

@ -318,6 +318,34 @@ fun Context.getMMSSender(msgId: Long): String {
return "" return ""
} }
fun Context.getUnreadCountsByThread(): Map<Long, Int> {
val result = HashMap<Long, Int>(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( fun Context.getConversations(
threadId: Long? = null, threadId: Long? = null,
privateContacts: ArrayList<SimpleContact> = ArrayList(), privateContacts: ArrayList<SimpleContact> = ArrayList(),
@ -349,6 +377,7 @@ fun Context.getConversations(
val conversations = ArrayList<Conversation>() val conversations = ArrayList<Conversation>()
val simpleContactHelper = SimpleContactsHelper(this) val simpleContactHelper = SimpleContactsHelper(this)
val blockedNumbers = getBlockedNumbers() val blockedNumbers = getBlockedNumbers()
val unreadMap = getUnreadCountsByThread()
try { try {
queryCursorUnsafe( queryCursorUnsafe(
uri, uri,
@ -397,6 +426,7 @@ fun Context.getConversations(
val read = cursor.getIntValue(Threads.READ) == 1 val read = cursor.getIntValue(Threads.READ) == 1
val archived = val archived =
if (archiveAvailable) cursor.getIntValue(Threads.ARCHIVED) == 1 else false if (archiveAvailable) cursor.getIntValue(Threads.ARCHIVED) == 1 else false
val unreadCount = if (!read) unreadMap[id] ?: 0 else 0
val conversation = Conversation( val conversation = Conversation(
threadId = id, threadId = id,
snippet = snippet, snippet = snippet,
@ -406,7 +436,8 @@ fun Context.getConversations(
photoUri = photoUri, photoUri = photoUri,
isGroupConversation = isGroupConversation, isGroupConversation = isGroupConversation,
phoneNumber = phoneNumbers.first(), phoneNumber = phoneNumbers.first(),
isArchived = archived isArchived = archived,
unreadCount = unreadCount,
) )
conversations.add(conversation) conversations.add(conversation)
} }
@ -973,6 +1004,7 @@ fun Context.markThreadMessagesRead(threadId: Long) {
contentResolver.update(uri, contentValues, selection, selectionArgs) contentResolver.update(uri, contentValues, selection, selectionArgs)
} }
messagesDB.markThreadRead(threadId) messagesDB.markThreadRead(threadId)
conversationsDB.markRead(threadId)
} }
fun Context.markThreadMessagesUnread(threadId: Long) { fun Context.markThreadMessagesUnread(threadId: Long) {
@ -985,6 +1017,7 @@ fun Context.markThreadMessagesUnread(threadId: Long) {
val selectionArgs = arrayOf(threadId.toString()) val selectionArgs = arrayOf(threadId.toString())
contentResolver.update(uri, contentValues, selection, selectionArgs) contentResolver.update(uri, contentValues, selection, selectionArgs)
} }
conversationsDB.markUnread(threadId)
} }
@SuppressLint("NewApi") @SuppressLint("NewApi")
@ -1258,7 +1291,8 @@ fun Context.createTemporaryThread(
phoneNumber = addresses.first(), phoneNumber = addresses.first(),
isScheduled = true, isScheduled = true,
usesCustomTitle = cachedConv?.usesCustomTitle == true, usesCustomTitle = cachedConv?.usesCustomTitle == true,
isArchived = false isArchived = false,
unreadCount = 0,
) )
try { try {
conversationsDB.insertOrUpdate(conversation) conversationsDB.insertOrUpdate(conversation)

View file

@ -17,7 +17,8 @@ data class Conversation(
@ColumnInfo(name = "phone_number") var phoneNumber: String, @ColumnInfo(name = "phone_number") var phoneNumber: String,
@ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false, @ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false,
@ColumnInfo(name = "uses_custom_title") var usesCustomTitle: 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 { companion object {
@ -32,7 +33,8 @@ data class Conversation(
old.title == new.title && old.title == new.title &&
old.photoUri == new.photoUri && old.photoUri == new.photoUri &&
old.isGroupConversation == new.isGroupConversation && old.isGroupConversation == new.isGroupConversation &&
old.phoneNumber == new.phoneNumber old.phoneNumber == new.phoneNumber &&
old.unreadCount == new.unreadCount
} }
} }
} }

View file

@ -88,12 +88,28 @@
<ImageView <ImageView
android:id="@+id/pin_indicator" android:id="@+id/pin_indicator"
android:layout_width="@dimen/pin_icon_size" android:layout_width="@dimen/small_icon_size"
android:layout_height="@dimen/pin_icon_size" android:layout_height="@dimen/small_icon_size"
android:alpha="0.7" android:alpha="0.7"
android:padding="@dimen/tiny_margin"
android:src="@drawable/ic_pin_filled_vector" android:src="@drawable/ic_pin_filled_vector"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/conversation_body_short"
app:layout_constraintEnd_toStartOf="@id/unread_count_badge"
app:layout_constraintTop_toTopOf="@id/conversation_body_short"
tools:visibility="visible" />
<TextView
android:id="@+id/unread_count_badge"
android:layout_width="@dimen/small_icon_size"
android:layout_height="@dimen/small_icon_size"
android:background="@drawable/circle_background"
android:gravity="center"
android:textSize="@dimen/list_tertiary_text_size"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/conversation_body_short" app:layout_constraintBottom_toBottomOf="@id/conversation_body_short"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/conversation_body_short" /> app:layout_constraintTop_toTopOf="@id/conversation_body_short"
tools:text="42"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -34,7 +34,7 @@ style:
active: true active: true
ignoreAnnotated: ["Composable"] ignoreAnnotated: ["Composable"]
ignoreEnums: true ignoreEnums: true
ignoreNumbers: ["-1", "0", "1", "2", "42", "1000"] ignoreNumbers: ["-1", "0", "1", "2", "42", "128", "256", "1000"]
MaxLineLength: MaxLineLength:
active: true active: true
maxLineLength: 120 maxLineLength: 120