Dismiss notifications when detail view is opened, show new bubble

This commit is contained in:
Philipp Heckel 2021-11-15 16:24:31 -05:00
parent a44e551809
commit 0ab3bdc2a0
13 changed files with 213 additions and 61 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "df0a0eab3fc3056bf12e04a09c084660",
"identityHash": "4b24fe9241d824ae94f32a31e41841c8",
"entities": [
{
"tableName": "Subscription",
@ -54,7 +54,7 @@
},
{
"tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `message` TEXT NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@ -80,6 +80,12 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationId",
"columnName": "notificationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleted",
"columnName": "deleted",
@ -100,7 +106,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'df0a0eab3fc3056bf12e04a09c084660')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4b24fe9241d824ae94f32a31e41841c8')"
]
}
}

View file

@ -12,11 +12,12 @@ data class Subscription(
@ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean,
@Ignore val notifications: Int,
@Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
) {
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean) : this(id, baseUrl, topic, instant, 0, 0, ConnectionState.NOT_APPLICABLE)
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean) : this(id, baseUrl, topic, instant, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
}
enum class ConnectionState {
@ -28,7 +29,8 @@ data class SubscriptionWithMetadata(
val baseUrl: String,
val topic: String,
val instant: Boolean,
val notifications: Int,
val totalCount: Int,
val newCount: Int,
val lastActive: Long
)
@ -38,7 +40,8 @@ data class Notification(
@ColumnInfo(name = "subscriptionId") val subscriptionId: Long,
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
@ColumnInfo(name = "message") val message: String,
@ColumnInfo(name = "deleted") val deleted: Boolean
@ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 2)
@ -71,7 +74,8 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription")
db.execSQL("CREATE UNIQUE INDEX index_Subscription_baseUrl_topic ON Subscription (baseUrl, topic)")
// Add "deleted" column
// Add "notificationId" & "deleted" columns
db.execSQL("ALTER TABLE Notification ADD COLUMN notificationId INTEGER NOT NULL DEFAULT('0')")
db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')")
}
}
@ -80,40 +84,56 @@ abstract class Database : RoomDatabase() {
@Dao
interface SubscriptionDao {
@Query(
"SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " +
"FROM subscription AS s " +
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " +
"GROUP BY s.id " +
"ORDER BY MAX(n.timestamp) DESC"
)
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
FROM Subscription AS s
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
GROUP BY s.id
ORDER BY MAX(n.timestamp) DESC
""")
fun listFlow(): Flow<List<SubscriptionWithMetadata>>
@Query(
"SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " +
"FROM subscription AS s " +
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " +
"GROUP BY s.id " +
"ORDER BY MAX(n.timestamp) DESC"
)
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
FROM Subscription AS s
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
GROUP BY s.id
ORDER BY MAX(n.timestamp) DESC
""")
fun list(): List<SubscriptionWithMetadata>
@Query(
"SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " +
"FROM subscription AS s " +
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " +
"WHERE s.baseUrl = :baseUrl AND s.topic = :topic " +
"GROUP BY s.id "
)
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
FROM Subscription AS s
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
WHERE s.baseUrl = :baseUrl AND s.topic = :topic
GROUP BY s.id
""")
fun get(baseUrl: String, topic: String): SubscriptionWithMetadata?
@Query(
"SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " +
"FROM subscription AS s " +
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " +
"WHERE s.id = :subscriptionId " +
"GROUP BY s.id "
)
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
FROM Subscription AS s
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
WHERE s.id = :subscriptionId
GROUP BY s.id
""")
fun get(subscriptionId: Long): SubscriptionWithMetadata?
@Insert
@ -140,6 +160,9 @@ interface NotificationDao {
@Query("SELECT * FROM notification WHERE id = :notificationId")
fun get(notificationId: String): Notification?
@Update
fun update(notification: Notification)
@Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId")
fun remove(notificationId: String)

View file

@ -4,10 +4,12 @@ import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
private val connectionStates = ConcurrentHashMap<Long, ConnectionState>()
private val connectionStatesLiveData = MutableLiveData(connectionStates)
val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ...
init {
Log.d(TAG, "Created $this")
@ -87,6 +89,10 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
return false
}
fun updateNotification(notification: Notification) {
notificationDao.update(notification)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun removeNotification(notificationId: String) {
@ -107,8 +113,9 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
baseUrl = s.baseUrl,
topic = s.topic,
instant = s.instant,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
notifications = s.notifications,
state = connectionState
)
}
@ -123,8 +130,9 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
baseUrl = s.baseUrl,
topic = s.topic,
instant = s.instant,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
notifications = s.notifications,
state = getState(s.id)
)
}

View file

