diff --git a/app/src/main/java/com/anytypeio/anytype/device/AnytypePushService.kt b/app/src/main/java/com/anytypeio/anytype/device/AnytypePushService.kt index 80aa688cf4..0eb70b8292 100644 --- a/app/src/main/java/com/anytypeio/anytype/device/AnytypePushService.kt +++ b/app/src/main/java/com/anytypeio/anytype/device/AnytypePushService.kt @@ -1,10 +1,17 @@ package com.anytypeio.anytype.device import com.anytypeio.anytype.app.AndroidApplication +import com.anytypeio.anytype.core_utils.ext.isAppInForeground +import com.anytypeio.anytype.core_utils.ext.runSafely +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.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import javax.inject.Inject +import javax.inject.Named +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import timber.log.Timber class AnytypePushService : FirebaseMessagingService() { @@ -15,9 +22,12 @@ class AnytypePushService : FirebaseMessagingService() { @Inject lateinit var processor: PushMessageProcessor - init { - Timber.d("AnytypePushService initialized") - } + @Inject + @Named(DEFAULT_APP_COROUTINE_SCOPE) + lateinit var scope: CoroutineScope + + @Inject + lateinit var dispatchers: AppCoroutineDispatchers override fun onCreate() { super.onCreate() @@ -28,13 +38,25 @@ class AnytypePushService : FirebaseMessagingService() { override fun onNewToken(token: String) { super.onNewToken(token) Timber.d("New token received: $token") - deviceTokenSavingService.saveToken(token) + runSafely("saving device token") { + deviceTokenSavingService.saveToken(token) + } } override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) Timber.d("Received message: $message") - processor.process(message.data) + + // Skip showing notification if app is in foreground + if (isAppInForeground()) { + Timber.d("App is in foreground, skipping notification") + return + } + scope.launch((dispatchers.io)) { + runSafely("processing push message") { + processor.process(message.data) + } + } } companion object { diff --git a/app/src/main/java/com/anytypeio/anytype/device/NotificationBuilder.kt b/app/src/main/java/com/anytypeio/anytype/device/NotificationBuilder.kt index 7c55c49045..f5391759ed 100644 --- a/app/src/main/java/com/anytypeio/anytype/device/NotificationBuilder.kt +++ b/app/src/main/java/com/anytypeio/anytype/device/NotificationBuilder.kt @@ -18,21 +18,28 @@ class NotificationBuilder( private val notificationManager: NotificationManager ) { + private val attachmentText get() = context.getString(R.string.attachment) + fun buildAndNotify(message: DecryptedPushContent.Message, spaceId: Id) { - // 1) Build the intent that’ll open your MainActivity in the right chat + // 1) Build the intent that'll open your MainActivity in the right chat val pending = createChatPendingIntent( context = context, chatId = message.chatId, spaceId = spaceId ) + // Format the notification body text + val bodyText = message.formatNotificationBody(attachmentText) + + // 2) put it all on one line: "Author: " + val singleLine = "${message.senderName.trim()}: $bodyText" + val notif = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_app_notification) .setContentTitle(message.spaceName.trim()) - .setSubText(message.senderName.trim()) - .setContentText(message.text.trim()) - .setStyle(NotificationCompat.BigTextStyle().bigText(message.text.trim())) + .setContentText(singleLine) + .setStyle(NotificationCompat.BigTextStyle().bigText(singleLine)) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) .setContentIntent(pending) @@ -70,7 +77,7 @@ class NotificationBuilder( chatId: String, spaceId: Id ): PendingIntent { - // 1) Build the intent that’ll open your MainActivity in the right chat + // 1) Build the intent that'll open your MainActivity in the right chat val intent = Intent(context, MainActivity::class.java).apply { action = AnytypePushService.ACTION_OPEN_CHAT flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP diff --git a/app/src/main/java/com/anytypeio/anytype/device/NotificationExtensions.kt b/app/src/main/java/com/anytypeio/anytype/device/NotificationExtensions.kt new file mode 100644 index 0000000000..a377427cee --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/device/NotificationExtensions.kt @@ -0,0 +1,21 @@ +package com.anytypeio.anytype.device + +import com.anytypeio.anytype.core_models.DecryptedPushContent + +/** + * Formats the notification body text by appending attachment indicator if needed. + * + * @param attachmentText Localized text to indicate presence of attachments + * @return Formatted body text with optional attachment indicator + */ +fun DecryptedPushContent.Message.formatNotificationBody(attachmentText: String): String { + val rawText = text.trim() + return when { + hasAttachments && rawText.isNotEmpty() -> + "$rawText \uD83D\uDCCE$attachmentText" + hasAttachments -> + "\uD83D\uDCCE$attachmentText" + else -> + rawText + } +} \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/notifications/PushDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/notifications/PushDI.kt index 5354c0791f..b6e446077d 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/notifications/PushDI.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/notifications/PushDI.kt @@ -7,6 +7,8 @@ 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.presentation.notifications.CryptoService import com.anytypeio.anytype.presentation.notifications.CryptoServiceImpl @@ -16,7 +18,9 @@ import com.anytypeio.anytype.presentation.notifications.PushKeyProvider import dagger.Component import dagger.Module import dagger.Provides +import javax.inject.Named import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope @Singleton @Component( @@ -86,4 +90,6 @@ interface PushContentDependencies : ComponentDependencies { fun deviceTokenSavingService(): DeviceTokenStoringService fun pushKeyProvider(): PushKeyProvider fun context(): Context + @Named(DEFAULT_APP_COROUTINE_SCOPE) fun scope(): CoroutineScope + fun dispatchers(): AppCoroutineDispatchers } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt b/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt index 1dec953ed2..0b32b4acc7 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt @@ -10,6 +10,7 @@ import android.os.Bundle import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NotificationManagerCompat import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView @@ -725,6 +726,7 @@ 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)) } diff --git a/app/src/test/java/com/anytypeio/anytype/device/DefaultPushMessageProcessorTest.kt b/app/src/test/java/com/anytypeio/anytype/device/DefaultPushMessageProcessorTest.kt index 2929be3b4a..426073af52 100644 --- a/app/src/test/java/com/anytypeio/anytype/device/DefaultPushMessageProcessorTest.kt +++ b/app/src/test/java/com/anytypeio/anytype/device/DefaultPushMessageProcessorTest.kt @@ -103,7 +103,8 @@ class DefaultPushMessageProcessorTest { msgId = "test-msg-id", text = "Test message", spaceName = "Test Space", - senderName = "Test Sender" + senderName = "Test Sender", + hasAttachments = true ) ) diff --git a/app/src/test/java/com/anytypeio/anytype/device/NotificationExtensionsTest.kt b/app/src/test/java/com/anytypeio/anytype/device/NotificationExtensionsTest.kt new file mode 100644 index 0000000000..3b9ee7e631 --- /dev/null +++ b/app/src/test/java/com/anytypeio/anytype/device/NotificationExtensionsTest.kt @@ -0,0 +1,44 @@ +package com.anytypeio.anytype.device + +import com.anytypeio.anytype.core_models.DecryptedPushContent +import org.junit.Assert.assertEquals +import org.junit.Test + +class NotificationExtensionsTest { + + @Test + fun `formatNotificationBody returns raw text when no attachments`() { + val message = createMessage(text = "Hello world", hasAttachments = false) + assertEquals("Hello world", message.formatNotificationBody("attachment")) + } + + @Test + fun `formatNotificationBody appends attachment indicator when only attachments present`() { + val message = createMessage(text = "", hasAttachments = true) + assertEquals("\uD83D\uDCCEattachment", message.formatNotificationBody("attachment")) + } + + @Test + fun `formatNotificationBody appends attachment indicator after text when both present`() { + val message = createMessage(text = "Hello world", hasAttachments = true) + assertEquals("Hello world \uD83D\uDCCEattachment", message.formatNotificationBody("attachment")) + } + + @Test + fun `formatNotificationBody trims whitespace from text`() { + val message = createMessage(text = " Hello world ", hasAttachments = false) + assertEquals("Hello world", message.formatNotificationBody("attachment")) + } + + private fun createMessage( + text: String, + hasAttachments: Boolean + ) = DecryptedPushContent.Message( + text = text, + hasAttachments = hasAttachments, + chatId = "test-chat", + senderName = "Test User", + spaceName = "Test Space", + msgId = "test-msg" + ) +} \ No newline at end of file diff --git a/core-models/src/main/java/com/anytypeio/anytype/core_models/DecryptedPushContent.kt b/core-models/src/main/java/com/anytypeio/anytype/core_models/DecryptedPushContent.kt index 2aeac5a551..9b7f1a898e 100644 --- a/core-models/src/main/java/com/anytypeio/anytype/core_models/DecryptedPushContent.kt +++ b/core-models/src/main/java/com/anytypeio/anytype/core_models/DecryptedPushContent.kt @@ -15,6 +15,7 @@ data class DecryptedPushContent( val msgId: String, val text: String, val spaceName: String, - val senderName: String + val senderName: String, + val hasAttachments: Boolean ) } \ No newline at end of file diff --git a/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/AndroidExtension.kt b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/AndroidExtension.kt index 5f9f4458e8..122ead36bd 100644 --- a/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/AndroidExtension.kt +++ b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/AndroidExtension.kt @@ -1,6 +1,7 @@ package com.anytypeio.anytype.core_utils.ext import android.app.Activity +import android.app.ActivityManager import android.content.ActivityNotFoundException import android.content.ClipboardManager import android.content.Context @@ -437,4 +438,11 @@ fun BaseBottomSheetComposeFragment.setupBottomSheetBehavior(paddingTop: Int) { state = BottomSheetBehavior.STATE_EXPANDED skipCollapsed = true } +} + +fun Context.isAppInForeground(): Boolean { + val appProcessInfo = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(appProcessInfo) + return appProcessInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND || + appProcessInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE } \ No newline at end of file diff --git a/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/Extensions.kt b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/Extensions.kt index 712b43d64d..b71db62641 100644 --- a/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/Extensions.kt +++ b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/Extensions.kt @@ -158,4 +158,12 @@ fun Long.readableFileSize(): String { return DecimalFormat("#,##0.#").format(this / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups] } -fun Throwable.msg(default: String = "Unknown error") = message ?: default \ No newline at end of file +fun Throwable.msg(default: String = "Unknown error") = message ?: default + +inline fun runSafely(actionDesc: String, block: () -> T) { + try { + block() + } catch (e: Exception) { + Timber.e(e, "Error during $actionDesc") + } +} \ No newline at end of file diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 0750f26bdd..c270628369 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -1916,6 +1916,7 @@ Please provide specific details of your needs here. Saturday Sunday File + Attachment Create your first space to get started It is empty here. diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/DecryptionPushContentServiceImplTest.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/DecryptionPushContentServiceImplTest.kt index f8a940da3e..aac1555cb7 100644 --- a/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/DecryptionPushContentServiceImplTest.kt +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/DecryptionPushContentServiceImplTest.kt @@ -133,11 +133,37 @@ class DecryptionPushContentServiceImplTest { fun `decrypt should successfully decrypt actual RemoteMessage data`() { // Given val actualKeyId = "626166797265696376626f79757979696a6c66636235677461336665736c6f716132656f646b707377766133326b6d6c6b76336870637366756971e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - val actualEncryptedPayload = "OllD4bCyF0VbI0VrEsz0aYFuj+X8cnsRvm1wDYJC6aCzIyBu99NhHJi3xbIX565cUvIB6tlCdFzRUDc1WJqV8/0dbaB5PZozwLwbv9Pk+Ozxgsu6AspYT8MAR67exZ2ekD3dSo3hoeqlD50bJYQQnWvTgRUns5WzOzDanwwMMXJncxERlB2BdiqC7S2LmU47dgxoMytwBaJXemw9wHiU7dPnICSDAbnNlJU6DAGTn0Rqc38GpMbDg8+u2ksa1gb7P+P8XwTn9AFRPFz4Ay/mM/5jxcignyRGm3PObxBfUCP8NDwl7jH+55Q2VUgC2SX7vVEBLb5mlNJu3DwkhJvB7iRssulypiQ8I1w+mJ+Xh3TG2RYbgjb4l48mNoecblL/hvaRh560T3OTqlWlVNh0c5wRd/eo5YH5zoXrQydk2JXO6vReEWaJQt+bPU2y6N6IUbpLlw2q7OQu9jRIF5T35R3XO8GU8CmyKmhlJK4xAvhOiKIc8X47BGfApY6hl3TSPea9dSEnb0+EB0YsC7DyRc7y3NL588+Yc0sfHLA5Mp2oWs9a" - val actualEncryptedData = Base64.decode(actualEncryptedPayload, Base64.DEFAULT) - - // Use the actual push key from the logs val actualKeyValue = "RT1gb7DyUW5tc5qCF92Jc3IlEQVOgxxBo6x2BP5T5mU=" + + // Create test content with the same values as in the original test + val testContent = DecryptedPushContent( + spaceId = "test-space", + type = 1, + senderId = "test-sender", + newMessage = DecryptedPushContent.Message( + chatId = "test-chat", + msgId = "test-msg", + text = "ooo", + spaceName = "Спейсдля пушей", + senderName = "Test not", + hasAttachments = false + ) + ) + + // Encrypt the test content + val keyBytes = Base64.decode(actualKeyValue, Base64.DEFAULT) + val keySpec = SecretKeySpec(keyBytes, "AES") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val nonce = ByteArray(12).apply { + java.security.SecureRandom().nextBytes(this) + } + val gcmSpec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec) + + val jsonString = Json.encodeToString(DecryptedPushContent.serializer(), testContent) + val ciphertext = cipher.doFinal(jsonString.toByteArray()) + val actualEncryptedData = nonce + ciphertext + whenever(pushKeyProvider.getPushKey()).thenReturn( mapOf(actualKeyId to PushKey(id = actualKeyId, value = actualKeyValue)) ) @@ -152,6 +178,47 @@ class DecryptionPushContentServiceImplTest { assertEquals("Спейсдля пушей", result?.newMessage?.spaceName) assertEquals("Test not", result?.newMessage?.senderName) assertEquals("ooo", result?.newMessage?.text) + assertEquals(false, result?.newMessage?.hasAttachments) + } + + @Test + fun `decrypt should successfully decrypt message with attachments`() { + // Given + val keyId = "test-key-id" + val key = "testKey123456789" + val keyAsBytes = key.toByteArray() + val value = Base64.encodeToString(keyAsBytes, Base64.DEFAULT) + + val content = DecryptedPushContent( + spaceId = "test-space", + type = 1, + senderId = "test-sender", + newMessage = DecryptedPushContent.Message( + chatId = "test-chat", + msgId = "test-msg", + text = "Test message with attachments", + spaceName = "Test Space", + senderName = "Test Sender", + hasAttachments = true + ) + ) + + val encryptedData = encryptTestData(content) + whenever(pushKeyProvider.getPushKey()).thenReturn( + mapOf(keyId to PushKey(id = keyId, value = value)) + ) + + // When + val result = decryptionService.decrypt(encryptedData, keyId) + + // Then + assertNotNull(result) + assertEquals(1, result?.type) + assertNotNull(result?.newMessage) + assertEquals("Test Space", result?.newMessage?.spaceName) + assertEquals("Test Sender", result?.newMessage?.senderName) + assertEquals("Test message with attachments", result?.newMessage?.text) + assertEquals(true, result?.newMessage?.hasAttachments) } private fun createTestContent(): DecryptedPushContent { @@ -164,7 +231,8 @@ class DecryptionPushContentServiceImplTest { msgId = testMsgId, text = "Test message", spaceName = "Test Space", - senderName = "Test Sender" + senderName = "Test Sender", + hasAttachments = false ) ) }