Skip to content

Commit 0a66c46

Browse files
authored
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: #264, #177
1 parent 627f194 commit 0a66c46

8 files changed

Lines changed: 110 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- Unread badge count for conversations ([#177])
10+
811
### Changed
912
- Optimized loading messages in conversations
1013
- 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
1316
- Fixed position reset when opening attachments in conversations ([#82])
1417
- Fixed automatic scroll to searched message in conversations ([#350])
1518
- Fixed non-standard text and avatar sizes in list items
19+
- Fixed "Mark as read" not working in some cases ([#264])
1620

1721
## [1.4.0] - 2025-10-12
1822
### Added
@@ -157,12 +161,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
157161
[#135]: https://github.com/FossifyOrg/Messages/issues/135
158162
[#153]: https://github.com/FossifyOrg/Messages/issues/153
159163
[#165]: https://github.com/FossifyOrg/Messages/issues/165
164+
[#177]: https://github.com/FossifyOrg/Messages/issues/177
160165
[#180]: https://github.com/FossifyOrg/Messages/issues/180
161166
[#209]: https://github.com/FossifyOrg/Messages/issues/209
162167
[#217]: https://github.com/FossifyOrg/Messages/issues/217
163168
[#225]: https://github.com/FossifyOrg/Messages/issues/225
164169
[#243]: https://github.com/FossifyOrg/Messages/issues/243
165170
[#262]: https://github.com/FossifyOrg/Messages/issues/262
171+
[#264]: https://github.com/FossifyOrg/Messages/issues/264
166172
[#274]: https://github.com/FossifyOrg/Messages/issues/274
167173
[#279]: https://github.com/FossifyOrg/Messages/issues/279
168174
[#287]: https://github.com/FossifyOrg/Messages/issues/287

app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ import org.fossify.messages.extensions.isGifMimeType
138138
import org.fossify.messages.extensions.isImageMimeType
139139
import org.fossify.messages.extensions.launchConversationDetails
140140
import org.fossify.messages.extensions.markMessageRead
141+
import org.fossify.messages.extensions.markThreadMessagesRead
141142
import org.fossify.messages.extensions.markThreadMessagesUnread
142143
import org.fossify.messages.extensions.messagesDB
143144
import org.fossify.messages.extensions.moveMessageToRecycleBin
@@ -302,6 +303,8 @@ class ThreadActivity : SimpleActivity() {
302303
binding.messageHolder.threadTypeMessage.setSelection(smsDraft.length)
303304
}
304305
}
306+
307+
markThreadMessagesRead(threadId)
305308
}
306309

307310
val bottomBarColor = getBottomBarColor()

app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.os.Parcelable
66
import android.util.TypedValue
77
import android.view.View
88
import android.view.ViewGroup
9+
import android.widget.TextView
910
import androidx.recyclerview.widget.DiffUtil
1011
import androidx.recyclerview.widget.RecyclerView
1112
import com.bumptech.glide.Glide
@@ -14,6 +15,7 @@ import org.fossify.commons.adapters.MyRecyclerViewListAdapter
1415
import org.fossify.commons.extensions.applyColorFilter
1516
import org.fossify.commons.extensions.beVisibleIf
1617
import org.fossify.commons.extensions.formatDateOrTime
18+
import org.fossify.commons.extensions.getContrastColor
1719
import org.fossify.commons.extensions.getTextSize
1820
import org.fossify.commons.extensions.setupViewBackground
1921
import org.fossify.commons.helpers.SimpleContactsHelper
@@ -171,21 +173,23 @@ abstract class BaseConversationsAdapter(
171173
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f)
172174
}
173175

174-
val style = if (conversation.read) {
175-
conversationBodyShort.alpha = 0.7f
176-
if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL
177-
} else {
176+
val isUnread = !conversation.read
177+
val style = if (isUnread) {
178178
conversationBodyShort.alpha = 1f
179179
if (conversation.isScheduled) Typeface.BOLD_ITALIC else Typeface.BOLD
180-
180+
} else {
181+
conversationBodyShort.alpha = 0.7f
182+
if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL
181183
}
182184
conversationAddress.setTypeface(null, style)
183185
conversationBodyShort.setTypeface(null, style)
186+
conversationDate.setTypeface(null, style)
184187

185188
arrayListOf(conversationAddress, conversationBodyShort, conversationDate).forEach {
186189
it.setTextColor(textColor)
187190
}
188191

192+
setupBadgeCount(unreadCountBadge, isUnread, conversation.unreadCount)
189193
// at group conversations we use an icon as the placeholder, not any letter
190194
val placeholder = if (conversation.isGroupConversation) {
191195
SimpleContactsHelper(activity).getColoredGroupIcon(conversation.title)
@@ -202,6 +206,21 @@ abstract class BaseConversationsAdapter(
202206
}
203207
}
204208

209+
private fun setupBadgeCount(view: TextView, isUnread: Boolean, count: Int) {
210+
view.apply {
211+
beVisibleIf(isUnread)
212+
if (isUnread) {
213+
text = when {
214+
count > MAX_UNREAD_BADGE_COUNT -> "$MAX_UNREAD_BADGE_COUNT+"
215+
count == 0 -> ""
216+
else -> count.toString()
217+
}
218+
setTextColor(properPrimaryColor.getContrastColor())
219+
background?.applyColorFilter(properPrimaryColor)
220+
}
221+
}
222+
}
223+
205224
override fun onChange(position: Int) = currentList.getOrNull(position)?.title ?: ""
206225

207226
private fun saveRecyclerViewState() {
@@ -221,4 +240,8 @@ abstract class BaseConversationsAdapter(
221240
return Conversation.areContentsTheSame(oldItem, newItem)
222241
}
223242
}
243+
244+
companion object {
245+
private const val MAX_UNREAD_BADGE_COUNT = 99
246+
}
224247
}

app/src/main/kotlin/org/fossify/messages/databases/MessagesDatabase.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
@file:Suppress("MagicNumber")
12
package org.fossify.messages.databases
23

34
import android.content.Context
@@ -29,7 +30,7 @@ import org.fossify.messages.models.RecycleBinMessage
2930
RecycleBinMessage::class,
3031
Draft::class
3132
],
32-
version = 9
33+
version = 10
3334
)
3435
@TypeConverters(Converters::class)
3536
abstract class MessagesDatabase : RoomDatabase() {
@@ -65,6 +66,7 @@ abstract class MessagesDatabase : RoomDatabase() {
6566
.addMigrations(MIGRATION_6_7)
6667
.addMigrations(MIGRATION_7_8)
6768
.addMigrations(MIGRATION_8_9)
69+
.addMigrations(MIGRATION_9_10)
6870
.build()
6971
}
7072
}
@@ -154,5 +156,13 @@ abstract class MessagesDatabase : RoomDatabase() {
154156
}
155157
}
156158
}
159+
160+
private val MIGRATION_9_10 = object : Migration(9, 10) {
161+
override fun migrate(db: SupportSQLiteDatabase) {
162+
db.apply {
163+
execSQL("ALTER TABLE conversations ADD COLUMN unread_count INTEGER NOT NULL DEFAULT 0")
164+
}
165+
}
166+
}
157167
}
158168
}

app/src/main/kotlin/org/fossify/messages/extensions/Context.kt

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,34 @@ fun Context.getMMSSender(msgId: Long): String {
318318
return ""
319319
}
320320

321+
fun Context.getUnreadCountsByThread(): Map<Long, Int> {
322+
val result = HashMap<Long, Int>(128)
323+
324+
fun bump(id: Long) {
325+
result[id] = (result[id] ?: 0) + 1
326+
}
327+
328+
// Unread SMS
329+
queryCursor(
330+
uri = Sms.CONTENT_URI,
331+
projection = arrayOf(Sms.THREAD_ID),
332+
selection = "${Sms.READ}=0 AND ${Sms.TYPE}=${Sms.MESSAGE_TYPE_INBOX}",
333+
selectionArgs = null,
334+
showErrors = false
335+
) { bump(it.getLongValue(Sms.THREAD_ID)) }
336+
337+
// Unread MMS
338+
queryCursor(
339+
uri = Mms.CONTENT_URI,
340+
projection = arrayOf(Mms.THREAD_ID),
341+
selection = "${Mms.READ}=0 AND ${Mms.MESSAGE_BOX}=${Mms.MESSAGE_BOX_INBOX}",
342+
selectionArgs = null,
343+
showErrors = false
344+
) { bump(it.getLongValue(Mms.THREAD_ID)) }
345+
346+
return result
347+
}
348+
321349
fun Context.getConversations(
322350
threadId: Long? = null,
323351
privateContacts: ArrayList<SimpleContact> = ArrayList(),
@@ -349,6 +377,7 @@ fun Context.getConversations(
349377
val conversations = ArrayList<Conversation>()
350378
val simpleContactHelper = SimpleContactsHelper(this)
351379
val blockedNumbers = getBlockedNumbers()
380+
val unreadMap = getUnreadCountsByThread()
352381
try {
353382
queryCursorUnsafe(
354383
uri,
@@ -397,6 +426,7 @@ fun Context.getConversations(
397426
val read = cursor.getIntValue(Threads.READ) == 1
398427
val archived =
399428
if (archiveAvailable) cursor.getIntValue(Threads.ARCHIVED) == 1 else false
429+
val unreadCount = if (!read) unreadMap[id] ?: 0 else 0
400430
val conversation = Conversation(
401431
threadId = id,
402432
snippet = snippet,
@@ -406,7 +436,8 @@ fun Context.getConversations(
406436
photoUri = photoUri,
407437
isGroupConversation = isGroupConversation,
408438
phoneNumber = phoneNumbers.first(),
409-
isArchived = archived
439+
isArchived = archived,
440+
unreadCount = unreadCount,
410441
)
411442
conversations.add(conversation)
412443
}
@@ -973,6 +1004,7 @@ fun Context.markThreadMessagesRead(threadId: Long) {
9731004
contentResolver.update(uri, contentValues, selection, selectionArgs)
9741005
}
9751006
messagesDB.markThreadRead(threadId)
1007+
conversationsDB.markRead(threadId)
9761008
}
9771009

9781010
fun Context.markThreadMessagesUnread(threadId: Long) {
@@ -985,7 +1017,8 @@ fun Context.markThreadMessagesUnread(threadId: Long) {
9851017
val selectionArgs = arrayOf(threadId.toString())
9861018
contentResolver.update(uri, contentValues, selection, selectionArgs)
9871019
}
988-
}
1020+
conversationsDB.markUnread(threadId)
1021+
}
9891022

9901023
@SuppressLint("NewApi")
9911024
fun Context.getThreadId(address: String): Long {
@@ -1258,7 +1291,8 @@ fun Context.createTemporaryThread(
12581291
phoneNumber = addresses.first(),
12591292
isScheduled = true,
12601293
usesCustomTitle = cachedConv?.usesCustomTitle == true,
1261-
isArchived = false
1294+
isArchived = false,
1295+
unreadCount = 0,
12621296
)
12631297
try {
12641298
conversationsDB.insertOrUpdate(conversation)

app/src/main/kotlin/org/fossify/messages/models/Conversation.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ data class Conversation(
1717
@ColumnInfo(name = "phone_number") var phoneNumber: String,
1818
@ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false,
1919
@ColumnInfo(name = "uses_custom_title") var usesCustomTitle: Boolean = false,
20-
@ColumnInfo(name = "archived") var isArchived: Boolean = false
20+
@ColumnInfo(name = "archived") var isArchived: Boolean = false,
21+
@ColumnInfo(name = "unread_count") var unreadCount: Int = 0,
2122
) {
2223

2324
companion object {
@@ -32,7 +33,8 @@ data class Conversation(
3233
old.title == new.title &&
3334
old.photoUri == new.photoUri &&
3435
old.isGroupConversation == new.isGroupConversation &&
35-
old.phoneNumber == new.phoneNumber
36+
old.phoneNumber == new.phoneNumber &&
37+
old.unreadCount == new.unreadCount
3638
}
3739
}
3840
}

app/src/main/res/layout/item_conversation.xml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,28 @@
8888

8989
<ImageView
9090
android:id="@+id/pin_indicator"
91-
android:layout_width="@dimen/pin_icon_size"
92-
android:layout_height="@dimen/pin_icon_size"
91+
android:layout_width="@dimen/small_icon_size"
92+
android:layout_height="@dimen/small_icon_size"
9393
android:alpha="0.7"
94+
android:padding="@dimen/tiny_margin"
9495
android:src="@drawable/ic_pin_filled_vector"
96+
android:visibility="gone"
9597
app:layout_constraintBottom_toBottomOf="@id/conversation_body_short"
96-
app:layout_constraintEnd_toEndOf="parent"
97-
app:layout_constraintTop_toTopOf="@id/conversation_body_short" />
98+
app:layout_constraintEnd_toStartOf="@id/unread_count_badge"
99+
app:layout_constraintTop_toTopOf="@id/conversation_body_short"
100+
tools:visibility="visible" />
98101

102+
<TextView
103+
android:id="@+id/unread_count_badge"
104+
android:layout_width="@dimen/small_icon_size"
105+
android:layout_height="@dimen/small_icon_size"
106+
android:background="@drawable/circle_background"
107+
android:gravity="center"
108+
android:textSize="@dimen/list_tertiary_text_size"
109+
android:visibility="gone"
110+
app:layout_constraintBottom_toBottomOf="@id/conversation_body_short"
111+
app:layout_constraintEnd_toEndOf="parent"
112+
app:layout_constraintTop_toTopOf="@id/conversation_body_short"
113+
tools:text="42"
114+
tools:visibility="visible" />
99115
</androidx.constraintlayout.widget.ConstraintLayout>

detekt.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ style:
3434
active: true
3535
ignoreAnnotated: ["Composable"]
3636
ignoreEnums: true
37-
ignoreNumbers: ["-1", "0", "1", "2", "42", "1000"]
37+
ignoreNumbers: ["-1", "0", "1", "2", "42", "128", "256", "1000"]
3838
MaxLineLength:
3939
active: true
4040
maxLineLength: 120

0 commit comments

Comments
 (0)