@ -10,6 +10,7 @@ import okhttp3.*
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.random.Random
class ApiService {
private val gson = Gson()
@ -74,7 +75,14 @@ class ApiService {
val line = source.readUtf8Line() ?: throw Exception("Unexpected response for $url: line is null")
val message = gson.fromJson(line, Message::class.java)
if (message.event == EVENT_MESSAGE) {
val notification = Notification(message.id, subscriptionId, message.time, message.message, false)
val notification = Notification(
id = message.id,
subscriptionId = subscriptionId,
timestamp = message.time,
message = message.message,
notificationId = Random.nextInt(),
deleted = false
)
notify(notification)
}
}
@ -93,7 +101,7 @@ class ApiService {
private fun fromString(subscriptionId: Long, s: String): Notification {
val n = gson.fromJson(s, Message::class.java)
return Notification(n.id, subscriptionId, n.time, n.message, false)
return Notification(n.id, subscriptionId, n.time, n.message, notificationId = 0, deleted = false)
}
private data class Message(

View file

@ -9,6 +9,7 @@ import io.heckel.ntfy.data.Notification
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.random.Random
class FirebaseService : FirebaseMessagingService() {
private val repository by lazy { (application as Application).repository }
@ -39,13 +40,21 @@ class FirebaseService : FirebaseMessagingService() {
// Add notification
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message, deleted = false)
val notification = Notification(
id = id,
subscriptionId = subscription.id,
timestamp = timestamp,
message = message,
notificationId = Random.nextInt(),
deleted = false
)
val added = repository.addNotification(notification)
val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
// Send notification (only if it's not already known)
if (added) {
if (added && !detailViewOpen) {
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
notifier.send(subscription, message)
notifier.send(subscription, notification)
}
}
}

View file

@ -11,6 +11,7 @@ import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.ui.DetailActivity
@ -18,15 +19,16 @@ import io.heckel.ntfy.ui.MainActivity
import kotlin.random.Random
class NotificationService(val context: Context) {
fun send(subscription: Subscription, message: String) {
fun send(subscription: Subscription, notification: Notification) {
val title = topicShortUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "Displaying notification $title: $message")
Log.d(TAG, "Displaying notification $title: ${notification.message}")
// Create an Intent for the activity you want to start
val intent = Intent(context, DetailActivity::class.java)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
val pendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent) // Add the intent, which inflates the back stack
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) // Get the PendingIntent containing the entire back stack
@ -36,7 +38,7 @@ class NotificationService(val context: Context) {
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_icon)
.setContentTitle(title)
.setContentText(message)
.setContentText(notification.message)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent) // Click target for notification
.setAutoCancel(true) // Cancel when notification is clicked
@ -47,7 +49,15 @@ class NotificationService(val context: Context) {
val channel = NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(Random.nextInt(), notificationBuilder.build())
notificationManager.notify(notification.notificationId, notificationBuilder.build())
}
fun cancel(notification: Notification) {
if (notification.notificationId != 0) {
Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}")
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notification.notificationId)
}
}
companion object {

View file

@ -205,9 +205,11 @@ class SubscriberService : Service() {
Log.d(TAG, "[$url] Received notification: $n")
scope.launch(Dispatchers.IO) {
val added = repository.addNotification(n)
if (added) {
val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
if (added && !detailViewOpen) {
Log.d(TAG, "[$url] Showing notification: $n")
notifier.send(subscription, n.message)
notifier.send(subscription, n)
}
}
}

View file

@ -26,11 +26,12 @@ import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.*
// TODO dismiss notifications when navigating to detail page
import java.util.concurrent.atomic.AtomicLong
class DetailActivity : AppCompatActivity(), ActionMode.Callback {
private val viewModel by viewModels<DetailViewModel> {
@ -39,6 +40,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
private val repository by lazy { (application as Application).repository }
private val api = ApiService()
private var subscriberManager: SubscriberManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent
// Which subscription are we looking at
private var subscriptionId: Long = 0L // Set in onCreate()
@ -63,6 +65,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
// Dependencies that depend on Context
subscriberManager = SubscriberManager(this)
notifier = NotificationService(this)
// Show 'Back' button
supportActionBar?.setDisplayHomeAsUpEnabled(true)
@ -105,6 +108,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
viewModel.list(subscriptionId).observe(this) {
it?.let {
// Show list view
adapter.submitList(it as MutableList<Notification>)
if (it.isEmpty()) {
mainListContainer.visibility = View.GONE
@ -113,13 +117,61 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
mainListContainer.visibility = View.VISIBLE
noEntriesText.visibility = View.GONE
}
// Cancel notifications that still have popups
maybeCancelNotificationPopups(it)
}
}
// Scroll up when new notification is added
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0) {
Log.d(TAG, "$itemCount item(s) inserted at $positionStart, scrolling to the top")
mainList.scrollToPosition(positionStart)
}
}
})
// React to changes in fast delivery setting
repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) {
subscriberManager?.refreshService(it)
}
// Mark this subscription as "open" so we don't receive notifications for it
Log.d(TAG, "onCreate hook: Marking subscription $subscriptionId as 'open'")
repository.detailViewSubscriptionId.set(subscriptionId)
}
override fun onResume() {
super.onResume()
Log.d(TAG, "onResume hook: Marking subscription $subscriptionId as 'open'")
repository.detailViewSubscriptionId.set(subscriptionId) // Mark as "open" so we don't send notifications while this is open
}
override fun onPause() {
super.onPause()
Log.d(TAG, "onResume hook: Marking subscription $subscriptionId as 'not open'")
repository.detailViewSubscriptionId.set(0) // Mark as closed
}
override fun onDestroy() {
repository.detailViewSubscriptionId.set(0) // Mark as closed
Log.d(TAG, "onDestroy hook: Marking subscription $subscriptionId as 'not open'")
super.onDestroy()
}
private fun maybeCancelNotificationPopups(notifications: List<Notification>) {
val notificationsWithPopups = notifications.filter { notification -> notification.notificationId != 0 }
if (notificationsWithPopups.isNotEmpty()) {
lifecycleScope.launch(Dispatchers.IO) {
notificationsWithPopups.forEach { notification ->
notifier?.cancel(notification)
repository.updateNotification(notification.copy(notificationId = 0))
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -421,5 +473,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
companion object {
const val TAG = "NtfyDetailActivity"
const val CANCEL_NOTIFICATION_DELAY_MILLIS = 20_000L
}
}

View file

@ -165,7 +165,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
baseUrl = baseUrl,
topic = topic,
instant = instant,
notifications = 0,
totalCount = 0,
newCount = 0,
lastActive = Date().time/1000
)
viewModel.add(subscription)
@ -221,8 +222,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification ->
repository.addNotification(notification)
notifier?.send(subscription, notification.message)
val notificationWithId = notification.copy(notificationId = Random.nextInt())
repository.addNotification(notificationWithId)
notifier?.send(subscription, notificationWithId)
newNotificationsCount++
}
}
@ -262,7 +264,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
val subscriptionId = data?.getLongExtra(EXTRA_SUBSCRIPTION_ID, 0)
val subscriptionBaseUrl = data?.getStringExtra(EXTRA_SUBSCRIPTION_BASE_URL)
val subscriptionTopic = data?.getStringExtra(EXTRA_SUBSCRIPTION_TOPIC)
val subscriptionInstant = data?.getBooleanExtra(EXTRA_SUBSCRIPTION_INSTANT, false)
Log.d(TAG, "Deleting subscription with subscription ID $subscriptionId (topic: $subscriptionTopic)")
subscriptionId?.let { id -> viewModel.remove(id) }

