1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-3707 Notifications | Clear push notifications for opened chat (#2475)

This commit is contained in:
Konstantin Ivanov 2025-05-29 11:11:24 +02:00 committed by GitHub
parent 6790b13882
commit 08902e61cf
Signed by: github
GPG key ID: B5690EEEBB952194
15 changed files with 343 additions and 69 deletions

View file

@ -7,28 +7,35 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_models.DecryptedPushContent
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.domain.notifications.NotificationBuilder
import com.anytypeio.anytype.domain.resources.StringResourceProvider
import com.anytypeio.anytype.ui.main.MainActivity
import kotlin.math.absoluteValue
import timber.log.Timber
class NotificationBuilder(
class NotificationBuilderImpl(
private val context: Context,
private val notificationManager: NotificationManager
) {
private val attachmentText get() = context.getString(R.string.attachment)
private val notificationManager: NotificationManager,
private val resourceProvider: StringResourceProvider
) : NotificationBuilder {
private val attachmentText get() = resourceProvider.getAttachmentText()
private val createdChannels = mutableSetOf<String>()
fun buildAndNotify(message: DecryptedPushContent.Message, spaceId: Id) {
override fun buildAndNotify(message: DecryptedPushContent.Message, spaceId: Id) {
val channelId = "${spaceId}_${message.chatId}"
// 1) Build the intent that'll open your MainActivity in the right chat
ensureChannelExists(
channelId = channelId,
channelName = sanitizeChannelName(message.spaceName)
)
// Create pending intent to open chat
val pending = createChatPendingIntent(
context = context,
chatId = message.chatId,
@ -37,18 +44,9 @@ class NotificationBuilder(
// Format the notification body text
val bodyText = message.formatNotificationBody(attachmentText)
// 2) put it all on one line: "Author: <bodyText>"
val singleLine = "${message.senderName.trim()}: $bodyText"
val channelName = sanitizeChannelName(message.spaceName)
createNotificationChannelIfNeeded(
channelId = spaceId,
channelName = channelName
)
val notif = NotificationCompat.Builder(context, spaceId)
val notif = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_app_notification)
.setContentTitle(message.spaceName.trim())
.setContentText(singleLine)
@ -68,29 +66,28 @@ class NotificationBuilder(
notificationManager.notify(System.currentTimeMillis().toInt(), notif)
}
private fun createNotificationChannelIfNeeded(
channelId: String,
channelName: String
) {
/**
* Ensures the notification channel (and group) exist before notifying.
*/
private fun ensureChannelExists(channelId: String, channelName: String) {
createChannelGroupIfNeeded()
if (createdChannels.contains(channelId)) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
channelName,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "New messages notifications"
enableLights(true)
enableVibration(true)
setShowBadge(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
group = CHANNEL_GROUP_ID
}
val channel = NotificationChannel(
channelId,
channelName,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "New messages notifications"
enableLights(true)
enableVibration(true)
setShowBadge(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
group = CHANNEL_GROUP_ID
}
notificationManager.createNotificationChannel(channel)
createdChannels.add(channelId)
}
notificationManager.createNotificationChannel(channel)
createdChannels.add(channelId)
}
/**
@ -124,7 +121,8 @@ class NotificationBuilder(
fun createChannelGroupIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
val existingGroup = notificationManager.getNotificationChannelGroup(CHANNEL_GROUP_ID)
val existingGroup =
notificationManager.getNotificationChannelGroup(CHANNEL_GROUP_ID)
if (existingGroup == null) {
val group = NotificationChannelGroup(CHANNEL_GROUP_ID, CHANNEL_GROUP_NAME)
notificationManager.createNotificationChannelGroup(group)
@ -135,7 +133,7 @@ class NotificationBuilder(
// Just create the group without checking if it exists
val group = NotificationChannelGroup(CHANNEL_GROUP_ID, CHANNEL_GROUP_NAME)
notificationManager.createNotificationChannelGroup(group)
} catch (e : Exception) {
} catch (e: Exception) {
Timber.e(e, "Error while creating or getting notification group")
val group = NotificationChannelGroup(CHANNEL_GROUP_ID, CHANNEL_GROUP_NAME)
notificationManager.createNotificationChannelGroup(group)
@ -143,6 +141,23 @@ class NotificationBuilder(
}
}
/**
* Deletes notifications and the channel for a specific chat in a space, so that
* when the user opens that chat, old notifications are cleared.
*/
override fun clearNotificationChannel(spaceId: String, chatId: String) {
val channelId = "${spaceId}_${chatId}"
// Remove posted notifications for this specific chat channel
notificationManager.activeNotifications
.filter { it.notification.channelId == channelId }
.forEach { notificationManager.cancel(it.id) }
// Delete the specific chat channel
notificationManager.deleteNotificationChannel(channelId)
createdChannels.remove(channelId)
}
private fun sanitizeChannelName(name: String): String {
return name.trim().replace(Regex("[^a-zA-Z0-9 _-]"), "_")
}

View file

@ -1,6 +1,7 @@
package com.anytypeio.anytype.device
import android.util.Base64
import com.anytypeio.anytype.domain.notifications.NotificationBuilder
import com.anytypeio.anytype.presentation.notifications.DecryptionPushContentService
interface PushMessageProcessor {

View file

@ -17,6 +17,7 @@ import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer
import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
import com.anytypeio.anytype.domain.notifications.NotificationBuilder
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
import com.anytypeio.anytype.domain.spaces.ClearLastOpenedSpace
import com.anytypeio.anytype.domain.workspace.SpaceManager
@ -103,4 +104,5 @@ interface ChatComponentDependencies : ComponentDependencies {
fun spaceManager(): SpaceManager
fun notificationPermissionManager(): NotificationPermissionManager
fun storelessSubscriptionContainer(): StorelessSubscriptionContainer
fun notificationBuilder(): NotificationBuilder
}

View file

@ -1,15 +1,15 @@
package com.anytypeio.anytype.di.feature.notifications
import android.app.NotificationManager
import android.content.Context
import com.anytypeio.anytype.device.AnytypePushService
import com.anytypeio.anytype.device.DefaultPushMessageProcessor
import com.anytypeio.anytype.device.NotificationBuilder
import com.anytypeio.anytype.device.PushMessageProcessor
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.di.main.ConfigModule.DEFAULT_APP_COROUTINE_SCOPE
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.device.DeviceTokenStoringService
import com.anytypeio.anytype.domain.notifications.NotificationBuilder
import com.anytypeio.anytype.domain.resources.StringResourceProvider
import com.anytypeio.anytype.presentation.notifications.CryptoService
import com.anytypeio.anytype.presentation.notifications.CryptoServiceImpl
import com.anytypeio.anytype.presentation.notifications.DecryptionPushContentService
@ -38,25 +38,6 @@ interface PushContentComponent {
@Module
object PushContentModule {
@JvmStatic
@Provides
@Singleton
fun provideNotificationManager(
context: Context
): NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@JvmStatic
@Provides
@Singleton
fun provideNotificationBuilder(
context: Context,
notificationManager: NotificationManager
): NotificationBuilder = NotificationBuilder(
context = context,
notificationManager = notificationManager
).apply {
createChannelGroupIfNeeded()
}
@JvmStatic
@Provides
@ -92,4 +73,6 @@ interface PushContentDependencies : ComponentDependencies {
fun context(): Context
@Named(DEFAULT_APP_COROUTINE_SCOPE) fun scope(): CoroutineScope
fun dispatchers(): AppCoroutineDispatchers
fun provider(): StringResourceProvider
fun notificationBuilder(): NotificationBuilder
}

View file

@ -1,5 +1,6 @@
package com.anytypeio.anytype.di.main
import android.app.NotificationManager
import android.content.Context
import android.content.SharedPreferences
import com.anytypeio.anytype.app.AnytypeNotificationService
@ -7,10 +8,13 @@ import com.anytypeio.anytype.data.auth.event.NotificationsDateChannel
import com.anytypeio.anytype.data.auth.event.NotificationsRemoteChannel
import com.anytypeio.anytype.data.auth.event.PushKeyDataChannel
import com.anytypeio.anytype.data.auth.event.PushKeyRemoteChannel
import com.anytypeio.anytype.device.NotificationBuilderImpl
import com.anytypeio.anytype.domain.account.AwaitAccountStartManager
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.chats.PushKeyChannel
import com.anytypeio.anytype.domain.notifications.NotificationBuilder
import com.anytypeio.anytype.domain.notifications.SystemNotificationService
import com.anytypeio.anytype.domain.resources.StringResourceProvider
import com.anytypeio.anytype.domain.workspace.NotificationsChannel
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.middleware.interactor.EventHandlerChannel
@ -128,4 +132,24 @@ object NotificationsModule {
context = context
)
}
@JvmStatic
@Provides
@Singleton
fun provideNotificationBuilder(
context: Context,
notificationManager: NotificationManager,
stringResourceProvider: StringResourceProvider
): NotificationBuilder = NotificationBuilderImpl(
context = context,
notificationManager = notificationManager,
resourceProvider = stringResourceProvider
)
@JvmStatic
@Provides
@Singleton
fun provideNotificationManager(
context: Context
): NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}

View file

@ -321,6 +321,11 @@ class ChatFragment : BaseComposeFragment() {
}
}
override fun onResume() {
super.onResume()
vm.onResume()
}
// DI
override fun injectDependencies() {

View file

@ -728,7 +728,6 @@ class MainActivity : AppCompatActivity(R.layout.activity_main), AppNavigation.Pr
override fun onResume() {
super.onResume()
NotificationManagerCompat.from(this).cancelAll()
mdnsProvider.start()
navigator.bind(findNavController(R.id.fragment))
}

View file

@ -3,6 +3,7 @@ package com.anytypeio.anytype.device
import android.os.Build
import android.util.Base64
import com.anytypeio.anytype.core_models.DecryptedPushContent
import com.anytypeio.anytype.domain.notifications.NotificationBuilder
import com.anytypeio.anytype.presentation.notifications.DecryptionPushContentService
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue

View file

@ -0,0 +1,220 @@
package com.anytypeio.anytype.device
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.service.notification.StatusBarNotification
import androidx.test.core.app.ApplicationProvider
import com.anytypeio.anytype.core_models.DecryptedPushContent
import com.anytypeio.anytype.domain.resources.StringResourceProvider
import kotlin.test.Test
import org.junit.After
import org.junit.Before
import org.junit.runner.RunWith
import org.mockito.Mockito.clearInvocations
import org.mockito.kotlin.any
import org.mockito.kotlin.argThat
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P]) // API 28
class NotificationBuilderTest {
private val context: Context = ApplicationProvider.getApplicationContext()
lateinit var notificationManager: NotificationManager
lateinit var stringResourceProvider: StringResourceProvider
private lateinit var builder: NotificationBuilderImpl
private val testSpaceId = "space123"
private val testChatId = "chat456"
// A simple stub for DecryptedPushContent.Message
private val message = DecryptedPushContent.Message(
chatId = testChatId,
senderName = "Alice",
spaceName = "My Space",
msgId = "msg789",
text = "Hello, this is a test message.",
hasAttachments = false
)
@Before
fun setUp() {
stringResourceProvider = mock<StringResourceProvider> {
on { getAttachmentText() } doReturn "[attachment]"
}
notificationManager = mock()
builder = NotificationBuilderImpl(context, notificationManager, stringResourceProvider)
}
@After
fun tearDown() {
clearInvocations(notificationManager)
}
@Test
fun `buildAndNotify should create channel and post notification`() {
// When
builder.buildAndNotify(message, testSpaceId)
// Then: a channel should be created with correct id and name
verify(notificationManager).createNotificationChannel(argThat {
id == "${testSpaceId}_${testChatId}" && name == "My Space"
})
// And a notification should be posted
verify(notificationManager).notify(any(), any<android.app.Notification>())
}
@Test
@Config(sdk = [Build.VERSION_CODES.O]) // API 26+ for channels
fun `clearNotificationChannel should cancel active and delete channel`() {
val channelId = "${testSpaceId}_${testChatId}"
// Prepare two mock StatusBarNotifications
val notif1: android.app.Notification = mock()
val notif2: android.app.Notification = mock()
whenever(notif1.channelId).thenReturn(channelId)
whenever(notif2.channelId).thenReturn("other")
// Wrap them in StatusBarNotification mocks
val sbn1: StatusBarNotification = mock()
val sbn2: StatusBarNotification = mock()
whenever(sbn1.notification).thenReturn(notif1)
whenever(sbn1.id).thenReturn(1)
whenever(sbn2.notification).thenReturn(notif2)
whenever(sbn2.id).thenReturn(2)
whenever(notificationManager.activeNotifications).thenReturn(arrayOf(sbn1, sbn2))
// Ensure channel exists
builder.buildAndNotify(message, testSpaceId)
// When
builder.clearNotificationChannel(testSpaceId, testChatId)
// Then active notifications for this channel are cancelled
verify(notificationManager).cancel(sbn1.id)
// Other channels remain
verify(notificationManager, never()).cancel(sbn2.id)
// And channel is deleted
verify(notificationManager).deleteNotificationChannel(channelId)
}
@Test
@Config(sdk = [Build.VERSION_CODES.O]) // API 26+ for channels
fun `clearNotificationChannel with multiple chats in same space should only clear specified chat`() {
// Prepare three mock notifications for different chats in the same space
val chat1 = "chat1"
val chat2 = "chat2"
val chat3 = "chat3"
val notif1: Notification = mock {
on { channelId } doReturn "${testSpaceId}_${chat1}"
}
val notif2: Notification = mock {
on { channelId } doReturn "${testSpaceId}_${chat2}"
}
val notif3: Notification = mock {
on { channelId } doReturn "${testSpaceId}_${chat3}"
}
val sbn1: StatusBarNotification = mock {
on { notification } doReturn notif1
on { id } doReturn 10
}
val sbn2: StatusBarNotification = mock {
on { notification } doReturn notif2
on { id } doReturn 20
}
val sbn3: StatusBarNotification = mock {
on { notification } doReturn notif3
on { id } doReturn 30
}
whenever(notificationManager.activeNotifications).thenReturn(arrayOf(sbn1, sbn2, sbn3))
// Ensure channels exist by sending a dummy notification for each chat
builder.buildAndNotify(message.copy(chatId = chat1), testSpaceId)
builder.buildAndNotify(message.copy(chatId = chat2), testSpaceId)
builder.buildAndNotify(message.copy(chatId = chat3), testSpaceId)
// Clear only chat2
builder.clearNotificationChannel(testSpaceId, chat2)
// Verify only notifications for chat2 were cancelled
verify(notificationManager, never()).cancel(10)
verify(notificationManager).cancel(20)
verify(notificationManager, never()).cancel(30)
// Verify only the specified channel was deleted
verify(notificationManager).deleteNotificationChannel("${testSpaceId}_${chat2}")
verify(notificationManager, never()).deleteNotificationChannel("${testSpaceId}_${chat1}")
verify(notificationManager, never()).deleteNotificationChannel("${testSpaceId}_${chat3}")
}
@Test
@Config(sdk = [Build.VERSION_CODES.O]) // API 26+ for channels
fun `clearNotificationChannel with multiple spaces and chats should only clear specified chat`() {
// Prepare notifications for different spaces and chats
val space1 = "space1"
val space2 = "space2"
val chat1 = "chat1"
val chat2 = "chat2"
val notif1: Notification = mock {
on { channelId } doReturn "${space1}_${chat1}"
}
val notif2: Notification = mock {
on { channelId } doReturn "${space1}_${chat2}"
}
val notif3: Notification = mock {
on { channelId } doReturn "${space2}_${chat1}"
}
val notif4: Notification = mock {
on { channelId } doReturn "${space2}_${chat2}"
}
val sbn1: StatusBarNotification = mock {
on { notification } doReturn notif1
on { id } doReturn 10
}
val sbn2: StatusBarNotification = mock {
on { notification } doReturn notif2
on { id } doReturn 20
}
val sbn3: StatusBarNotification = mock {
on { notification } doReturn notif3
on { id } doReturn 30
}
val sbn4: StatusBarNotification = mock {
on { notification } doReturn notif4
on { id } doReturn 40
}
whenever(notificationManager.activeNotifications).thenReturn(arrayOf(sbn1, sbn2, sbn3, sbn4))
// Ensure channels exist by sending a dummy notification for each
builder.buildAndNotify(message.copy(chatId = chat1), space1)
builder.buildAndNotify(message.copy(chatId = chat2), space1)
builder.buildAndNotify(message.copy(chatId = chat1), space2)
builder.buildAndNotify(message.copy(chatId = chat2), space2)
// Clear only space1_chat2
builder.clearNotificationChannel(space1, chat2)
// Verify only notifications for space1_chat2 were cancelled
verify(notificationManager, never()).cancel(10)
verify(notificationManager).cancel(20)
verify(notificationManager, never()).cancel(30)
verify(notificationManager, never()).cancel(40)
// Verify only the specified channel was deleted
verify(notificationManager).deleteNotificationChannel("${space1}_${chat2}")
verify(notificationManager, never()).deleteNotificationChannel("${space1}_${chat1}")
verify(notificationManager, never()).deleteNotificationChannel("${space2}_${chat1}")
verify(notificationManager, never()).deleteNotificationChannel("${space2}_${chat2}")
}
}

View file

@ -0,0 +1,9 @@
package com.anytypeio.anytype.domain.notifications
import com.anytypeio.anytype.core_models.DecryptedPushContent
import com.anytypeio.anytype.core_models.Id
interface NotificationBuilder {
fun buildAndNotify(message: DecryptedPushContent.Message, spaceId: Id)
fun clearNotificationChannel(spaceId: String, chatId: String)
}

View file

@ -10,4 +10,5 @@ interface StringResourceProvider {
fun getSetOfObjectsTitle(): String
fun getPropertiesFormatPrettyString(format: RelationFormat): String
fun getDefaultSpaceName(): String
fun getAttachmentText(): String
}

View file

@ -33,6 +33,7 @@ import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionCon
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer.Store
import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
import com.anytypeio.anytype.domain.notifications.NotificationBuilder
import com.anytypeio.anytype.domain.objects.CreateObjectFromUrl
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
import com.anytypeio.anytype.domain.objects.getTypeOfObject
@ -85,7 +86,8 @@ class ChatViewModel @Inject constructor(
private val getLinkPreview: GetLinkPreview,
private val createObjectFromUrl: CreateObjectFromUrl,
private val notificationPermissionManager: NotificationPermissionManager,
private val spacePermissionProvider: UserPermissionProvider
private val spacePermissionProvider: UserPermissionProvider,
private val notificationBuilder: NotificationBuilder
) : BaseViewModel(), ExitToVaultDelegate by exitToVaultDelegate {
private val visibleRangeUpdates = MutableSharedFlow<Pair<Id, Id>>(
@ -168,6 +170,13 @@ class ChatViewModel @Inject constructor(
}
}
fun onResume() {
notificationBuilder.clearNotificationChannel(
spaceId = vmParams.space.id,
chatId = vmParams.ctx
)
}
private suspend fun proceedWithObservingChatMessages(
account: Id,
chat: Id

View file

@ -15,8 +15,7 @@ import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer
import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
import com.anytypeio.anytype.domain.`object`.OpenObject
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.domain.notifications.NotificationBuilder
import com.anytypeio.anytype.domain.objects.CreateObjectFromUrl
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
import com.anytypeio.anytype.presentation.notifications.NotificationPermissionManager
@ -43,19 +42,20 @@ class ChatViewModelFactory @Inject constructor(
private val getLinkPreview: GetLinkPreview,
private val createObjectFromUrl: CreateObjectFromUrl,
private val notificationPermissionManager: NotificationPermissionManager,
private val spacePermissionProvider: UserPermissionProvider
private val spacePermissionProvider: UserPermissionProvider,
private val notificationBuilder: NotificationBuilder
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = ChatViewModel(
vmParams = params,
chatContainer = chatContainer,
addChatMessage = addChatMessage,
editChatMessage = editChatMessage,
deleteChatMessage = deleteChatMessage,
toggleChatMessageReaction = toggleChatMessageReaction,
members = members,
getAccount = getAccount,
deleteChatMessage = deleteChatMessage,
urlBuilder = urlBuilder,
editChatMessage = editChatMessage,
spaceViews = spaceViews,
dispatchers = dispatchers,
uploadFile = uploadFile,
@ -65,6 +65,7 @@ class ChatViewModelFactory @Inject constructor(
getLinkPreview = getLinkPreview,
createObjectFromUrl = createObjectFromUrl,
notificationPermissionManager = notificationPermissionManager,
spacePermissionProvider = spacePermissionProvider
spacePermissionProvider = spacePermissionProvider,
notificationBuilder = notificationBuilder
) as T
}

View file

@ -55,4 +55,8 @@ class StringResourceProviderImpl @Inject constructor(private val context: Contex
override fun getDefaultSpaceName(): String {
return context.getString(R.string.onboarding_my_first_space)
}
override fun getAttachmentText(): String {
return context.getString(R.string.attachment)
}
}