View file

@ -50,13 +50,14 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image)
private val newItemsView: TextView = itemView.findViewById(R.id.main_item_new)
fun bind(subscription: Subscription) {
this.subscription = subscription
var statusMessage = if (subscription.notifications == 1) {
context.getString(R.string.main_item_status_text_one, subscription.notifications)
var statusMessage = if (subscription.totalCount == 1) {
context.getString(R.string.main_item_status_text_one, subscription.totalCount)
} else {
context.getString(R.string.main_item_status_text_not_one, subscription.notifications)
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
}
if (subscription.instant && subscription.state == ConnectionState.RECONNECTING) {
statusMessage += ", " + context.getString(R.string.main_item_status_reconnecting)
@ -76,6 +77,12 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
} else {
instantImageView.visibility = View.GONE
}
if (subscription.newCount > 0) {
newItemsView.visibility = View.VISIBLE
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
} else {
newItemsView.visibility = View.GONE
}
itemView.setOnClickListener { onClick(subscription) }
itemView.setOnLongClickListener { onLongClick(subscription); true }
if (selected.contains(subscription.id)) {

View file

@ -11,6 +11,7 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.random.Random
class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
// Every time the worker is changed, the periodic work has to be REPLACEd.
@ -27,10 +28,16 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
try {
repository.getSubscriptions().forEach{ subscription ->
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
val newNotifications = repository
.onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) }
newNotifications.forEach { notification ->
repository.addNotification(notification)
notifier.send(subscription, notification.message)
val added = repository.addNotification(notification)
val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
if (added && !detailViewOpen) {
notifier.send(subscription, notification)
}
}
}
Log.d(TAG, "Finished polling for new notifications")

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval" >
<solid android:color="#338574" />
</shape>

View file

@ -43,5 +43,18 @@
app:layout_constraintTop_toTopOf="@+id/main_item_instant_image"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="15dp"
android:paddingTop="2dp"/>
<TextView
android:text="99+"
android:layout_width="24dp"
android:layout_height="24dp" android:id="@+id/main_item_new"
android:layout_marginTop="3dp"
android:layout_gravity="center"
android:background="@drawable/ic_circle"
android:gravity="center"
android:textColor="@android:color/white"
app:layout_constraintTop_toBottomOf="@+id/main_item_date"
app:layout_constraintEnd_toEndOf="@+id/main_item_date"
app:layout_constraintStart_toEndOf="@+id/main_item_instant_image"
android:textSize="10sp" android:textStyle="bold"/>
</androidx.constraintlayout.widget.ConstraintLayout>